You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

599 lines
21 KiB

  1. /*
  2. * Copyright 2014 XWiki SAS
  3. *
  4. * This program is free software: you can redistribute it and/or modify
  5. * it under the terms of the GNU Affero General Public License as published by
  6. * the Free Software Foundation, either version 3 of the License, or
  7. * (at your option) any later version.
  8. *
  9. * This program is distributed in the hope that it will be useful,
  10. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  11. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  12. * GNU Affero General Public License for more details.
  13. *
  14. * You should have received a copy of the GNU Affero General Public License
  15. * along with this program. If not, see <http://www.gnu.org/licenses/>.
  16. */
  17. define([
  18. '/pad/html-patcher.js',
  19. '/pad/errorbox.js',
  20. '/common/messages.js',
  21. '/bower_components/reconnectingWebsocket/reconnecting-websocket.js',
  22. '/pad/rangy.js',
  23. '/common/chainpad.js',
  24. '/common/otaml.js',
  25. '/bower_components/jquery/dist/jquery.min.js',
  26. '/bower_components/tweetnacl/nacl-fast.min.js'
  27. ], function (HTMLPatcher, ErrorBox, Messages, ReconnectingWebSocket) {
  28. window.ErrorBox = ErrorBox;
  29. var $ = window.jQuery;
  30. var Rangy = window.rangy;
  31. Rangy.init();
  32. var ChainPad = window.ChainPad;
  33. var Otaml = window.Otaml;
  34. var Nacl = window.nacl;
  35. var PARANOIA = true;
  36. var module = { exports: {} };
  37. /**
  38. * If an error is encountered but it is recoverable, do not immediately fail
  39. * but if it keeps firing errors over and over, do fail.
  40. */
  41. var MAX_RECOVERABLE_ERRORS = 15;
  42. /** Maximum number of milliseconds of lag before we fail the connection. */
  43. var MAX_LAG_BEFORE_DISCONNECT = 20000;
  44. /** Id of the element for getting debug info. */
  45. var DEBUG_LINK_CLS = 'rtwysiwyg-debug-link';
  46. /** Id of the div containing the user list. */
  47. var USER_LIST_CLS = 'rtwysiwyg-user-list';
  48. /** Id of the div containing the lag info. */
  49. var LAG_ELEM_CLS = 'rtwysiwyg-lag';
  50. /** The toolbar class which contains the user list, debug link and lag. */
  51. var TOOLBAR_CLS = 'rtwysiwyg-toolbar';
  52. /** Key in the localStore which indicates realtime activity should be disallowed. */
  53. var LOCALSTORAGE_DISALLOW = 'rtwysiwyg-disallow';
  54. var SPINNER_DISAPPEAR_TIME = 3000;
  55. // ------------------ Trapping Keyboard Events ---------------------- //
  56. var bindEvents = function (element, events, callback, unbind) {
  57. for (var i = 0; i < events.length; i++) {
  58. var e = events[i];
  59. if (element.addEventListener) {
  60. if (unbind) {
  61. element.removeEventListener(e, callback, false);
  62. } else {
  63. element.addEventListener(e, callback, false);
  64. }
  65. } else {
  66. if (unbind) {
  67. element.detachEvent('on' + e, callback);
  68. } else {
  69. element.attachEvent('on' + e, callback);
  70. }
  71. }
  72. }
  73. };
  74. var bindAllEvents = function (wysiwygDiv, docBody, onEvent, unbind)
  75. {
  76. bindEvents(docBody,
  77. ['textInput', 'keydown', 'keyup', 'select', 'cut', 'paste'],
  78. onEvent,
  79. unbind);
  80. bindEvents(wysiwygDiv,
  81. ['mousedown','mouseup','click'],
  82. onEvent,
  83. unbind);
  84. };
  85. var SPINNER = [ '-', '\\', '|', '/' ];
  86. var kickSpinner = function (spinnerElement, reversed) {
  87. var txt = spinnerElement.textContent || '-';
  88. var inc = (reversed) ? -1 : 1;
  89. spinnerElement.textContent = SPINNER[(SPINNER.indexOf(txt) + inc) % SPINNER.length];
  90. spinnerElement.timeout && clearTimeout(spinnerElement.timeout);
  91. spinnerElement.timeout = setTimeout(function () {
  92. spinnerElement.textContent = '';
  93. }, SPINNER_DISAPPEAR_TIME);
  94. };
  95. var checkLag = function (realtime, lagElement) {
  96. var lag = realtime.getLag();
  97. var lagSec = lag.lag/1000;
  98. lagElement.textContent = "Lag: ";
  99. if (lag.waiting && lagSec > 1) {
  100. lagElement.textContent += "?? " + Math.floor(lagSec);
  101. } else {
  102. lagElement.textContent += lagSec;
  103. }
  104. };
  105. var isSocketDisconnected = function (socket, realtime) {
  106. var sock = socket._socket;
  107. return sock.readyState === sock.CLOSING
  108. || sock.readyState === sock.CLOSED
  109. || (realtime.getLag().waiting && realtime.getLag().lag > MAX_LAG_BEFORE_DISCONNECT);
  110. };
  111. var updateUserList = function (myUserName, listElement, userList) {
  112. var meIdx = userList.indexOf(myUserName);
  113. if (meIdx === -1) {
  114. listElement.textContent = Messages.synchronizing;
  115. return;
  116. }
  117. if (userList.length === 1) {
  118. listElement.textContent = Messages.editingAlone;
  119. } else if (userList.length === 2) {
  120. listElement.textContent = Messages.editingWithOneOtherPerson;
  121. } else {
  122. listElement.textContent = Messages.editingWith + ' ' + (userList.length - 1) +
  123. Messages.otherPeople;
  124. }
  125. };
  126. var createUserList = function (container) {
  127. var id = uid();
  128. $(container).prepend('<div class="' + USER_LIST_CLS + '" id="'+id+'"></div>');
  129. return $('#'+id)[0];
  130. };
  131. var abort = function (socket, realtime) {
  132. realtime.abort();
  133. try { socket._socket.close(); } catch (e) { }
  134. $('.'+USER_LIST_CLS).text(Messages.disconnected);
  135. $('.'+LAG_ELEM_CLS).text("");
  136. };
  137. var createDebugInfo = function (cause, realtime, docHTML, allMessages) {
  138. return JSON.stringify({
  139. cause: cause,
  140. realtimeUserDoc: realtime.getUserDoc(),
  141. realtimeAuthDoc: realtime.getAuthDoc(),
  142. docHTML: docHTML,
  143. allMessages: allMessages,
  144. });
  145. };
  146. var handleError = function (socket, realtime, err, docHTML, allMessages) {
  147. var internalError = createDebugInfo(err, realtime, docHTML, allMessages);
  148. abort(socket, realtime);
  149. ErrorBox.show('error', docHTML, internalError);
  150. };
  151. var getDocHTML = function (doc) {
  152. return doc.body.innerHTML;
  153. };
  154. var makeHTMLOperation = function (oldval, newval) {
  155. try {
  156. var op = Otaml.makeHTMLOperation(oldval, newval);
  157. if (PARANOIA && op) {
  158. // simulate running the patch.
  159. var res = HTMLPatcher.patchString(oldval, op.offset, op.toRemove, op.toInsert);
  160. if (res !== newval) {
  161. console.log(op);
  162. console.log(oldval);
  163. console.log(newval);
  164. console.log(res);
  165. throw new Error();
  166. }
  167. // check matching bracket count
  168. // TODO(cjd): this can fail even if the patch is valid because of brackets in
  169. // html attributes.
  170. var removeText = oldval.substring(op.offset, op.offset + op.toRemove);
  171. if (((removeText).match(/</g) || []).length !==
  172. ((removeText).match(/>/g) || []).length)
  173. {
  174. throw new Error();
  175. }
  176. if (((op.toInsert).match(/</g) || []).length !==
  177. ((op.toInsert).match(/>/g) || []).length)
  178. {
  179. throw new Error();
  180. }
  181. }
  182. return op;
  183. } catch (e) {
  184. if (PARANOIA) {
  185. $(document.body).append('<textarea id="makeOperationErr"></textarea>');
  186. $('#makeOperationErr').val(oldval + '\n\n\n\n\n\n\n\n\n\n' + newval);
  187. console.log(e.stack);
  188. }
  189. return {
  190. offset: 0,
  191. toRemove: oldval.length,
  192. toInsert: newval
  193. };
  194. }
  195. };
  196. // chrome sometimes generates invalid html but it corrects it the next time around.
  197. var fixChrome = function (docText, doc, contentWindow) {
  198. for (var i = 0; i < 10; i++) {
  199. var docElem = doc.createElement('div');
  200. docElem.innerHTML = docText;
  201. var newDocText = docElem.innerHTML;
  202. var fixChromeOp = makeHTMLOperation(docText, newDocText);
  203. if (!fixChromeOp) { return docText; }
  204. HTMLPatcher.applyOp(docText,
  205. fixChromeOp,
  206. doc.body,
  207. Rangy,
  208. contentWindow);
  209. docText = getDocHTML(doc);
  210. if (newDocText === docText) { return docText; }
  211. }
  212. throw new Error();
  213. };
  214. var fixSafari_STATE_OUTSIDE = 0;
  215. var fixSafari_STATE_IN_TAG = 1;
  216. var fixSafari_STATE_IN_ATTR = 2;
  217. var fixSafari_HTML_ENTITIES_REGEX = /('|"|<|>|&lt;|&gt;)/g;
  218. var fixSafari = function (html) {
  219. var state = fixSafari_STATE_OUTSIDE;
  220. return html.replace(fixSafari_HTML_ENTITIES_REGEX, function (x) {
  221. switch (state) {
  222. case fixSafari_STATE_OUTSIDE: {
  223. if (x === '<') { state = fixSafari_STATE_IN_TAG; }
  224. return x;
  225. }
  226. case fixSafari_STATE_IN_TAG: {
  227. switch (x) {
  228. case '"': state = fixSafari_STATE_IN_ATTR; break;
  229. case '>': state = fixSafari_STATE_OUTSIDE; break;
  230. case "'": throw new Error("single quoted attribute");
  231. }
  232. return x;
  233. }
  234. case fixSafari_STATE_IN_ATTR: {
  235. switch (x) {
  236. case '&lt;': return '<';
  237. case '&gt;': return '>';
  238. case '"': state = fixSafari_STATE_IN_TAG; break;
  239. }
  240. return x;
  241. }
  242. };
  243. throw new Error();
  244. });
  245. };
  246. var getFixedDocText = function (doc, ifrWindow) {
  247. var docText = getDocHTML(doc);
  248. docText = fixChrome(docText, doc, ifrWindow);
  249. docText = fixSafari(docText);
  250. return docText;
  251. };
  252. var uid = function () {
  253. return 'rtwysiwyg-uid-' + String(Math.random()).substring(2);
  254. };
  255. var checkLag = function (realtime, lagElement) {
  256. var lag = realtime.getLag();
  257. var lagSec = lag.lag/1000;
  258. var lagMsg = Messages.lag + ' ';
  259. if (lag.waiting && lagSec > 1) {
  260. lagMsg += "?? " + Math.floor(lagSec);
  261. } else {
  262. lagMsg += lagSec;
  263. }
  264. lagElement.textContent = lagMsg;
  265. };
  266. var createLagElement = function (container) {
  267. var id = uid();
  268. $(container).append('<div class="' + LAG_ELEM_CLS + '" id="'+id+'"></div>');
  269. return $('#'+id)[0];
  270. };
  271. var createSpinner = function (container) {
  272. var id = uid();
  273. $(container).append('<div class="rtwysiwyg-spinner" id="'+id+'"></div>');
  274. return $('#'+id)[0];
  275. };
  276. var createRealtimeToolbar = function (container) {
  277. var id = uid();
  278. $(container).prepend(
  279. '<div class="' + TOOLBAR_CLS + '" id="' + id + '">' +
  280. '<div class="rtwysiwyg-toolbar-leftside"></div>' +
  281. '<div class="rtwysiwyg-toolbar-rightside"></div>' +
  282. '</div>'
  283. );
  284. var toolbar = $('#'+id);
  285. toolbar.append([
  286. '<style>',
  287. '.' + TOOLBAR_CLS + ' {',
  288. ' color: #666;',
  289. ' font-weight: bold;',
  290. // ' background-color: #f0f0ee;',
  291. // ' border-bottom: 1px solid #DDD;',
  292. // ' border-top: 3px solid #CCC;',
  293. // ' border-right: 2px solid #CCC;',
  294. // ' border-left: 2px solid #CCC;',
  295. ' height: 26px;',
  296. ' margin-bottom: -3px;',
  297. ' display: inline-block;',
  298. ' width: 100%;',
  299. '}',
  300. '.' + TOOLBAR_CLS + ' div {',
  301. ' padding: 0 10px;',
  302. ' height: 1.5em;',
  303. // ' background: #f0f0ee;',
  304. ' line-height: 25px;',
  305. ' height: 22px;',
  306. '}',
  307. '.rtwysiwyg-toolbar-leftside {',
  308. ' float: left;',
  309. '}',
  310. '.rtwysiwyg-toolbar-rightside {',
  311. ' float: right;',
  312. '}',
  313. '.rtwysiwyg-lag {',
  314. ' float: right;',
  315. '}',
  316. '.rtwysiwyg-spinner {',
  317. ' float: left;',
  318. '}',
  319. '.gwt-TabBar {',
  320. ' display:none;',
  321. '}',
  322. '.' + DEBUG_LINK_CLS + ':link { color:transparent; }',
  323. '.' + DEBUG_LINK_CLS + ':link:hover { color:blue; }',
  324. '.gwt-TabPanelBottom { border-top: 0 none; }',
  325. '</style>'
  326. ].join('\n'));
  327. return toolbar;
  328. };
  329. var makeWebsocket = function (url) {
  330. var socket = new ReconnectingWebSocket(url);
  331. var out = {
  332. onOpen: [],
  333. onClose: [],
  334. onError: [],
  335. onMessage: [],
  336. send: function (msg) { socket.send(msg); },
  337. close: function () { socket.close(); },
  338. _socket: socket
  339. };
  340. var mkHandler = function (name) {
  341. return function (evt) {
  342. for (var i = 0; i < out[name].length; i++) {
  343. if (out[name][i](evt) === false) { return; }
  344. }
  345. };
  346. };
  347. socket.onopen = mkHandler('onOpen');
  348. socket.onclose = mkHandler('onClose');
  349. socket.onerror = mkHandler('onError');
  350. socket.onmessage = mkHandler('onMessage');
  351. return out;
  352. };
  353. var encryptStr = function (str, key) {
  354. var array = Nacl.util.decodeUTF8(str);
  355. var nonce = Nacl.randomBytes(24);
  356. var packed = Nacl.secretbox(array, nonce, key);
  357. if (!packed) { throw new Error(); }
  358. return Nacl.util.encodeBase64(nonce) + "|" + Nacl.util.encodeBase64(packed);
  359. };
  360. var decryptStr = function (str, key) {
  361. var arr = str.split('|');
  362. if (arr.length !== 2) { throw new Error(); }
  363. var nonce = Nacl.util.decodeBase64(arr[0]);
  364. var packed = Nacl.util.decodeBase64(arr[1]);
  365. var unpacked = Nacl.secretbox.open(packed, nonce, key);
  366. if (!unpacked) { throw new Error(); }
  367. return Nacl.util.encodeUTF8(unpacked);
  368. };
  369. // this is crap because of bencoding messages... it should go away....
  370. var splitMessage = function (msg, sending) {
  371. var idx = 0;
  372. var nl;
  373. for (var i = ((sending) ? 0 : 1); i < 3; i++) {
  374. nl = msg.indexOf(':',idx);
  375. idx = nl + Number(msg.substring(idx,nl)) + 1;
  376. }
  377. return [ msg.substring(0,idx), msg.substring(msg.indexOf(':',idx) + 1) ];
  378. };
  379. var encrypt = function (msg, key) {
  380. var spl = splitMessage(msg, true);
  381. var json = JSON.parse(spl[1]);
  382. // non-patches are not encrypted.
  383. if (json[0] !== 2) { return msg; }
  384. json[1] = encryptStr(JSON.stringify(json[1]), key);
  385. var res = JSON.stringify(json);
  386. return spl[0] + res.length + ':' + res;
  387. };
  388. var decrypt = function (msg, key) {
  389. var spl = splitMessage(msg, false);
  390. var json = JSON.parse(spl[1]);
  391. // non-patches are not encrypted.
  392. if (json[0] !== 2) { return msg; }
  393. if (typeof(json[1]) !== 'string') { throw new Error(); }
  394. json[1] = JSON.parse(decryptStr(json[1], key));
  395. var res = JSON.stringify(json);
  396. return spl[0] + res.length + ':' + res;
  397. };
  398. var start = module.exports.start = function (websocketUrl, userName, channel, cryptKey)
  399. {
  400. var passwd = 'y';
  401. var wysiwygDiv = document.getElementById('cke_1_contents');
  402. var ifr = wysiwygDiv.getElementsByTagName('iframe')[0];
  403. var doc = ifr.contentWindow.document;
  404. var socket = makeWebsocket(websocketUrl);
  405. var onEvent = function () { };
  406. var toolbar = createRealtimeToolbar('#cke_1_toolbox');
  407. var allMessages = [];
  408. var isErrorState = false;
  409. var initializing = true;
  410. var recoverableErrorCount = 0;
  411. var error = function (recoverable, err) {
  412. console.log(new Error().stack);
  413. console.log('error: ' + err.stack);
  414. if (recoverable && recoverableErrorCount++ < MAX_RECOVERABLE_ERRORS) { return; }
  415. var realtime = socket.realtime;
  416. var docHtml = getDocHTML(doc);
  417. isErrorState = true;
  418. handleError(socket, realtime, err, docHtml, allMessages);
  419. };
  420. var attempt = function (func) {
  421. return function () {
  422. var e;
  423. try { return func.apply(func, arguments); } catch (ee) { e = ee; }
  424. if (e) {
  425. console.log(e.stack);
  426. error(true, e);
  427. }
  428. };
  429. };
  430. var checkSocket = function () {
  431. if (isSocketDisconnected(socket, socket.realtime) && !socket.intentionallyClosing) {
  432. //isErrorState = true;
  433. //abort(socket, socket.realtime);
  434. //ErrorBox.show('disconnected', getDocHTML(doc));
  435. return true;
  436. }
  437. return false;
  438. };
  439. socket.onOpen.push(function (evt) {
  440. if (!initializing) {
  441. socket.realtime.start();
  442. return;
  443. }
  444. var realtime = socket.realtime =
  445. ChainPad.create(userName,
  446. passwd,
  447. channel,
  448. getDocHTML(doc),
  449. { transformFunction: Otaml.transform });
  450. //createDebugLink(realtime, doc, allMessages, toolbar);
  451. var userListElement = createUserList(toolbar.find('.rtwysiwyg-toolbar-leftside'));
  452. var spinner = createSpinner(toolbar.find('.rtwysiwyg-toolbar-rightside'));
  453. var lagElement = createLagElement(toolbar.find('.rtwysiwyg-toolbar-rightside'));
  454. setInterval(function () {
  455. if (initializing || isSocketDisconnected(socket, realtime)) { return; }
  456. checkLag(realtime, lagElement);
  457. }, 3000);
  458. onEvent = function () {
  459. if (isErrorState) { return; }
  460. if (initializing) { return; }
  461. var oldDocText = realtime.getUserDoc();
  462. var docText = getFixedDocText(doc, ifr.contentWindow);
  463. var op = attempt(Otaml.makeTextOperation)(oldDocText, docText);
  464. if (!op) { return; }
  465. if (op.toRemove > 0) {
  466. attempt(realtime.remove)(op.offset, op.toRemove);
  467. }
  468. if (op.toInsert.length > 0) {
  469. attempt(realtime.insert)(op.offset, op.toInsert);
  470. }
  471. if (realtime.getUserDoc() !== docText) {
  472. error(false, 'realtime.getUserDoc() !== docText');
  473. }
  474. };
  475. var userDocBeforePatch;
  476. var incomingPatch = function () {
  477. if (isErrorState) { return; }
  478. kickSpinner(spinner);
  479. if (initializing) { return; }
  480. userDocBeforePatch = userDocBeforePatch || getFixedDocText(doc, ifr.contentWindow);
  481. if (PARANOIA && userDocBeforePatch != getFixedDocText(doc, ifr.contentWindow)) {
  482. error(false, "userDocBeforePatch != getFixedDocText(doc, ifr.contentWindow)");
  483. }
  484. var op = attempt(makeHTMLOperation)(userDocBeforePatch, realtime.getUserDoc());
  485. if (!op) { return; }
  486. attempt(HTMLPatcher.applyOp)(
  487. userDocBeforePatch, op, doc.body, rangy, ifr.contentWindow);
  488. };
  489. realtime.onUserListChange(function (userList) {
  490. updateUserList(userName, userListElement, userList);
  491. if (!initializing || userList.indexOf(userName) === -1) { return; }
  492. // if we spot ourselves being added to the document, we'll switch
  493. // 'initializing' off because it means we're fully synced.
  494. initializing = false;
  495. incomingPatch();
  496. });
  497. socket.onMessage.push(function (evt) {
  498. if (isErrorState) { return; }
  499. var message = decrypt(evt.data, cryptKey);
  500. allMessages.push(message);
  501. if (!initializing) {
  502. if (PARANOIA) { onEvent(); }
  503. userDocBeforePatch = realtime.getUserDoc();
  504. }
  505. realtime.message(message);
  506. });
  507. realtime.onMessage(function (message) {
  508. if (isErrorState) { return; }
  509. message = encrypt(message, cryptKey);
  510. try {
  511. socket.send(message);
  512. } catch (e) {
  513. error(true, e.stack);
  514. }
  515. });
  516. realtime.onPatch(incomingPatch);
  517. bindAllEvents(wysiwygDiv, doc.body, onEvent, false);
  518. setInterval(function () {
  519. if (isErrorState || checkSocket()) {
  520. userListElement.textContent = Messages.reconnecting;
  521. lagElement.textContent = '';
  522. }
  523. }, 200);
  524. realtime.start();
  525. //console.log('started');
  526. });
  527. return {
  528. onEvent: function () { onEvent(); }
  529. };
  530. };
  531. return module.exports;
  532. });