mirror of
https://github.com/ZoneMinder/zoneminder.git
synced 2026-10-03 16:05:24 -04:00
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.
496 lines
17 KiB
JavaScript
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);
|
|
};
|
|
}
|