Files
zoneminder/web/js/EventStream.js
T
Isaac Connor 40129a5564 refactor: replace the auth_hash/auth_relay globals with one ZMAuth credential
The page kept two copies of the same secret: auth_relay, the query fragment
every AJAX call is authenticated with, and auth_hash, the bare hash stamped
into stream <img> URLs. Different responses updated different copies, so
they could drift, and a drifted auth_hash produced stream URLs that zms
rejects.

ZMAuth stores only the relay and derives the hash from it, so the two cannot
disagree. Its helpers cover the four shapes the call sites used:

  zmAuth.hash          derived, '' under the plain/none relay forms
  zmAuth.update(data)  absorb the auth fields of any response
  zmAuth.appendTo(url) authenticate a url, no-op when auth is off
  zmAuth.applyTo(src)  point a stream url at the current credential

appendTo also removes the `x ? '&'+x : ''` guard repeated at every call
site, some of which had omitted it and emitted a dangling '?'.

Migrates all call sites across web/js and the classic skin, and drops both
globals from skin.js.php.

Tests: tests/js/auth-helpers.test.js, 44 passing.
2026-08-09 10:29:46 -04:00

496 lines
17 KiB
JavaScript

"use strict";
/**
* EventStream - Manages a persistent zms MJPEG connection for event playback.
*
* Mirrors the MonitorStream.js constructor-function pattern. Frames arrive via
* a hidden <img> receiving a multipart MJPEG stream from zms and are drawn to
* a caller-supplied <canvas> on each img.onload.
*
* Commands (seek, pause, play, rate changes) are sent to zms over its existing
* command-socket protocol via AJAX, exactly like MonitorStream and event.js.
*
* @param {Object} config
* @param {number} config.monitorId
* @param {number} config.monitorWidth - Native monitor width
* @param {number} config.monitorHeight - Native monitor height
* @param {string} config.url - URL to index.php (for command AJAX)
* @param {string} config.url_to_zms - PathToZMS base URL
* @param {HTMLCanvasElement} config.canvas
* @param {number} [config.scale=100] - Scale percentage
*/
function EventStream(config) {
this.monitorId = config.monitorId;
this.monitorWidth = config.monitorWidth;
this.monitorHeight = config.monitorHeight;
this.url = config.url;
this.url_to_zms = config.url_to_zms;
this.canvas = config.canvas;
this.scale = config.scale ? parseInt(config.scale) : 100;
this.connKey = null;
this.img = null;
this.started = false;
this.paused = false;
this.stopped = false;
this.currentEventId = null;
this.rate = 100;
this.status = null;
this.streamCmdTimer = null;
this.ajaxQueue = null;
this.rafId = null;
// Recovery state
this.consecutiveErrors = 0;
this.maxRecoveryAttempts = 5;
this.recoveryDelay = 1000; // ms, doubles on each retry
this.recoveryTimer = null;
this.lastOptions = null; // saved for restart after recovery
// Callbacks — set by the consumer
this.onStatus = null;
this.onError = null;
// How often to poll zms for status (ms). Use the global if available,
// otherwise fall back to a sensible default.
this.statusInterval = (typeof statusRefreshTimeout !== 'undefined')
? statusRefreshTimeout
: (typeof streamTimeout !== 'undefined') ? streamTimeout : 2000;
// Command parameters template — matches MonitorStream / event.js protocol
this.streamCmdParms = {
view: 'request',
request: 'stream',
connkey: null
};
// -------------------------------------------------------------------------
// connKey generation (identical to MonitorStream)
// -------------------------------------------------------------------------
this.genConnKey = function() {
return (Math.floor((Math.random() * 999999) + 1))
.toLocaleString('en-US', {minimumIntegerDigits: 6, useGrouping: false});
};
// -------------------------------------------------------------------------
// start(eventId, options) — Begin streaming an event
// -------------------------------------------------------------------------
/**
* @param {number|string} eventId
* @param {Object} [options]
* @param {number} [options.time] - Epoch seconds to start at
* @param {number} [options.frame=1] - Frame ID to start at
* @param {number} [options.rate=100] - Playback rate (100 = 1x)
* @param {string} [options.replay='none']
* @param {number} [options.maxfps] - Max FPS for the stream
*/
this.start = function(eventId, options) {
options = options || {};
// An explicit start supersedes any pending recovery.
if (this.recoveryTimer) {
clearTimeout(this.recoveryTimer);
this.recoveryTimer = null;
}
// Tear down any existing connection first. Re-using a live <img> would
// abort its MJPEG stream behind zms's back and orphan the zms process.
if (this.started || this.img) this.teardown(this.started);
this.currentEventId = eventId;
this.rate = (options.rate !== undefined) ? options.rate : 100;
this.paused = false;
this.stopped = false;
this.lastOptions = Object.assign({}, options);
// Fresh connkey for this stream
this.connKey = this.genConnKey();
this.streamCmdParms.connkey = this.connKey;
// Build zms URL
var src = this.url_to_zms +
'?source=event' +
'&mode=jpeg' +
'&event=' + eventId +
'&monitor=' + this.monitorId +
'&scale=' + this.scale +
'&rate=' + this.rate +
'&maxfps=' + (options.maxfps || 5) +
'&replay=' + (options.replay || 'none') +
'&connkey=' + this.connKey;
if (options.frame) {
src += '&frame=' + options.frame;
}
if (options.time) {
src += '&time=' + options.time;
}
// Auth
if (typeof zmAuth !== 'undefined') {
src = zmAuth.appendTo(src);
}
// Use a DOM <img> element for MJPEG reception. Browsers natively
// update a DOM <img> with each frame from a multipart/x-mixed-replace
// response, but a detached Image() object does not reliably trigger
// onload per frame. We position it off-screen and draw from it to
// the canvas on a requestAnimationFrame loop.
if (!this.img) {
this.img = document.createElement('img');
this.img.style.cssText = 'position:absolute;left:-9999px;top:-9999px;' +
'width:1px;height:1px;visibility:hidden;';
document.body.appendChild(this.img);
}
var self = this;
this.img.onerror = function() {
console.warn('EventStream: MJPEG stream error for event ' +
self.currentEventId + ' (monitor ' + self.monitorId + ')');
self.streamCmdTimer = clearInterval(self.streamCmdTimer);
if (self.rafId) {
cancelAnimationFrame(self.rafId);
self.rafId = null;
}
// Attempt recovery — zms likely died
self.recover();
};
// onload fires once when the first MJPEG frame arrives, confirming
// the zms process is running and the command socket is ready.
this.img.onload = function() {
// Successful frame — reset error counter
self.consecutiveErrors = 0;
self.recoveryDelay = 1000;
if (!self.streamCmdTimer) {
self.streamCmdQuery();
self.streamCmdTimer = setInterval(
self.streamCmdQuery.bind(self), self.statusInterval
);
}
};
// Start the rAF draw loop — draws whenever the browser has
// decoded a new MJPEG frame into the img element.
this.startDrawLoop();
// Setting src starts the MJPEG connection
this.img.src = src;
this.started = true;
};
// -------------------------------------------------------------------------
// teardown(quit) — Drop the connection, timers and draw loop.
// Pass quit=false when zms is already gone, so we don't ask a dead process
// to exit. Detaching the img handlers before clearing src keeps our own
// teardown from firing onerror and looking like a stream failure.
// -------------------------------------------------------------------------
this.teardown = function(quit) {
if (quit && this.started) this.streamCommand(CMD_QUIT);
this.streamCmdTimer = clearInterval(this.streamCmdTimer);
if (this.rafId) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
if (this.img) {
this.img.onload = null;
this.img.onerror = null;
this.img.src = '';
if (this.img.parentNode) {
this.img.parentNode.removeChild(this.img);
}
this.img = null;
}
this.started = false;
this.connKey = null;
this.streamCmdParms.connkey = null;
};
// -------------------------------------------------------------------------
// stop() — Stop the current stream
// -------------------------------------------------------------------------
this.stop = function() {
if (this.recoveryTimer) {
clearTimeout(this.recoveryTimer);
this.recoveryTimer = null;
}
if (!this.started) return;
this.teardown(true);
this.paused = false;
this.stopped = false;
this.consecutiveErrors = 0;
this.recoveryDelay = 1000;
};
// -------------------------------------------------------------------------
// recover() — Attempt to restart after zms death
// -------------------------------------------------------------------------
this.recover = function() {
// A recovery is already scheduled. Without this, every error arriving
// while we wait to retry (the status poll keeps firing, and each reply
// is another Error) would queue another restart and inflate the attempt
// count until we give up on a stream that was never retried once.
if (this.recoveryTimer) return;
this.consecutiveErrors++;
var self = this;
var eventId = this.currentEventId;
var opts = Object.assign({}, this.lastOptions || {});
opts.rate = this.rate;
// Drop the dead connection before deciding whether to retry, so that
// giving up leaves nothing running. zms is already gone, so no CMD_QUIT.
this.teardown(false);
this.stopped = false;
if (this.consecutiveErrors > this.maxRecoveryAttempts) {
console.error('EventStream: max recovery attempts reached for monitor ' +
this.monitorId + ', giving up');
if (this.onError) this.onError('Stream recovery failed');
return;
}
console.warn('EventStream: recovery attempt ' + this.consecutiveErrors +
'/' + this.maxRecoveryAttempts + ' for monitor ' + this.monitorId);
// Delay before restarting — exponential backoff
this.recoveryTimer = setTimeout(function() {
self.recoveryTimer = null;
// teardown() cleared started, so a consumer polling for a live stream
// may have restarted us while we waited. Restarting again here would
// abort that healthy stream and orphan its zms, which fails the img
// and lands us straight back in recover().
if (self.started) return;
self.start(eventId, opts);
}, this.recoveryDelay);
this.recoveryDelay = Math.min(this.recoveryDelay * 2, 10000);
};
// -------------------------------------------------------------------------
// seek(offset) — Seek within the current event (seconds from start)
// -------------------------------------------------------------------------
this.seek = function(offset) {
if (!this.started) return;
this.streamCommand({command: CMD_SEEK, offset: offset});
};
// -------------------------------------------------------------------------
// seekToTime(epochSecs) — Seek by wall-clock time
// -------------------------------------------------------------------------
this.seekToTime = function(epochSecs) {
if (!this.started || !this.status) return;
// status.event gives us the current event ID; we need the event's
// start time to compute an offset. If the caller hasn't provided
// event metadata we fall back to duration-based estimation.
//
// For montagereview integration the caller will typically have the
// event start time available in the global `events` object.
var eventStartSecs = null;
if (typeof events !== 'undefined' && events[this.currentEventId]) {
eventStartSecs = events[this.currentEventId].StartTimeSecs;
}
if (eventStartSecs) {
var offset = epochSecs - eventStartSecs;
if (offset < 0) offset = 0;
this.seek(offset);
}
};
// -------------------------------------------------------------------------
// setRate(rate) — Change playback rate (100 = 1x realtime)
// -------------------------------------------------------------------------
this.setRate = function(rate) {
this.rate = rate;
if (!this.started) return;
this.streamCommand({command: CMD_VARPLAY, rate: rate});
};
// -------------------------------------------------------------------------
// pause() / play()
// -------------------------------------------------------------------------
this.pause = function() {
if (!this.started) return;
this.paused = true;
this.streamCommand(CMD_PAUSE);
};
this.play = function() {
if (!this.started) return;
this.paused = false;
this.streamCommand(CMD_PLAY);
};
// -------------------------------------------------------------------------
// setScale(scale) — Change the stream scale
// -------------------------------------------------------------------------
this.setScale = function(scale) {
this.scale = scale;
if (!this.started) return;
this.streamCommand({command: CMD_SCALE, scale: scale});
};
// -------------------------------------------------------------------------
// switchEvent(eventId, options) — Switch to a different event
// -------------------------------------------------------------------------
this.switchEvent = function(eventId, options) {
if (this.recoveryTimer) {
clearTimeout(this.recoveryTimer);
this.recoveryTimer = null;
}
// Reset recovery state for fresh event
this.consecutiveErrors = 0;
this.recoveryDelay = 1000;
// start() tears the old stream down (sending CMD_QUIT) and brings the new
// event up in one step. Doing the teardown here instead and starting from
// a timer would leave started=false in between, and a consumer polling for
// a live stream would start its own before the timer fired.
this.start(eventId, options);
};
// -------------------------------------------------------------------------
// streamCommand(command) — Send a command to zms via AJAX
// -------------------------------------------------------------------------
this.streamCommand = function(command) {
if (!this.started) {
return;
}
var params = Object.assign({}, this.streamCmdParms);
if (typeof command === 'object') {
for (var key in command) {
if (command.hasOwnProperty(key)) params[key] = command[key];
}
} else {
params.command = command;
}
this.streamCmdReq(params);
};
// -------------------------------------------------------------------------
// streamCmdReq(params) — Low-level AJAX to the command socket
// -------------------------------------------------------------------------
this.streamCmdReq = function(params) {
var self = this;
this.ajaxQueue = jQuery.ajaxQueue({
url: zmAuth.appendTo(this.url),
xhrFields: {withCredentials: true},
data: params,
dataType: 'json'
})
.done(function(respObj) {
self.getStreamCmdResponse(respObj);
})
.fail(function(jqXHR, textStatus) {
if (textStatus === 'abort') return;
console.warn('EventStream: AJAX failed for monitor ' +
self.monitorId + ': ' + textStatus);
// AJAX failure likely means zms has died (socket gone)
self.recover();
});
};
// -------------------------------------------------------------------------
// streamCmdQuery() — Periodic CMD_QUERY for status updates
// -------------------------------------------------------------------------
this.streamCmdQuery = function() {
if (this.started) {
var params = Object.assign({}, this.streamCmdParms);
params.command = CMD_QUERY;
this.streamCmdReq(params);
}
};
// -------------------------------------------------------------------------
// getStreamCmdResponse(respObj) — Handle CMD_QUERY / command responses
// -------------------------------------------------------------------------
this.getStreamCmdResponse = function(respObj) {
if (!respObj) return;
if (respObj.result === 'Error' || respObj.result === 'Err') {
console.warn('EventStream: command error for monitor ' +
this.monitorId);
// Error response means stream.php couldn't talk to zms — recover
this.recover();
return;
}
// Successful response — reset error counter
this.consecutiveErrors = 0;
this.recoveryDelay = 1000;
if (!respObj.status) return;
this.status = respObj.status;
// Update the credential if the server sent a fresh one
if (typeof zmAuth !== 'undefined') {
zmAuth.update(this.status);
}
// Track paused and stopped state from server
if (this.status.paused !== undefined) {
this.paused = !!this.status.paused;
}
if (this.status.stopped !== undefined) {
this.stopped = !!this.status.stopped;
}
// Notify consumer
if (this.onStatus) {
this.onStatus(this.status);
}
};
// -------------------------------------------------------------------------
// startDrawLoop() — rAF loop that copies the MJPEG img to the canvas
// -------------------------------------------------------------------------
this.startDrawLoop = function() {
var self = this;
function loop() {
if (!self.started) return;
self.drawFrame();
self.rafId = requestAnimationFrame(loop);
}
this.rafId = requestAnimationFrame(loop);
};
// -------------------------------------------------------------------------
// drawFrame() — Draw the current MJPEG frame to the canvas
// -------------------------------------------------------------------------
this.drawFrame = function() {
if (!this.canvas || !this.img) return;
// Only draw if the img has decoded at least one frame
if (!this.img.naturalWidth) return;
var ctx = this.canvas.getContext('2d');
ctx.drawImage(this.img, 0, 0, this.canvas.width, this.canvas.height);
if (this.onFrameDrawn) this.onFrameDrawn(this.canvas);
};
}