commit
1508c7ba71
16 changed files with 7627 additions and 0 deletions
Split View
Diff Options
-
3.bowerrc
-
2.gitignore
-
189ChainPadSrv.js
-
55Storage.js
-
BINand_so_it_begins.png
-
26bower.json
-
9package.json
-
17readme.md
-
24server.js
-
1434www/chainpad.js
-
483www/html-patcher.js
-
16www/index.html
-
52www/main.js
-
1003www/otaml.js
-
3738www/rangy.js
-
576www/realtime-wysiwyg.js
@ -0,0 +1,3 @@ |
|||
{ |
|||
"directory" : "www/bower" |
|||
} |
|||
@ -0,0 +1,2 @@ |
|||
www/bower/* |
|||
node_modules |
|||
@ -0,0 +1,189 @@ |
|||
/* |
|||
* Copyright 2014 XWiki SAS |
|||
* |
|||
* This program is free software: you can redistribute it and/or modify |
|||
* it under the terms of the GNU Affero General Public License as published by |
|||
* the Free Software Foundation, either version 3 of the License, or |
|||
* (at your option) any later version. |
|||
* |
|||
* This program is distributed in the hope that it will be useful, |
|||
* but WITHOUT ANY WARRANTY; without even the implied warranty of |
|||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|||
* GNU Affero General Public License for more details. |
|||
* |
|||
* You should have received a copy of the GNU Affero General Public License |
|||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|||
*/ |
|||
var WebSocket = require('ws'); |
|||
|
|||
var REGISTER = 0; |
|||
var REGISTER_ACK = 1; |
|||
var PATCH = 2; |
|||
var DISCONNECT = 3; |
|||
var PING = 4; |
|||
var PONG = 5; |
|||
|
|||
var parseMessage = function (msg) { |
|||
var passLen = msg.substring(0,msg.indexOf(':')); |
|||
msg = msg.substring(passLen.length+1); |
|||
var pass = msg.substring(0,Number(passLen)); |
|||
msg = msg.substring(pass.length); |
|||
|
|||
var unameLen = msg.substring(0,msg.indexOf(':')); |
|||
msg = msg.substring(unameLen.length+1); |
|||
var userName = msg.substring(0,Number(unameLen)); |
|||
msg = msg.substring(userName.length); |
|||
|
|||
var channelIdLen = msg.substring(0,msg.indexOf(':')); |
|||
msg = msg.substring(channelIdLen.length+1); |
|||
var channelId = msg.substring(0,Number(channelIdLen)); |
|||
msg = msg.substring(channelId.length); |
|||
|
|||
var contentStrLen = msg.substring(0,msg.indexOf(':')); |
|||
msg = msg.substring(contentStrLen.length+1); |
|||
var contentStr = msg.substring(0,Number(contentStrLen)); |
|||
|
|||
return { |
|||
user: userName, |
|||
pass: pass, |
|||
channelId: channelId, |
|||
content: JSON.parse(contentStr) |
|||
}; |
|||
}; |
|||
|
|||
// get the password off the message before sending it to other clients.
|
|||
var popPassword = function (msg) { |
|||
var passLen = msg.substring(0,msg.indexOf(':')); |
|||
return msg.substring(passLen.length+1 + Number(passLen)); |
|||
}; |
|||
|
|||
var sendMsg = function (msg, socket) { |
|||
socket.send(msg); |
|||
}; |
|||
|
|||
var sendChannelMessage = function (ctx, channel, msg, cb) { |
|||
ctx.store.message(channel.name, msg, function () { |
|||
channel.forEach(function (user) { |
|||
try { |
|||
sendMsg(msg, user.socket); |
|||
} catch (e) { |
|||
console.log(e.stack); |
|||
dropClient(ctx, userPass); |
|||
} |
|||
}); |
|||
cb && cb(); |
|||
}); |
|||
}; |
|||
|
|||
var mkMessage = function (user, channel, content) { |
|||
content = JSON.stringify(content); |
|||
return user.length + ':' + user + |
|||
channel.length + ':' + channel + |
|||
content.length + ':' + content; |
|||
}; |
|||
|
|||
var dropClient = function (ctx, userpass) { |
|||
var client = ctx.registeredClients[userpass]; |
|||
if (client.socket.readyState !== WebSocket.CLOSING |
|||
&& client.socket.readyState !== WebSocket.CLOSED) |
|||
{ |
|||
try { |
|||
client.socket.close(); |
|||
} catch (e) { |
|||
console.log("Failed to disconnect ["+client.userName+"], attempting to terminate"); |
|||
try { |
|||
client.socket.terminate(); |
|||
} catch (ee) { |
|||
console.log("Failed to terminate ["+client.userName+"] *shrug*"); |
|||
} |
|||
} |
|||
} |
|||
|
|||
for (var i = 0; i < client.channels.length; i++) { |
|||
var chanName = client.channels[i]; |
|||
var chan = ctx.channels[chanName]; |
|||
var idx = chan.indexOf(client); |
|||
if (idx < 0) { throw new Error(); } |
|||
console.log("Removing ["+client.userName+"] from channel ["+chanName+"]"); |
|||
chan.splice(idx, 1); |
|||
if (chan.length === 0) { |
|||
console.log("Removing empty channel ["+chanName+"]"); |
|||
delete ctx.channels[chanName]; |
|||
} else { |
|||
sendChannelMessage(ctx, chan, mkMessage(client.userName, chanName, [DISCONNECT,0])); |
|||
} |
|||
} |
|||
delete ctx.registeredClients[userpass]; |
|||
}; |
|||
|
|||
var handleMessage = function (ctx, socket, msg) { |
|||
var parsed = parseMessage(msg); |
|||
var userPass = parsed.user + ':' + parsed.pass; |
|||
msg = popPassword(msg); |
|||
|
|||
if (parsed.content[0] === REGISTER) { |
|||
console.log("[" + userPass + "] registered"); |
|||
var client = ctx.registeredClients[userPass] = ctx.registeredClients[userPass] || { |
|||
channels: [parsed.channelId], |
|||
userName: parsed.user |
|||
}; |
|||
if (client.socket && client.socket !== socket) { client.socket.close(); } |
|||
client.socket = socket; |
|||
|
|||
var chan = ctx.channels[parsed.channelId] = ctx.channels[parsed.channelId] || []; |
|||
chan.name = parsed.channelId; |
|||
chan.push(client); |
|||
|
|||
// we send a register ack right away but then we fallthrough
|
|||
// to let other users know that we were registered.
|
|||
sendMsg(mkMessage('', parsed.channelId, [1,0]), socket); |
|||
sendChannelMessage(ctx, chan, msg, function () { |
|||
ctx.store.getMessages(chan.name, function (msg) { |
|||
sendMsg(msg, socket); |
|||
}); |
|||
}); |
|||
} |
|||
|
|||
if (parsed.content[0] === PING) { |
|||
// 31:xwiki:XWiki.Admin-141475016907510:RWJ5xF2+SL17:[5,1414752676547]
|
|||
// 1:y31:xwiki:XWiki.Admin-141475016907510:RWJ5xF2+SL17:[4,1414752676547]
|
|||
sendMsg(mkMessage(parsed.user, parsed.channelId, [ PONG, parsed.content[1] ]), socket); |
|||
return; |
|||
} |
|||
|
|||
var client = ctx.registeredClients[userPass]; |
|||
if (typeof(client) === 'undefined') { throw new Error('unregistered'); } |
|||
|
|||
var channel = ctx.channels[parsed.channelId]; |
|||
if (typeof(channel) === 'undefined') { throw new Error('no such channel'); } |
|||
|
|||
if (channel.indexOf(client) === -1) { throw new Error('client not in channel'); } |
|||
|
|||
sendChannelMessage(ctx, channel, msg); |
|||
}; |
|||
|
|||
var create = module.exports.create = function (socketServer, store) { |
|||
var ctx = { |
|||
registeredClients: {}, |
|||
channels: {}, |
|||
store: store |
|||
}; |
|||
|
|||
socketServer.on('connection', function(socket) { |
|||
socket.on('message', function(message) { |
|||
try { |
|||
handleMessage(ctx, socket, message); |
|||
} catch (e) { |
|||
console.log(e.stack); |
|||
socket.close(); |
|||
} |
|||
}); |
|||
socket.on('close', function (evt) { |
|||
for (client in ctx.registeredClients) { |
|||
if (ctx.registeredClients[client].socket === socket) { |
|||
dropClient(ctx, client); |
|||
} |
|||
} |
|||
}); |
|||
}); |
|||
}; |
|||
@ -0,0 +1,55 @@ |
|||
/* |
|||
* Copyright 2014 XWiki SAS |
|||
* |
|||
* This program is free software: you can redistribute it and/or modify |
|||
* it under the terms of the GNU Affero General Public License as published by |
|||
* the Free Software Foundation, either version 3 of the License, or |
|||
* (at your option) any later version. |
|||
* |
|||
* This program is distributed in the hope that it will be useful, |
|||
* but WITHOUT ANY WARRANTY; without even the implied warranty of |
|||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|||
* GNU Affero General Public License for more details. |
|||
* |
|||
* You should have received a copy of the GNU Affero General Public License |
|||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|||
*/ |
|||
var MongoClient = require('mongodb').MongoClient; |
|||
|
|||
var MONGO_URI = "mongodb://demo_user:demo_password@ds027769.mongolab.com:27769/demo_database"; |
|||
var COLLECTION_NAME = 'cryptpad'; |
|||
|
|||
var insert = function (coll, channelName, content, cb) { |
|||
var val = {chan: channelName, msg:content, time: (new Date()).getTime()}; |
|||
coll.insertOne(val, {}, function (err, r) { |
|||
if (err || (r.insertedCount !== 1)) { |
|||
console.log('failed to insert ' + err); |
|||
return; |
|||
} |
|||
cb(); |
|||
}); |
|||
}; |
|||
|
|||
var getMessages = function (coll, channelName, cb) { |
|||
coll.find({chan:channelName}).forEach(function (doc) { |
|||
cb(doc.msg); |
|||
}, function (err) { |
|||
if (!err) { return; } |
|||
console.log('error ' + err); |
|||
}); |
|||
}; |
|||
|
|||
module.exports.create = function (conf, cb) { |
|||
MongoClient.connect(conf.mongoUri, function(err, db) { |
|||
var coll = db.collection(conf.mongoCollectionName); |
|||
if (err) { throw err; } |
|||
cb({ |
|||
message: function (channelName, content, cb) { |
|||
insert(coll, channelName, content, cb); |
|||
}, |
|||
getMessages: function (channelName, msgHandler) { |
|||
getMessages(coll, channelName, msgHandler); |
|||
} |
|||
}); |
|||
}); |
|||
}; |
|||
@ -0,0 +1,26 @@ |
|||
{ |
|||
"name": "cryptpad", |
|||
"version": "0.1.0", |
|||
"authors": [ |
|||
"Caleb James DeLisle <cjd@cjdns.fr>" |
|||
], |
|||
"description": "realtime collaborative visual editor with zero knowlege server", |
|||
"main": "www/index.html", |
|||
"moduleType": [ |
|||
"node" |
|||
], |
|||
"license": "AGPLv3", |
|||
"ignore": [ |
|||
"**/.*", |
|||
"node_modules", |
|||
"bower_components", |
|||
"test", |
|||
"tests" |
|||
], |
|||
"dependencies": { |
|||
"jquery": "~2.1.1", |
|||
"tweetnacl": "~0.12.2", |
|||
"ckeditor": "~4.4.5", |
|||
"requirejs": "~2.1.15" |
|||
} |
|||
} |
|||
@ -0,0 +1,9 @@ |
|||
{ |
|||
"name": "cryptpad", |
|||
"description": "realtime collaborative visual editor with zero knowlege server", |
|||
"dependencies": { |
|||
"express": "~4.10.1", |
|||
"ws": "~0.4.32", |
|||
"mongodb": "~2.0.5" |
|||
} |
|||
} |
|||
@ -0,0 +1,17 @@ |
|||
# CryptPad |
|||
|
|||
Unity is Strength - Collaboration is Key |
|||
|
|||
 |
|||
|
|||
CryptPad is a **zero knowledge** realtime collaborative editor. |
|||
Encryption carried out in your web browser protects the data from the server, the cloud |
|||
and the NSA. This project uses the [CKEdit] Visual Editor and the [ChainPad] realtime |
|||
engine. The secret key is stored in the URL [fragment identifier] which is never sent to |
|||
the server but is available to javascript so by sharing the URL, you give authorization |
|||
|
|||
|
|||
Realtime Collaboration with |
|||
|
|||
|
|||
[fragment identifier]: http://en.wikipedia.org/wiki/Fragment_identifier |
|||
@ -0,0 +1,24 @@ |
|||
var Express = require('express'); |
|||
var Http = require('http'); |
|||
var WebSocketServer = require('ws').Server; |
|||
var ChainPadSrv = require('./ChainPadSrv'); |
|||
var Storage = require('./Storage'); |
|||
|
|||
var config = { |
|||
httpPort: 3000, |
|||
mongoUri: "mongodb://demo_user:demo_password@ds027769.mongolab.com:27769/demo_database", |
|||
mongoCollectionName: 'cryptpad' |
|||
}; |
|||
|
|||
var app = Express(); |
|||
app.use(Express.static(__dirname + '/www')); |
|||
|
|||
var httpServer = Http.createServer(app); |
|||
httpServer.listen(config.httpPort); |
|||
console.log('listening on port ' + config.httpPort); |
|||
|
|||
var wsSrv = new WebSocketServer({server: httpServer}); |
|||
Storage.create(config, function (store) { |
|||
console.log('DB connected'); |
|||
ChainPadSrv.create(wsSrv, store); |
|||
}); |
|||
1434
www/chainpad.js
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
@ -0,0 +1,483 @@ |
|||
/* |
|||
* Copyright 2014 XWiki SAS |
|||
* |
|||
* This program is free software: you can redistribute it and/or modify |
|||
* it under the terms of the GNU Affero General Public License as published by |
|||
* the Free Software Foundation, either version 3 of the License, or |
|||
* (at your option) any later version. |
|||
* |
|||
* This program is distributed in the hope that it will be useful, |
|||
* but WITHOUT ANY WARRANTY; without even the implied warranty of |
|||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|||
* GNU Affero General Public License for more details. |
|||
* |
|||
* You should have received a copy of the GNU Affero General Public License |
|||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|||
*/ |
|||
define([ |
|||
'bower/jquery/dist/jquery.min', |
|||
'otaml' |
|||
], function () { |
|||
|
|||
var $ = jQuery; |
|||
var Otaml = window.Otaml; |
|||
var module = { exports: {} }; |
|||
var PARANOIA = true; |
|||
|
|||
var debug = function (x) { }; |
|||
debug = function (x) { console.log(x); }; |
|||
|
|||
var getNextSiblingDeep = function (node, parent) |
|||
{ |
|||
if (node.firstChild) { return node.firstChild; } |
|||
do { |
|||
if (node.nextSibling) { return node.nextSibling; } |
|||
node = node.parentNode; |
|||
} while (node && node !== parent); |
|||
}; |
|||
|
|||
var getOuterHTML = function (node) |
|||
{ |
|||
var html = node.outerHTML; |
|||
if (html) { return html; } |
|||
if (node.parentNode && node.parentNode.childNodes.length === 1) { |
|||
return node.parentNode.innerHTML; |
|||
} |
|||
var div = document.createElement('div'); |
|||
div.appendChild(node.cloneNode(true)); |
|||
return div.innerHTML; |
|||
}; |
|||
|
|||
var nodeFromHTML = function (html) |
|||
{ |
|||
var e = document.createElement('div'); |
|||
e.innerHTML = html; |
|||
return e.childNodes[0]; |
|||
}; |
|||
|
|||
var getInnerHTML = function (node) |
|||
{ |
|||
var html = node.innerHTML; |
|||
if (html) { return html; } |
|||
var outerHTML = getOuterHTML(node); |
|||
var tw = Otaml.tagWidth(outerHTML); |
|||
if (!tw) { return outerHTML; } |
|||
return outerHTML.substring(tw, outerHTML.lastIndexOf('</')); |
|||
}; |
|||
|
|||
var uniqueId = function () { return 'uid-'+(''+Math.random()).slice(2); }; |
|||
|
|||
var offsetOfNodeOuterHTML = function (docText, node, dom, ifrWindow) |
|||
{ |
|||
if (PARANOIA && getInnerHTML(dom) !== docText) { throw new Error(); } |
|||
if (PARANOIA && !node) { throw new Error(); } |
|||
|
|||
// can't get the index of the outerHTML of the dom in a string with only the innerHTML.
|
|||
if (node === dom) { throw new Error(); } |
|||
|
|||
var content = getOuterHTML(node); |
|||
var idx = docText.lastIndexOf(content); |
|||
if (idx === -1) { throw new Error(); } |
|||
|
|||
if (idx !== docText.indexOf(content)) { |
|||
var idTag = uniqueId(); |
|||
var span = ifrWindow.document.createElement('span'); |
|||
span.setAttribute('id', idTag); |
|||
var spanHTML = '<span id="'+idTag+'"></span>'; |
|||
if (PARANOIA && spanHTML !== span.outerHTML) { throw new Error(); } |
|||
|
|||
node.parentNode.insertBefore(span, node); |
|||
var newDocText = getInnerHTML(dom); |
|||
idx = newDocText.lastIndexOf(spanHTML); |
|||
if (idx === -1 || idx !== newDocText.indexOf(spanHTML)) { throw new Error(); } |
|||
node.parentNode.removeChild(span); |
|||
|
|||
if (PARANOIA && getInnerHTML(dom) !== docText) { throw new Error(); } |
|||
} |
|||
|
|||
if (PARANOIA && docText.indexOf(content, idx) !== idx) { throw new Error() } |
|||
return idx; |
|||
}; |
|||
|
|||
var patchString = module.exports.patchString = function (oldString, offset, toRemove, toInsert) |
|||
{ |
|||
return oldString.substring(0, offset) + toInsert + oldString.substring(offset + toRemove); |
|||
}; |
|||
|
|||
var getNodeAtOffset = function (docText, offset, dom) |
|||
{ |
|||
if (PARANOIA && dom.childNodes.length && docText !== dom.innerHTML) { throw new Error(); } |
|||
if (offset < 0) { throw new Error(); } |
|||
|
|||
var idx = 0; |
|||
for (var i = 0; i < dom.childNodes.length; i++) { |
|||
var childOuterHTML = getOuterHTML(dom.childNodes[i]); |
|||
if (PARANOIA && docText.indexOf(childOuterHTML, idx) !== idx) { throw new Error(); } |
|||
if (i === 0 && idx >= offset) { |
|||
return { node: dom, pos: 0 }; |
|||
} |
|||
if (idx + childOuterHTML.length > offset) { |
|||
var childInnerHTML = childOuterHTML; |
|||
var tw = Otaml.tagWidth(childOuterHTML); |
|||
if (tw) { |
|||
childInnerHTML = childOuterHTML.substring(tw, childOuterHTML.lastIndexOf('</')); |
|||
} |
|||
if (offset - idx - tw < 0) { |
|||
if (offset - idx === 0) { |
|||
return { node: dom.childNodes[i], pos: 0 }; |
|||
} |
|||
break; |
|||
} |
|||
return getNodeAtOffset(childInnerHTML, offset - idx - tw, dom.childNodes[i]); |
|||
} |
|||
idx += childOuterHTML.length; |
|||
} |
|||
|
|||
if (dom.nodeName[0] === '#text') { |
|||
if (offset > docText.length) { throw new Error(); } |
|||
var beforeOffset = docText.substring(0, offset); |
|||
if (beforeOffset.indexOf('&') > -1) { |
|||
var tn = nodeFromHTML(beforeOffset); |
|||
offset = tn.data.length; |
|||
} |
|||
} else { |
|||
offset = 0; |
|||
} |
|||
|
|||
return { node: dom, pos: offset }; |
|||
}; |
|||
|
|||
var relocatedPositionInNode = function (newNode, oldNode, offset) |
|||
{ |
|||
if (newNode.nodeName !== '#text' || oldNode.nodeName !== '#text' || offset === 0) { |
|||
offset = 0; |
|||
} else if (oldNode.data === newNode.data) { |
|||
// fallthrough
|
|||
} else if (offset > newNode.length) { |
|||
offset = newNode.length; |
|||
} else if (oldNode.data.substring(0, offset) === newNode.data.substring(0, offset)) { |
|||
// keep same offset and fall through
|
|||
} else { |
|||
var rOffset = oldNode.length - offset; |
|||
if (oldNode.data.substring(offset) === |
|||
newNode.data.substring(newNode.length - rOffset)) |
|||
{ |
|||
offset = newNode.length - rOffset; |
|||
} else { |
|||
offset = 0; |
|||
} |
|||
} |
|||
return { node: newNode, pos: offset }; |
|||
}; |
|||
|
|||
var pushNode = function (list, node) { |
|||
if (node.nodeName === '#text') { |
|||
list.push.apply(list, node.data.split('')); |
|||
} else { |
|||
list.push('#' + node.nodeName); |
|||
} |
|||
}; |
|||
|
|||
var getChildPath = function (parent) { |
|||
var out = []; |
|||
for (var next = parent; next; next = getNextSiblingDeep(next, parent)) { |
|||
pushNode(out, next); |
|||
} |
|||
return out; |
|||
}; |
|||
|
|||
var tryFromBeginning = function (oldPath, newPath) { |
|||
for (var i = 0; i < oldPath.length; i++) { |
|||
if (oldPath[i] !== newPath[i]) { return i; } |
|||
} |
|||
return oldPath.length; |
|||
}; |
|||
|
|||
var tryFromEnd = function (oldPath, newPath) { |
|||
for (var i = 1; i <= oldPath.length; i++) { |
|||
if (oldPath[oldPath.length - i] !== newPath[newPath.length - i]) { |
|||
return false; |
|||
} |
|||
} |
|||
return true; |
|||
}; |
|||
|
|||
/** |
|||
* returns 2 arrays (before and after). |
|||
* before is string representations (see nodeId()) of all nodes before the target |
|||
* node and after is representations of all nodes which follow. |
|||
*/ |
|||
var getNodePaths = function (parent, node) { |
|||
var before = []; |
|||
var next = parent; |
|||
for (; next && next !== node; next = getNextSiblingDeep(next, parent)) { |
|||
pushNode(before, next); |
|||
} |
|||
|
|||
if (next !== node) { throw new Error(); } |
|||
|
|||
var after = []; |
|||
next = getNextSiblingDeep(next, parent); |
|||
for (; next; next = getNextSiblingDeep(next, parent)) { |
|||
pushNode(after, next); |
|||
} |
|||
|
|||
return { before: before, after: after }; |
|||
}; |
|||
|
|||
var nodeAtIndex = function (parent, idx) { |
|||
var node = parent; |
|||
for (var i = 0; i < idx; i++) { |
|||
if (node.nodeName === '#text') { |
|||
if (i + node.data.length > idx) { return node; } |
|||
i += node.data.length - 1; |
|||
} |
|||
node = getNextSiblingDeep(node); |
|||
} |
|||
return node; |
|||
}; |
|||
|
|||
var getRelocatedPosition = function (newParent, oldParent, oldNode, oldOffset, origText, op) |
|||
{ |
|||
var newPath = getChildPath(newParent); |
|||
if (newPath.length === 1) { |
|||
return { node: null, pos: 0 }; |
|||
} |
|||
var oldPaths = getNodePaths(oldParent, oldNode); |
|||
|
|||
var idx = -1; |
|||
var fromBeginning = tryFromBeginning(oldPaths.before, newPath); |
|||
if (fromBeginning === oldPaths.before.length) { |
|||
idx = oldPaths.before.length; |
|||
} else if (tryFromEnd(oldPaths.after, newPath)) { |
|||
idx = (newPath.length - oldPaths.after.length - 1); |
|||
} else { |
|||
idx = fromBeginning; |
|||
var id = 'relocate-' + String(Math.random()).substring(2); |
|||
$(document.body).append('<textarea id="'+id+'"></textarea>'); |
|||
$('#'+id).val(JSON.stringify([origText, op, newPath, getChildPath(oldParent), oldPaths])); |
|||
} |
|||
|
|||
var out = nodeAtIndex(newParent, idx); |
|||
return relocatedPositionInNode(out, oldNode, oldOffset); |
|||
}; |
|||
|
|||
// We can't create a real range until the new parent is installed in the document
|
|||
// but we need the old range to be in the document so we can do comparisons
|
|||
// so create a "pseudo" range instead.
|
|||
var getRelocatedPseudoRange = function (newParent, oldParent, range, origText, op) |
|||
{ |
|||
if (!range.startContainer) { |
|||
throw new Error(); |
|||
} |
|||
if (!newParent) { throw new Error(); } |
|||
|
|||
// Copy because tinkering in the dom messes up the original range.
|
|||
var startContainer = range.startContainer; |
|||
var startOffset = range.startOffset; |
|||
var endContainer = range.endContainer; |
|||
var endOffset = range.endOffset; |
|||
|
|||
var newStart = |
|||
getRelocatedPosition(newParent, oldParent, startContainer, startOffset, origText, op); |
|||
|
|||
if (!newStart.node) { |
|||
// there is probably nothing left of the document so just clear the selection.
|
|||
endContainer = null; |
|||
} |
|||
|
|||
var newEnd = { node: newStart.node, pos: newStart.pos }; |
|||
if (endContainer) { |
|||
if (endContainer !== startContainer) { |
|||
newEnd = getRelocatedPosition(newParent, oldParent, endContainer, endOffset, origText, op); |
|||
} else if (endOffset !== startOffset) { |
|||
newEnd = { |
|||
node: newStart.node, |
|||
pos: relocatedPositionInNode(newStart.node, endContainer, endOffset).pos |
|||
}; |
|||
} else { |
|||
newEnd = { node: newStart.node, pos: newStart.pos }; |
|||
} |
|||
} |
|||
|
|||
return { start: newStart, end: newEnd }; |
|||
}; |
|||
|
|||
var replaceAllChildren = function (parent, newParent) |
|||
{ |
|||
var c; |
|||
while ((c = parent.firstChild)) { |
|||
parent.removeChild(c); |
|||
} |
|||
while ((c = newParent.firstChild)) { |
|||
newParent.removeChild(c); |
|||
parent.appendChild(c); |
|||
} |
|||
}; |
|||
|
|||
var isAncestorOf = function (maybeDecendent, maybeAncestor) { |
|||
while ((maybeDecendent = maybeDecendent.parentNode)) { |
|||
if (maybeDecendent === maybeAncestor) { return true; } |
|||
} |
|||
return false; |
|||
}; |
|||
|
|||
var getSelectedRange = function (rangy, ifrWindow, selection) { |
|||
selection = selection || rangy.getSelection(ifrWindow); |
|||
if (selection.rangeCount === 0) { |
|||
return; |
|||
} |
|||
var range = selection.getRangeAt(0); |
|||
range.backward = (selection.rangeCount === 1 && selection.isBackward()); |
|||
if (!range.startContainer) { |
|||
throw new Error(); |
|||
} |
|||
|
|||
// Occasionally, some browsers *cough* firefox *cough* will attach the range to something
|
|||
// which has been used in the past but is nolonger part of the dom...
|
|||
if (range.startContainer && |
|||
isAncestorOf(range.startContainer, ifrWindow.document)) |
|||
{ |
|||
return range; |
|||
} |
|||
|
|||
return; |
|||
}; |
|||
|
|||
var applyHTMLOp = function (docText, op, dom, rangy, ifrWindow) |
|||
{ |
|||
var parent = getNodeAtOffset(docText, op.offset, dom).node; |
|||
var htmlToRemove = docText.substring(op.offset, op.offset + op.toRemove); |
|||
|
|||
var parentInnerHTML; |
|||
var indexOfInnerHTML; |
|||
var localOffset; |
|||
for (;;) { |
|||
for (;;) { |
|||
parentInnerHTML = parent.innerHTML; |
|||
if (typeof(parentInnerHTML) !== 'undefined' |
|||
&& parentInnerHTML.indexOf(htmlToRemove) !== -1) |
|||
{ |
|||
break; |
|||
} |
|||
if (parent === dom || !(parent = parent.parentNode)) { throw new Error(); } |
|||
} |
|||
|
|||
var indexOfOuterHTML = 0; |
|||
var tw = 0; |
|||
if (parent !== dom) { |
|||
indexOfOuterHTML = offsetOfNodeOuterHTML(docText, parent, dom, ifrWindow); |
|||
tw = Otaml.tagWidth(docText.substring(indexOfOuterHTML)); |
|||
} |
|||
indexOfInnerHTML = indexOfOuterHTML + tw; |
|||
|
|||
localOffset = op.offset - indexOfInnerHTML; |
|||
|
|||
if (localOffset >= 0 && localOffset + op.toRemove <= parentInnerHTML.length) { |
|||
break; |
|||
} |
|||
|
|||
parent = parent.parentNode; |
|||
if (!parent) { throw new Error(); } |
|||
} |
|||
|
|||
if (PARANOIA && |
|||
docText.substr(indexOfInnerHTML, parentInnerHTML.length) !== parentInnerHTML) |
|||
{ |
|||
throw new Error(); |
|||
} |
|||
|
|||
var newParentInnerHTML = |
|||
patchString(parentInnerHTML, localOffset, op.toRemove, op.toInsert); |
|||
|
|||
// Create a temp container for holding the children of the parent node.
|
|||
// Once we've identified the new range, we'll return the nodes to the
|
|||
// original parent. This is because parent might be the <body> and we
|
|||
// don't want to destroy all of our event listeners.
|
|||
var babysitter = ifrWindow.document.createElement('div'); |
|||
// give it a uid so that we can prove later that it's not in the document,
|
|||
// see getSelectedRange()
|
|||
babysitter.setAttribute('id', uniqueId()); |
|||
babysitter.innerHTML = newParentInnerHTML; |
|||
|
|||
var range = getSelectedRange(rangy, ifrWindow); |
|||
|
|||
// doesn't intersect at all
|
|||
if (!range || !range.containsNode(parent, true)) { |
|||
replaceAllChildren(parent, babysitter); |
|||
return; |
|||
} |
|||
|
|||
var pseudoRange = getRelocatedPseudoRange(babysitter, parent, range, rangy); |
|||
range.detach(); |
|||
replaceAllChildren(parent, babysitter); |
|||
if (pseudoRange.start.node) { |
|||
var selection = rangy.getSelection(ifrWindow); |
|||
var newRange = rangy.createRange(); |
|||
newRange.setStart(pseudoRange.start.node, pseudoRange.start.pos); |
|||
newRange.setEnd(pseudoRange.end.node, pseudoRange.end.pos); |
|||
selection.setSingleRange(newRange); |
|||
} |
|||
return; |
|||
}; |
|||
|
|||
var applyHTMLOpHammer = function (docText, op, dom, rangy, ifrWindow) |
|||
{ |
|||
var newDocText = patchString(docText, op.offset, op.toRemove, op.toInsert); |
|||
var babysitter = ifrWindow.document.createElement('body'); |
|||
// give it a uid so that we can prove later that it's not in the document,
|
|||
// see getSelectedRange()
|
|||
babysitter.setAttribute('id', uniqueId()); |
|||
babysitter.innerHTML = newDocText; |
|||
|
|||
var range = getSelectedRange(rangy, ifrWindow); |
|||
|
|||
// doesn't intersect at all
|
|||
if (!range) { |
|||
replaceAllChildren(dom, babysitter); |
|||
return; |
|||
} |
|||
|
|||
var pseudoRange = getRelocatedPseudoRange(babysitter, dom, range, docText, op); |
|||
range.detach(); |
|||
replaceAllChildren(dom, babysitter); |
|||
if (pseudoRange.start.node) { |
|||
var selection = rangy.getSelection(ifrWindow); |
|||
var newRange = rangy.createRange(); |
|||
newRange.setStart(pseudoRange.start.node, pseudoRange.start.pos); |
|||
newRange.setEnd(pseudoRange.end.node, pseudoRange.end.pos); |
|||
selection.setSingleRange(newRange); |
|||
} |
|||
return; |
|||
}; |
|||
|
|||
/* Return whether the selection range has been "dirtied" and needs to be reloaded. */ |
|||
var applyOp = module.exports.applyOp = function (docText, op, dom, rangy, ifrWindow) |
|||
{ |
|||
if (PARANOIA && docText !== getInnerHTML(dom)) { throw new Error(); } |
|||
|
|||
if (op.offset + op.toRemove > docText.length) { |
|||
throw new Error(); |
|||
} |
|||
try { |
|||
applyHTMLOp(docText, op, dom, rangy, ifrWindow); |
|||
var result = patchString(docText, op.offset, op.toRemove, op.toInsert); |
|||
var innerHTML = getInnerHTML(dom); |
|||
if (result !== innerHTML) { |
|||
$(document.body).append('<textarea id="statebox"></textarea>'); |
|||
$(document.body).append('<textarea id="errorbox"></textarea>'); |
|||
var SEP = '\n\n\n\n\n\n\n\n\n\n'; |
|||
$('#statebox').val(docText + SEP + result + SEP + innerHTML); |
|||
var diff = Otaml.makeTextOperation(result, innerHTML); |
|||
$('#errorbox').val(JSON.stringify(op) + '\n' + JSON.stringify(diff)); |
|||
throw new Error(); |
|||
} |
|||
} catch (err) { |
|||
if (PARANOIA) { console.log(err.stack); } |
|||
// The big hammer
|
|||
dom.innerHTML = patchString(docText, op.offset, op.toRemove, op.toInsert); |
|||
} |
|||
}; |
|||
|
|||
return module.exports; |
|||
}); |
|||
@ -0,0 +1,16 @@ |
|||
<!DOCTYPE html> |
|||
<html> |
|||
<head> |
|||
<!--<title>Sample - CKEditor</title>--> |
|||
<meta content="text/html; charset=utf-8" http-equiv="content-type"/> |
|||
<script data-main="main" src="bower/requirejs/require.js"></script> |
|||
</head> |
|||
<body> |
|||
<form action="#" method="post"> |
|||
<textarea cols="80" id="editor1" name="editor1" rows="10"> |
|||
Loading... (or maybe you have Javascript disabled?) |
|||
</textarea> |
|||
</form> |
|||
</body> |
|||
</html> |
|||
|
|||
@ -0,0 +1,52 @@ |
|||
define([ |
|||
'realtime-wysiwyg', |
|||
'bower/jquery/dist/jquery.min', |
|||
'bower/ckeditor/ckeditor', |
|||
'bower/tweetnacl/nacl-fast.min' |
|||
], function (RTWysiwyg) { |
|||
var Ckeditor = window.CKEDITOR; |
|||
var Nacl = window.nacl; |
|||
var $ = jQuery; |
|||
|
|||
var module = { exports: {} }; |
|||
|
|||
var parseKey = function (str) { |
|||
var array = Nacl.util.decodeBase64(str); |
|||
var hash = Nacl.hash(array); |
|||
return { lookupKey: hash.subarray(32), cryptKey: hash.subarray(0,32) }; |
|||
}; |
|||
|
|||
var genKey = function () { |
|||
return Nacl.util.encodeBase64(Nacl.randomBytes(18)); |
|||
}; |
|||
|
|||
var userName = function () { |
|||
return Nacl.util.encodeBase64(Nacl.randomBytes(8)); |
|||
}; |
|||
|
|||
$(function () { |
|||
if (window.location.href.indexOf('#') === -1) { |
|||
window.location.href = window.location.href + '#' + genKey(); |
|||
} |
|||
$(window).on('hashchange', function() { |
|||
window.location.reload(); |
|||
}); |
|||
var key = parseKey(window.location.hash.substring(1)); |
|||
var editor = Ckeditor.replace('editor1', { |
|||
removeButtons: 'Source,Maximize', |
|||
}); |
|||
editor.on('instanceReady', function () { |
|||
//editor.execCommand('maximize');
|
|||
var ifr = window.ifr = $('iframe')[0]; |
|||
ifr.contentDocument.body.innerHTML = '<p>It works!</p>'; |
|||
|
|||
var rtw = |
|||
RTWysiwyg.start(window.location.href.replace(/#.*$/, '').replace(/^http/, 'ws'), |
|||
userName(), |
|||
{}, |
|||
Nacl.util.encodeBase64(key.lookupKey).substring(0,10), |
|||
key.cryptKey); |
|||
editor.on('change', function () { rtw.onEvent(); }); |
|||
}); |
|||
}); |
|||
}); |
|||
1003
www/otaml.js
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
3738
www/rangy.js
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
@ -0,0 +1,576 @@ |
|||
/* |
|||
* Copyright 2014 XWiki SAS |
|||
* |
|||
* This program is free software: you can redistribute it and/or modify |
|||
* it under the terms of the GNU Affero General Public License as published by |
|||
* the Free Software Foundation, either version 3 of the License, or |
|||
* (at your option) any later version. |
|||
* |
|||
* This program is distributed in the hope that it will be useful, |
|||
* but WITHOUT ANY WARRANTY; without even the implied warranty of |
|||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|||
* GNU Affero General Public License for more details. |
|||
* |
|||
* You should have received a copy of the GNU Affero General Public License |
|||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|||
*/ |
|||
define([ |
|||
'html-patcher', |
|||
'rangy', |
|||
'chainpad', |
|||
'otaml', |
|||
'bower/jquery/dist/jquery.min', |
|||
'bower/tweetnacl/nacl-fast.min' |
|||
], function (HTMLPatcher) { |
|||
var $ = window.jQuery; |
|||
var Rangy = window.rangy; |
|||
Rangy.init(); |
|||
var ChainPad = window.ChainPad; |
|||
var Otaml = window.Otaml; |
|||
var Nacl = window.nacl; |
|||
|
|||
var ErrorBox = {}; |
|||
|
|||
var PARANOIA = true; |
|||
|
|||
|
|||
|
|||
var module = { exports: {} }; |
|||
|
|||
/** |
|||
* If an error is encountered but it is recoverable, do not immediately fail |
|||
* but if it keeps firing errors over and over, do fail. |
|||
*/ |
|||
var MAX_RECOVERABLE_ERRORS = 15; |
|||
|
|||
/** Maximum number of milliseconds of lag before we fail the connection. */ |
|||
var MAX_LAG_BEFORE_DISCONNECT = 20000; |
|||
|
|||
/** Id of the element for getting debug info. */ |
|||
var DEBUG_LINK_CLS = 'rtwysiwyg-debug-link'; |
|||
|
|||
/** Id of the div containing the user list. */ |
|||
var USER_LIST_CLS = 'rtwysiwyg-user-list'; |
|||
|
|||
/** Id of the div containing the lag info. */ |
|||
var LAG_ELEM_CLS = 'rtwysiwyg-lag'; |
|||
|
|||
/** The toolbar class which contains the user list, debug link and lag. */ |
|||
var TOOLBAR_CLS = 'rtwysiwyg-toolbar'; |
|||
|
|||
/** Key in the localStore which indicates realtime activity should be disallowed. */ |
|||
var LOCALSTORAGE_DISALLOW = 'rtwysiwyg-disallow'; |
|||
|
|||
// ------------------ Trapping Keyboard Events ---------------------- //
|
|||
|
|||
var bindEvents = function (element, events, callback, unbind) { |
|||
for (var i = 0; i < events.length; i++) { |
|||
var e = events[i]; |
|||
if (element.addEventListener) { |
|||
if (unbind) { |
|||
element.removeEventListener(e, callback, false); |
|||
} else { |
|||
element.addEventListener(e, callback, false); |
|||
} |
|||
} else { |
|||
if (unbind) { |
|||
element.detachEvent('on' + e, callback); |
|||
} else { |
|||
element.attachEvent('on' + e, callback); |
|||
} |
|||
} |
|||
} |
|||
}; |
|||
|
|||
var bindAllEvents = function (wysiwygDiv, docBody, onEvent, unbind) |
|||
{ |
|||
bindEvents(docBody, |
|||
['textInput', 'keydown', 'keyup', 'select', 'cut', 'paste'], |
|||
onEvent, |
|||
unbind); |
|||
bindEvents(wysiwygDiv, |
|||
['mousedown','mouseup','click'], |
|||
onEvent, |
|||
unbind); |
|||
}; |
|||
|
|||
var checkLag = function (realtime, lagElement) { |
|||
var lag = realtime.getLag(); |
|||
var lagSec = lag.lag/1000; |
|||
lagElement.textContent = "Lag: "; |
|||
if (lag.waiting && lagSec > 1) { |
|||
lagElement.textContent += "?? " + Math.floor(lagSec); |
|||
} else { |
|||
lagElement.textContent += lagSec; |
|||
} |
|||
}; |
|||
|
|||
var isSocketDisconnected = function (socket, realtime) { |
|||
return socket._socket.readyState === socket.CLOSING |
|||
|| socket._socket.readyState === socket.CLOSED |
|||
|| (realtime.getLag().waiting && realtime.getLag().lag > MAX_LAG_BEFORE_DISCONNECT); |
|||
}; |
|||
|
|||
var updateUserList = function (myUserName, listElement, userList, messages) { |
|||
var meIdx = userList.indexOf(myUserName); |
|||
if (meIdx === -1) { |
|||
listElement.text(messages.disconnected); |
|||
return; |
|||
} |
|||
listElement.text(messages.editingWith + ' ' + (userList.length - 1) + ' people'); |
|||
}; |
|||
|
|||
var createUserList = function (realtime, myUserName, container, messages) { |
|||
var id = uid(); |
|||
$(container).prepend('<div class="' + USER_LIST_CLS + '" id="'+id+'"></div>'); |
|||
var listElement = $('#'+id); |
|||
realtime.onUserListChange(function (userList) { |
|||
updateUserList(myUserName, listElement, userList, messages); |
|||
}); |
|||
return listElement; |
|||
}; |
|||
|
|||
var abort = function (socket, realtime) { |
|||
realtime.abort(); |
|||
try { socket._socket.close(); } catch (e) { } |
|||
$('.'+USER_LIST_CLS).text("Disconnected"); |
|||
$('.'+LAG_ELEM_CLS).text(""); |
|||
}; |
|||
|
|||
var createDebugInfo = function (cause, realtime, docHTML, allMessages) { |
|||
return JSON.stringify({ |
|||
cause: cause, |
|||
realtimeUserDoc: realtime.getUserDoc(), |
|||
realtimeAuthDoc: realtime.getAuthDoc(), |
|||
docHTML: docHTML, |
|||
allMessages: allMessages, |
|||
}); |
|||
}; |
|||
|
|||
var handleError = function (socket, realtime, err, docHTML, allMessages) { |
|||
var internalError = createDebugInfo(err, realtime, docHTML, allMessages); |
|||
abort(socket, realtime); |
|||
ErrorBox.show('error', docHTML, internalError); |
|||
}; |
|||
|
|||
var getDocHTML = function (doc) { |
|||
return doc.body.innerHTML; |
|||
}; |
|||
|
|||
var makeHTMLOperation = function (oldval, newval) { |
|||
try { |
|||
var op = Otaml.makeHTMLOperation(oldval, newval); |
|||
|
|||
if (PARANOIA && op) { |
|||
// simulate running the patch.
|
|||
var res = HTMLPatcher.patchString(oldval, op.offset, op.toRemove, op.toInsert); |
|||
if (res !== newval) { |
|||
console.log(op); |
|||
console.log(oldval); |
|||
console.log(newval); |
|||
console.log(res); |
|||
throw new Error(); |
|||
} |
|||
|
|||
// check matching bracket count
|
|||
// TODO(cjd): this can fail even if the patch is valid because of brackets in
|
|||
// html attributes.
|
|||
var removeText = oldval.substring(op.offset, op.offset + op.toRemove); |
|||
if (((removeText).match(/</g) || []).length !== |
|||
((removeText).match(/>/g) || []).length) |
|||
{ |
|||
throw new Error(); |
|||
} |
|||
|
|||
if (((op.toInsert).match(/</g) || []).length !== |
|||
((op.toInsert).match(/>/g) || []).length) |
|||
{ |
|||
throw new Error(); |
|||
} |
|||
} |
|||
|
|||
return op; |
|||
|
|||
} catch (e) { |
|||
if (PARANOIA) { |
|||
$(document.body).append('<textarea id="makeOperationErr"></textarea>'); |
|||
$('#makeOperationErr').val(oldval + '\n\n\n\n\n\n\n\n\n\n' + newval); |
|||
console.log(e.stack); |
|||
} |
|||
return { |
|||
offset: 0, |
|||
toRemove: oldval.length, |
|||
toInsert: newval |
|||
}; |
|||
} |
|||
}; |
|||
|
|||
// chrome sometimes generates invalid html but it corrects it the next time around.
|
|||
var fixChrome = function (docText, doc, contentWindow) { |
|||
for (var i = 0; i < 10; i++) { |
|||
var docElem = doc.createElement('div'); |
|||
docElem.innerHTML = docText; |
|||
var newDocText = docElem.innerHTML; |
|||
var fixChromeOp = makeHTMLOperation(docText, newDocText); |
|||
if (!fixChromeOp) { return docText; } |
|||
HTMLPatcher.applyOp(docText, |
|||
fixChromeOp, |
|||
doc.body, |
|||
Rangy, |
|||
contentWindow); |
|||
docText = getDocHTML(doc); |
|||
if (newDocText === docText) { return docText; } |
|||
} |
|||
throw new Error(); |
|||
}; |
|||
|
|||
var fixSafari_STATE_OUTSIDE = 0; |
|||
var fixSafari_STATE_IN_TAG = 1; |
|||
var fixSafari_STATE_IN_ATTR = 2; |
|||
var fixSafari_HTML_ENTITIES_REGEX = /('|"|<|>|<|>)/g; |
|||
|
|||
var fixSafari = function (html) { |
|||
var state = fixSafari_STATE_OUTSIDE; |
|||
return html.replace(fixSafari_HTML_ENTITIES_REGEX, function (x) { |
|||
switch (state) { |
|||
case fixSafari_STATE_OUTSIDE: { |
|||
if (x === '<') { state = fixSafari_STATE_IN_TAG; } |
|||
return x; |
|||
} |
|||
case fixSafari_STATE_IN_TAG: { |
|||
switch (x) { |
|||
case '"': state = fixSafari_STATE_IN_ATTR; break; |
|||
case '>': state = fixSafari_STATE_OUTSIDE; break; |
|||
case "'": throw new Error("single quoted attribute"); |
|||
} |
|||
return x; |
|||
} |
|||
case fixSafari_STATE_IN_ATTR: { |
|||
switch (x) { |
|||
case '<': return '<'; |
|||
case '>': return '>'; |
|||
case '"': state = fixSafari_STATE_IN_TAG; break; |
|||
} |
|||
return x; |
|||
} |
|||
}; |
|||
throw new Error(); |
|||
}); |
|||
}; |
|||
|
|||
var getFixedDocText = function (doc, ifrWindow) { |
|||
var docText = getDocHTML(doc); |
|||
docText = fixChrome(docText, doc, ifrWindow); |
|||
docText = fixSafari(docText); |
|||
return docText; |
|||
}; |
|||
|
|||
var uid = function () { |
|||
return 'rtwysiwyg-uid-' + String(Math.random()).substring(2); |
|||
}; |
|||
|
|||
var checkLag = function (realtime, lagElement, messages) { |
|||
var lag = realtime.getLag(); |
|||
var lagSec = lag.lag/1000; |
|||
var lagMsg = messages.lag + ' '; |
|||
if (lag.waiting && lagSec > 1) { |
|||
lagMsg += "?? " + Math.floor(lagSec); |
|||
} else { |
|||
lagMsg += lagSec; |
|||
} |
|||
lagElement.text(lagMsg); |
|||
}; |
|||
|
|||
var createLagElement = function (socket, realtime, container, messages) { |
|||
var id = uid(); |
|||
$(container).append('<div class="' + LAG_ELEM_CLS + '" id="'+id+'"></div>'); |
|||
var lagElement = $('#'+id); |
|||
var intr = setInterval(function () { |
|||
checkLag(realtime, lagElement, messages); |
|||
}, 3000); |
|||
socket.onClose.push(function () { clearTimeout(intr); }); |
|||
return lagElement; |
|||
}; |
|||
|
|||
var createRealtimeToolbar = function (container) { |
|||
var id = uid(); |
|||
$(container).prepend( |
|||
'<div class="' + TOOLBAR_CLS + '" id="' + id + '">' + |
|||
'<div class="rtwysiwyg-toolbar-leftside"></div>' + |
|||
'<div class="rtwysiwyg-toolbar-rightside"></div>' + |
|||
'</div>' |
|||
); |
|||
var toolbar = $('#'+id); |
|||
toolbar.append([ |
|||
'<style>', |
|||
'.' + TOOLBAR_CLS + ' {', |
|||
' color: #666;', |
|||
' font-weight: bold;', |
|||
' background-color: #f0f0ee;', |
|||
' border-bottom: 1px solid #DDD;', |
|||
' border-top: 3px solid #CCC;', |
|||
' border-right: 2px solid #CCC;', |
|||
' border-left: 2px solid #CCC;', |
|||
' height: 26px;', |
|||
' margin-bottom: -3px;', |
|||
' display: inline-block;', |
|||
' width: 100%;', |
|||
'}', |
|||
'.' + TOOLBAR_CLS + ' div {', |
|||
' padding: 0 10px;', |
|||
' height: 1.5em;', |
|||
' background: #f0f0ee;', |
|||
' line-height: 25px;', |
|||
' height: 22px;', |
|||
'}', |
|||
'.rtwysiwyg-toolbar-leftside {', |
|||
' float: left;', |
|||
'}', |
|||
'.rtwysiwyg-toolbar-rightside {', |
|||
' float: right;', |
|||
'}', |
|||
'.rtwysiwyg-lag {', |
|||
' float: right;', |
|||
'}', |
|||
'.gwt-TabBar {', |
|||
' display:none;', |
|||
'}', |
|||
'.' + DEBUG_LINK_CLS + ':link { color:transparent; }', |
|||
'.' + DEBUG_LINK_CLS + ':link:hover { color:blue; }', |
|||
'.gwt-TabPanelBottom { border-top: 0 none; }', |
|||
'</style>' |
|||
].join('\n')); |
|||
return toolbar; |
|||
}; |
|||
|
|||
var makeWebsocket = function (url) { |
|||
var socket = new WebSocket(url); |
|||
var out = { |
|||
onOpen: [], |
|||
onClose: [], |
|||
onError: [], |
|||
onMessage: [], |
|||
send: function (msg) { socket.send(msg); }, |
|||
close: function () { socket.close(); }, |
|||
_socket: socket |
|||
}; |
|||
var mkHandler = function (name) { |
|||
return function (evt) { |
|||
for (var i = 0; i < out[name].length; i++) { |
|||
if (out[name][i](evt) === false) { return; } |
|||
} |
|||
}; |
|||
}; |
|||
socket.onopen = mkHandler('onOpen'); |
|||
socket.onclose = mkHandler('onClose'); |
|||
socket.onerror = mkHandler('onError'); |
|||
socket.onmessage = mkHandler('onMessage'); |
|||
return out; |
|||
}; |
|||
|
|||
var encryptStr = function (str, key) { |
|||
var array = Nacl.util.decodeUTF8(str); |
|||
var nonce = Nacl.randomBytes(24); |
|||
var packed = Nacl.secretbox(array, nonce, key); |
|||
if (!packed) { throw new Error(); } |
|||
return Nacl.util.encodeBase64(nonce) + "|" + Nacl.util.encodeBase64(packed); |
|||
}; |
|||
var decryptStr = function (str, key) { |
|||
var arr = str.split('|'); |
|||
if (arr.length !== 2) { throw new Error(); } |
|||
var nonce = Nacl.util.decodeBase64(arr[0]); |
|||
var packed = Nacl.util.decodeBase64(arr[1]); |
|||
var unpacked = Nacl.secretbox.open(packed, nonce, key); |
|||
if (!unpacked) { throw new Error(); } |
|||
return Nacl.util.encodeUTF8(unpacked); |
|||
}; |
|||
|
|||
// this is crap because of bencoding messages... it should go away....
|
|||
var splitMessage = function (msg, sending) { |
|||
var idx = 0; |
|||
var nl; |
|||
for (var i = ((sending) ? 0 : 1); i < 3; i++) { |
|||
nl = msg.indexOf(':',idx); |
|||
idx = nl + Number(msg.substring(idx,nl)) + 1; |
|||
} |
|||
return [ msg.substring(0,idx), msg.substring(msg.indexOf(':',idx) + 1) ]; |
|||
}; |
|||
|
|||
var encrypt = function (msg, key) { |
|||
var spl = splitMessage(msg, true); |
|||
var json = JSON.parse(spl[1]); |
|||
// non-patches are not encrypted.
|
|||
if (json[0] !== 2) { return msg; } |
|||
json[1] = encryptStr(JSON.stringify(json[1]), key); |
|||
var res = JSON.stringify(json); |
|||
return spl[0] + res.length + ':' + res; |
|||
}; |
|||
|
|||
var decrypt = function (msg, key) { |
|||
var spl = splitMessage(msg, false); |
|||
var json = JSON.parse(spl[1]); |
|||
// non-patches are not encrypted.
|
|||
if (json[0] !== 2) { return msg; } |
|||
if (typeof(json[1]) !== 'string') { throw new Error(); } |
|||
json[1] = JSON.parse(decryptStr(json[1], key)); |
|||
var res = JSON.stringify(json); |
|||
return spl[0] + res.length + ':' + res; |
|||
}; |
|||
|
|||
var start = module.exports.start = |
|||
function (websocketUrl, userName, messages, channel, cryptKey) |
|||
{ |
|||
var passwd = 'y'; |
|||
var wysiwygDiv = document.getElementById('cke_1_contents'); |
|||
var ifr = wysiwygDiv.getElementsByTagName('iframe')[0]; |
|||
var doc = ifr.contentWindow.document; |
|||
var socket = makeWebsocket(websocketUrl); |
|||
var onEvent = function () { }; |
|||
|
|||
var toolbar = createRealtimeToolbar('#xwikieditcontent'); |
|||
|
|||
socket.onClose.push(function () { |
|||
$(toolbar).remove(); |
|||
}); |
|||
|
|||
var allMessages = []; |
|||
var isErrorState = false; |
|||
var initializing = true; |
|||
var recoverableErrorCount = 0; |
|||
var error = function (recoverable, err) { |
|||
console.log('error: ' + err.stack); |
|||
if (recoverable && recoverableErrorCount++ < MAX_RECOVERABLE_ERRORS) { return; } |
|||
var realtime = socket.realtime; |
|||
var docHtml = getDocHTML(doc); |
|||
isErrorState = true; |
|||
handleError(socket, realtime, err, docHtml, allMessages); |
|||
}; |
|||
var attempt = function (func) { |
|||
return function () { |
|||
var e; |
|||
try { return func.apply(func, arguments); } catch (ee) { e = ee; } |
|||
if (e) { |
|||
console.log(e.stack); |
|||
error(true, e); |
|||
} |
|||
}; |
|||
}; |
|||
var checkSocket = function () { |
|||
if (isSocketDisconnected(socket, socket.realtime) && !socket.intentionallyClosing) { |
|||
isErrorState = true; |
|||
abort(socket, socket.realtime); |
|||
ErrorBox.show('disconnected', getDocHTML(doc)); |
|||
return true; |
|||
} |
|||
return false; |
|||
}; |
|||
|
|||
socket.onOpen.push(function (evt) { |
|||
|
|||
var realtime = socket.realtime = |
|||
ChainPad.create(userName, |
|||
passwd, |
|||
channel, |
|||
getDocHTML(doc), |
|||
{ transformFunction: Otaml.transform }); |
|||
|
|||
//createDebugLink(realtime, doc, allMessages, toolbar, messages);
|
|||
|
|||
createLagElement(socket, |
|||
realtime, |
|||
toolbar.find('.rtwysiwyg-toolbar-rightside'), |
|||
messages); |
|||
|
|||
createUserList(realtime, |
|||
userName, |
|||
toolbar.find('.rtwysiwyg-toolbar-leftside'), |
|||
messages); |
|||
|
|||
onEvent = function () { |
|||
if (isErrorState) { return; } |
|||
if (initializing) { return; } |
|||
|
|||
var oldDocText = realtime.getUserDoc(); |
|||
var docText = getFixedDocText(doc, ifr.contentWindow); |
|||
var op = attempt(Otaml.makeTextOperation)(oldDocText, docText); |
|||
|
|||
if (!op) { return; } |
|||
|
|||
if (op.toRemove > 0) { |
|||
attempt(realtime.remove)(op.offset, op.toRemove); |
|||
} |
|||
if (op.toInsert.length > 0) { |
|||
attempt(realtime.insert)(op.offset, op.toInsert); |
|||
} |
|||
|
|||
if (realtime.getUserDoc() !== docText) { |
|||
error(false, 'realtime.getUserDoc() !== docText'); |
|||
} |
|||
}; |
|||
|
|||
var userDocBeforePatch; |
|||
var incomingPatch = function () { |
|||
if (isErrorState || initializing) { return; } |
|||
userDocBeforePatch = userDocBeforePatch || getFixedDocText(doc, ifr.contentWindow); |
|||
if (PARANOIA && userDocBeforePatch != getFixedDocText(doc, ifr.contentWindow)) { |
|||
error(false, "userDocBeforePatch != getFixedDocText(doc, ifr.contentWindow)"); |
|||
} |
|||
var op = attempt(makeHTMLOperation)(userDocBeforePatch, realtime.getUserDoc()); |
|||
if (!op) { return; } |
|||
attempt(HTMLPatcher.applyOp)( |
|||
userDocBeforePatch, op, doc.body, rangy, ifr.contentWindow); |
|||
}; |
|||
|
|||
realtime.onUserListChange(function (userList) { |
|||
if (!initializing && userList.indexOf(userName) === -1) { return; } |
|||
// if we spot ourselves being added to the document, we'll switch
|
|||
// 'initializing' off because it means we're fully synced.
|
|||
initializing = false; |
|||
userDocBeforePatch = realtime.getUserDoc(); |
|||
incomingPatch(); |
|||
}); |
|||
|
|||
socket.onMessage.push(function (evt) { |
|||
if (isErrorState) { return; } |
|||
var message = decrypt(evt.data, cryptKey); |
|||
allMessages.push(message); |
|||
if (!initializing) { |
|||
if (PARANOIA) { onEvent(); } |
|||
userDocBeforePatch = realtime.getUserDoc(); |
|||
} |
|||
realtime.message(message); |
|||
}); |
|||
realtime.onMessage(function (message) { |
|||
if (isErrorState) { return; } |
|||
message = encrypt(message, cryptKey); |
|||
try { |
|||
socket.send(message); |
|||
} catch (e) { |
|||
if (!checkSocket()) { error(true, e.stack); } |
|||
} |
|||
}); |
|||
|
|||
realtime.onPatch(incomingPatch); |
|||
|
|||
socket.onError.push(function (err) { |
|||
if (isErrorState) { return; } |
|||
if (!checkSocket()) { error(true, err); } |
|||
}); |
|||
|
|||
bindAllEvents(wysiwygDiv, doc.body, onEvent, false); |
|||
|
|||
setInterval(function () { |
|||
if (isErrorState || checkSocket()) { return; } |
|||
}, 200); |
|||
|
|||
realtime.start(); |
|||
|
|||
//console.log('started');
|
|||
}); |
|||
return { |
|||
onEvent: function () { onEvent(); } |
|||
}; |
|||
}; |
|||
|
|||
return module.exports; |
|||
}); |
|||
Write
Preview
Loading…
Cancel
Save