├── config.json ├── package.json ├── util.js ├── README.md ├── logger.js ├── whiteboardDB.js ├── dbUtil.js └── whiteboard.js /config.json: -------------------------------------------------------------------------------- 1 | { 2 | "db": "/home/mwhite/cloud/stylusdata/cloudwrite.sqlite", 3 | "log-level": "debug", 4 | "log-path": null, 5 | "allow-anon": true 6 | } 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cloudwrite", 3 | "version": "0.0.1", 4 | "homepage": "http://bitbucket.com/pbsurf/CloudWrite", 5 | "author": "Stylus Labs (http://styluslabs.com/)", 6 | "main": "./whiteboard.js", 7 | "dependencies": { 8 | "sqlite3": "*", 9 | "moment": "*", 10 | "minimist": "*" 11 | }, 12 | "engines": { 13 | "node": ">= 0.10.0" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /util.js: -------------------------------------------------------------------------------- 1 | var crypto = require('crypto'); 2 | 3 | exports.md5 = function(str) 4 | { 5 | return crypto.createHash('md5').update(str).digest('hex'); 6 | } 7 | 8 | exports.rndstr = function() 9 | { 10 | return (1 + Math.PI * Math.random()).toString(36).slice(2); 11 | } 12 | 13 | exports.mergeHash = function(dest, src) 14 | { 15 | for (var p in src) { 16 | if (src.hasOwnProperty(p)) { 17 | dest[p] = src[p]; 18 | } 19 | } 20 | return dest; 21 | }; 22 | 23 | Array.prototype.remove = function(e) 24 | { 25 | for (var i = 0; i < this.length; i++) { 26 | if (e == this[i]) { return this.splice(i, 1); } 27 | } 28 | return null; 29 | }; 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Stylusboard # 2 | 3 | Shared whiteboard server for [Stylus Labs Write](http://www.styluslabs.com) - enables multiple users to collaborate in real time on a handwritten document. 4 | To set your server as the default in Write, enable advanced preferences, then in the Advanced -> Whiteboard server field enter the host name or IP address of the machine running this server. 5 | 6 | To connect to a different server and/or with a different username than the default, use the following format for the whiteboard ID in the Open Whiteboard and Create Whiteboard dialogs: 7 | ``` 8 | [user[:password]@server/]whiteboard_id 9 | ``` 10 | 11 | Usage: Install node.js and npm, clone this repo and run `npm install`, then run `node whiteboard.js`. Tested with node.js 12.18.3 (and 0.10.36) 12 | 13 | Linux executable packaged with jx: http://www.styluslabs.com/write/stylusboard (Download and run this to get started quickly). 14 | 15 | By default, runs in anonymous mode, accepting all connections. To require login, specify a sqlite database file with the --db argument or db option in config file. dbUtil.js can be used to create and add users to the database - run `node dbUtil.js help` for options. 16 | 17 | Contact support at styluslabs.com with any issues or to request changes to the client-side code in Write. 18 | -------------------------------------------------------------------------------- /logger.js: -------------------------------------------------------------------------------- 1 | // logging 2 | // winston is the most popular node logging package, but is overkill for us 3 | // Refs: 4 | // - http://docs.nodejitsu.com/articles/intermediate/how-to-log 5 | // - http://devgigs.blogspot.com/2014/01/mastering-nodejs-logging.html 6 | 7 | var fs = require("fs"); 8 | 9 | var loggers = {}; 10 | 11 | function Logger() 12 | { 13 | var self = {}; 14 | // default log level 15 | self.logLevel = 'warn'; 16 | self.setLogLevel = function(level) { self.logLevel = level; return self; }; 17 | 18 | // output stream 19 | self.logStream = process.stdout; 20 | self.setLogFile = function(filename) { 21 | self.logStream = fs.createWriteStream(filename, {flags: 'a'}); 22 | return self; 23 | }; 24 | 25 | var logimpl = function(level, args) 26 | { 27 | var levels = ['error', 'warn', 'info', 'debug']; 28 | if(levels.indexOf(level) > levels.indexOf(self.logLevel)) 29 | return; 30 | 31 | var msgs = []; 32 | for(var ii = 0; ii < args.length; ii++) { 33 | msgs.push((typeof args[ii] !== 'string') ? JSON.stringify(args[ii]) : args[ii]); 34 | } 35 | var message = level + ': '+ msgs.join(" "); 36 | // remove trailing newline if present 37 | if(message.substr(-1) === '\n') 38 | message = message.slice(0, -1); 39 | 40 | self.logStream.write(message + '\n'); 41 | // always print errors to console 42 | if(level === 'error' && self.logStream !== process.stdout) 43 | console.log(message); 44 | } 45 | 46 | self.log = function(level) { logimpl(level, Array.prototype.slice.call(arguments, 1)); } 47 | self.error = function() { logimpl('error', arguments); } 48 | self.warn = function() { logimpl('warn', arguments); } 49 | self.info = function() { logimpl('info', arguments); } 50 | self.debug = function() { logimpl('debug', arguments); } 51 | return self; 52 | } 53 | 54 | // look up logger by name or create a new one 55 | module.exports = function(name) 56 | { 57 | if(name && loggers[name]) 58 | return loggers[name]; 59 | var logger = Logger(); 60 | if(name) 61 | loggers[name] = logger; 62 | return logger; 63 | } 64 | -------------------------------------------------------------------------------- /whiteboardDB.js: -------------------------------------------------------------------------------- 1 | var sqlite3 = require('sqlite3'); //.verbose() - for enabling full stack traces on error 2 | var util = require('./util'); 3 | 4 | var md5 = util.md5; 5 | 6 | exports.openDB = function(path, callback) 7 | { 8 | var db = new sqlite3.Database(path, callback); 9 | db.exec( 10 | "CREATE TABLE IF NOT EXISTS users( \ 11 | id INTEGER PRIMARY KEY, \ 12 | username TEXT, \ 13 | password TEXT, \ 14 | email TEXT, \ 15 | displayname TEXT); \ 16 | CREATE INDEX IF NOT EXISTS username_index ON users(username);" 17 | ); 18 | // don't bother creating index on email since it is only used for lost password lookups 19 | //CREATE INDEX IF NOT EXISTS email_index ON users(email); 20 | //db.run("INSERT OR IGNORE INTO users(id, username, password, email, displayname) VALUES(1, 'user1', ?, 'user1@styluslabs.com', 'User 1');", 21 | // crypto.createHash("md5").update("pw1").digest("hex")); 22 | //db.run("INSERT OR IGNORE INTO users(id, username, password, email, displayname) VALUES(2, 'user2', ?, 'user2@styluslabs.com', 'User 2');", 23 | // crypto.createHash("md5").update("pw2").digest("hex")); 24 | return db; 25 | } 26 | 27 | // password handling 28 | 29 | var randSalt = function(len) 30 | { 31 | var set = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; 32 | var salt = ''; 33 | for (var i = 0; i < len; i++) { 34 | var p = Math.floor(Math.random() * set.length); 35 | salt += set[p]; 36 | } 37 | return salt; 38 | } 39 | 40 | exports.saltAndHash = function(pass) 41 | { 42 | // this fixed salt must match value used in app 43 | var salt = "styluslabs"; // randSalt(10); 44 | return md5(salt + pass); //salt + md5(salt + pass); 45 | } 46 | 47 | exports.validatePassword = function(candidatePass, hashedPass) 48 | { 49 | //var salt = hashedPass.substr(0, 10); 50 | //var candidateHash = salt + md5(salt + candidatePass); 51 | var salt = "styluslabs"; 52 | var candidateHash = md5(salt + candidatePass); 53 | return hashedPass === candidateHash; 54 | } 55 | 56 | // app sends md5(md5(salt + pass) + challenge) 57 | exports.validateAppLogin = function(candidateHash, challenge, hashedPass) 58 | { 59 | var validHash = md5(hashedPass + challenge) 60 | return candidateHash === validHash; 61 | } 62 | -------------------------------------------------------------------------------- /dbUtil.js: -------------------------------------------------------------------------------- 1 | var wbDB = require('./whiteboardDB'); 2 | var fs = require('fs'); 3 | var pargs = require('minimist')(process.argv); 4 | 5 | var printHelp = function() 6 | { 7 | console.log("Stylus Labs whiteboard database utility"); 8 | console.log("Available commands:"); 9 | //console.log(" node dbTool.js --db createdb"); 10 | console.log(" adduser [[ ]] - add new user"); 11 | console.log(" updatepw - change password for user"); 12 | console.log(" rmuser - delete user"); 13 | console.log(" list - list all users"); 14 | console.log("Arguments:"); 15 | console.log(" --db (required)"); 16 | console.log("Example:"); 17 | console.log(" node dbUtil.js --db db1.sqlite adduser user1 passwd1"); 18 | } 19 | 20 | if(!pargs["db"]) { 21 | printHelp(); 22 | process.exit(-101); 23 | } 24 | 25 | if(!fs.existsSync(pargs["db"])) 26 | console.log("Database file " + pargs["db"] + " will be created."); 27 | 28 | var db = wbDB.openDB(pargs["db"], function(err) { 29 | if(err) { 30 | console.log("Error opening database " + pargs["db"] + ": ", err); 31 | process.exit(-102); 32 | } 33 | }); 34 | 35 | // copied from stylusweb app 36 | 37 | var addNewAccount = function(newData, callback) 38 | { 39 | // check for conflict 40 | db.get("SELECT username, email FROM users WHERE username = ? OR email = ?", newData.user, newData.email, 41 | function(err, row) { 42 | if(row) { 43 | callback(row.username == newData.user ? 'username-taken' : 'email-taken'); 44 | } 45 | else { 46 | // no conflict 47 | var pwhash = wbDB.saltAndHash(newData.pass); 48 | db.run("INSERT INTO users(username, password, email, displayname) VALUES(?, ?, ?, ?)", 49 | newData.user, pwhash, newData.email, newData.displayname, callback); 50 | } 51 | }); 52 | } 53 | 54 | var updatePasswordByUser = function(username, newPass, callback) 55 | { 56 | var pwhash = wbDB.saltAndHash(newPass); 57 | db.run("UPDATE users SET password = ? WHERE username = ?", pwhash, username, callback); 58 | } 59 | 60 | var deleteAccountByUser = function(username, callback) 61 | { 62 | db.run("DELETE FROM users WHERE username = ?", username, callback); 63 | } 64 | 65 | 66 | // commands 67 | args = pargs._.slice(2); 68 | var cmd = args[0]; 69 | 70 | if(cmd == "adduser") { 71 | var userData = {'user': args[1], 'pass': args[2], 'email': args[3], 'displayname': args[4]}; 72 | addNewAccount(userData, function(e){ 73 | if(e) 74 | console.log("Error adding user: ", e); 75 | else 76 | console.log("User added."); 77 | }); 78 | } 79 | else if(cmd == "updatepw") { 80 | var user = args[1]; 81 | var pass = args[2]; 82 | updatePasswordByUser(user, pass, function(e){ 83 | if(e) 84 | console.log("Error updating password: ", e); 85 | else if(!this.changes) 86 | console.log("User not found."); 87 | else 88 | console.log("Password updated."); 89 | }); 90 | } 91 | else if(cmd == "rmuser") { 92 | var user = args[1]; 93 | deleteAccountByUser(user, function(e){ 94 | if(e) 95 | console.log("Error removing user: ", e); 96 | else 97 | console.log("User deleted."); 98 | }); 99 | } 100 | else if(cmd == "list") { 101 | db.all("SELECT username FROM users", function(err, rows) { 102 | rows.forEach(function(row){ console.log(row.username); }); 103 | }); 104 | } 105 | else { 106 | printHelp(); 107 | } 108 | -------------------------------------------------------------------------------- /whiteboard.js: -------------------------------------------------------------------------------- 1 | // insert `debugger;` and run `node debug sync1.js` for basic debugging 2 | // use https://github.com/node-inspector/node-inspector for advanced debugging 3 | // JXCore packaging: jx package whiteboard.js stylusboard -native 4 | 5 | // example: node whiteboard.js --db=/home/mwhite/cloud/stylusdata/cloudwrite.sqlite --log-level=debug --log-path="/var/log/styluslabs" 6 | 7 | var net = require("net"); 8 | var http = require("http"); 9 | var url = require("url"); 10 | var moment = require('moment'); 11 | 12 | var logger = require('./logger'); 13 | 14 | var util = require('./util'); 15 | var md5 = util.md5; 16 | var rndstr = util.rndstr; 17 | 18 | // handle command line args - don't remove leading items since the number can vary based on how we're started 19 | var pargs = require('minimist')(process.argv); //.slice(2)); 20 | 21 | // handle optional JSON config file (specified as command line arg) 22 | /* { 23 | "db": "/home/mwhite/stylusdata/cloudwrite.sqlite", 24 | "log-level": "debug", 25 | "log-path": null, 26 | "allow-anon": true, 27 | } */ 28 | // note that command line args override config-file 29 | if(pargs["config-file"]) { 30 | var fs = require('fs'); 31 | pargs = util.mergeHash(JSON.parse(fs.readFileSync(pargs["config-file"])), pargs); 32 | } 33 | 34 | // DB setup 35 | var db = false; 36 | if(pargs["db"]) { 37 | var wbDB = require('./whiteboardDB'); 38 | db = wbDB.openDB(pargs["db"], function(err) { 39 | if(err) { 40 | console.log("Error opening database " + pargs["db"] + ": ", err); 41 | process.exit(-102); 42 | } 43 | else { 44 | db.get("SELECT COUNT(1) AS nusers FROM users;", function(err, row) { 45 | if(row) 46 | console.log("Database loaded with " + row.nusers + " users."); 47 | }); 48 | } 49 | }); 50 | } 51 | else 52 | console.log("No database specified: running in anonymous mode."); 53 | 54 | // Use HTTP API on separate port for everything except actual SWB 55 | // - for now, let's go with a default of short random string id: 56 | // 1. authenticate; reprompt for credentials on failure 57 | // 2. request random session id from server 58 | // 3. show share doc box with session id, allowing user to change id to something meaningful 59 | // 4. request new shared session from server; if session already exists, prompt user to connect to existing session 60 | // - must prompt so user isn't surprised to see the doc they are looking at be replaced 61 | // - future options include: 62 | // - session password to provide security while allowing session name to be meaningful 63 | // - option to restrict access to users within the same organization (as specified at sign up) 64 | // - option to restrict access to specified list of users 65 | // - full URL as session ID for easy web access to sessions or site installs of server 66 | 67 | function Client(stream) 68 | { 69 | this.name = null; 70 | this.stream = stream; 71 | this.remote = stream.remoteAddress + ":" + stream.remotePort; 72 | this.cmdstr = ""; 73 | this.tempdata = ""; 74 | this.expectdatalen = 0; 75 | } 76 | 77 | function Whiteboard(repo, attribs, token) 78 | { 79 | this.repo = repo; 80 | this.token = token; 81 | this.attribs = attribs; 82 | this.clients = []; 83 | this.history = ""; 84 | this.destructor = null; 85 | } 86 | 87 | var whiteboards = {}; 88 | 89 | // HTTP API server 90 | 91 | function Session(user, token) 92 | { 93 | this.user = user; 94 | this.token = token; 95 | this.ctime = Date.now(); 96 | } 97 | 98 | var sessions = {}; 99 | 100 | var apilog = logger('apilog'); 101 | apilog.setLogLevel(pargs["log-level"] || process.env.STYLUS_LOG_LEVEL || 'info'); 102 | //process.env.STYLUS_LOG_PATH && apilog.setLogFile(process.env.STYLUS_LOG_PATH + "/apiserver.log"); 103 | pargs["log-path"] && apilog.setLogFile(pargs["log-path"] + "/apiserver.log"); 104 | 105 | 106 | var apiserver = http.createServer(function (request, response) 107 | { 108 | var parsed = url.parse(request.url, true); // parseQueryString = true 109 | var path = parsed.pathname; 110 | var args = parsed.query; 111 | // extract cookies 112 | var cookies = {}; 113 | request.headers['cookie'] && request.headers['cookie'].split(';').forEach(function(cookie) { 114 | var parts = cookie.split('='); 115 | cookies[parts[0].trim()] = (parts[1] || "").trim(); 116 | }); 117 | // logging 118 | response.addListener('finish', function () { 119 | apilog.info(request.socket.remoteAddress + ' - [' + moment().utc().format('DD MMMM YYYY HH:mm:ss') + ' GMT] "' 120 | + request.method + ' ' + request.url + '" ' + response.statusCode + ' - ' + request.headers['user-agent'] + '"'); 121 | }); 122 | 123 | // debug page 124 | if(path == "/v1/debug" && pargs["enable-test"]) { //&& args["secret"] == "123456") { 125 | var replacer = function (key, value) { 126 | if(key == "history" || key == "tempdata") 127 | return "[ " + value.length + " bytes ]"; 128 | else if(key == "whiteboard" || key == "stream") 129 | return "[ Circular ]"; 130 | else 131 | return value; 132 | } 133 | response.writeHead(200); 134 | response.end("whiteboards = " + JSON.stringify(whiteboards, replacer, 2) 135 | + "\n\nsessions = " + JSON.stringify(sessions, null, 2)); 136 | } 137 | 138 | // new users are added directly to database by web server 139 | if(path == "/v1/auth") { 140 | var acceptauth = function() { 141 | var token = rndstr(); 142 | sessions[token] = new Session(args["user"], token); 143 | response.writeHead(200, { 144 | 'Set-Cookie': 'session=' + token, 145 | 'Content-Type': 'text/plain' 146 | }); 147 | response.end(); 148 | } 149 | 150 | if(!db) { 151 | // if no DB, accept all connections 152 | acceptauth(); 153 | return; 154 | } 155 | // lookup user in DB 156 | db.get("SELECT password FROM users WHERE username = ?", args["user"], function(err, row) { 157 | if(row && wbDB.validateAppLogin(args["signature"], args["timestamp"], row.password)) { 158 | // TODO: actually verify that timestamp is within acceptable range 159 | // ... rather, the proper approach would be for the client to request a token and use that instead 160 | // of timestamp to generate signature 161 | acceptauth(); 162 | return; 163 | } 164 | //console.log("Auth failed for: " + request.url); 165 | // fall thru for all error cases 166 | response.writeHead(401); 167 | response.end("error: invalid username or password"); 168 | }); 169 | return; 170 | } 171 | // verify session cookie for all other paths 172 | var session = sessions[cookies["session"]]; 173 | if(!session) { 174 | response.writeHead(403); 175 | response.end(); 176 | return; 177 | } 178 | if(session.ctime + 5 * 60 * 1000 < Date.now()) { 179 | delete sessions[cookies["session"]]; 180 | response.writeHead(408); 181 | response.end("error: session expired"); 182 | return; 183 | } 184 | 185 | if(path == "/v1/createswb" || path == "/v1/openswb") { 186 | var repo = args["name"]; 187 | if(!repo || (path == "/v1/openswb" && !whiteboards[repo]) || (path == "/v1/createswb" && whiteboards[repo])) { 188 | response.writeHead(404); 189 | response.end(); 190 | return; 191 | } 192 | if(!whiteboards[repo]) { 193 | var a = []; 194 | for(var k in args) { 195 | if(!args.hasOwnProperty || args.hasOwnProperty(k)) { 196 | a.push(k + "='" + args[k] + "'"); 197 | } 198 | } 199 | whiteboards[repo] = new Whiteboard(repo, a.join(" "), rndstr()); 200 | } 201 | var wb = whiteboards[repo]; 202 | var token = md5(session.user + wb.token); 203 | response.writeHead(200); 204 | response.end(""); 205 | } 206 | else { 207 | response.writeHead(404); 208 | response.end(); 209 | } 210 | }); 211 | 212 | apiserver.listen(7000); 213 | 214 | 215 | // shared whiteboarding server - basically just echos everything it receives to all clients 216 | // we now rely on HTTP API server to create the whiteboard 217 | 218 | // even with flush() of socket on client, no guarantee that commands will always be at the start of data chunks! 219 | 220 | var swblog = logger('swblog'); 221 | swblog.setLogLevel(pargs["log-level"] || process.env.STYLUS_LOG_LEVEL || 'info'); 222 | //process.env.STYLUS_LOG_PATH && swblog.setLogFile(process.env.STYLUS_LOG_PATH + "/swbserver.log"); 223 | pargs["log-path"] && swblog.setLogFile(pargs["log-path"] + "/swbserver.log"); 224 | 225 | // seconds to ms 226 | var destroyDelay = pargs["del-delay"]*1000 || 0; 227 | 228 | var swbserver = net.createServer(function (stream) 229 | { 230 | var client = new Client(stream); 231 | stream.setTimeout(0); 232 | stream.setEncoding("binary"); 233 | swblog.info(client.remote + " connected"); 234 | 235 | stream.on("data", function (data) 236 | { 237 | // don't print everything unless explicitly requested 238 | if(pargs["dump"]) 239 | swblog.debug("SWB server rcvd from " + client.remote + " data:", data); 240 | while(data.length > 0) { 241 | if(client.expectdatalen > 0) { 242 | client.tempdata += data.substr(0, client.expectdatalen); 243 | if(client.expectdatalen > data.length) { 244 | client.expectdatalen -= data.length; 245 | return; 246 | } 247 | swblog.debug("SWB server rcvd " + client.tempdata.length + " bytes of data from " + client.remote); 248 | data = data.substr(client.expectdatalen); 249 | client.expectdatalen = 0; 250 | var wb = client.whiteboard; 251 | wb.history += client.tempdata; 252 | wb.clients.forEach(function(c) { 253 | // echo to all clients, including sender 254 | c.stream.write(client.tempdata, "binary"); 255 | }); 256 | client.tempdata = ""; 257 | // fall through to handle rest of data ... after checking length again 258 | continue; 259 | } 260 | 261 | var delimidx = data.indexOf('\n'); 262 | if(delimidx < 0) { 263 | client.cmdstr += data; 264 | return; 265 | } 266 | client.cmdstr += data.substr(0, delimidx); 267 | data = data.substr(delimidx + 1); 268 | 269 | swblog.debug(client.remote + " sent command:", client.cmdstr); 270 | var parsed = {}; 271 | try { 272 | if(client.cmdstr[0] == '/') 273 | parsed = url.parse(client.cmdstr, true); // parseQueryString = true 274 | } 275 | catch(ex) {} 276 | var command = parsed.pathname; 277 | var args = parsed.query; 278 | if(command == "/info") { 279 | // /info?document= 280 | // get list of current SWB users 281 | var repo = args["document"]; 282 | if(whiteboards[repo]) { 283 | stream.write(whiteboards[repo].clients.join(",")); 284 | } 285 | else { 286 | stream.write("-"); 287 | } 288 | } 289 | else if(command == "/start") { 290 | // arguments: version (protocal version) - ignored for now;, user, document, (history) offset (optional), 291 | // token = MD5(user .. whiteboard.token) 292 | // history offset is 0 on initial connection; can be >0 when reconnecting 293 | var repo = args["document"]; 294 | var wb = whiteboards[repo]; 295 | if(args["token"] == 'SCRIBBLE_SYNC_TEST' && pargs["enable-test"]) { 296 | swblog.info(client.remote + ": connecting to test whiteboard " + repo + " as " + args["user"]); 297 | if(!wb) { 298 | wb = new Whiteboard(repo); 299 | whiteboards[repo] = wb; 300 | } 301 | } 302 | else if(!wb || args["token"] != md5(args["user"] + wb.token)) { 303 | swblog.warn(client.remote + ": whiteboard not found or invalid token"); 304 | stream.write("\n"); 305 | clientdisconn(); 306 | return; 307 | } 308 | client.whiteboard = wb; 309 | client.name = args["user"]; 310 | // send history 311 | if(wb.history.length > 0) { 312 | var histoffset = parseInt(args["offset"]); 313 | if(histoffset > 0) 314 | stream.write(wb.history.slice(histoffset), "binary"); 315 | else 316 | stream.write(wb.history, "binary"); 317 | } 318 | wb.clients.push(client); 319 | if(wb.destructor) { 320 | clearTimeout(wb.destructor); 321 | wb.destructor = null; 322 | } 323 | 324 | // if user was already connected as a different client, remove old client ... we've waited until new 325 | // client has been added to wb.clients so disconn() won't delete the SWB if only one user. Also 326 | // have to wait until history is sent! 327 | wb.clients.forEach(function(c) { 328 | // use full disconnect procedure to send "disconnect" signal since we'll send "connect" signal below 329 | if(c.name == args["user"] && c != client) { 330 | swblog.info("disconnecting " + c.remote + " due to connection of " + client.remote + " for user: " + c.name); 331 | c.stream.write("\n"); 332 | disconn(c); 333 | } 334 | }); 335 | 336 | // client can use uuid to distinguish this connect message from previous ones when reconnecting 337 | var msg = "\n"; 338 | wb.history += msg; 339 | wb.clients.forEach(function(c) { 340 | c.stream.write(msg, "binary"); 341 | }); 342 | } 343 | else if(command == "/data") { 344 | client.expectdatalen = parseInt(args["length"]); 345 | } 346 | else if(command == "/end") { 347 | clientdisconn(); 348 | return; 349 | } 350 | else { 351 | swblog.warn(client.remote + " sent invalid command:", client.cmdstr); 352 | clientdisconn(); 353 | return; 354 | } 355 | client.cmdstr = ""; 356 | } 357 | }); 358 | 359 | function disconn(client) 360 | { 361 | swblog.info(client.remote + " disconnected"); 362 | var wb = client.whiteboard; 363 | if(wb && wb.clients.remove(client)) { 364 | if(wb.clients.length == 0) { 365 | // delete whiteboard after specified delay after last user disconnects 366 | wb.destructor = setTimeout(function () { 367 | swblog.info("deleting whiteboard:", wb.repo); 368 | delete whiteboards[wb.repo]; 369 | }, destroyDelay); 370 | } 371 | else { 372 | var msg = "\n"; 373 | wb.history += msg; 374 | wb.clients.forEach(function(c) { 375 | c.stream.write(msg, "binary"); 376 | }); 377 | } 378 | } 379 | //client.stream.removeAllListeners(); 380 | client.stream.end(); 381 | } 382 | 383 | function clientdisconn() { disconn(client); } 384 | 385 | stream.on("end", function () { swblog.warn("disconnect due to stream end"); disconn(client); }); 386 | stream.on("error", function (err) { swblog.warn("disconnect due to stream error:", err); disconn(client); }); 387 | 388 | }); 389 | 390 | swbserver.listen(7001); 391 | --------------------------------------------------------------------------------