Session recording viewer can now stream.

Ylian Saint-Hilaire committed Nov 7, 2021 at 18:07 UTC 7974b43b3d0085cb2a9757bc3b3c371c2ef5a5ad
5 files changed +297 -37
public/images/link7.png
Binary files /dev/null and b/public/images/link7.png differ
public/scripts/common-0.0.1.js
+2 -1
@@ -115,7 +115,7 @@ function isSafeString(str) { return ((typeof str == 'string') && (str.indexOf('<
115 function isSafeString2(str) { return ((typeof str == 'string') && (str.indexOf('<') == -1) && (str.indexOf('>') == -1) && (str.indexOf('&') == -1) && (str.indexOf('"') == -1) && (str.indexOf('\'') == -1) && (str.indexOf('+') == -1) && (str.indexOf('(') == -1) && (str.indexOf(')') == -1) && (str.indexOf('#') == -1) && (str.indexOf('%') == -1)) };
116
117 // Parse URL arguments, only keep safe values
118 -function parseUriArgs() {
118 +function parseUriArgs(decodeUrl) {
119 var href = window.document.location.href;
120 if (href.endsWith('#')) { href = href.substring(0, href.length - 1); }
121 var name, r = {}, parsedUri = href.split(/[\?&|]/);
@@ -124,6 +124,7 @@ function parseUriArgs() {
124 var arg = parsedUri[j], i = arg.indexOf('=');
125 name = arg.substring(0, i);
126 r[name] = arg.substring(i + 1);
127 + if (decodeUrl) { r[name] = decodeURIComponent(arg.substring(i + 1)); }
128 if (!isSafeString(r[name])) { delete r[name]; } else { var x = parseInt(r[name]); if (x == r[name]) { r[name] = x; } }
129 }
130 return r;
views/default.handlebars
+5 -1
@@ -14986,7 +14986,11 @@
14986 if (rec.protocol == 200) { sessionName += ' - ' + "Messenger"; }
14987
14988 var actions = '', icon = 'm0';
14989 - if (rec.present == 1) { icon = 'm1'; actions = '<div style=cursor:pointer;float:right><a onclick=downloadFile("recordings.ashx?file=' + encodeURIComponentEx(rec.filename) + '")><img src=images/link4.png height=10 width=10 title="Download Recording"></a>&nbsp;</div>'; }
14989 + if (rec.present == 1) {
14990 + icon = 'm1';
14991 + actions = '<div style=cursor:pointer;float:right><a onclick=downloadFile("recordings.ashx?file=' + encodeURIComponentEx(rec.filename) + '")><img src=images/link4.png height=10 width=10 title="Download Recording"></a>&nbsp;</div>';
14992 + actions += '<div style=cursor:pointer;float:right><a href="player.htm?stream=' + encodeURIComponentEx(rec.filename) + '")><img src=images/link7.png height=10 width=10 title="Play Recording"></a>&nbsp;</div>';
14993 + }
14994 var x = '<tr tabindex=0 onmouseover=userMouseHover2(this,1) onmouseout=userMouseHover2(this,0) onkeypress="if (event.key==\'Enter\') showRecordingDialog(event,\'' + i + '\')"><td style=cursor:pointer>';
14995 x += '<div class=bar style=width:100%>';
14996 //x += '<div class=baricon><input class=RecordingCheckbox value="' + encodeURIComponentEx(rec.filename) + '" onclick=p52updateInfo() type=checkbox></div>';
views/player.handlebars
+227 -33
@@ -29,7 +29,7 @@
29 <input id="ConvertAsWebM" style="display:none" type=button value="Convert to WebM" onclick="saveAsWebMfile()">&nbsp;
30 </div>
31 <div>
32 - <input id="OpenFileButton" type=button value="Open File..." onclick="openfile()">
32 + <input id="OpenFileButton" type=button value="Open File..." onclick="openfile()" style="display:none">
33 <span id="deskstatus"></span>
34 </div>
35 </div>
@@ -113,6 +113,13 @@
113 var videoWriterCurrentFrame = null;
114 var videoFrameDuration = 100;
115 var browser = null;
116 + var domainUrl = '{{{domainurl}}}';
117 + var urlargs;
118 +
119 + // Streaming values
120 + var ws = null;
121 + var streamingBlockSize = 102400; // 100k block
122 + var streamingBlockCache = {};
123
124 function start() {
125 // Detect what browser is in use
@@ -129,6 +136,7 @@
136 }
137 })(window.navigator.userAgent.toLowerCase());
138
139 + urlargs = parseUriArgs(true);
140 window.onresize = deskAdjust;
141 document.ondrop = ondrop;
142 document.ondragover = ondragover;
@@ -139,19 +147,127 @@
147
148 // Make the dialog box movable
149 dialogBoxDrag();
150 +
151 + // Check if we need to stream a session
152 + if (urlargs.stream != null) {
153 + QV('metadatadiv', true);
154 + QH('metadatadiv', "Connecting to server...");
155 + ws = new WebSocket(window.location.protocol.replace('http', 'ws') + '//' + window.location.host + domainUrl + 'recordings.ashx?file=' + urlargs.stream + (urlargs.key ? ('&key=' + urlargs.key) : ''));
156 + ws.binaryType = 'arraybuffer';
157 + ws.onopen = function (e) { console.log('Session Streaming - Connected'); }
158 + ws.onmessage = function (msg) {
159 + if (typeof msg.data != 'string') {
160 + var uint8View = new Uint8Array(msg.data);
161 + var blocknum = (((uint8View[4] << 24) + (uint8View[5] << 16) + (uint8View[6] << 8) + uint8View[7]) / streamingBlockSize);
162 + //console.log('Session Streaming - Got block: ' + blocknum);
163 + streamingBlockCache[blocknum] = msg.data;
164 + var pendingFetchStreamingData2 = [], pendingFetchStreamingData3 = [];
165 + for (var i in pendingFetchStreamingData) {
166 + var j = pendingFetchStreamingData[i].missingBlocks.indexOf(blocknum);
167 + if (j >= 0) { pendingFetchStreamingData[i].missingBlocks.splice(i, 1); }
168 + if (pendingFetchStreamingData[i].missingBlocks.length == 0) {
169 + pendingFetchStreamingData3.push(pendingFetchStreamingData[i]);
170 + } else {
171 + pendingFetchStreamingData2.push(pendingFetchStreamingData[i]);
172 + }
173 + }
174 + pendingFetchStreamingData = pendingFetchStreamingData2;
175 + for (var i in pendingFetchStreamingData3) {
176 + fetchStreamingData(pendingFetchStreamingData3[i].fr, pendingFetchStreamingData3[i].start, pendingFetchStreamingData3[i].end);
177 + }
178 + return;
179 + } else {
180 + var command = null;
181 + try { command = JSON.parse(msg.data); } catch (ex) { console.log(ex); return; }
182 + if ((command == null) || (typeof command.action != 'string')) return;
183 + switch (command.action) {
184 + case 'info': {
185 + console.log('Session Streaming - Session file size: ' + command.size);
186 + if ((typeof command.name != 'string') || (typeof command.size != 'number')) break;
187 + recFile = { name: command.name, size: command.size, streaming: true };
188 + readLastBlock(function (type, flags, time, extras) {
189 + if (type == 3) {
190 + // File is ok
191 + recFileEndTime = time;
192 + recFileExtras = extras;
193 + readNextBlock(processFirstBlock);
194 + } else {
195 + // This is not a good file
196 + recFileEndTime = 0;
197 + }
198 + });
199 + break;
200 + }
201 + }
202 +
203 + }
204 + }
205 + ws.onclose = function (e) { console.log('Session Streaming - Disconnected'); restart(); }
206 + } else {
207 + QV('OpenFileButton', true);
208 + }
209 + }
210 +
211 + // Pending fetch requests
212 + var pendingFetchStreamingData = [];
213 +
214 + // Get a section of the recorded file
215 + function fetchStreamingData(fr, start, end) {
216 + // Start by looking at what blocks are required
217 + var firstBlock = Math.floor(start / streamingBlockSize);
218 + var lastBlock = Math.floor(end / streamingBlockSize);
219 + var missingBlocks = [];
220 + for (var i = firstBlock; i <= lastBlock; i++) {
221 + if ((streamingBlockCache[i] == null) || (streamingBlockCache[i] === 1)) { missingBlocks.push(i); fetchStreamingBlock(i); }
222 + fetchStreamingBlock(i + 1); // Pre-fetch block
223 + fetchStreamingBlock(i + 2); // Pre-fetch block
224 + }
225 + if (missingBlocks.length == 0) {
226 + // We have all the blocks we need, assemble the data now
227 + var outputptr = 0;
228 + var output = new ArrayBuffer(end - start);
229 + var outputBytes = new Uint8Array(output);
230 + for (var i = firstBlock; i <= lastBlock; i++) {
231 + var block = streamingBlockCache[i]; // Get a block with data we need
232 + var blockstart = (i * streamingBlockSize); // Compute the block starting data pointer
233 + var blockend = blockstart + (block.byteLength - 8); // Compute the block ending data pointer
234 + var r1 = Math.max(start, blockstart); // Compute where we need to start data copy
235 + var r2 = Math.min(end, blockend); // Compute where we need to end data copy
236 + var p1 = r1 - blockstart; // Compute where in the block to start data copy
237 + var p2 = r2 - r1; // Computer how many byte to copy from the block
238 + var subblock = block.slice(8 + p1, 8 + p1 + p2); // Get the sub-block of data we need
239 + outputBytes.set(new Uint8Array(subblock), outputptr); // Copy the sub-block into the main block
240 + outputptr += p2; // Move the pointer forward
241 + }
242 + fr.onload({ target: { result: ArrayBufferToString(output) } } ); // Event the block of data
243 + } else {
244 + pendingFetchStreamingData.push({ fr: fr, start: start, end: end, missingBlocks: missingBlocks });
245 + }
246 + }
247 +
248 + // Request a block of data from the server
249 + function fetchStreamingBlock(n) {
250 + if (streamingBlockCache[n] != null) return;
251 + streamingBlockCache[n] = 1; // Mark the block as being requested
252 + if ((n * streamingBlockSize) >= recFile.size) return;
253 + var len = streamingBlockSize;
254 + if (((n + 1) * streamingBlockSize) >= recFile.size) { len = (recFile.size - (n * streamingBlockSize)); }
255 + ws.send('{"action":"get","ptr":' + (n * streamingBlockSize) + ',"size":' + len + '}');
256 }
257
258 function readNextBlock(func) {
259 if ((recFilePtr + 16) > recFile.size) { QS('progressbar').width = '100%'; func(-1); } else {
260 var fr = new FileReader();
147 - fr.onload = function () {
148 - var type = ReadShort(this.result, 0);
149 - var flags = ReadShort(this.result, 2);
150 - var size = ReadInt(this.result, 4);
151 - var time = (ReadInt(this.result, 8) << 32) + ReadInt(this.result, 12);
261 + fr.onload = function (r) {
262 + var result = r.target.result;
263 + var type = ReadShort(result, 0);
264 + var flags = ReadShort(result, 2);
265 + var size = ReadInt(result, 4);
266 + var time = (ReadInt(result, 8) << 32) + ReadInt(result, 12);
267 if ((recFilePtr + 16 + size) > recFile.size) { QS('progressbar').width = '100%'; func(-1); } else {
268 var fr2 = new FileReader();
154 - fr2.onload = function () {
269 + fr2.onload = function (r) {
270 + var result = r.target.result;
271 recFilePtr += (16 + size);
272 if (recFileEndTime == 0) {
273 // File pointer progress bar
@@ -160,59 +276,89 @@
276 // Time progress bar
277 QS('progressbar').width = Math.floor(((recFileLastTime - recFileStartTime) / (recFileEndTime - recFileStartTime)) * 100) + '%';
278 }
163 - func(type, flags, time, this.result);
279 + func(type, flags, time, result);
280 };
165 - fr2.readAsBinaryString(recFile.slice(recFilePtr + 16, recFilePtr + 16 + size));
281 + if (ws == null) {
282 + fr2.readAsBinaryString(recFile.slice(recFilePtr + 16, recFilePtr + 16 + size));
283 + } else {
284 + fetchStreamingData(fr2, recFilePtr + 16, recFilePtr + 16 + size);
285 + }
286 }
287 };
168 - fr.readAsBinaryString(recFile.slice(recFilePtr, recFilePtr + 16));
288 + if (ws == null) {
289 + fr.readAsBinaryString(recFile.slice(recFilePtr, recFilePtr + 16));
290 + } else {
291 + fetchStreamingData(fr, recFilePtr, recFilePtr + 16);
292 + }
293 }
294 }
295
296 function readBlockAt(ptr, func) {
297 var fr = new FileReader();
174 - fr.onload = function () {
175 - var type = ReadShort(this.result, 0);
176 - var flags = ReadShort(this.result, 2);
177 - var size = ReadInt(this.result, 4);
178 - var time = (ReadInt(this.result, 8) << 32) + ReadInt(this.result, 12);
298 + fr.onload = function (r) {
299 + var result = r.target.result;
300 + var type = ReadShort(result, 0);
301 + var flags = ReadShort(result, 2);
302 + var size = ReadInt(result, 4);
303 + var time = (ReadInt(result, 8) << 32) + ReadInt(result, 12);
304 if ((ptr + 16 + size) > recFile.size) { func(-1); } else {
305 var fr2 = new FileReader();
181 - fr2.onload = function () { func(type, flags, time, this.result); };
182 - fr2.readAsBinaryString(recFile.slice(ptr + 16, ptr + 16 + size));
306 + fr2.onload = function (r) {
307 + var result = r.target.result;
308 + func(type, flags, time, result);
309 + };
310 + if (ws == null) {
311 + fr2.readAsBinaryString(recFile.slice(ptr + 16, ptr + 16 + size));
312 + } else {
313 + fetchStreamingData(fr2, ptr + 16, ptr + 16 + size);
314 + }
315 }
316 };
185 - fr.readAsBinaryString(recFile.slice(ptr, ptr + 16));
317 + if (ws == null) {
318 + fr.readAsBinaryString(recFile.slice(ptr, ptr + 16));
319 + } else {
320 + fetchStreamingData(fr, ptr, ptr + 16);
321 + }
322 }
323
324 function readLastBlock(func) {
325 if (recFile.size < 32) { func(-1); } else {
326 var fr = new FileReader();
191 - fr.onload = function () {
192 - var type = ReadShort(this.result, 0);
193 - var flags = ReadShort(this.result, 2);
194 - var size = ReadInt(this.result, 4);
195 - var time = (ReadInt(this.result, 8) << 32) + ReadInt(this.result, 12);
196 - var magic = this.result.substring(16, 32);
327 + fr.onload = function (r) {
328 + var result = r.target.result;
329 + var type = ReadShort(result, 0);
330 + var flags = ReadShort(result, 2);
331 + var size = ReadInt(result, 4);
332 + var time = (ReadInt(result, 8) << 32) + ReadInt(result, 12);
333 + var magic = result.substring(16, 32);
334 if ((type == 3) && (size == 16) && (magic == 'MeshCentralMCNDX')) {
335 // Extra metadata present, lets read it.
336 var fr2 = new FileReader();
200 - fr2.onload = function () {
201 - var xtype = ReadShort(this.result, 0);
202 - var xflags = ReadShort(this.result, 2);
203 - var xsize = ReadInt(this.result, 4);
204 - var xtime = (ReadInt(this.result, 8) << 32) + ReadInt(this.result, 12);
205 - var extras = JSON.parse(this.result.substring(16));
337 + fr2.onload = function (r) {
338 + var result = r.target.result;
339 + var xtype = ReadShort(result, 0);
340 + var xflags = ReadShort(result, 2);
341 + var xsize = ReadInt(result, 4);
342 + var xtime = (ReadInt(result, 8) << 32) + ReadInt(result, 12);
343 + var extras = JSON.parse(result.substring(16));
344 func(type, flags, xtime, extras); // Include extra metadata
345 }
208 - fr2.readAsBinaryString(recFile.slice(time, recFile.size - 32));
346 + if (ws == null) {
347 + fr2.readAsBinaryString(recFile.slice(time, recFile.size - 32));
348 + } else {
349 + fetchStreamingData(fr2, time, recFile.size - 32);
350 + }
351 } else if ((type == 3) && (size == 16) && (magic == 'MeshCentralMCREC')) {
352 func(type, flags, time); // No extra metadata
353 } else {
354 func(-1); // Fail
355 }
356 };
215 - fr.readAsBinaryString(recFile.slice(recFile.size - 32, recFile.size));
357 + if (ws == null) {
358 + fr.readAsBinaryString(recFile.slice(recFile.size - 32, recFile.size));
359 + } else {
360 + fetchStreamingData(fr, recFile.size - 32, recFile.size);
361 + }
362 }
363 }
364
@@ -451,7 +597,11 @@
597 QS('progressbar').width = '0px';
598 QH('timespan', '00:00:00');
599 QV('metadatadiv', true);
454 - QH('metadatadiv', '<span style=\"font-family:Arial,Helvetica Neue,Helvetica,sans-serif;font-size:28px\">MeshCentral Session Player</span><br /><br /><span style=color:gray>' + "Drag & drop a .mcrec file or click \"Open File...\"" + '</span>');
600 + if (urlargs.stream == null) {
601 + QH('metadatadiv', '<span style=\"font-family:Arial,Helvetica Neue,Helvetica,sans-serif;font-size:28px\">MeshCentral Session Player</span><br /><br /><span style=color:gray>' + "Drag & drop a .mcrec file or click \"Open File...\"" + '</span>');
602 + } else {
603 + QH('metadatadiv', '');
604 + }
605 QV('DeskParent', true);
606 QV('TermParent', false);
607 }
@@ -845,6 +995,50 @@
995 }
996 }
997
998 + function ArrayBufferToString(buffer) {
999 + return BinaryToString(String.fromCharCode.apply(null, Array.prototype.slice.apply(new Uint8Array(buffer))));
1000 + }
1001 +
1002 + function StringToArrayBuffer(string) {
1003 + return StringToUint8Array(string).buffer;
1004 + }
1005 +
1006 + function BinaryToString(binary) {
1007 + var error;
1008 + try {
1009 + return decodeURIComponent(escape(binary));
1010 + } catch (_error) {
1011 + error = _error;
1012 + if (error instanceof URIError) { return binary; } else { throw error; }
1013 + }
1014 + }
1015 +
1016 + function StringToBinary(string) {
1017 + var chars, code, i, isUCS2, len, _i;
1018 + len = string.length;
1019 + chars = [];
1020 + isUCS2 = false;
1021 + for (i = _i = 0; 0 <= len ? _i < len : _i > len; i = 0 <= len ? ++_i : --_i) {
1022 + code = String.prototype.charCodeAt.call(string, i);
1023 + if (code > 255) { isUCS2 = true; chars = null; break; } else { chars.push(code); }
1024 + }
1025 + if (isUCS2 === true) {
1026 + return unescape(encodeURIComponent(string));
1027 + } else {
1028 + return String.fromCharCode.apply(null, Array.prototype.slice.apply(chars));
1029 + }
1030 + }
1031 +
1032 + function StringToUint8Array(string) {
1033 + var binary, binLen, buffer, chars, i, _i;
1034 + binary = StringToBinary(string);
1035 + binLen = binary.length;
1036 + buffer = new ArrayBuffer(binLen);
1037 + chars = new Uint8Array(buffer);
1038 + for (i = _i = 0; 0 <= binLen ? _i < binLen : _i > binLen; i = 0 <= binLen ? ++_i : --_i) { chars[i] = String.prototype.charCodeAt.call(binary, i); }
1039 + return chars;
1040 + }
1041 +
1042 start();
1043 </script>
1044 </body>
webserver.js
+63 -2
@@ -3456,13 +3456,13 @@ module.exports.CreateWebServer = function (parent, db, args, certificates) {
3456 }
3457 }
3458
3459 - // Download a desktop recording
3459 + // Download a session recording
3460 function handleGetRecordings(req, res) {
3461 const domain = checkUserIpAddress(req, res);
3462 if (domain == null) return;
3463
3464 // Check the query
3465 - if ((domain.sessionrecording == null) || (req.query.file == null) || (obj.common.IsFilenameValid(req.query.file) !== true)) { res.sendStatus(401); return; }
3465 + if ((domain.sessionrecording == null) || (req.query.file == null) || (obj.common.IsFilenameValid(req.query.file) !== true) || (req.query.file.endsWith('.mcrec') == false)) { res.sendStatus(401); return; }
3466
3467 // Get the recording path
3468 var recordingsPath = null;
@@ -3482,6 +3482,66 @@ module.exports.CreateWebServer = function (parent, db, args, certificates) {
3482 try { res.sendFile(obj.path.join(recordingsPath, req.query.file)); } catch (ex) { res.sendStatus(404); }
3483 }
3484
3485 + // Stream a session recording
3486 + function handleGetRecordingsWebSocket(ws, req) {
3487 + var domain = checkAgentIpAddress(ws, req);
3488 + if (domain == null) { parent.debug('web', 'Got recordings file transfer connection with bad domain or blocked IP address ' + req.clientIp + ', dropping.'); try { ws.close(); } catch (ex) { } return; }
3489 +
3490 + // Check the query
3491 + if ((domain.sessionrecording == null) || (req.query.file == null) || (obj.common.IsFilenameValid(req.query.file) !== true) || (req.query.file.endsWith('.mcrec') == false)) { try { ws.close(); } catch (ex) { } return; }
3492 +
3493 + // Get the recording path
3494 + var recordingsPath = null;
3495 + if (domain.sessionrecording.filepath) { recordingsPath = domain.sessionrecording.filepath; } else { recordingsPath = parent.recordpath; }
3496 + if (recordingsPath == null) { try { ws.close(); } catch (ex) { } return; }
3497 +
3498 + // Get the user and check user rights
3499 + var authUserid = null;
3500 + if ((req.session != null) && (typeof req.session.userid == 'string')) { authUserid = req.session.userid; }
3501 + if (authUserid == null) { try { ws.close(); } catch (ex) { } return; }
3502 + const user = obj.users[authUserid];
3503 + if (user == null) { try { ws.close(); } catch (ex) { } return; }
3504 + if ((user.siteadmin & 512) == 0) { try { ws.close(); } catch (ex) { } return; } // Check if we have right to get recordings
3505 + const filefullpath = obj.path.join(recordingsPath, req.query.file);
3506 +
3507 + obj.fs.stat(filefullpath, function(err, stats) {
3508 + if (err) {
3509 + try { ws.close(); } catch (ex) { } // File does not exist
3510 + } else {
3511 + obj.fs.open(filefullpath, function (err, fd) {
3512 + if (err == null) {
3513 + // When data is received from the web socket
3514 + ws.on('message', function (msg) {
3515 + if (typeof msg != 'string') return;
3516 + var command;
3517 + try { command = JSON.parse(msg); } catch (e) { return; }
3518 + if ((command == null) || (typeof command.action != 'string')) return;
3519 + switch (command.action) {
3520 + case 'get': {
3521 + const buffer = Buffer.alloc(8 + command.size);
3522 + //buffer.writeUInt32BE((command.ptr >> 32), 0);
3523 + buffer.writeUInt32BE((command.ptr & 0xFFFFFFFF), 4);
3524 + obj.fs.read(fd, buffer, 8, command.size, command.ptr, function (err, bytesRead, buffer) { if (bytesRead > (buffer.length - 8)) { buffer = buffer.slice(0, bytesRead + 8); } ws.send(buffer); });
3525 + break;
3526 + }
3527 + }
3528 + });
3529 +
3530 + // If error, do nothing
3531 + ws.on('error', function (err) { try { ws.close(); } catch (ex) { } obj.fs.close(fd, function (err) { }); });
3532 +
3533 + // If the web socket is closed
3534 + ws.on('close', function (req) { try { ws.close(); } catch (ex) { } obj.fs.close(fd, function (err) { }); });
3535 +
3536 + ws.send(JSON.stringify({ "action": "info", "name": req.query.file, "size": stats.size }));
3537 + } else {
3538 + try { ws.close(); } catch (ex) { }
3539 + }
3540 + });
3541 + }
3542 + });
3543 + }
3544 +
3545 // Serve the player page
3546 function handlePlayerRequest(req, res) {
3547 const domain = checkUserIpAddress(req, res);
@@ -5738,6 +5798,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates) {
5798 obj.app.get(url + 'welcome.jpg', handleWelcomeImageRequest);
5799 obj.app.get(url + 'welcome.png', handleWelcomeImageRequest);
5800 obj.app.get(url + 'recordings.ashx', handleGetRecordings);
5801 + obj.app.ws(url + 'recordings.ashx', handleGetRecordingsWebSocket);
5802 obj.app.get(url + 'player.htm', handlePlayerRequest);
5803 obj.app.get(url + 'player', handlePlayerRequest);
5804 obj.app.get(url + 'sharing', handleSharingRequest);