-
Notifications
You must be signed in to change notification settings - Fork 9
/
Copy pathwhiteboard.js
390 lines (352 loc) · 13.3 KB
/
whiteboard.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
// insert `debugger;` and run `node debug sync1.js` for basic debugging
// use https://github.com/node-inspector/node-inspector for advanced debugging
// JXCore packaging: jx package whiteboard.js stylusboard -native
// example: node whiteboard.js --db=/home/mwhite/cloud/stylusdata/cloudwrite.sqlite --log-level=debug --log-path="/var/log/styluslabs"
var net = require("net");
var http = require("http");
var url = require("url");
var moment = require('moment');
var logger = require('./logger');
var util = require('./util');
var md5 = util.md5;
var rndstr = util.rndstr;
// handle command line args - don't remove leading items since the number can vary based on how we're started
var pargs = require('minimist')(process.argv); //.slice(2));
// handle optional JSON config file (specified as command line arg)
/* {
"db": "/home/mwhite/stylusdata/cloudwrite.sqlite",
"log-level": "debug",
"log-path": null,
"allow-anon": true,
} */
// note that command line args override config-file
if(pargs["config-file"]) {
var fs = require('fs');
pargs = util.mergeHash(JSON.parse(fs.readFileSync(pargs["config-file"])), pargs);
}
// DB setup
var db = false;
if(pargs["db"]) {
var wbDB = require('./whiteboardDB');
db = wbDB.openDB(pargs["db"], function(err) {
if(err) {
console.log("Error opening database " + pargs["db"] + ": ", err);
process.exit(-102);
}
else {
db.get("SELECT COUNT(1) AS nusers FROM users;", function(err, row) {
if(row)
console.log("Database loaded with " + row.nusers + " users.");
});
}
});
}
else
console.log("No database specified: running in anonymous mode.");
// Use HTTP API on separate port for everything except actual SWB
// - for now, let's go with a default of short random string id:
// 1. authenticate; reprompt for credentials on failure
// 2. request random session id from server
// 3. show share doc box with session id, allowing user to change id to something meaningful
// 4. request new shared session from server; if session already exists, prompt user to connect to existing session
// - must prompt so user isn't surprised to see the doc they are looking at be replaced
// - future options include:
// - session password to provide security while allowing session name to be meaningful
// - option to restrict access to users within the same organization (as specified at sign up)
// - option to restrict access to specified list of users
// - full URL as session ID for easy web access to sessions or site installs of server
function Client(stream)
{
this.name = null;
this.stream = stream;
this.remote = stream.remoteAddress + ":" + stream.remotePort;
this.cmdstr = "";
this.tempdata = "";
this.expectdatalen = 0;
}
function Whiteboard(repo, attribs, token)
{
this.repo = repo;
this.token = token;
this.attribs = attribs;
this.clients = [];
this.history = "";
this.destructor = null;
}
var whiteboards = {};
// HTTP API server
function Session(user, token)
{
this.user = user;
this.token = token;
this.ctime = Date.now();
}
var sessions = {};
var apilog = logger('apilog');
apilog.setLogLevel(pargs["log-level"] || process.env.STYLUS_LOG_LEVEL || 'info');
//process.env.STYLUS_LOG_PATH && apilog.setLogFile(process.env.STYLUS_LOG_PATH + "/apiserver.log");
pargs["log-path"] && apilog.setLogFile(pargs["log-path"] + "/apiserver.log");
var apiserver = http.createServer(function (request, response)
{
var parsed = url.parse(request.url, true); // parseQueryString = true
var path = parsed.pathname;
var args = parsed.query;
// extract cookies
var cookies = {};
request.headers['cookie'] && request.headers['cookie'].split(';').forEach(function(cookie) {
var parts = cookie.split('=');
cookies[parts[0].trim()] = (parts[1] || "").trim();
});
// logging
response.addListener('finish', function () {
apilog.info(request.socket.remoteAddress + ' - [' + moment().utc().format('DD MMMM YYYY HH:mm:ss') + ' GMT] "'
+ request.method + ' ' + request.url + '" ' + response.statusCode + ' - ' + request.headers['user-agent'] + '"');
});
// debug page
if(path == "/v1/debug" && pargs["enable-test"]) { //&& args["secret"] == "123456") {
var replacer = function (key, value) {
if(key == "history" || key == "tempdata")
return "[ " + value.length + " bytes ]";
else if(key == "whiteboard" || key == "stream")
return "[ Circular ]";
else
return value;
}
response.writeHead(200);
response.end("whiteboards = " + JSON.stringify(whiteboards, replacer, 2)
+ "\n\nsessions = " + JSON.stringify(sessions, null, 2));
}
// new users are added directly to database by web server
if(path == "/v1/auth") {
var acceptauth = function() {
var token = rndstr();
sessions[token] = new Session(args["user"], token);
response.writeHead(200, {
'Set-Cookie': 'session=' + token,
'Content-Type': 'text/plain'
});
response.end();
}
if(!db) {
// if no DB, accept all connections
acceptauth();
return;
}
// lookup user in DB
db.get("SELECT password FROM users WHERE username = ?", args["user"], function(err, row) {
if(row && wbDB.validateAppLogin(args["signature"], args["timestamp"], row.password)) {
// TODO: actually verify that timestamp is within acceptable range
// ... rather, the proper approach would be for the client to request a token and use that instead
// of timestamp to generate signature
acceptauth();
return;
}
//console.log("Auth failed for: " + request.url);
// fall thru for all error cases
response.writeHead(401);
response.end("error: invalid username or password");
});
return;
}
// verify session cookie for all other paths
var session = sessions[cookies["session"]];
if(!session) {
response.writeHead(403);
response.end();
return;
}
if(session.ctime + 5 * 60 * 1000 < Date.now()) {
delete sessions[cookies["session"]];
response.writeHead(408);
response.end("error: session expired");
return;
}
if(path == "/v1/createswb" || path == "/v1/openswb") {
var repo = args["name"];
if(!repo || (path == "/v1/openswb" && !whiteboards[repo]) || (path == "/v1/createswb" && whiteboards[repo])) {
response.writeHead(404);
response.end();
return;
}
if(!whiteboards[repo]) {
var a = [];
for(var k in args) {
if(!args.hasOwnProperty || args.hasOwnProperty(k)) {
a.push(k + "='" + args[k] + "'");
}
}
whiteboards[repo] = new Whiteboard(repo, a.join(" "), rndstr());
}
var wb = whiteboards[repo];
var token = md5(session.user + wb.token);
response.writeHead(200);
response.end("<swb " + wb.attribs + " user='" + session.user + "' token='" + token + "'/>");
}
else {
response.writeHead(404);
response.end();
}
});
apiserver.listen(7000);
// shared whiteboarding server - basically just echos everything it receives to all clients
// we now rely on HTTP API server to create the whiteboard
// even with flush() of socket on client, no guarantee that commands will always be at the start of data chunks!
var swblog = logger('swblog');
swblog.setLogLevel(pargs["log-level"] || process.env.STYLUS_LOG_LEVEL || 'info');
//process.env.STYLUS_LOG_PATH && swblog.setLogFile(process.env.STYLUS_LOG_PATH + "/swbserver.log");
pargs["log-path"] && swblog.setLogFile(pargs["log-path"] + "/swbserver.log");
// seconds to ms
var destroyDelay = pargs["del-delay"]*1000 || 0;
var swbserver = net.createServer(function (stream)
{
var client = new Client(stream);
stream.setTimeout(0);
stream.setEncoding("binary");
swblog.info(client.remote + " connected");
stream.on("data", function (data)
{
// don't print everything unless explicitly requested
if(pargs["dump"])
swblog.debug("SWB server rcvd from " + client.remote + " data:", data);
while(data.length > 0) {
if(client.expectdatalen > 0) {
client.tempdata += data.substr(0, client.expectdatalen);
if(client.expectdatalen > data.length) {
client.expectdatalen -= data.length;
return;
}
swblog.debug("SWB server rcvd " + client.tempdata.length + " bytes of data from " + client.remote);
data = data.substr(client.expectdatalen);
client.expectdatalen = 0;
var wb = client.whiteboard;
wb.history += client.tempdata;
wb.clients.forEach(function(c) {
// echo to all clients, including sender
c.stream.write(client.tempdata, "binary");
});
client.tempdata = "";
// fall through to handle rest of data ... after checking length again
continue;
}
var delimidx = data.indexOf('\n');
if(delimidx < 0) {
client.cmdstr += data;
return;
}
client.cmdstr += data.substr(0, delimidx);
data = data.substr(delimidx + 1);
swblog.debug(client.remote + " sent command:", client.cmdstr);
var parsed = {};
try {
if(client.cmdstr[0] == '/')
parsed = url.parse(client.cmdstr, true); // parseQueryString = true
}
catch(ex) {}
var command = parsed.pathname;
var args = parsed.query;
if(command == "/info") {
// /info?document=<docname>
// get list of current SWB users
var repo = args["document"];
if(whiteboards[repo]) {
stream.write(whiteboards[repo].clients.join(","));
}
else {
stream.write("-");
}
}
else if(command == "/start") {
// arguments: version (protocal version) - ignored for now;, user, document, (history) offset (optional),
// token = MD5(user .. whiteboard.token)
// history offset is 0 on initial connection; can be >0 when reconnecting
var repo = args["document"];
var wb = whiteboards[repo];
if(args["token"] == 'SCRIBBLE_SYNC_TEST' && pargs["enable-test"]) {
swblog.info(client.remote + ": connecting to test whiteboard " + repo + " as " + args["user"]);
if(!wb) {
wb = new Whiteboard(repo);
whiteboards[repo] = wb;
}
}
else if(!wb || args["token"] != md5(args["user"] + wb.token)) {
swblog.warn(client.remote + ": whiteboard not found or invalid token");
stream.write("<undo><accessdenied message='Whiteboard not found. Please try again.'/></undo>\n");
clientdisconn();
return;
}
client.whiteboard = wb;
client.name = args["user"];
// send history
if(wb.history.length > 0) {
var histoffset = parseInt(args["offset"]);
if(histoffset > 0)
stream.write(wb.history.slice(histoffset), "binary");
else
stream.write(wb.history, "binary");
}
wb.clients.push(client);
if(wb.destructor) {
clearTimeout(wb.destructor);
wb.destructor = null;
}
// if user was already connected as a different client, remove old client ... we've waited until new
// client has been added to wb.clients so disconn() won't delete the SWB if only one user. Also
// have to wait until history is sent!
wb.clients.forEach(function(c) {
// use full disconnect procedure to send "disconnect" signal since we'll send "connect" signal below
if(c.name == args["user"] && c != client) {
swblog.info("disconnecting " + c.remote + " due to connection of " + client.remote + " for user: " + c.name);
c.stream.write("<undo><accessdenied message='User logged in from another location.'/></undo>\n");
disconn(c);
}
});
// client can use uuid to distinguish this connect message from previous ones when reconnecting
var msg = "<undo><connect name='" + client.name + "' uuid='" + args["uuid"] + "'/></undo>\n";
wb.history += msg;
wb.clients.forEach(function(c) {
c.stream.write(msg, "binary");
});
}
else if(command == "/data") {
client.expectdatalen = parseInt(args["length"]);
}
else if(command == "/end") {
clientdisconn();
return;
}
else {
swblog.warn(client.remote + " sent invalid command:", client.cmdstr);
clientdisconn();
return;
}
client.cmdstr = "";
}
});
function disconn(client)
{
swblog.info(client.remote + " disconnected");
var wb = client.whiteboard;
if(wb && wb.clients.remove(client)) {
if(wb.clients.length == 0) {
// delete whiteboard after specified delay after last user disconnects
wb.destructor = setTimeout(function () {
swblog.info("deleting whiteboard:", wb.repo);
delete whiteboards[wb.repo];
}, destroyDelay);
}
else {
var msg = "<undo><disconnect name='" + client.name + "'/></undo>\n";
wb.history += msg;
wb.clients.forEach(function(c) {
c.stream.write(msg, "binary");
});
}
}
//client.stream.removeAllListeners();
client.stream.end();
}
function clientdisconn() { disconn(client); }
stream.on("end", function () { swblog.warn("disconnect due to stream end"); disconn(client); });
stream.on("error", function (err) { swblog.warn("disconnect due to stream error:", err); disconn(client); });
});
swbserver.listen(7001);