Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with
or
.
Download ZIP
Browse files

Server-side objects; Error messages; reaping and sign off (bugs 12, 1…

…3, 18, 21)

* Server-side has been mostly broken down into smaller modular components
* IM now works on Node.js (as a consequence of refactor/redesign)
* `Error` and `Success` `Package` types added (essentially, generic types)
* Users notified of sign off when user is reaped by `Session.IM` reaper
  • Loading branch information...
commit 065d5f84a769eb6923d82da6fdcadb40ea6b99b6 1 parent 8338839
Joshua Gross authored
View
2  README.md
@@ -19,7 +19,7 @@ Install `Node.js`:
make install
Install Node Package Manager (`npm`):
- See instructions at [isaacs' npm git repo](http://github.com/isaacs/npm).
+ See instructions at http://github.com/isaacs/npm.
Install `Express.js`:
npm install express
View
172 server/app.js
@@ -12,7 +12,7 @@
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
-//
+//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
@@ -25,13 +25,16 @@
//
var sys = require('sys');
-require.paths.unshift('express/lib');
+require.paths.unshift(require.paths[0] + '/express');
require('express');
require('express/plugins');
Object.merge(global, require('ext'));
-Object.merge(global, require('./session.js')); // Ugly.
+Object.merge(global, require('./session')); // Ugly.
+
+Object.merge(global, require('./settings'));
+try { Object.merge(global, require('./settings.local')); } catch(e) {}
-require('settings.js');
+var chat = require('./chat');
configure('development', function() {
use(Logger);
@@ -44,134 +47,53 @@ configure(function() {
use(Session.IM, {lifetime: (15).minutes,
reapInterval: (1).minute,
authentication:
- require('libs/authenticate/' + AUTH_LIBRARY)
+ require('./libs/authenticate/' + AUTH_LIBRARY)
});
set('root', __dirname);
});
-var AjaxIM = new Class({
- // === {{{ AjaxIM.constructor() }}} ===
- //
- // Initializes the frontend webserver and the backend Memcache server, which provides
- // and easy-to-use API for controlling the server from other scripts.
- constructor: function() {
- if(typeof this.config.port != 'number')
- throw new TypeError();
-
- get('/listen', function() {
- // Do nothing.
- });
-
- post('/send', function() {
- this.send()
- });
-
- get('/status', this.status);
-
- run(PORT, HOST);
- }
+get('/test_cookie', function() {
+ var utils = require('express/utils');
+ this.cookie('sessionid', utils.uid());
+ this.respond(200, 'cookie set');
+});
- // === {{{ AjaxIM.send() }}} ===
- //
- // Send a message to the user specified in the query and return a
- // result declaring whether or not the message was sent. Messages
- // are only sent if the user has an active session.
- this.send = function() {
- var sent = false;
+get('/listen', function() {
+ // Do nothing.
+});
- var user = self._session(this.request, 'object');
- var to = this.request.uri.params['to'] || '';
-
- if(!user) {
- self._d('An unknown user tried to send a message to [' + to + '] without being authenticated.');
- return this.response.reply(200, {'r': 'error', 'e': 'no session found'});
- }
-
- if(user.username && to &&
- to in self.users &&
- self.users[to].callback
- ) {
- var time = Math.round(Date.now() / 1000);
- self.users[to].callback({
- t: 'm',
- s: user.username,
- r: to,
- m: this.request.uri.params.message
- });
- sent = true;
- }
-
- self._d('User [' + user.username + '] sent a message to [' + to + '] ' + (sent ? 'successfully.' : 'UNSUCCESSFULLY.'));
+get('/message/user/:username', function(username) {
+ chat.AjaxIM.messageUser(this.session, username,
+ new chat.Message(
+ this.session,
+ this.param('body') || ''
+ ));
+});
- self.users[user.username].active();
- self.sessions[user.session_id].active();
- this.response.reply(200, {'sent': sent});
- };
+post('/message/user/:username', function(username) {
+ chat.AjaxIM.messageUser(this.session, username,
+ new chat.Message(
+ this.session,
+ this.params.post['body'] || ''
+ ));
+});
- // === {{{ AjaxIM.status() }}} ===
- //
- // Update a user's status based on the query parameters; this includes
- // both their status code and any custom status message associated with
- // that code. If the status update is successful, send an update to the
- // user's friends.
- this.status = function() {
- var status_updated = false;
+post('/message/user/:username/typing', function(username) {
+ if('state' in this.params.post &&
+ -~chat.TYPING_STATES.indexOf('typing' + this.params.post.state)) {
+ chat.AjaxIM.messageUser(this.session, username,
+ new chat.Status(
+ this.session,
+ 'typing' + this.params.post.state
+ ));
+ }
+});
- var user = self._session(this.request, 'object');
-
- if(!user) {
- self._d('An unknown user tried to change their status without being authenticated.');
- return this.response.reply(200, {'r': 'error', 'e': 'no session found'});
- }
-
- var status = this.request.uri.params.status;
- var statusMsg = status + ':' + this.request.uri.params.message;
-
- user.friends.forEach(function(f) {
- if(f.u in self.users) {
- var group = null;
- for(var i=0; i < self.users[f.u]['friends'].length; i++) {
- if(self.users[f.u]['friends'][i].u == user.username) {
- self.users[f.u]['friends'][i].s = status;
- group = self.users[f.u]['friends'][i].g;
- break;
- }
- }
-
- self.users[f.u].callback({t: 's', s: user.username, r: f.u, m: statusMsg, g: group});
- }
- });
-
- self._d('User [' + user.username + '] set his/her status to [' + statusMsg + ']. Friends notified.');
-
- self.users[user.username].status = {s: status, m: this.request.uri.params.message};
- self.users[user.username].active();
- self.sessions[user.session_id].active();
- this.response.reply(200, {status_updated: status_updated});
- };
-
- // === {{{ AjaxIM.online() }}} ===
- //
- // Return a list of currently signed in users and their statuses
- // sans the status messages.
- this.online = function() {
- var user = self._session(this.request, 'object');
-
- if(!user) {
- self._d('An unknown user tried to retrieve a list of online users without being authenticated.');
- return this.response.reply(200, {'r': 'error', 'e': 'no session found'});
- }
-
- this.response.reply(200, this.onlineList);
- };
-
- // === {{{ AjaxIM.onlineTotal() }}} ===
- //
- // Return a count of the number of online users.
- this.onlineTotal = function() {
- this.response.reply(200, {count: self.onlineCount});
- };
-};
+post('/status', function() {
+ if('status' in this.params.post &&
+ -~chat.STATUSES.indexOf(this.params.post.status)) {
+ this.session.status = this.params.post.status;
+ }
+});
-var im = new AjaxIM(config);
-im.init();
+run(APP_PORT, APP_HOST);
View
186 server/chat.js
@@ -1,10 +1,188 @@
-var utils = require('express/utils');
+var utils = require('express/utils'),
+ events = require('events'),
+ sys = require('sys');
-exports.Message = new Class({
+exports.AjaxIM = AjaxIM = new (new Class({
+ // === {{{ AjaxIM.constructor() }}} ===
+ //
+ // Initializes the frontend webserver and the backend Memcache server, which provides
+ // and easy-to-use API for controlling the server from other scripts.
+ constructor: function() {
+ this.users = [];
+ this.events = new events.EventEmitter();
+ this.events.addListener('update', (function(package) {
+ if(package.constructor === exports.Offline) {
+ for(var i = 0, l = this.users.length; i < l; i++) {
+ if(this.users[i].get('username') == package.user)
+ this.users.splice(i, 1);
+ }
+ }
+ }).bind(this));
+ },
+
+ messageUser: function(session, user, package) {
+ if(!(user in session.convos)) {
+ try {
+ var user_id = this.findUser(user).id;
+
+ session.convos[user] =
+ new exports.Conversation(session.id, user_id);
+
+ session.respond(new exports.Success('sent'));
+ } catch(e) {
+ session.respond(new exports.Error('user not online'));
+ return;
+ }
+ }
+
+ try {
+ session.convos[user].send(package);
+ } catch(e) {
+ session.respond(new exports.Error(e.description));
+ }
+ },
+
+ findUser: function(username) {
+ return this.users.find(function(e) {
+ return e.get('username') == username;
+ });
+ },
+
+ // === {{{ AjaxIM.online() }}} ===
+ //
+ // Return a list of currently signed in users and their statuses.
+ online: function() {
+ },
+
+ // === {{{ AjaxIM.onlineTotal() }}} ===
+ //
+ // Return a count of the number of online users.
+ onlineTotal: function() {
+ }
+}));
+
+var Package = new Class({
+ _sanitize: function(content) {
+ // strip HTML
+ return content.replace(/<(.|\n)*?>/g, '');
+ },
+
+ associate: function(room) {
+ this.room = room.id;
+ }
+});
+
+exports.Error = Package.extend({
+ constructor: function(error) {
+ this.error = error;
+ },
+
+ toString: function() {
+ return JSON.encode({
+ type: 'error',
+ error: this.error
+ });
+ }
+});
+
+exports.Success = Package.extend({
+ constructor: function(success) {
+ this.success = success;
+ },
+
+ toString: function() {
+ return JSON.encode({
+ type: 'success',
+ success: this.success
+ });
+ }
+});
+
+exports.Message = Package.extend({
+ constructor: function(user, body) {
+ this.user = user;
+ this.body = body;
+ },
+
+ toString: function() {
+ return JSON.encode({
+ type: 'message',
+ user: this.user.get('username'),
+ room: this.room,
+ body: this._sanitize(this.body)
+ });
+ }
+});
+
+exports.Notice = Package.extend({
+ constructor: function(user, info) {
+ this.user = user;
+ this.info = info;
+ },
+
+ toString: function() {
+ return JSON.encode({
+ type: 'notice',
+ user: this.user.get('username'),
+ room: this.room,
+ info: this.info
+ });
+ }
});
-exports.Notification = new Class({
+exports.TYPING_STATES = ['typing+', 'typing~', 'typing-'];
+exports.STATUSES = ['available', 'away', 'idle'];
+exports.Status = Package.extend({
+ constructor: function(user, status, message) {
+ var statuses = exports.STATUSES + exports.TYPING_STATES;
+
+ this.user = user;
+ this.status = -~statuses.indexOf(status) ? status : statuses[0];
+ this.message = message;
+ },
+
+ toString: function() {
+ return JSON.encode({
+ type: 'status',
+ user: this.user.get('username'),
+ status: this.status,
+ message: this._sanitize(this.message)
+ });
+ }
});
-exports.Room = new Class({
+exports.Offline = Package.extend({
+ constructor: function(user) {
+ this.user = user;
+ },
+
+ toString: function() {
+ // A special type of status
+ return JSON.encode({
+ type: 'status',
+ user: this.user.get('username'),
+ status: 'offline',
+ message: ''
+ });
+ }
+});
+
+exports.Conversation = new Class({
+ constructor: function(you, them) {
+ this.you = you;
+ this.them = them;
+ this.last_updated = Date.now();
+ },
+
+ send: function(package) {
+ this.touch();
+ if(user = Session.IM.get(this.them))
+ user.notify(package);
+ else
+ throw new Error('user not online');
+ },
+
+ touch: function() {
+ this.last_updated = Date.now();
+ }
});
View
16 server/libs/authenticate/index.js
@@ -1,12 +1,22 @@
exports.cookie = 'ajaxim_session';
-exports.authenticate = function(request) {
+exports.authenticate = function(request, callback) {
// Verify user based on request.
// On failure, redirect user to auth form
- return {
+ callback({
username: 'username',
displayname: 'John Smith',
otherinfo: 'any other relevant key/values'
- };
+ });
+};
+
+exports.friends = function(user, callback) {
+ // Create a friends list based on given user data
+
+ callback([
+ 'username1',
+ 'username2',
+ 'username3'
+ ]);
};
View
115 server/session.js
@@ -1,4 +1,7 @@
-var utils = require('express/utils');
+var utils = require('express/utils'),
+ events = require('events'),
+ chat = require('./chat'),
+ sys = require('sys');
var User = Base.extend({
constructor: function(id, data) {
@@ -6,7 +9,25 @@ var User = Base.extend({
this.connection = null;
this.listeners = [];
this.message_queue = [];
+ this.convos = {};
+
+ Session.IM.authentication.friends(data.username, (function(friends) {
+ this.friends = friends;
+ }).bind(this));
+
this._data = data;
+
+ this.events = new events.EventEmitter();
+ this.events.addListener('status', function(value) {
+ chat.AjaxIM.events.emit('update', new chat.Status(this, value));
+ });
+
+ chat.AjaxIM.events.addListener('update', (function(package) {
+ if(this.friends.indexOf(package.user))
+ this.notify(package);
+ }).bind(this));
+
+ chat.AjaxIM.users.push(this);
},
connected: function(conn) {
@@ -32,13 +53,8 @@ var User = Base.extend({
code = 200;
}
- if(typeof message != 'string') {
- try {
- message = JSON.encode(message);
- } except(e) {
- throw new Error('Could not JSON encode message content!');
- }
- }
+ if(typeof message != 'string')
+ message = message.toString();
if(type == 'connection' && this.connection) {
this.connection.respond(code, message, 'UTF-8');
@@ -46,24 +62,42 @@ var User = Base.extend({
if(!this.listeners.length)
this.message_queue.push(arguments);
- var notify_run, self = this;
+ var notify_run, cx = this.listeners.slice();
(notify_run = function(conn) {
return function() {
- if(!conn) (callback ? callback() : return);
+ if(!conn) {
+ if(callback) callback();
+ return;
+ }
- conn.respond(code, message,
- notify_run(self.listeners.pop()));
+ conn.respond(code, message, 'UTF-8',
+ notify_run(cx.shift()));
};
- })(this.listeners.pop())();
+ })(cx.shift())();
}
},
+ signoff: function(callback) {
+ chat.AjaxIM.events.emit('update', new chat.Offline(this));
+
+ if(callback) callback()
+ },
+
get: function(key, def) {
if(key == 'id') return this.id;
if(key in this._data)
return this._data[key];
else
return def || false;
+ },
+
+ get status() {
+ return this.status;
+ },
+
+ set status(value) {
+ this.status = value;
+ this.events.emit('status', value);
}
});
@@ -72,24 +106,41 @@ Store.Memory.IM = Store.Memory.extend({
constructor: function(options) {
Store.Memory.call(this);
- this.auth = options.authenticate;
+ this.auth = options.authentication;
},
fetch: function(req, callback) {
- var sid = req.cookie(this.auth.cookie);
+ var sid = req.cookie(this.auth.cookie),
+ self = this;
if(sid && this.store[sid]) {
- callback(null, this.store[sid], false);
+ callback(null, this.store[sid]);
} else {
- this.generate(req, callback);
+ this.generate(sid, req, function(err, session) {
+ self.commit(session);
+ callback(err, session);
+ });
}
},
-
- generate: function(req, callback) {
- if(data = this.auth.authenticate(req)) {
- var sid = req.cookie(this.auth.cookie);
- callback(null, new User(sid, data), true);
+
+ reap: function(ms) {
+ var threshold = +new Date(Date.now() - ms),
+ sids = Object.keys(this.store);
+ for(var i = 0, len = sids.length; i < len; ++i) {
+ this.store[sids[i]].signoff((function() {
+ this.destroy(sids[i]);
+ }).bind(this));
}
+ },
+
+ generate: function(sid, req, callback) {
+ this.auth.authenticate(req, function(data) {
+ if(data) {
+ callback(null, new User(sid, data));
+ } else {
+ callback(true);
+ }
+ });
}
});
@@ -106,6 +157,10 @@ Session.IM = Plugin.extend({
setInterval(function(self) {
self.store.reap(self.lifetime || (1).day);
}, this.reapInterval || this.reapEvery || (1).hour, this);
+ },
+
+ get: function(session_id) {
+ return this.store.store[session_id] || false;
}
},
@@ -114,7 +169,7 @@ Session.IM = Plugin.extend({
if(event.request.url.pathname === '/favicon.ico')
return;
- Session.IM.store.fetch(event.request, function(err, session, is_new) {
+ Session.IM.store.fetch(event.request, function(err, session) {
if(err) return callback(err);
event.request.session = session;
@@ -122,15 +177,15 @@ Session.IM = Plugin.extend({
if(event.request.url.pathname == '/listen') {
session.listener(event.request);
-
- callback();
- if(is_new) session.notify({type: 'noop'});
- else if(session.message_queue.length)
- session._send(session.message_queue.shift());
+ Session.IM.store.commit(event.request.session);
+
+ if(msg = session.message_queue.shift())
+ session._send(msg);
} else {
session.connection = event.request;
- callback();
}
+
+ callback();
});
return true;
@@ -144,4 +199,4 @@ Session.IM = Plugin.extend({
true;
}
}
-});
+});
View
2  server/settings.js
@@ -22,4 +22,4 @@ API_KEY = 'FG34tbNW$n5aw4E6Y&U&6inBFDs';
// This is the library (from libs/authenticate/) that we will use to
// authenticate a user signing in. The value should be the name of the file
// without the '.js' part. 'index' is the default library.
-AUTH_LIBRARY = 'index';
+AUTH_LIBRARY = 'index';
Please sign in to comment.
Something went wrong with that request. Please try again.