Permalink
Find file
Fetching contributors…
Cannot retrieve contributors at this time
417 lines (314 sloc) 10.2 KB
EventEmitter = require('events').EventEmitter
Promise = require 'bluebird'
path = require 'path'
favicon = require 'static-favicon'
logger = require 'morgan'
express = require 'express'
validate = require './lib/validate'
middleware = require './lib/middleware'
uuid4 = require './lib/uuid4'
app = express()
http = require('http').Server(app)
io = require('socket.io')(http)
app.use favicon()
app.use logger('dev')
app.use express.static(path.join(__dirname, 'public'))
isMatch = (partner, criteria) ->
Object.keys(criteria).every (key) ->
criteria[key].indexOf(partner[key]) != -1
#
# Rooms
#
class Room extends EventEmitter
Room.byName = {}
# Return a list of rooms with public metadata
Room.list = ->
new Promise (resolve, reject) ->
open = []
for name, {meta} of Room.byName
if meta and meta.open
open.push(name)
resolve(open)
constructor: (@name, @ownerId, @options={}) ->
if Room.byName[@name]
throw new Error("Duplicate room")
else
Room.byName[@name] = this
# Set the EventEmitter's max listeners to unlimited
this.setMaxListeners(0)
# Current room state
@profiles = {}
@permissions = {}
@tail = []
# Default options
@options.blacklist ?= []
@options.defaultPermissions ?= ["say", "me"]
@options.meta ?= null
@on 'broadcast', (roomName, line) ->
# Keep a "tail" of the last sent lines
@tail.push(line)
@tail.shift() if @tail.length > 50
command: (userId, type, data) ->
new Promise (resolve, reject) =>
if userId != @ownerId and type not in @permissions[userId]
reject(403)
else
switch type
when "setmeta"
if userId == @ownerId
@options.meta = data
else
return reject(403)
@broadcast(userId, "user #{type}", data)
resolve()
broadcast: (userId, type, data={}) ->
@emit 'broadcast', @name,
timestamp: new Date()
userId: userId
userName: @profiles[userId].name
type: type
data: data
join: (user) ->
new Promise (resolve, reject) =>
userId = user.id
canJoin =
(userId == @ownerId or
(userId not in @options.blacklist and
(!@whitelist or userId in @options.whitelist)))
if !canJoin
reject(403)
else if @profiles[userId]
reject(409)
else
@profiles[userId] = user.profile
@permissions[userId] = @options.defaultPermissions
@broadcast(userId, 'server join', user.profile)
# Forward user events separately to the `broadcast` message so that
# users (including owner) cannot fake profile updates, etc.
handlers = {}
forward = ['removed', 'disconnected', 'resocketed', 'profileUpdated']
for e in forward
propertyName = "handleUser"+e[0].toUpperCase()+e.substr(1)
handlers[e] = this[propertyName].bind(this, userId)
user.on e, handlers[e]
@on 'broadcast', (roomName, details) ->
# This is a bit hacky. Would probably be better to use a channel than
# to have the room add internal event handlers on user.
user.handleRoomBroadcast(roomName, details)
@once 'leave:'+user.id, =>
@removeListener 'broadcast', user.handleRoomBroadcast
for e in forward
user.removeListener e, handlers[e]
handlers = null
resolve(meta: @options.meta, profiles: @profiles, tail: @tail)
leave: (userId) ->
new Promise (resolve, reject) =>
if @profiles[userId]
@broadcast(userId, 'server leave')
@emit('leave:'+userId)
delete @profiles[userId]
delete @permissions[userId]
resolve()
else
reject(409)
handleUserRemoved: (userId) ->
@leave(userId)
handleUserDisconnected: (userId) ->
@broadcast(userId, 'server disconnected')
handleUserResocketed: (userId) ->
@broadcast(userId, 'server resocketed')
handleUserProfileUpdated: (userId, profile) ->
@broadcast(userId, 'server profileUpdated', profile)
@profiles[userId] = profile
# Setup default room
new Room("#general", meta: {description: "General chat", open: true})
#
# Users
#
class User extends EventEmitter
User.nextId = 0
User.byId = {}
User.byName = {}
User.disconnected = {}
# Check for users who've been disconnected for over 30 seconds roughly once
# every second.
cleanupInterval = 1000
cleanupTime = 15000
cleanup = ->
now = new Date().getTime()
for userId, time of User.disconnected
if (now - time) > cleanupTime
User.byId[userId].cleanup()
setTimeout(cleanup, cleanupInterval)
setTimeout(cleanup, cleanupInterval)
constructor: (@socket) ->
@id = String(User.nextId += 1)
@notifications = []
@blocked = []
@secret = uuid4()
User.byId[@id] = this
# Set the EventEmitter's max listeners to unlimited
this.setMaxListeners(0)
@setupSocket()
# Given the user a new socket
resocket: (socket) ->
@emit('resocketed')
@socket.disconnect('resocketed')
@socket = socket
delete User.disconnected[@id]
delete @profile.disconnected
@onSetProfile(@profile)
@setupSocket()
# Completel remove the user from memory, without any possibility of taking
# it back at a later time
cleanup: ->
@emit('removed')
delete User.byId[@id]
delete User.disconnected[@id]
delete User.byName[@name]
serialize: ->
JSON.stringify(this, ['id', 'name', 'profile'])
# Disconnect the user for bad behaviour
redCard: ->
@socket.disconnect 'goodbye'
@cleanup()
#
# Handle events for the user
#
setupSocket: ->
@socket.on 'disconnect', @onDisconnect.bind(this)
# Setup listeners on the new socket
commands =
setProfile: {minDelay: 6000, allowAnonymous: true}
setBlocked: {minDelay: 1000}
listRooms: {minDelay: 10000, allowAnonymous: true}
joinRoom: {minDelay: 3000}
leaveRoom: {}
subscribeToNotifications: {}
unsubscribeFromNotifications: {}
command: {}
for command, options of commands
property = 'on'+command[0].toUpperCase()+command.substr(1)
# Disconnect a user if their message causes an exception
if process.env.NODE_ENV != "development"
handler = middleware.catchErrors.call(this, this[property])
else
handler = this[property]
# Rate limit commands
handler = middleware.rateLimit.call(this, handler, options.minDelay ? 50)
# Require the user has a name unless otherwise specified
handler = middleware.requireName.call(this, handler) unless options.allowAnonymous
# Setup this command on the socket
@socket.on(command, handler)
onDisconnect: ->
if @profile
@emit('disconnected')
@profile.disconnected = true
@onSetProfile(@profile)
User.disconnected[@id] = new Date().getTime()
else
@cleanup()
onSetProfile: (profile) ->
existing = User.byName[profile.name]
if existing and existing != this
@socket.emit 'setProfile', 409, 'name'
else if !validate.profile(profile)
@redCard()
else
# Set name
delete User.byName[@name]
@name = profile.name
User.byName[@name] = this
oldProfile = @profile if @profile
@profile = profile
@emit('profileUpdated', profile)
@socket.emit 'setProfile', 200, profile
onListRooms: ->
Room.list().then(
(result) => @socket.emit 'listRooms', 200, result
(err) => @socket.emit 'listRooms', 500
)
onJoinRoom: (name) ->
room = Room.byName[name]
if !room
@socket.emit 'joinRoom', 404, name
else
room.join(this).then(
(result) =>
@socket.emit 'joinRoom', 200, result
(err) =>
@socket.emit 'joinRoom', err
)
onLeaveRoom: (room) ->
room = Room.byName[name]
if !room
@socket.emit 'leaveRoom', 404, name
else
room.leave(@id).then(
(result) => @socket.emit 'leaveRoom', 200, result
(err) => @socket.emit 'leaveRoom', err
)
onSetBlocked: (blocked) ->
if !validate.blocked(blocked) or @id in blocked
@redCard()
else
@blocked = blocked
@socket.emit 'setBlocked', 200
onSubscribeToNotifications: ->
onUnsubscribeFromNotifications: ->
onCommand: (roomName, type, data) ->
# TODO: validate based on type
if (type == "say" or type == "me") and !validate.text(data)
@redCard()
else if room = Room.byName[roomName]
room.command(@id, type, data).then(
=> @socket.emit 'command', 200
(err) => @socket.emit 'command', err
)
else if user = User.byId[roomName]
# TODO: probably is a nicer way of doing this...
user.handlePrivateMessage(@id, @profile.name, type, data)
@socket.emit 'command', 200
else
@socket.emit 'command', 404
#
# Handle messages from other sources
#
handleNotification: (userId, notification) =>
handleRoomBroadcast: (roomName, line) =>
if line.type.substr(0, 6) == "server" or line.userId not in @blocked
@socket.emit 'roomBroadcast', roomName, line
handlePrivateMessage: (userId, userName, type, data) =>
if userId not in @blocked
@socket.emit 'privateMessage',
timestamp: new Date()
userId: userId
userName: userName
type: "user #{type}"
data: data
io.on 'connection', (socket) ->
socket.join 'global'
# Create a user for the new socket
user = new User(socket)
# Let the user know an unguessable secret, allowing them to take the user
# back in the event of a temporary disconnection
socket.emit 'newId', String(user.id), user.secret
# Allow users to take over a previous id
socket.on 'takeId', (id, secret) ->
takeUser = User.byId[id]
if takeUser and takeUser.secret == secret
user.cleanup()
user = takeUser
user.resocket(socket)
socket.emit 'takeId', 200
else
user.redCard()
handleShutdown = ->
io.to('global').emit('shutdown')
process.exit()
process.on "SIGINT", handleShutdown
process.on "SIGTERM", handleShutdown
app.all '*', (req, res) -> res.status(404).send '404 - Page not found'
if process.env.NODE_ENV == 'development'
app.use require('errorhandler')()
http.listen Number(process.env.PORT or 3000)