Bläddra i källkod

Merge branch 'master' into pr-contact-form-suggestions

Paulo Reis 10 år sedan
förälder
incheckning
444120f8b1
45 ändrade filer med 1437 tillägg och 161 borttagningar
  1. 23 13
      services/web/Gruntfile.coffee
  2. 16 0
      services/web/README.md
  3. 9 4
      services/web/app/coffee/Features/Authentication/AuthenticationController.coffee
  4. 1 1
      services/web/app/coffee/Features/Blog/BlogController.coffee
  5. 19 2
      services/web/app/coffee/Features/Collaborators/CollaboratorsHandler.coffee
  6. 4 8
      services/web/app/coffee/Features/Compile/CompileController.coffee
  7. 1 1
      services/web/app/coffee/Features/Compile/CompileManager.coffee
  8. 9 6
      services/web/app/coffee/Features/PasswordReset/PasswordResetController.coffee
  9. 5 3
      services/web/app/coffee/Features/Project/ProjectDeleter.coffee
  10. 10 7
      services/web/app/coffee/Features/User/UserController.coffee
  11. 126 0
      services/web/app/coffee/Features/User/UserSessionsManager.coffee
  12. 4 1
      services/web/app/coffee/infrastructure/Server.coffee
  13. 43 10
      services/web/app/views/layout.jade
  14. 3 3
      services/web/app/views/layout/footer.jade
  15. 1 1
      services/web/app/views/project/editor/editor.jade
  16. 13 6
      services/web/app/views/project/editor/pdf.jade
  17. 4 0
      services/web/config/settings.defaults.coffee
  18. 0 1
      services/web/package.json
  19. 11 1
      services/web/public/coffee/ide.coffee
  20. 3 1
      services/web/public/coffee/ide/hotkeys/controllers/HotkeysController.coffee
  21. 2 1
      services/web/public/coffee/ide/human-readable-logs/HumanReadableLogs.coffee
  22. 14 14
      services/web/public/coffee/ide/human-readable-logs/HumanReadableLogsRules.coffee
  23. 38 12
      services/web/public/coffee/ide/pdf/controllers/PdfController.coffee
  24. 2 1
      services/web/public/coffee/ide/pdfng/directives/pdfRenderer.coffee
  25. 9 1
      services/web/public/coffee/ide/pdfng/directives/pdfViewer.coffee
  26. 25 1
      services/web/public/coffee/ide/settings/services/settings.coffee
  27. 3 1
      services/web/public/coffee/ide/share/controllers/ShareController.coffee
  28. 1 5
      services/web/public/coffee/ide/wordcount/controllers/WordCountModalController.coffee
  29. 18 2
      services/web/public/coffee/main/event.coffee
  30. BIN
      services/web/public/img/sprite.png
  31. 11 2
      services/web/public/stylesheets/app/editor/pdf.less
  32. 105 0
      services/web/public/stylesheets/app/sprites.less
  33. 5 0
      services/web/public/stylesheets/components/footer.less
  34. 1 0
      services/web/public/stylesheets/style.less
  35. 32 25
      services/web/test/UnitTests/coffee/Authentication/AuthenticationControllerTests.coffee
  36. 19 4
      services/web/test/UnitTests/coffee/Collaborators/CollaboratorsHandlerTests.coffee
  37. 1 5
      services/web/test/UnitTests/coffee/Compile/CompileControllerTests.coffee
  38. 1 1
      services/web/test/UnitTests/coffee/Compile/CompileManagerTests.coffee
  39. 11 1
      services/web/test/UnitTests/coffee/PasswordReset/PasswordResetControllerTests.coffee
  40. 9 1
      services/web/test/UnitTests/coffee/Project/ProjectDeleterTests.coffee
  41. 15 12
      services/web/test/UnitTests/coffee/User/UserControllerTests.coffee
  42. 484 0
      services/web/test/UnitTests/coffee/User/UserSessionsManagerTests.coffee
  43. 253 0
      services/web/test/acceptance/coffee/SessionTests.coffee
  44. 46 3
      services/web/test/acceptance/coffee/helpers/User.coffee
  45. 27 0
      services/web/test/acceptance/coffee/helpers/redis.coffee

+ 23 - 13
services/web/Gruntfile.coffee

@@ -17,8 +17,9 @@ module.exports = (grunt) ->
 	grunt.loadNpmTasks 'grunt-contrib-watch'
 	grunt.loadNpmTasks 'grunt-contrib-watch'
 	grunt.loadNpmTasks 'grunt-parallel'
 	grunt.loadNpmTasks 'grunt-parallel'
 	grunt.loadNpmTasks 'grunt-exec'
 	grunt.loadNpmTasks 'grunt-exec'
-	grunt.loadNpmTasks 'grunt-contrib-imagemin'
 	grunt.loadNpmTasks 'grunt-contrib-cssmin'
 	grunt.loadNpmTasks 'grunt-contrib-cssmin'
+	# grunt.loadNpmTasks 'grunt-contrib-imagemin'
+	# grunt.loadNpmTasks 'grunt-sprity'
 
 
 	config =
 	config =
 
 
@@ -47,18 +48,26 @@ module.exports = (grunt) ->
 					stream:true
 					stream:true
 
 
 
 
-		imagemin:
-			dynamic:                       
-				files: [{
-					expand: true
-					cwd: 'public/img/'
-					src: ['**/*.{png,jpg,gif}']
-					dest: 'public/img/'
-				}]
-			options:
-				interlaced:false
-				optimizationLevel: 7
-
+		# imagemin:
+		# 	dynamic:                       
+		# 		files: [{
+		# 			expand: true
+		# 			cwd: 'public/img/'
+		# 			src: ['**/*.{png,jpg,gif}']
+		# 			dest: 'public/img/'
+		# 		}]
+		# 	options:
+		# 		interlaced:false
+		# 		optimizationLevel: 7
+
+		# sprity:
+		# 	sprite:
+		# 		options:
+		# 			cssPath:"/img/"
+		# 			'style': '../../public/stylesheets/app/sprites.less'
+		# 			margin: 0
+		# 		src: ['./public/img/flags/24/*.png']
+		# 		dest: './public/img/sprite'
 
 
 
 
 		coffee:
 		coffee:
@@ -218,6 +227,7 @@ module.exports = (grunt) ->
 				pattern: "@@RELEASE@@"
 				pattern: "@@RELEASE@@"
 				replacement: process.env.BUILD_NUMBER || "(unknown build)"
 				replacement: process.env.BUILD_NUMBER || "(unknown build)"
 
 
+
 			
 			
 
 
 		availabletasks:
 		availabletasks:

+ 16 - 0
services/web/README.md

@@ -6,9 +6,17 @@ web-sharelatex is the front-end web service of the open-source web-based collabo
 It serves all the HTML pages, CSS and javascript to the client. web-sharelatex also contains 
 It serves all the HTML pages, CSS and javascript to the client. web-sharelatex also contains 
 a lot of logic around creating and editing projects, and account management.
 a lot of logic around creating and editing projects, and account management.
 
 
+
 The rest of the ShareLaTeX stack, along with information about contributing can be found in the 
 The rest of the ShareLaTeX stack, along with information about contributing can be found in the 
 [sharelatex/sharelatex](https://github.com/sharelatex/sharelatex) repository.
 [sharelatex/sharelatex](https://github.com/sharelatex/sharelatex) repository.
 
 
+Build process
+----------------
+
+web-sharelatex uses [Grunt](http://gruntjs.com/) to build its front-end related assets.
+
+Image processing tasks are commented out in the gruntfile and the needed packages aren't presently in the project's `package.json`. If the images need to be processed again (minified and sprited), start by fetching the packages (`npm install grunt-contrib-imagemin grunt-sprity`), then *decomment* the tasks in `Gruntfile.coffee`. After this, the tasks can be called (explicitly, via `grunt imagemin` and `grunt sprity`).
+
 Unit test status
 Unit test status
 ----------------
 ----------------
 
 
@@ -42,3 +50,11 @@ in the `public/img/iconshock` directory found via
 [findicons.com](http://findicons.com/icon/498089/height?id=526085#)
 [findicons.com](http://findicons.com/icon/498089/height?id=526085#)
 
 
 
 
+## Acceptance Tests
+
+To run the Acceptance tests:
+
+- set `allowPublicAccess` to true, either in the configuration file,
+  or by setting the environment variable `SHARELATEX_ALLOW_PUBLIC_ACCESS` to `true`
+- start the server (`grunt`)
+- in a separate terminal, run `grunt test:acceptance`

+ 9 - 4
services/web/app/coffee/Features/Authentication/AuthenticationController.coffee

@@ -9,11 +9,12 @@ Url = require("url")
 Settings = require "settings-sharelatex"
 Settings = require "settings-sharelatex"
 basicAuth = require('basic-auth-connect')
 basicAuth = require('basic-auth-connect')
 UserHandler = require("../User/UserHandler")
 UserHandler = require("../User/UserHandler")
+UserSessionsManager = require("../User/UserSessionsManager")
 
 
 module.exports = AuthenticationController =
 module.exports = AuthenticationController =
 	login: (req, res, next = (error) ->) ->
 	login: (req, res, next = (error) ->) ->
 		AuthenticationController.doLogin req.body, req, res, next
 		AuthenticationController.doLogin req.body, req, res, next
-	
+
 	doLogin: (options, req, res, next) ->
 	doLogin: (options, req, res, next) ->
 		email = options.email?.toLowerCase()
 		email = options.email?.toLowerCase()
 		password = options.password
 		password = options.password
@@ -61,7 +62,7 @@ module.exports = AuthenticationController =
 	requireLogin: () ->
 	requireLogin: () ->
 		doRequest = (req, res, next = (error) ->) ->
 		doRequest = (req, res, next = (error) ->) ->
 			if !req.session.user?
 			if !req.session.user?
-				AuthenticationController._redirectToLoginOrRegisterPage(req, res) 
+				AuthenticationController._redirectToLoginOrRegisterPage(req, res)
 			else
 			else
 				req.user = req.session.user
 				req.user = req.session.user
 				return next()
 				return next()
@@ -92,9 +93,9 @@ module.exports = AuthenticationController =
 
 
 	_redirectToLoginOrRegisterPage: (req, res)->
 	_redirectToLoginOrRegisterPage: (req, res)->
 		if req.query.zipUrl? or req.query.project_name?
 		if req.query.zipUrl? or req.query.project_name?
-			return AuthenticationController._redirectToRegisterPage(req, res) 
+			return AuthenticationController._redirectToRegisterPage(req, res)
 		else
 		else
-			AuthenticationController._redirectToLoginPage(req, res) 
+			AuthenticationController._redirectToLoginPage(req, res)
 
 
 
 
 	_redirectToLoginPage: (req, res) ->
 	_redirectToLoginPage: (req, res) ->
@@ -132,6 +133,8 @@ module.exports = AuthenticationController =
 			isAdmin: user.isAdmin
 			isAdmin: user.isAdmin
 			email: user.email
 			email: user.email
 			referal_id: user.referal_id
 			referal_id: user.referal_id
+			session_created: (new Date()).toISOString()
+			ip_address: req.ip
 		# Regenerate the session to get a new sessionID (cookie value) to
 		# Regenerate the session to get a new sessionID (cookie value) to
 		# protect against session fixation attacks
 		# protect against session fixation attacks
 		oldSession = req.session
 		oldSession = req.session
@@ -141,4 +144,6 @@ module.exports = AuthenticationController =
 			req.session[key] = value
 			req.session[key] = value
 
 
 		req.session.user = lightUser
 		req.session.user = lightUser
+
+		UserSessionsManager.trackSession(user, req.sessionID, () ->)
 		callback()
 		callback()

+ 1 - 1
services/web/app/coffee/Features/Blog/BlogController.coffee

@@ -20,7 +20,7 @@ module.exports = BlogController =
 
 
 		logger.log url:url, "proxying request to blog api"
 		logger.log url:url, "proxying request to blog api"
 		request.get blogUrl, (err, r, data)->
 		request.get blogUrl, (err, r, data)->
-			if r?.statusCode == 404
+			if r?.statusCode == 404 or r?.statusCode == 403
 				return ErrorController.notFound(req, res, next)
 				return ErrorController.notFound(req, res, next)
 			if err?
 			if err?
 				return res.send 500
 				return res.send 500

+ 19 - 2
services/web/app/coffee/Features/Collaborators/CollaboratorsHandler.coffee

@@ -30,12 +30,17 @@ module.exports = CollaboratorsHandler =
 	getMembersWithPrivilegeLevels: (project_id, callback = (error, members) ->) ->
 	getMembersWithPrivilegeLevels: (project_id, callback = (error, members) ->) ->
 		CollaboratorsHandler.getMemberIdsWithPrivilegeLevels project_id, (error, members = []) ->
 		CollaboratorsHandler.getMemberIdsWithPrivilegeLevels project_id, (error, members = []) ->
 			return callback(error) if error?
 			return callback(error) if error?
+			result = []
 			async.mapLimit members, 3,
 			async.mapLimit members, 3,
 				(member, cb) ->
 				(member, cb) ->
 					UserGetter.getUser member.id, (error, user) ->
 					UserGetter.getUser member.id, (error, user) ->
 						return cb(error) if error?
 						return cb(error) if error?
-						return cb(null, { user: user, privilegeLevel: member.privilegeLevel })
-				callback
+						if user?
+							result.push { user: user, privilegeLevel: member.privilegeLevel }
+						cb()
+				(error) ->
+					return callback(error) if error?
+					callback null, result
 	
 	
 	getMemberIdPrivilegeLevel: (user_id, project_id, callback = (error, privilegeLevel) ->) ->
 	getMemberIdPrivilegeLevel: (user_id, project_id, callback = (error, privilegeLevel) ->) ->
 		# In future if the schema changes and getting all member ids is more expensive (multiple documents)
 		# In future if the schema changes and getting all member ids is more expensive (multiple documents)
@@ -82,6 +87,18 @@ module.exports = CollaboratorsHandler =
 				logger.error err: err, "problem removing user from project collaberators"
 				logger.error err: err, "problem removing user from project collaberators"
 			callback(err)
 			callback(err)
 	
 	
+	removeUserFromAllProjets: (user_id, callback = (error) ->) ->
+		CollaboratorsHandler.getProjectsUserIsCollaboratorOf user_id, { _id: 1 }, (error, readAndWriteProjects = [], readOnlyProjects = []) ->
+			return callback(error) if error?
+			allProjects = readAndWriteProjects.concat(readOnlyProjects)
+			jobs = []
+			for project in allProjects
+				do (project) ->
+					jobs.push (cb) ->
+						return cb() if !project?
+						CollaboratorsHandler.removeUserFromProject project._id, user_id, cb
+			async.series jobs, callback
+	
 	addEmailToProject: (project_id, adding_user_id, unparsed_email, privilegeLevel, callback = (error, user) ->) ->
 	addEmailToProject: (project_id, adding_user_id, unparsed_email, privilegeLevel, callback = (error, user) ->) ->
 		emails = mimelib.parseAddresses(unparsed_email)
 		emails = mimelib.parseAddresses(unparsed_email)
 		email = emails[0]?.address?.toLowerCase()
 		email = emails[0]?.address?.toLowerCase()

+ 4 - 8
services/web/app/coffee/Features/Compile/CompileController.coffee

@@ -29,8 +29,6 @@ module.exports = CompileController =
 				options.compiler = req.body.compiler
 				options.compiler = req.body.compiler
 			if req.body?.draft
 			if req.body?.draft
 				options.draft = req.body.draft
 				options.draft = req.body.draft
-			if req.query?.isolated is "true"
-				options.isolated = true
 			logger.log {options:options, project_id:project_id, user_id:user_id}, "got compile request"
 			logger.log {options:options, project_id:project_id, user_id:user_id}, "got compile request"
 			CompileManager.compile project_id, user_id, options, (error, status, outputFiles, clsiServerId, limits, validationProblems) ->
 			CompileManager.compile project_id, user_id, options, (error, status, outputFiles, clsiServerId, limits, validationProblems) ->
 				return next(error) if error?
 				return next(error) if error?
@@ -44,17 +42,15 @@ module.exports = CompileController =
 				}
 				}
 
 
 	_compileAsUser: (req, callback) ->
 	_compileAsUser: (req, callback) ->
-		# callback with user_id if isolated flag is set on request, undefined otherwise
-		isolated = req.query?.isolated is "true"
-		if isolated
+		# callback with user_id if per-user, undefined otherwise
+		if not Settings.disablePerUserCompiles
 			AuthenticationController.getLoggedInUserId req, callback # -> (error, user_id)
 			AuthenticationController.getLoggedInUserId req, callback # -> (error, user_id)
 		else
 		else
 			callback() # do a per-project compile, not per-user
 			callback() # do a per-project compile, not per-user
 
 
 	_downloadAsUser: (req, callback) ->
 	_downloadAsUser: (req, callback) ->
-		# callback with user_id if isolated flag or user_id param is set on request, undefined otherwise
-		isolated = req.query?.isolated is "true" or req.params.user_id?
-		if isolated
+		# callback with user_id if per-user, undefined otherwise
+		if not Settings.disablePerUserCompiles
 			AuthenticationController.getLoggedInUserId req, callback # -> (error, user_id)
 			AuthenticationController.getLoggedInUserId req, callback # -> (error, user_id)
 		else
 		else
 			callback() # do a per-project compile, not per-user
 			callback() # do a per-project compile, not per-user

+ 1 - 1
services/web/app/coffee/Features/Compile/CompileManager.coffee

@@ -38,7 +38,7 @@ module.exports = CompileManager =
 							for key, value of limits
 							for key, value of limits
 								options[key] = value
 								options[key] = value
 							# only pass user_id down to clsi if this is a per-user compile
 							# only pass user_id down to clsi if this is a per-user compile
-							compileAsUser = if options.isolated then user_id else undefined
+							compileAsUser = if Settings.disablePerUserCompiles then undefined else user_id
 							ClsiManager.sendRequest project_id, compileAsUser, options, (error, status, outputFiles, clsiServerId, validationProblems) ->
 							ClsiManager.sendRequest project_id, compileAsUser, options, (error, status, outputFiles, clsiServerId, validationProblems) ->
 								return callback(error) if error?
 								return callback(error) if error?
 								logger.log files: outputFiles, "output files"
 								logger.log files: outputFiles, "output files"

+ 9 - 6
services/web/app/coffee/Features/PasswordReset/PasswordResetController.coffee

@@ -2,6 +2,7 @@ PasswordResetHandler = require("./PasswordResetHandler")
 RateLimiter = require("../../infrastructure/RateLimiter")
 RateLimiter = require("../../infrastructure/RateLimiter")
 AuthenticationController = require("../Authentication/AuthenticationController")
 AuthenticationController = require("../Authentication/AuthenticationController")
 UserGetter = require("../User/UserGetter")
 UserGetter = require("../User/UserGetter")
+UserSessionsManager = require("../User/UserSessionsManager")
 logger = require "logger-sharelatex"
 logger = require "logger-sharelatex"
 
 
 module.exports =
 module.exports =
@@ -47,11 +48,13 @@ module.exports =
 		PasswordResetHandler.setNewUserPassword passwordResetToken?.trim(), password?.trim(), (err, found, user_id) ->
 		PasswordResetHandler.setNewUserPassword passwordResetToken?.trim(), password?.trim(), (err, found, user_id) ->
 			return next(err) if err?
 			return next(err) if err?
 			if found
 			if found
-				if req.body.login_after
-					UserGetter.getUser user_id, {email: 1}, (err, user) ->
-						return next(err) if err?
-						AuthenticationController.doLogin {email:user.email, password: password}, req, res, next
-				else
-					res.sendStatus 200
+				UserSessionsManager.revokeAllUserSessions {_id: user_id}, [], (err) ->
+					return next(err) if err?
+					if req.body.login_after
+						UserGetter.getUser user_id, {email: 1}, (err, user) ->
+							return next(err) if err?
+							AuthenticationController.doLogin {email:user.email, password: password}, req, res, next
+					else
+						res.sendStatus 200
 			else
 			else
 				res.sendStatus 404
 				res.sendStatus 404

+ 5 - 3
services/web/app/coffee/Features/Project/ProjectDeleter.coffee

@@ -24,9 +24,11 @@ module.exports = ProjectDeleter =
 		update = {deletedByExternalDataSource: false}
 		update = {deletedByExternalDataSource: false}
 		Project.update conditions, update, {}, callback
 		Project.update conditions, update, {}, callback
 
 
-	deleteUsersProjects: (owner_id, callback)->
-		logger.log owner_id:owner_id, "deleting users projects"
-		Project.remove owner_ref:owner_id, callback
+	deleteUsersProjects: (user_id, callback)->
+		logger.log {user_id}, "deleting users projects"
+		Project.remove owner_ref:user_id, (error) ->
+			return callback(error) if error?
+			CollaboratorsHandler.removeUserFromAllProjets user_id, callback
 
 
 	deleteProject: (project_id, callback = (error) ->) ->
 	deleteProject: (project_id, callback = (error) ->) ->
 		# archiveProject takes care of the clean-up
 		# archiveProject takes care of the clean-up

+ 10 - 7
services/web/app/coffee/Features/User/UserController.coffee

@@ -8,6 +8,7 @@ logger = require("logger-sharelatex")
 metrics = require("../../infrastructure/Metrics")
 metrics = require("../../infrastructure/Metrics")
 Url = require("url")
 Url = require("url")
 AuthenticationManager = require("../Authentication/AuthenticationManager")
 AuthenticationManager = require("../Authentication/AuthenticationManager")
+UserSessionsManager = require("./UserSessionsManager")
 UserUpdater = require("./UserUpdater")
 UserUpdater = require("./UserUpdater")
 settings = require "settings-sharelatex"
 settings = require "settings-sharelatex"
 
 
@@ -81,9 +82,12 @@ module.exports = UserController =
 	logout : (req, res)->
 	logout : (req, res)->
 		metrics.inc "user.logout"
 		metrics.inc "user.logout"
 		logger.log user: req?.session?.user, "logging out"
 		logger.log user: req?.session?.user, "logging out"
+		sessionId = req.sessionID
+		user = req?.session?.user
 		req.session.destroy (err)->
 		req.session.destroy (err)->
 			if err
 			if err
 				logger.err err: err, 'error destorying session'
 				logger.err err: err, 'error destorying session'
+			UserSessionsManager.untrackSession(user, sessionId)
 			res.redirect '/login'
 			res.redirect '/login'
 
 
 	register : (req, res, next = (error) ->)->
 	register : (req, res, next = (error) ->)->
@@ -117,16 +121,15 @@ module.exports = UserController =
 					logger.log user: user, "password changed"
 					logger.log user: user, "password changed"
 					AuthenticationManager.setUserPassword user._id, newPassword1, (error) ->
 					AuthenticationManager.setUserPassword user._id, newPassword1, (error) ->
 						return next(error) if error?
 						return next(error) if error?
-						res.send
-							message:
-							  type:'success'
-							  text:'Your password has been changed'
+						UserSessionsManager.revokeAllUserSessions user, [req.sessionID], (err) ->
+							return next(err) if err?
+							res.send
+								message:
+									type:'success'
+									text:'Your password has been changed'
 			else
 			else
 				logger.log user: user, "current password wrong"
 				logger.log user: user, "current password wrong"
 				res.send
 				res.send
 					message:
 					message:
 					  type:'error'
 					  type:'error'
 					  text:'Your old password is wrong'
 					  text:'Your old password is wrong'
-
-
-

+ 126 - 0
services/web/app/coffee/Features/User/UserSessionsManager.coffee

@@ -0,0 +1,126 @@
+Settings = require('settings-sharelatex')
+redis = require('redis-sharelatex')
+logger = require("logger-sharelatex")
+Async = require('async')
+_ = require('underscore')
+
+rclient = redis.createClient(Settings.redis.web)
+
+module.exports = UserSessionsManager =
+
+	_sessionSetKey: (user) ->
+		return "UserSessions:#{user._id}"
+
+	# mimic the key used by the express sessions
+	_sessionKey: (sessionId) ->
+		return "sess:#{sessionId}"
+
+	trackSession: (user, sessionId, callback=(err)-> ) ->
+		if !user?
+			logger.log {sessionId}, "no user to track, returning"
+			return callback(null)
+		if !sessionId?
+			logger.log {user_id: user._id}, "no sessionId to track, returning"
+			return callback(null)
+		logger.log {user_id: user._id, sessionId}, "onLogin handler"
+		sessionSetKey = UserSessionsManager._sessionSetKey(user)
+		value = UserSessionsManager._sessionKey sessionId
+		rclient.multi()
+			.sadd(sessionSetKey, value)
+			.expire(sessionSetKey, "#{Settings.cookieSessionLength}")
+			.exec (err, response) ->
+				if err?
+					logger.err {err, user_id: user._id, sessionSetKey}, "error while adding session key to UserSessions set"
+					return callback(err)
+				UserSessionsManager._checkSessions(user, () ->)
+				callback()
+
+	untrackSession: (user, sessionId, callback=(err)-> ) ->
+		if !user?
+			logger.log {sessionId}, "no user to untrack, returning"
+			return callback(null)
+		if !sessionId?
+			logger.log {user_id: user._id}, "no sessionId to untrack, returning"
+			return callback(null)
+		logger.log {user_id: user._id, sessionId}, "onLogout handler"
+		sessionSetKey = UserSessionsManager._sessionSetKey(user)
+		value = UserSessionsManager._sessionKey sessionId
+		rclient.multi()
+			.srem(sessionSetKey, value)
+			.expire(sessionSetKey, "#{Settings.cookieSessionLength}")
+			.exec (err, response) ->
+				if err?
+					logger.err {err, user_id: user._id, sessionSetKey}, "error while removing session key from UserSessions set"
+					return callback(err)
+				UserSessionsManager._checkSessions(user, () ->)
+				callback()
+
+	revokeAllUserSessions: (user, retain, callback=(err)->) ->
+		if !retain
+			retain = []
+		retain = retain.map((i) -> UserSessionsManager._sessionKey(i))
+		if !user
+			logger.log {}, "no user to revoke sessions for, returning"
+			return callback(null)
+		logger.log {user_id: user._id}, "revoking all existing sessions for user"
+		sessionSetKey = UserSessionsManager._sessionSetKey(user)
+		rclient.smembers sessionSetKey, (err, sessionKeys) ->
+			if err?
+				logger.err {err, user_id: user._id, sessionSetKey}, "error getting contents of UserSessions set"
+				return callback(err)
+			keysToDelete = _.filter(sessionKeys, (k) -> k not in retain)
+			if keysToDelete.length == 0
+				logger.log {user_id: user._id}, "no sessions in UserSessions set to delete, returning"
+				return callback(null)
+			logger.log {user_id: user._id, count: keysToDelete.length}, "deleting sessions for user"
+			rclient.multi()
+				.del(keysToDelete)
+				.srem(sessionSetKey, keysToDelete)
+				.exec (err, result) ->
+					if err?
+						logger.err {err, user_id: user._id, sessionSetKey}, "error revoking all sessions for user"
+						return callback(err)
+					callback(null)
+
+	touch: (user, callback=(err)->) ->
+		if !user
+			logger.log {}, "no user to touch sessions for, returning"
+			return callback(null)
+		sessionSetKey = UserSessionsManager._sessionSetKey(user)
+		rclient.expire sessionSetKey, "#{Settings.cookieSessionLength}", (err, response) ->
+			if err?
+				logger.err {err, user_id: user._id}, "error while updating ttl on UserSessions set"
+				return callback(err)
+			callback(null)
+
+	_checkSessions: (user, callback=(err)->) ->
+		if !user
+			logger.log {}, "no user, returning"
+			return callback(null)
+		logger.log {user_id: user._id}, "checking sessions for user"
+		sessionSetKey = UserSessionsManager._sessionSetKey(user)
+		rclient.smembers sessionSetKey, (err, sessionKeys) ->
+			if err?
+				logger.err {err, user_id: user._id, sessionSetKey}, "error getting contents of UserSessions set"
+				return callback(err)
+			logger.log {user_id: user._id, count: sessionKeys.length}, "checking sessions for user"
+			Async.series(
+				sessionKeys.map(
+					(key) ->
+						(next) ->
+							rclient.get key, (err, val) ->
+								if err?
+									return next(err)
+								if !val?
+									logger.log {user_id: user._id, key}, ">> removing key from UserSessions set"
+									rclient.srem sessionSetKey, key, (err, result) ->
+										if err?
+											return next(err)
+										return next(null)
+								else
+									next()
+				)
+				, (err, results) ->
+					logger.log {user_id: user._id}, "done checking sessions for user"
+					return callback(err)
+			)

+ 4 - 1
services/web/app/coffee/infrastructure/Server.coffee

@@ -31,6 +31,7 @@ translations = require("translations-sharelatex").setup(Settings.i18n)
 Modules = require "./Modules"
 Modules = require "./Modules"
 
 
 ErrorController = require "../Features/Errors/ErrorController"
 ErrorController = require "../Features/Errors/ErrorController"
+UserSessionsManager = require "../Features/User/UserSessionsManager"
 
 
 metrics.mongodb.monitor(Path.resolve(__dirname + "/../../../node_modules/mongojs/node_modules/mongodb"), logger)
 metrics.mongodb.monitor(Path.resolve(__dirname + "/../../../node_modules/mongojs/node_modules/mongodb"), logger)
 metrics.mongodb.monitor(Path.resolve(__dirname + "/../../../node_modules/mongoose/node_modules/mongodb"), logger)
 metrics.mongodb.monitor(Path.resolve(__dirname + "/../../../node_modules/mongoose/node_modules/mongodb"), logger)
@@ -89,6 +90,8 @@ webRouter.use translations.setLangBasedOnDomainMiddlewear
 # Measure expiry from last request, not last login
 # Measure expiry from last request, not last login
 webRouter.use (req, res, next) ->
 webRouter.use (req, res, next) ->
 	req.session.touch()
 	req.session.touch()
+	if req?.session?.user?
+		UserSessionsManager.touch(req.session.user, (err)->)
 	next()
 	next()
 
 
 webRouter.use ReferalConnect.use
 webRouter.use ReferalConnect.use
@@ -114,7 +117,7 @@ app.use (req, res, next) ->
 
 
 apiRouter.get "/status", (req, res)->
 apiRouter.get "/status", (req, res)->
 	res.send("web sharelatex is alive")
 	res.send("web sharelatex is alive")
-	
+
 profiler = require "v8-profiler"
 profiler = require "v8-profiler"
 apiRouter.get "/profile", (req, res) ->
 apiRouter.get "/profile", (req, res) ->
 	time = parseInt(req.query.time || "1000")
 	time = parseInt(req.query.time || "1000")

+ 43 - 10
services/web/app/views/layout.jade

@@ -48,17 +48,50 @@ html(itemscope, itemtype='http://schema.org/Product')
 			script(type='text/javascript').
 			script(type='text/javascript').
 				window.ga = function() { console.log("Sending to GA", arguments) };
 				window.ga = function() { console.log("Sending to GA", arguments) };
 
 
-		// Heap Analytics
-		if (settings.analytics && settings.analytics.heap && session && session.user)
+		// Countly Analytics
+		if (settings.analytics && settings.analytics.countly && settings.analytics.countly.token)
 			script(type="text/javascript").
 			script(type="text/javascript").
-				window.heap=window.heap||[],heap.load=function(e,t){window.heap.appid=e,window.heap.config=t=t||{};var n=t.forceSSL||"https:"===document.location.protocol,a=document.createElement("script");a.type="text/javascript",a.async=!0,a.src=(n?"https:":"http:")+"//cdn.heapanalytics.com/js/heap-"+e+".js";var o=document.getElementsByTagName("script")[0];o.parentNode.insertBefore(a,o);for(var r=function(e){return function(){heap.push([e].concat(Array.prototype.slice.call(arguments,0)))}},p=["clearEventProperties","identify","setEventProperties","track","unsetEventProperty"],c=0;c<p.length;c++)heap[p[c]]=r(p[c])};
-				heap.load("#{settings.analytics.heap.token}");
-			script(type="text/javascript").
-				heap.identify({
-					handle: "#{session.user._id}",
-					email: "#{session.user.email}",
-				})
-		// End Heap Analytics
+				var Countly = Countly || {};
+				Countly.q = Countly.q || [];
+				Countly.app_key = '#{settings.analytics.countly.token}';
+				Countly.url = 'https://try.count.ly'; 
+
+				Countly.q.push(['track_sessions']);
+				Countly.q.push(['track_pageview']);
+
+				(function() {
+					var cly = document.createElement('script'); cly.type = 'text/javascript';
+					cly.async = true;
+					//enter url of script here
+					cly.src = 'https://cdnjs.cloudflare.com/ajax/libs/countly-sdk-web/16.6.0/countly.min.js';
+					cly.onload = function(){Countly.init()};
+					var s = document.getElementsByTagName('script')[0]; s.parentNode.insertBefore(cly, s);
+				})();
+
+			if (session && session.user)
+				script(type="text/javascript").
+					Countly.q.push(['change_id', '#{session.user._id}', true ]);
+
+					Countly.q.push(['user_details', {
+						email: '#{session.user.email}',
+						custom: {
+							userId: '#{session.user._id}',
+						}
+					}]);
+
+			if (justRegistered)
+				script(type="text/javascript").
+					Countly.q.push(['add_event',{
+						key: 'user-registered'
+					}]);
+
+			if (justLoggedIn)
+				script(type="text/javascript").
+
+					Countly.q.push(['add_event',{
+						key: 'user-logged-in'
+					}]);
+		// End countly Analytics
 				
 				
 		script(type="text/javascript").
 		script(type="text/javascript").
 			window.csrfToken = "#{csrfToken}";
 			window.csrfToken = "#{csrfToken}";

+ 3 - 3
services/web/app/views/layout/footer.jade

@@ -15,7 +15,7 @@ footer.site-footer
 							aria-expanded="false",
 							aria-expanded="false",
 							tooltip="#{translate('language')}"
 							tooltip="#{translate('language')}"
 						)
 						)
-							img(src="/img/flags/24/#{currentLngCode}.png")
+							figure(class="sprite-icon sprite-icon-lang sprite-icon-#{currentLngCode}")
 
 
 						ul.dropdown-menu(role="menu")
 						ul.dropdown-menu(role="menu")
 							li.dropdown-header #{translate("language")}
 							li.dropdown-header #{translate("language")}
@@ -23,9 +23,9 @@ footer.site-footer
 								if !subdomainDetails.hide
 								if !subdomainDetails.hide
 									li.lngOption
 									li.lngOption
 										a.menu-indent(href=subdomainDetails.url+currentUrl)
 										a.menu-indent(href=subdomainDetails.url+currentUrl)
-											img(src="/img/flags/24/#{subdomainDetails.lngCode}.png")
+											figure(class="sprite-icon sprite-icon-lang sprite-icon-#{subdomainDetails.lngCode}")
 											|  #{translate(subdomainDetails.lngCode)}
 											|  #{translate(subdomainDetails.lngCode)}
-										
+										//- img(src="/img/flags/24/.png")
 				each item in nav.left_footer
 				each item in nav.left_footer
 					li
 					li
 						if item.url
 						if item.url

+ 1 - 1
services/web/app/views/project/editor/editor.jade

@@ -35,7 +35,7 @@ div.full-size(
 			resize-on="layout:main:resize,layout:pdf:resize",
 			resize-on="layout:main:resize,layout:pdf:resize",
 			annotations="pdf.logEntryAnnotations[editor.open_doc_id]",
 			annotations="pdf.logEntryAnnotations[editor.open_doc_id]",
 			read-only="!permissions.write",
 			read-only="!permissions.write",
-			on-ctrl-enter="recompile"
+			on-ctrl-enter="recompileViaKey"
 		)
 		)
 
 
 	.ui-layout-east
 	.ui-layout-east

+ 13 - 6
services/web/app/views/project/editor/pdf.jade

@@ -119,7 +119,11 @@ div.full-size.pdf(ng-controller="PdfController")
 						ng-bind-html="wikiEnabled ? entry.humanReadableHint : stripHTMLFromString(entry.humanReadableHint)")
 						ng-bind-html="wikiEnabled ? entry.humanReadableHint : stripHTMLFromString(entry.humanReadableHint)")
 					.card-hint-actions.clearfix
 					.card-hint-actions.clearfix
 						.card-hint-ext-link(ng-if="wikiEnabled")
 						.card-hint-ext-link(ng-if="wikiEnabled")
-							a(ng-href="{{ entry.extraInfoURL }}", target="_blank")
+							a(
+								ng-href="{{ entry.extraInfoURL }}",
+								ng-click="trackLogHintsLearnMore()"
+								target="_blank"
+							)
 								i.fa.fa-external-link
 								i.fa.fa-external-link
 								|&nbsp;#{translate("log_hint_extra_info")}
 								|&nbsp;#{translate("log_hint_extra_info")}
 						.card-hint-feedback(
 						.card-hint-feedback(
@@ -128,12 +132,12 @@ div.full-size.pdf(ng-controller="PdfController")
 						)
 						)
 							label.card-hint-feedback-label #{translate("log_hint_feedback_label")}
 							label.card-hint-feedback-label #{translate("log_hint_feedback_label")}
 							a.card-hint-feedback-positive(
 							a.card-hint-feedback-positive(
-								ng-click="feedbackSent = true;"
+								ng-click="trackLogHintsPositiveFeedback(entry.ruleId); feedbackSent = true;"
 								href
 								href
 							) #{translate("answer_yes")}
 							) #{translate("answer_yes")}
 							span &nbsp;/&nbsp;
 							span &nbsp;/&nbsp;
 							a.card-hint-feedback-negative(
 							a.card-hint-feedback-negative(
-								ng-click="feedbackSent = true;"
+								ng-click="trackLogHintsNegativeFeedback(entry.ruleId); feedbackSent = true;"
 								href
 								href
 							) #{translate("answer_no")}
 							) #{translate("answer_no")}
 						.card-hint-feedback(ng-show="feedbackSent")
 						.card-hint-feedback(ng-show="feedbackSent")
@@ -141,7 +145,7 @@ div.full-size.pdf(ng-controller="PdfController")
 				p.entry-content(ng-show="entry.content") {{ entry.content.trim() }}
 				p.entry-content(ng-show="entry.content") {{ entry.content.trim() }}
 
 
 		p
 		p
-			.pull-right
+			.files-dropdown-container
 				a.btn.btn-default.btn-sm(
 				a.btn.btn-default.btn-sm(
 					href,
 					href,
 					tooltip="#{translate('clear_cached_files')}",
 					tooltip="#{translate('clear_cached_files')}",
@@ -151,12 +155,15 @@ div.full-size.pdf(ng-controller="PdfController")
 				)
 				)
 					i.fa.fa-trash-o
 					i.fa.fa-trash-o
 				| &nbsp;
 				| &nbsp;
-				div.dropdown(style="display: inline-block;", dropdown)
+				div.files-dropdown(
+					ng-class="shouldDropUp ? 'dropup' : 'dropdown'"
+					dropdown
+				)
 					a.btn.btn-default.btn-sm(
 					a.btn.btn-default.btn-sm(
 						href
 						href
 						dropdown-toggle
 						dropdown-toggle
 					)
 					)
-						| !{translate("other_logs_and_files")} 
+						| !{translate("other_logs_and_files")}
 						span.caret
 						span.caret
 					ul.dropdown-menu.dropdown-menu-right
 					ul.dropdown-menu.dropdown-menu-right
 						li(ng-repeat="file in pdf.outputFiles")
 						li(ng-repeat="file in pdf.outputFiles")

+ 4 - 0
services/web/config/settings.defaults.coffee

@@ -262,6 +262,10 @@ module.exports = settings =
 	# Should we allow access to any page without logging in? This includes
 	# Should we allow access to any page without logging in? This includes
 	# public projects, /learn, /templates, about pages, etc.
 	# public projects, /learn, /templates, about pages, etc.
 	allowPublicAccess: if process.env["SHARELATEX_ALLOW_PUBLIC_ACCESS"] == 'true' then true else false
 	allowPublicAccess: if process.env["SHARELATEX_ALLOW_PUBLIC_ACCESS"] == 'true' then true else false
+
+	# Use a single compile directory for all users in a project
+	# (otherwise each user has their own directory)
+	# disablePerUserCompiles: true
 	
 	
 	# Maximum size of text documents in the real-time editing system.
 	# Maximum size of text documents in the real-time editing system.
 	max_doc_length: 2 * 1024 * 1024 # 2mb
 	max_doc_length: 2 * 1024 * 1024 # 2mb

+ 0 - 1
services/web/package.json

@@ -70,7 +70,6 @@
     "grunt-contrib-clean": "0.5.0",
     "grunt-contrib-clean": "0.5.0",
     "grunt-contrib-coffee": "0.10.0",
     "grunt-contrib-coffee": "0.10.0",
     "grunt-contrib-cssmin": "^1.0.1",
     "grunt-contrib-cssmin": "^1.0.1",
-    "grunt-contrib-imagemin": "^1.0.1",
     "grunt-contrib-less": "0.9.0",
     "grunt-contrib-less": "0.9.0",
     "grunt-contrib-requirejs": "0.4.1",
     "grunt-contrib-requirejs": "0.4.1",
     "grunt-contrib-watch": "^1.0.0",
     "grunt-contrib-watch": "^1.0.0",

+ 11 - 1
services/web/public/coffee/ide.coffee

@@ -42,7 +42,7 @@ define [
 	ReferencesManager
 	ReferencesManager
 ) ->
 ) ->
 
 
-	App.controller "IdeController", ($scope, $timeout, ide, localStorage) ->
+	App.controller "IdeController", ($scope, $timeout, ide, localStorage, event_tracking) ->
 		# Don't freak out if we're already in an apply callback
 		# Don't freak out if we're already in an apply callback
 		$scope.$originalApply = $scope.$apply
 		$scope.$originalApply = $scope.$apply
 		$scope.$apply = (fn = () ->) ->
 		$scope.$apply = (fn = () ->) ->
@@ -69,6 +69,16 @@ define [
 
 
 		$scope.chat = {}
 		$scope.chat = {}
 
 
+		# Tracking code.
+		$scope.$watch "ui.view", (newView, oldView) ->
+			event_tracking.sendCountly "ide-open-view-#{ newView }" if newView?
+
+		$scope.$watch "ui.chatOpen", (isOpen) ->
+			event_tracking.sendCountly "ide-open-chat" if isOpen
+
+		$scope.$watch "ui.leftMenuShown", (isOpen) ->
+			event_tracking.sendCountly "ide-open-left-menu" if isOpen
+		# End of tracking code.
 
 
 		window._ide = ide
 		window._ide = ide
 
 

+ 3 - 1
services/web/public/coffee/ide/hotkeys/controllers/HotkeysController.coffee

@@ -2,8 +2,10 @@ define [
 	"base"
 	"base"
 	"ace/ace"
 	"ace/ace"
 ], (App) ->
 ], (App) ->
-	App.controller "HotkeysController", ($scope, $modal) ->
+	App.controller "HotkeysController", ($scope, $modal, event_tracking) ->
 		$scope.openHotkeysModal = ->
 		$scope.openHotkeysModal = ->
+			event_tracking.sendCountly "ide-open-hotkeys-modal"
+
 			$modal.open {
 			$modal.open {
 				templateUrl: "hotkeysModalTemplate"
 				templateUrl: "hotkeysModalTemplate"
 				controller:  "HotkeysModalController"
 				controller:  "HotkeysModalController"

+ 2 - 1
services/web/public/coffee/ide/human-readable-logs/HumanReadableLogs.coffee

@@ -12,7 +12,8 @@ define [
 			ruleDetails = _getRule entry.message
 			ruleDetails = _getRule entry.message
 
 
 			if (ruleDetails?)
 			if (ruleDetails?)
-				entry.ruleId = 'hint_' + ruleDetails.regexToMatch.toString().replace(/[^a-zA-Z0-9]/g, '_').toLowerCase() if ruleDetails.regexToMatch?
+				entry.ruleId = 'hint_' + ruleDetails.regexToMatch.toString().replace(/\s/g, '_').slice(1, -1) if ruleDetails.regexToMatch?
+				
 				entry.humanReadableHint = ruleDetails.humanReadableHint if ruleDetails.humanReadableHint?
 				entry.humanReadableHint = ruleDetails.humanReadableHint if ruleDetails.humanReadableHint?
 				entry.extraInfoURL = ruleDetails.extraInfoURL if ruleDetails.extraInfoURL?
 				entry.extraInfoURL = ruleDetails.extraInfoURL if ruleDetails.extraInfoURL?
 	
 	

+ 14 - 14
services/web/public/coffee/ide/human-readable-logs/HumanReadableLogsRules.coffee

@@ -12,7 +12,7 @@ define -> [
 		"""
 		"""
 	,
 	,
 		regexToMatch: /Display math should end with \$\$/
 		regexToMatch: /Display math should end with \$\$/
-		extraInfoURL: "https://www.sharelatex.com/learn/Errors/Display_math_should_end_with_$$."
+		extraInfoURL: "https://www.sharelatex.com/learn/Errors/Display_math_should_end_with_$$"
 		humanReadableHint: """
 		humanReadableHint: """
 			You have forgotten a $ sign at the end of 'display math' mode. When writing in display math mode, you must always math write inside $$ \u2026 $$. Check that the number of $s match around each math expression.
 			You have forgotten a $ sign at the end of 'display math' mode. When writing in display math mode, you must always math write inside $$ \u2026 $$. Check that the number of $s match around each math expression.
 		"""
 		"""
@@ -24,67 +24,67 @@ define -> [
 		"""
 		"""
 	,
 	,
 		regexToMatch: /(undefined )?[rR]eference(s)?.+(undefined)?/
 		regexToMatch: /(undefined )?[rR]eference(s)?.+(undefined)?/
-		extraInfoURL: "https://www.sharelatex.com/learn/Errors/There_were_undefined_references."
+		extraInfoURL: "https://www.sharelatex.com/learn/Errors/There_were_undefined_references"
 		humanReadableHint: """
 		humanReadableHint: """
 			You have referenced something which has not yet been labelled. If you have labelled it already, make sure that what is written inside \\ref{...} is the same as what is written inside \\label{...}.
 			You have referenced something which has not yet been labelled. If you have labelled it already, make sure that what is written inside \\ref{...} is the same as what is written inside \\label{...}.
 		"""
 		"""
 	,
 	,
 		regexToMatch: /Citation .+ on page .+ undefined on input line .+/
 		regexToMatch: /Citation .+ on page .+ undefined on input line .+/
-		extraInfoURL: "https://www.sharelatex.com/learn/Errors/Citation_XXX_on_page_XXX_undefined_on_input_line_XXX."
+		extraInfoURL: "https://www.sharelatex.com/learn/Errors/Citation_XXX_on_page_XXX_undefined_on_input_line_XXX"
 		humanReadableHint: """
 		humanReadableHint: """
 			You have cited something which is not included in your bibliography. Make sure that the citation (\\cite{...}) has a corresponding key in your bibliography, and that both are spelled the same way.
 			You have cited something which is not included in your bibliography. Make sure that the citation (\\cite{...}) has a corresponding key in your bibliography, and that both are spelled the same way.
 		"""
 		"""
 	,
 	,
 		regexToMatch: /(Label .+)? multiply[ -]defined( labels)?/
 		regexToMatch: /(Label .+)? multiply[ -]defined( labels)?/
-		extraInfoURL: "https://www.sharelatex.com/learn/Errors/There_were_multiply-defined_labels."
+		extraInfoURL: "https://www.sharelatex.com/learn/Errors/There_were_multiply-defined_labels"
 		humanReadableHint: """
 		humanReadableHint: """
 			You have used the same label more than once. Check that each \\label{...} labels only one item.
 			You have used the same label more than once. Check that each \\label{...} labels only one item.
 		"""
 		"""
 	,
 	,
 		regexToMatch: /`!?h' float specifier changed to `!?ht'/
 		regexToMatch: /`!?h' float specifier changed to `!?ht'/
-		extraInfoURL: "https://www.sharelatex.com/learn/Errors/%60!h%27_float_specifier_changed_to_%60!ht%27."
+		extraInfoURL: "https://www.sharelatex.com/learn/Errors/%60!h%27_float_specifier_changed_to_%60!ht%27"
 		humanReadableHint: """
 		humanReadableHint: """
 			The float specifier 'h' is too strict of a demand for LaTeX to place your float in a nice way here. Try relaxing it by using 'ht', or even 'htbp' if necessary. If you want to try keep the float here anyway, check out the <a target=\"_blank\" href=\"https://www.sharelatex.com/learn/Positioning_of_Figures\">float package</a>.
 			The float specifier 'h' is too strict of a demand for LaTeX to place your float in a nice way here. Try relaxing it by using 'ht', or even 'htbp' if necessary. If you want to try keep the float here anyway, check out the <a target=\"_blank\" href=\"https://www.sharelatex.com/learn/Positioning_of_Figures\">float package</a>.
 		"""
 		"""
 	,
 	,
 		regexToMatch: /No positions in optional float specifier/
 		regexToMatch: /No positions in optional float specifier/
-		extraInfoURL: "https://www.sharelatex.com/learn/Errors/No_positions_in_optional_float_specifier."
+		extraInfoURL: "https://www.sharelatex.com/learn/Errors/No_positions_in_optional_float_specifier"
 		humanReadableHint: """
 		humanReadableHint: """
-			You have forgotten to include a float specifier, which tells LaTeX where to position your figure. Find out more about float specifiers <a target=\"_blank\" href=\"https://www.sharelatex.com/learn/Positioning_of_Figures\">here</a>.
+			You have forgotten to include a float specifier, which tells LaTeX where to position your figure. To fix this, either insert a float specifier inside the square brackets (e.g. \begin{figure}[h]), or remove the square brackets (e.g. \begin{figure}). Find out more about float specifiers <a target=\"_blank\" href=\"https://www.sharelatex.com/learn/Positioning_of_Figures\">here</a>.
 		"""
 		"""
 	,
 	,
 		regexToMatch: /Undefined control sequence/
 		regexToMatch: /Undefined control sequence/
-		extraInfoURL: "https://www.sharelatex.com/learn/Errors/Undefined_control_sequence."
+		extraInfoURL: "https://www.sharelatex.com/learn/Errors/Undefined_control_sequence"
 		humanReadableHint: """
 		humanReadableHint: """
 			The compiler is having trouble understanding a command you have used. Check that the command is spelled correctly. If the command is part of a package, make sure you have included the package in your preamble using \\usepackage{...}.
 			The compiler is having trouble understanding a command you have used. Check that the command is spelled correctly. If the command is part of a package, make sure you have included the package in your preamble using \\usepackage{...}.
 		"""
 		"""
 	,
 	,
 		regexToMatch: /File .+ not found/
 		regexToMatch: /File .+ not found/
-		extraInfoURL: "https://www.sharelatex.com/learn/Errors/File_XXX_not_found_on_input_line_XXX."
+		extraInfoURL: "https://www.sharelatex.com/learn/Errors/File_XXX_not_found_on_input_line_XXX"
 		humanReadableHint: """
 		humanReadableHint: """
 			The compiler cannot find the file you want to include. Make sure that you have <a target=\"_blank\" href=\"https://www.sharelatex.com/learn/Including_images_in_ShareLaTeX\">uploaded the file</a> and <a target=\"_blank\" href=\"https://www.sharelatex.com/learn/Errors/File_XXX_not_found_on_input_line_XXX.\">specified the file location correctly</a>.
 			The compiler cannot find the file you want to include. Make sure that you have <a target=\"_blank\" href=\"https://www.sharelatex.com/learn/Including_images_in_ShareLaTeX\">uploaded the file</a> and <a target=\"_blank\" href=\"https://www.sharelatex.com/learn/Errors/File_XXX_not_found_on_input_line_XXX.\">specified the file location correctly</a>.
 		"""
 		"""
 	,
 	,
 		regexToMatch: /LaTeX Error: Unknown graphics extension: \..+/
 		regexToMatch: /LaTeX Error: Unknown graphics extension: \..+/
-		extraInfoURL: "https://www.sharelatex.com/learn/Errors/LaTeX_Error:_Unknown_graphics_extension:_.gif."
+		extraInfoURL: "https://www.sharelatex.com/learn/Errors/LaTeX_Error:_Unknown_graphics_extension:_.XXX"
 		humanReadableHint: """
 		humanReadableHint: """
 			The compiler does not recognise the file type of one of your images. Make sure you are using a <a target=\"_blank\" href=\"https://www.sharelatex.com/learn/Errors/LaTeX_Error:_Unknown_graphics_extension:_.gif.\">supported image format</a> for your choice of compiler, and check that there are no periods (.) in the name of your image.
 			The compiler does not recognise the file type of one of your images. Make sure you are using a <a target=\"_blank\" href=\"https://www.sharelatex.com/learn/Errors/LaTeX_Error:_Unknown_graphics_extension:_.gif.\">supported image format</a> for your choice of compiler, and check that there are no periods (.) in the name of your image.
 		"""
 		"""
 	,
 	,
 		regexToMatch: /LaTeX Error: Unknown float option `H'/
 		regexToMatch: /LaTeX Error: Unknown float option `H'/
-		extraInfoURL: "https://www.sharelatex.com/learn/Errors/LaTeX_Error:_Unknown_float_option_%60H%27."
+		extraInfoURL: "https://www.sharelatex.com/learn/Errors/LaTeX_Error:_Unknown_float_option_%60H%27"
 		humanReadableHint: """
 		humanReadableHint: """
 			The compiler isn't recognizing the float option 'H'. Include \\usepackage{float} in your preamble to fix this.
 			The compiler isn't recognizing the float option 'H'. Include \\usepackage{float} in your preamble to fix this.
 		"""
 		"""
 	,
 	,
-		regexToMatch: /LaTeX Error: Unknown float option `.+'/
-		extraInfoURL: "https://www.sharelatex.com/learn/Errors/LaTeX_Error:_Unknown_float_option_%60H%27."
+		regexToMatch: /LaTeX Error: Unknown float option `q'/
+		extraInfoURL: "https://www.sharelatex.com/learn/Errors/LaTeX_Error:_Unknown_float_option_%60q%27"
 		humanReadableHint: """
 		humanReadableHint: """
 			You have used a float specifier which the compiler does not understand. You can learn more about the different float options available for placing figures <a target=\"_blank\" href=\"https://www.sharelatex.com/learn/Positioning_of_Figures\">here</a>.
 			You have used a float specifier which the compiler does not understand. You can learn more about the different float options available for placing figures <a target=\"_blank\" href=\"https://www.sharelatex.com/learn/Positioning_of_Figures\">here</a>.
 		"""
 		"""
 	,
 	,
 		regexToMatch: /LaTeX Error: \\math.+ allowed only in math mode/
 		regexToMatch: /LaTeX Error: \\math.+ allowed only in math mode/
-		extraInfoURL: "https://www.sharelatex.com/learn/Errors/LaTeX_Error:_%5Cmathrm_allowed_only_in_math_mode."
+		extraInfoURL: "https://www.sharelatex.com/learn/Errors/LaTeX_Error:_%5Cmathrm_allowed_only_in_math_mode"
 		humanReadableHint: """
 		humanReadableHint: """
 			You have used a font command which is only available in math mode. To use this command, you must be in maths mode (E.g. $ \u2026 $ or \\begin{math} \u2026 \\end{math}). If you want to use it outside of math mode, use the text version instead: \\textrm, \\textit, etc.
 			You have used a font command which is only available in math mode. To use this command, you must be in maths mode (E.g. $ \u2026 $ or \\begin{math} \u2026 \\end{math}). If you want to use it outside of math mode, use the text version instead: \\textrm, \\textit, etc.
 		"""
 		"""

+ 38 - 12
services/web/public/coffee/ide/pdf/controllers/PdfController.coffee

@@ -15,6 +15,33 @@ define [
 		$scope.shouldShowLogs = false
 		$scope.shouldShowLogs = false
 		$scope.wikiEnabled = window.wikiEnabled;
 		$scope.wikiEnabled = window.wikiEnabled;
 
 
+		# view logic to check whether the files dropdown should "drop up" or "drop down"
+		$scope.shouldDropUp = false
+
+		logsContainerEl	= document.querySelector ".pdf-logs"
+		filesDropdownEl	= logsContainerEl?.querySelector ".files-dropdown"
+
+		# get the top coordinate of the files dropdown as a ratio (to the logs container height)
+		# logs container supports scrollable content, so it's possible that ratio > 1.
+		getFilesDropdownTopCoordAsRatio = () ->
+			 filesDropdownEl?.getBoundingClientRect().top / logsContainerEl?.getBoundingClientRect().height
+
+		$scope.$watch "shouldShowLogs", (shouldShow) ->
+			if shouldShow
+				$scope.$applyAsync () -> 
+					$scope.shouldDropUp = getFilesDropdownTopCoordAsRatio() > 0.65
+
+		# log hints tracking
+		$scope.trackLogHintsLearnMore = () ->
+			event_tracking.sendCountly "logs-hints-learn-more"
+
+		trackLogHintsFeedback = (isPositive, hintId) ->
+			event_tracking.send "log-hints", (if isPositive then "feedback-positive" else "feedback-negative"), hintId
+			event_tracking.sendCountly (if isPositive then "log-hints-feedback-positive" else "log-hints-feedback-negative"), { hintId }
+
+		$scope.trackLogHintsPositiveFeedback = (hintId) -> trackLogHintsFeedback true, hintId
+		$scope.trackLogHintsNegativeFeedback = (hintId) -> trackLogHintsFeedback false, hintId
+
 		if ace.require("ace/lib/useragent").isMac
 		if ace.require("ace/lib/useragent").isMac
 			$scope.modifierKey = "Cmd"
 			$scope.modifierKey = "Cmd"
 		else
 		else
@@ -50,8 +77,6 @@ define [
 			params = {}
 			params = {}
 			if options.isAutoCompile
 			if options.isAutoCompile
 				params["auto_compile"]=true
 				params["auto_compile"]=true
-			if perUserCompile # send ?isolated=true for per-user compiles
-				params["isolated"] = true
 			return $http.post url, {
 			return $http.post url, {
 				rootDoc_id: options.rootDocOverride_id or null
 				rootDoc_id: options.rootDocOverride_id or null
 				draft: $scope.draft
 				draft: $scope.draft
@@ -125,9 +150,6 @@ define [
 				# convert the qs hash into a query string and append it
 				# convert the qs hash into a query string and append it
 				$scope.pdf.qs = createQueryString qs
 				$scope.pdf.qs = createQueryString qs
 				$scope.pdf.url += $scope.pdf.qs
 				$scope.pdf.url += $scope.pdf.qs
-				# special case for the download url
-				if perUserCompile
-					qs.isolated = true
 				# Save all downloads as files
 				# Save all downloads as files
 				qs.popupDownload = true
 				qs.popupDownload = true
 				$scope.pdf.downloadUrl = "/project/#{$scope.project_id}/output/output.pdf" + createQueryString(qs)
 				$scope.pdf.downloadUrl = "/project/#{$scope.project_id}/output/output.pdf" + createQueryString(qs)
@@ -147,8 +169,6 @@ define [
 					else
 					else
 						file.name = file.path
 						file.name = file.path
 					qs = {}
 					qs = {}
-					if perUserCompile
-						qs.isolated = true
 					if response.clsiServerId?
 					if response.clsiServerId?
 						qs.clsiserverid = response.clsiServerId
 						qs.clsiserverid = response.clsiServerId
 					file.url = "/project/#{project_id}/output/#{file.path}" +	createQueryString qs
 					file.url = "/project/#{project_id}/output/#{file.path}" +	createQueryString qs
@@ -237,7 +257,7 @@ define [
 			return null
 			return null
 
 
 		normalizeFilePath = (path) ->
 		normalizeFilePath = (path) ->
-			path = path.replace(/^(.*)\/compiles\/[0-9a-f]{24}\/(\.\/)?/, "")
+			path = path.replace(/^(.*)\/compiles\/[0-9a-f]{24}(-[0-9a-f]{24})?\/(\.\/)?/, "")
 			path = path.replace(/^\/compile\//, "")
 			path = path.replace(/^\/compile\//, "")
 
 
 			rootDocDirname = ide.fileTreeManager.getRootDocDirname()
 			rootDocDirname = ide.fileTreeManager.getRootDocDirname()
@@ -248,6 +268,9 @@ define [
 
 
 		$scope.recompile = (options = {}) ->
 		$scope.recompile = (options = {}) ->
 			return if $scope.pdf.compiling
 			return if $scope.pdf.compiling
+
+			event_tracking.sendCountlySampled "editor-recompile-sampled", options
+
 			$scope.pdf.compiling = true
 			$scope.pdf.compiling = true
 
 
 			ide.$scope.$broadcast("flush-changes")
 			ide.$scope.$broadcast("flush-changes")
@@ -267,6 +290,9 @@ define [
 
 
 		# This needs to be public.
 		# This needs to be public.
 		ide.$scope.recompile = $scope.recompile
 		ide.$scope.recompile = $scope.recompile
+		# This method is a simply wrapper and exists only for tracking purposes.
+		ide.$scope.recompileViaKey = () ->
+			$scope.recompile { keyShortcut: true }
 
 
 		$scope.clearCache = () ->
 		$scope.clearCache = () ->
 			$http {
 			$http {
@@ -274,13 +300,13 @@ define [
 				method: "DELETE"
 				method: "DELETE"
 				params:
 				params:
 					clsiserverid:ide.clsiServerId
 					clsiserverid:ide.clsiServerId
-					isolated: perUserCompile
 				headers:
 				headers:
 					"X-Csrf-Token": window.csrfToken
 					"X-Csrf-Token": window.csrfToken
 			}
 			}
 
 
 		$scope.toggleLogs = () ->
 		$scope.toggleLogs = () ->
 			$scope.shouldShowLogs = !$scope.shouldShowLogs
 			$scope.shouldShowLogs = !$scope.shouldShowLogs
+			event_tracking.sendCountly "ide-open-logs" if $scope.shouldShowLogs
 
 
 		$scope.showPdf = () ->
 		$scope.showPdf = () ->
 			$scope.pdf.view = "pdf"
 			$scope.pdf.view = "pdf"
@@ -288,6 +314,7 @@ define [
 
 
 		$scope.toggleRawLog = () ->
 		$scope.toggleRawLog = () ->
 			$scope.pdf.showRawLog = !$scope.pdf.showRawLog
 			$scope.pdf.showRawLog = !$scope.pdf.showRawLog
+			event_tracking.sendCountly "logs-view-raw" if $scope.pdf.showRawLog
 
 
 		$scope.openClearCacheModal = () ->
 		$scope.openClearCacheModal = () ->
 			modalInstance = $modal.open(
 			modalInstance = $modal.open(
@@ -361,7 +388,6 @@ define [
 							line: row + 1
 							line: row + 1
 							column: column
 							column: column
 							clsiserverid:ide.clsiServerId
 							clsiserverid:ide.clsiServerId
-							isolated: perUserCompile
 						}
 						}
 					})
 					})
 					.success (data) ->
 					.success (data) ->
@@ -407,7 +433,6 @@ define [
 							h: h.toFixed(2)
 							h: h.toFixed(2)
 							v: v.toFixed(2)
 							v: v.toFixed(2)
 							clsiserverid:ide.clsiServerId
 							clsiserverid:ide.clsiServerId
-							isolated: perUserCompile
 						}
 						}
 					})
 					})
 					.success (data) ->
 					.success (data) ->
@@ -442,8 +467,9 @@ define [
 					ide.editorManager.openDoc(doc, gotoLine: line)
 					ide.editorManager.openDoc(doc, gotoLine: line)
 	]
 	]
 
 
-	App.controller "PdfLogEntryController", ["$scope", "ide", ($scope, ide) ->
+	App.controller "PdfLogEntryController", ["$scope", "ide", "event_tracking", ($scope, ide, event_tracking) ->
 		$scope.openInEditor = (entry) ->
 		$scope.openInEditor = (entry) ->
+			event_tracking.sendCountly 'logs-jump-to-location'
 			entity = ide.fileTreeManager.findEntityByPath(entry.file)
 			entity = ide.fileTreeManager.findEntityByPath(entry.file)
 			return if !entity? or entity.type != "doc"
 			return if !entity? or entity.type != "doc"
 			if entry.line?
 			if entry.line?

+ 2 - 1
services/web/public/coffee/ide/pdfng/directives/pdfRenderer.coffee

@@ -12,7 +12,8 @@ define [
 
 
 			constructor: (@url, @options) ->
 			constructor: (@url, @options) ->
 				# PDFJS.disableFontFace = true  # avoids repaints, uses worker more
 				# PDFJS.disableFontFace = true  # avoids repaints, uses worker more
-				# PDFJS.disableAutoFetch = true # enable this to prevent loading whole file
+				if @options.disableAutoFetch
+					PDFJS.disableAutoFetch = true # prevent loading whole file
 				# PDFJS.disableStream
 				# PDFJS.disableStream
 				# PDFJS.disableRange
 				# PDFJS.disableRange
 				@scale = @options.scale || 1
 				@scale = @options.scale || 1

+ 9 - 1
services/web/public/coffee/ide/pdfng/directives/pdfViewer.coffee

@@ -27,13 +27,21 @@ define [
 			$scope.document.destroy() if $scope.document?
 			$scope.document.destroy() if $scope.document?
 			$scope.loadCount = if $scope.loadCount? then $scope.loadCount + 1 else 1
 			$scope.loadCount = if $scope.loadCount? then $scope.loadCount + 1 else 1
 			# TODO need a proper url manipulation library to add to query string
 			# TODO need a proper url manipulation library to add to query string
-			$scope.document = new PDFRenderer($scope.pdfSrc + '&pdfng=true' , {
+			url = $scope.pdfSrc
+			# add 'pdfng=true' to show that we are using the angular pdfjs viewer
+			queryStringExists = url.match(/\?/)
+			url = url + (if not queryStringExists then '?' else '&') + 'pdfng=true'
+			# for isolated compiles, load the pdf on-demand because nobody will overwrite it
+			onDemandLoading = window.location?.search?.match(/isolated=true/)?
+			$scope.document = new PDFRenderer(url, {
 				scale: 1,
 				scale: 1,
+				disableAutoFetch: if onDemandLoading then true else undefined
 				navigateFn: (ref) ->
 				navigateFn: (ref) ->
 					# this function captures clicks on the annotation links
 					# this function captures clicks on the annotation links
 					$scope.navigateTo = ref
 					$scope.navigateTo = ref
 					$scope.$apply()
 					$scope.$apply()
 				progressCallback: (progress) ->
 				progressCallback: (progress) ->
+					return if onDemandLoading is true # don't show progress for on-demand page loading
 					$scope.$emit 'progress', progress
 					$scope.$emit 'progress', progress
 				loadedCallback: () ->
 				loadedCallback: () ->
 					$scope.$emit 'loaded'
 					$scope.$emit 'loaded'

+ 25 - 1
services/web/public/coffee/ide/settings/services/settings.coffee

@@ -1,19 +1,43 @@
 define [
 define [
 	"base"
 	"base"
 ], (App) ->
 ], (App) ->
-	App.factory "settings", ["ide", (ide) ->
+	App.factory "settings", ["ide", "event_tracking", (ide, event_tracking) ->
 		return {
 		return {
 			saveSettings: (data) ->
 			saveSettings: (data) ->
+				# Tracking code.
+				for key in Object.keys(data)
+					changedSetting = key
+					changedSettingVal = data[key]
+					event_tracking.sendCountly "setting-changed", { changedSetting, changedSettingVal }
+				# End of tracking code.
+				
 				data._csrf = window.csrfToken
 				data._csrf = window.csrfToken
 				ide.$http.post "/user/settings", data
 				ide.$http.post "/user/settings", data
 
 
+
 			saveProjectSettings: (data) ->
 			saveProjectSettings: (data) ->
+				# Tracking code.
+				for key in Object.keys(data)
+					changedSetting = key
+					changedSettingVal = data[key]
+					event_tracking.sendCountly "project-setting-changed", { changedSetting, changedSettingVal}
+				# End of tracking code.
+				
 				data._csrf = window.csrfToken
 				data._csrf = window.csrfToken
 				ide.$http.post "/project/#{ide.project_id}/settings", data
 				ide.$http.post "/project/#{ide.project_id}/settings", data
+
 				
 				
 			saveProjectAdminSettings: (data) ->
 			saveProjectAdminSettings: (data) ->
+				# Tracking code.
+				for key in Object.keys(data)
+					changedSetting = key
+					changedSettingVal = data[key]
+					event_tracking.sendCountly "project-admin-setting-changed", { changedSetting, changedSettingVal }
+				# End of tracking code.
+				
 				data._csrf = window.csrfToken
 				data._csrf = window.csrfToken
 				ide.$http.post "/project/#{ide.project_id}/settings/admin", data
 				ide.$http.post "/project/#{ide.project_id}/settings/admin", data
+
 				
 				
 		}
 		}
 	]
 	]

+ 3 - 1
services/web/public/coffee/ide/share/controllers/ShareController.coffee

@@ -1,8 +1,10 @@
 define [
 define [
 	"base"
 	"base"
 ], (App) ->
 ], (App) ->
-	App.controller "ShareController", ["$scope", "$modal", ($scope, $modal) ->
+	App.controller "ShareController", ["$scope", "$modal", "event_tracking", ($scope, $modal, event_tracking) ->
 		$scope.openShareProjectModal = () ->
 		$scope.openShareProjectModal = () ->
+			event_tracking.sendCountly "ide-open-share-modal"
+
 			$modal.open(
 			$modal.open(
 				templateUrl: "shareProjectModalTemplate"
 				templateUrl: "shareProjectModalTemplate"
 				controller:  "ShareProjectModalController"
 				controller:  "ShareProjectModalController"

+ 1 - 5
services/web/public/coffee/ide/wordcount/controllers/WordCountModalController.coffee

@@ -5,15 +5,11 @@ define [
 		$scope.status = 
 		$scope.status = 
 			loading:true
 			loading:true
 
 
-		# enable per-user containers by default
-		perUserCompile = true
-
 		opts =
 		opts =
 			url:"/project/#{ide.project_id}/wordcount"
 			url:"/project/#{ide.project_id}/wordcount"
 			method:"GET"
 			method:"GET"
 			params:
 			params:
 				clsiserverid:ide.clsiServerId
 				clsiserverid:ide.clsiServerId
-				isolated: perUserCompile
 		$http opts
 		$http opts
 			.success (data) ->
 			.success (data) ->
 				$scope.status.loading = false
 				$scope.status.loading = false
@@ -22,4 +18,4 @@ define [
 				$scope.status.error = true
 				$scope.status.error = true
 
 
 		$scope.cancel = () ->
 		$scope.cancel = () ->
-			$modalInstance.dismiss('cancel')
+			$modalInstance.dismiss('cancel')

+ 18 - 2
services/web/public/coffee/main/event.coffee

@@ -6,10 +6,26 @@ define [
 		return {
 		return {
 			send: (category, action, label, value)->
 			send: (category, action, label, value)->
 				ga('send', 'event', category, action, label, value)
 				ga('send', 'event', category, action, label, value)
-				event_name = "#{action}-#{category}"
-				window?.heap?.track?(event_name, {label, value})
+
+			sendCountly: (key, segmentation) ->
+				eventData = { key }
+				eventData.segmentation = segmentation if segmentation?				
+				Countly?.q.push([ "add_event", eventData ]);
+
+			sendCountlySampled: (key, segmentation) ->
+				@sendCountly key, segmentation if Math.random() < .01 
 		}
 		}
 
 
+	# App.directive "countlyTrack", () ->
+	# 	return {
+	# 		restrict: "A"
+	# 		scope: false,
+	# 		link: (scope, el, attrs) ->
+	# 			eventKey  = attrs.countlyTrack
+	# 			if (eventKey?)
+	# 				el.on "click", () ->
+	# 					console.log eventKey
+	# 	}
 
 
 	#header
 	#header
 	$('.navbar a').on "click", (e)->
 	$('.navbar a').on "click", (e)->

BIN
services/web/public/img/sprite.png


+ 11 - 2
services/web/public/stylesheets/app/editor/pdf.less

@@ -122,7 +122,7 @@
 			font-weight: 700;
 			font-weight: 700;
 
 
 			.fa {
 			.fa {
-				display: none;
+				opacity: 0;
 			}
 			}
 		}
 		}
 		.entry-message {
 		.entry-message {
@@ -138,7 +138,7 @@
 		&:hover .line-no {
 		&:hover .line-no {
 			color: inherit;
 			color: inherit;
 			.fa {
 			.fa {
-				display: inline-block;
+				opacity: 1;
 			}
 			}
 		}
 		}
 
 
@@ -284,3 +284,12 @@
 		margin-top: 20px;
 		margin-top: 20px;
 	}
 	}
 }
 }
+
+.files-dropdown-container {
+	.pull-right();
+	position: relative;
+}
+
+	.files-dropdown {
+		display: inline-block;
+	}

+ 105 - 0
services/web/public/stylesheets/app/sprites.less

@@ -0,0 +1,105 @@
+
+.sprite-icon {
+  background-image: url('/img/sprite.png');
+}
+
+.sprite-icon-ko {
+  background-position: -0px -0px;
+  width: 24px;
+  height: 24px;
+}
+.sprite-icon-cn {
+  background-position: -0px -24px;
+  width: 24px;
+  height: 24px;
+}
+.sprite-icon-da {
+  background-position: -0px -48px;
+  width: 24px;
+  height: 24px;
+}
+.sprite-icon-de {
+  background-position: -0px -72px;
+  width: 24px;
+  height: 24px;
+}
+.sprite-icon-en {
+  background-position: -0px -96px;
+  width: 24px;
+  height: 24px;
+}
+.sprite-icon-es {
+  background-position: -0px -120px;
+  width: 24px;
+  height: 24px;
+}
+.sprite-icon-fi {
+  background-position: -0px -144px;
+  width: 24px;
+  height: 24px;
+}
+.sprite-icon-fr {
+  background-position: -0px -168px;
+  width: 24px;
+  height: 24px;
+}
+.sprite-icon-it {
+  background-position: -0px -192px;
+  width: 24px;
+  height: 24px;
+}
+.sprite-icon-ja {
+  background-position: -0px -216px;
+  width: 24px;
+  height: 24px;
+}
+.sprite-icon-cs {
+  background-position: -0px -240px;
+  width: 24px;
+  height: 24px;
+}
+.sprite-icon-nl {
+  background-position: -0px -264px;
+  width: 24px;
+  height: 24px;
+}
+.sprite-icon-no {
+  background-position: -0px -288px;
+  width: 24px;
+  height: 24px;
+}
+.sprite-icon-pl {
+  background-position: -0px -312px;
+  width: 24px;
+  height: 24px;
+}
+.sprite-icon-pt {
+  background-position: -0px -336px;
+  width: 24px;
+  height: 24px;
+}
+.sprite-icon-ru {
+  background-position: -0px -360px;
+  width: 24px;
+  height: 24px;
+}
+.sprite-icon-sv {
+  background-position: -0px -384px;
+  width: 24px;
+  height: 24px;
+}
+.sprite-icon-tr {
+  background-position: -0px -408px;
+  width: 24px;
+  height: 24px;
+}
+.sprite-icon-uk {
+  background-position: -0px -432px;
+  width: 24px;
+  height: 24px;
+}
+.sprite-icon-zh-CN {
+  background-position: -0px -456px;
+  width: 24px;
+  height: 24px;
+}

+ 5 - 0
services/web/public/stylesheets/components/footer.less

@@ -23,3 +23,8 @@ footer.site-footer {
 		}
 		}
 	}
 	}
 }
 }
+
+.sprite-icon-lang {
+	display: inline-block;
+	vertical-align: middle;
+}

+ 1 - 0
services/web/public/stylesheets/style.less

@@ -73,3 +73,4 @@
 @import "app/wiki.less";
 @import "app/wiki.less";
 @import "app/translations.less";
 @import "app/translations.less";
 @import "app/contact-us.less";
 @import "app/contact-us.less";
+@import "app/sprites.less";

+ 32 - 25
services/web/test/UnitTests/coffee/Authentication/AuthenticationControllerTests.coffee

@@ -21,6 +21,10 @@ describe "AuthenticationController", ->
 			"../User/UserHandler": @UserHandler = {setupLoginData:sinon.stub()}
 			"../User/UserHandler": @UserHandler = {setupLoginData:sinon.stub()}
 			"logger-sharelatex": @logger = { log: sinon.stub(), error: sinon.stub() }
 			"logger-sharelatex": @logger = { log: sinon.stub(), error: sinon.stub() }
 			"settings-sharelatex": {}
 			"settings-sharelatex": {}
+			"../User/UserSessionsManager": @UserSessionsManager =
+				trackSession: sinon.stub()
+				untrackSession: sinon.stub()
+				revokeAllUserSessions: sinon.stub().callsArgWith(1, null)
 		@user =
 		@user =
 			_id: ObjectId()
 			_id: ObjectId()
 			email: @email = "USER@example.com"
 			email: @email = "USER@example.com"
@@ -57,7 +61,7 @@ describe "AuthenticationController", ->
 						@res.statusCode.should.equal 429
 						@res.statusCode.should.equal 429
 						done()
 						done()
 				@AuthenticationController.login(@req, @res)
 				@AuthenticationController.login(@req, @res)
-			
+
 		describe 'when the user is authenticated', ->
 		describe 'when the user is authenticated', ->
 			beforeEach ->
 			beforeEach ->
 				@LoginRateLimiter.processLoginRequest.callsArgWith(1, null, true)
 				@LoginRateLimiter.processLoginRequest.callsArgWith(1, null, true)
@@ -76,7 +80,7 @@ describe "AuthenticationController", ->
 				@AuthenticationController.establishUserSession
 				@AuthenticationController.establishUserSession
 					.calledWith(@req, @user)
 					.calledWith(@req, @user)
 					.should.equal true
 					.should.equal true
-					
+
 			it "should set res.session.justLoggedIn", ->
 			it "should set res.session.justLoggedIn", ->
 				@req.session.justLoggedIn.should.equal true
 				@req.session.justLoggedIn.should.equal true
 
 
@@ -95,7 +99,7 @@ describe "AuthenticationController", ->
 				@logger.log
 				@logger.log
 					.calledWith(email: @email.toLowerCase(), user_id: @user._id.toString(), "successful log in")
 					.calledWith(email: @email.toLowerCase(), user_id: @user._id.toString(), "successful log in")
 					.should.equal true
 					.should.equal true
-				
+
 
 
 		describe 'when the user is not authenticated', ->
 		describe 'when the user is not authenticated', ->
 			beforeEach ->
 			beforeEach ->
@@ -112,7 +116,7 @@ describe "AuthenticationController", ->
 
 
 			it "should not establish a session", ->
 			it "should not establish a session", ->
 				@AuthenticationController.establishUserSession.called.should.equal false
 				@AuthenticationController.establishUserSession.called.should.equal false
-			
+
 			it "should not setup the user data in the background", ->
 			it "should not setup the user data in the background", ->
 				@UserHandler.setupLoginData.called.should.equal false
 				@UserHandler.setupLoginData.called.should.equal false
 
 
@@ -137,12 +141,12 @@ describe "AuthenticationController", ->
 	describe "getLoggedInUserId", ->
 	describe "getLoggedInUserId", ->
 
 
 		beforeEach ->
 		beforeEach ->
-			@req = 
+			@req =
 				session :{}
 				session :{}
 
 
 		it "should return the user id from the session", (done)->
 		it "should return the user id from the session", (done)->
 			@user_id = "2134"
 			@user_id = "2134"
-			@req.session.user = 
+			@req.session.user =
 				_id:@user_id
 				_id:@user_id
 			@AuthenticationController.getLoggedInUserId @req, (err, user_id)=>
 			@AuthenticationController.getLoggedInUserId @req, (err, user_id)=>
 				expect(user_id).to.equal @user_id
 				expect(user_id).to.equal @user_id
@@ -168,7 +172,7 @@ describe "AuthenticationController", ->
 	describe "getLoggedInUser", ->
 	describe "getLoggedInUser", ->
 		beforeEach ->
 		beforeEach ->
 			@UserGetter.getUser = sinon.stub().callsArgWith(1, null, @user)
 			@UserGetter.getUser = sinon.stub().callsArgWith(1, null, @user)
-			
+
 		describe "with an established session", ->
 		describe "with an established session", ->
 			beforeEach ->
 			beforeEach ->
 				@req.session =
 				@req.session =
@@ -189,7 +193,7 @@ describe "AuthenticationController", ->
 				_id: "user-id-123"
 				_id: "user-id-123"
 				email: "user@sharelatex.com"
 				email: "user@sharelatex.com"
 			@middleware = @AuthenticationController.requireLogin()
 			@middleware = @AuthenticationController.requireLogin()
-			
+
 		describe "when the user is logged in", ->
 		describe "when the user is logged in", ->
 			beforeEach ->
 			beforeEach ->
 				@req.session =
 				@req.session =
@@ -219,13 +223,13 @@ describe "AuthenticationController", ->
 		beforeEach ->
 		beforeEach ->
 			@req.headers = {}
 			@req.headers = {}
 			@AuthenticationController.httpAuth = sinon.stub()
 			@AuthenticationController.httpAuth = sinon.stub()
-		
+
 		describe "with white listed url", ->
 		describe "with white listed url", ->
 			beforeEach ->
 			beforeEach ->
 				@AuthenticationController.addEndpointToLoginWhitelist "/login"
 				@AuthenticationController.addEndpointToLoginWhitelist "/login"
 				@req._parsedUrl.pathname = "/login"
 				@req._parsedUrl.pathname = "/login"
 				@AuthenticationController.requireGlobalLogin @req, @res, @next
 				@AuthenticationController.requireGlobalLogin @req, @res, @next
-			
+
 			it "should call next() directly", ->
 			it "should call next() directly", ->
 				@next.called.should.equal true
 				@next.called.should.equal true
 
 
@@ -235,9 +239,9 @@ describe "AuthenticationController", ->
 				@req._parsedUrl.pathname = "/login"
 				@req._parsedUrl.pathname = "/login"
 				@req.url = "/login?query=something"
 				@req.url = "/login?query=something"
 				@AuthenticationController.requireGlobalLogin @req, @res, @next
 				@AuthenticationController.requireGlobalLogin @req, @res, @next
-			
+
 			it "should call next() directly", ->
 			it "should call next() directly", ->
-				@next.called.should.equal true		
+				@next.called.should.equal true
 
 
 		describe "with http auth", ->
 		describe "with http auth", ->
 			beforeEach ->
 			beforeEach ->
@@ -248,20 +252,20 @@ describe "AuthenticationController", ->
 				@AuthenticationController.httpAuth
 				@AuthenticationController.httpAuth
 					.calledWith(@req, @res, @next)
 					.calledWith(@req, @res, @next)
 					.should.equal true
 					.should.equal true
-			
+
 		describe "with a user session", ->
 		describe "with a user session", ->
 			beforeEach ->
 			beforeEach ->
 				@req.session =
 				@req.session =
 					user: {"mock": "user"}
 					user: {"mock": "user"}
 				@AuthenticationController.requireGlobalLogin @req, @res, @next
 				@AuthenticationController.requireGlobalLogin @req, @res, @next
-			
+
 			it "should call next() directly", ->
 			it "should call next() directly", ->
 				@next.called.should.equal true
 				@next.called.should.equal true
-			
+
 		describe "with no login credentials", ->
 		describe "with no login credentials", ->
 			beforeEach ->
 			beforeEach ->
 				@AuthenticationController.requireGlobalLogin @req, @res, @next
 				@AuthenticationController.requireGlobalLogin @req, @res, @next
-				
+
 			it "should redirect to the /login page", ->
 			it "should redirect to the /login page", ->
 				@res.redirectedTo.should.equal "/login"
 				@res.redirectedTo.should.equal "/login"
 
 
@@ -274,7 +278,7 @@ describe "AuthenticationController", ->
 			@req.query = {}
 			@req.query = {}
 
 
 		describe "they have come directly to the url", ->
 		describe "they have come directly to the url", ->
-			beforeEach -> 
+			beforeEach ->
 				@req.query = {}
 				@req.query = {}
 				@middleware(@req, @res, @next)
 				@middleware(@req, @res, @next)
 
 
@@ -284,7 +288,7 @@ describe "AuthenticationController", ->
 
 
 		describe "they have come via a templates link", ->
 		describe "they have come via a templates link", ->
 
 
-			beforeEach -> 
+			beforeEach ->
 				@req.query.zipUrl = "something"
 				@req.query.zipUrl = "something"
 				@middleware(@req, @res, @next)
 				@middleware(@req, @res, @next)
 
 
@@ -293,8 +297,8 @@ describe "AuthenticationController", ->
 				@AuthenticationController._redirectToLoginPage.calledWith(@req, @res).should.equal false
 				@AuthenticationController._redirectToLoginPage.calledWith(@req, @res).should.equal false
 
 
 		describe "they have been invited to a project", ->
 		describe "they have been invited to a project", ->
-			
-			beforeEach -> 
+
+			beforeEach ->
 				@req.query.project_name = "something"
 				@req.query.project_name = "something"
 				@middleware(@req, @res, @next)
 				@middleware(@req, @res, @next)
 
 
@@ -333,7 +337,7 @@ describe "AuthenticationController", ->
 		beforeEach ->
 		beforeEach ->
 			@UserUpdater.updateUser = sinon.stub().callsArg(2)
 			@UserUpdater.updateUser = sinon.stub().callsArg(2)
 			@AuthenticationController._recordSuccessfulLogin(@user._id, @callback)
 			@AuthenticationController._recordSuccessfulLogin(@user._id, @callback)
-			
+
 		it "should increment the user.login.success metric", ->
 		it "should increment the user.login.success metric", ->
 			@Metrics.inc
 			@Metrics.inc
 				.calledWith("user.login.success")
 				.calledWith("user.login.success")
@@ -349,7 +353,7 @@ describe "AuthenticationController", ->
 	describe "_recordFailedLogin", ->
 	describe "_recordFailedLogin", ->
 		beforeEach ->
 		beforeEach ->
 			@AuthenticationController._recordFailedLogin(@callback)
 			@AuthenticationController._recordFailedLogin(@callback)
-			
+
 		it "should increment the user.login.failed metric", ->
 		it "should increment the user.login.failed metric", ->
 			@Metrics.inc
 			@Metrics.inc
 				.calledWith("user.login.failed")
 				.calledWith("user.login.failed")
@@ -365,6 +369,7 @@ describe "AuthenticationController", ->
 				destroy : sinon.stub()
 				destroy : sinon.stub()
 			@req.sessionStore =
 			@req.sessionStore =
 				generate: sinon.stub()
 				generate: sinon.stub()
+			@req.ip = "1.2.3.4"
 			@AuthenticationController.establishUserSession @req, @user, @callback
 			@AuthenticationController.establishUserSession @req, @user, @callback
 
 
 		it "should set the session user to a basic version of the user", ->
 		it "should set the session user to a basic version of the user", ->
@@ -374,13 +379,15 @@ describe "AuthenticationController", ->
 			@req.session.user.last_name.should.equal @user.last_name
 			@req.session.user.last_name.should.equal @user.last_name
 			@req.session.user.referal_id.should.equal @user.referal_id
 			@req.session.user.referal_id.should.equal @user.referal_id
 			@req.session.user.isAdmin.should.equal @user.isAdmin
 			@req.session.user.isAdmin.should.equal @user.isAdmin
-			
+			@req.session.user.ip_address.should.equal @req.ip
+			expect(typeof @req.session.user.ip_address).to.equal 'string'
+			expect(typeof @req.session.user.session_created).to.equal 'string'
+
 		it "should destroy the session", ->
 		it "should destroy the session", ->
 			@req.session.destroy.called.should.equal true
 			@req.session.destroy.called.should.equal true
-			
+
 		it "should regenerate the session to protect against session fixation", ->
 		it "should regenerate the session to protect against session fixation", ->
 			@req.sessionStore.generate.calledWith(@req).should.equal true
 			@req.sessionStore.generate.calledWith(@req).should.equal true
 
 
 		it "should return the callback", ->
 		it "should return the callback", ->
 			@callback.called.should.equal true
 			@callback.called.should.equal true
-

+ 19 - 4
services/web/test/UnitTests/coffee/Collaborators/CollaboratorsHandlerTests.coffee

@@ -77,17 +77,19 @@ describe "CollaboratorsHandler", ->
 				{ id: "read-only-ref-2", privilegeLevel: "readOnly" }
 				{ id: "read-only-ref-2", privilegeLevel: "readOnly" }
 				{ id: "read-write-ref-1", privilegeLevel: "readAndWrite" }
 				{ id: "read-write-ref-1", privilegeLevel: "readAndWrite" }
 				{ id: "read-write-ref-2", privilegeLevel: "readAndWrite" }
 				{ id: "read-write-ref-2", privilegeLevel: "readAndWrite" }
+				{ id: "doesnt-exist", privilegeLevel: "readAndWrite" }
 			])
 			])
 			@UserGetter.getUser = sinon.stub()
 			@UserGetter.getUser = sinon.stub()
 			@UserGetter.getUser.withArgs("read-only-ref-1").yields(null, { _id: "read-only-ref-1" })
 			@UserGetter.getUser.withArgs("read-only-ref-1").yields(null, { _id: "read-only-ref-1" })
 			@UserGetter.getUser.withArgs("read-only-ref-2").yields(null, { _id: "read-only-ref-2" })
 			@UserGetter.getUser.withArgs("read-only-ref-2").yields(null, { _id: "read-only-ref-2" })
 			@UserGetter.getUser.withArgs("read-write-ref-1").yields(null, { _id: "read-write-ref-1" })
 			@UserGetter.getUser.withArgs("read-write-ref-1").yields(null, { _id: "read-write-ref-1" })
 			@UserGetter.getUser.withArgs("read-write-ref-2").yields(null, { _id: "read-write-ref-2" })
 			@UserGetter.getUser.withArgs("read-write-ref-2").yields(null, { _id: "read-write-ref-2" })
+			@UserGetter.getUser.withArgs("doesnt-exist").yields(null, null)
 			@CollaboratorHandler.getMembersWithPrivilegeLevels @project_id, @callback
 			@CollaboratorHandler.getMembersWithPrivilegeLevels @project_id, @callback
 		
 		
 		it "should return an array of members with their privilege levels", ->
 		it "should return an array of members with their privilege levels", ->
 			@callback
 			@callback
-				.calledWith(undefined, [
+				.calledWith(null, [
 					{ user: { _id: "read-only-ref-1" }, privilegeLevel: "readOnly" }
 					{ user: { _id: "read-only-ref-1" }, privilegeLevel: "readOnly" }
 					{ user: { _id: "read-only-ref-2" }, privilegeLevel: "readOnly" }
 					{ user: { _id: "read-only-ref-2" }, privilegeLevel: "readOnly" }
 					{ user: { _id: "read-write-ref-1" }, privilegeLevel: "readAndWrite" }
 					{ user: { _id: "read-write-ref-1" }, privilegeLevel: "readAndWrite" }
@@ -274,6 +276,19 @@ describe "CollaboratorsHandler", ->
 			it "should not add any users to the proejct", ->
 			it "should not add any users to the proejct", ->
 				@CollaboratorHandler.addUserIdToProject.called.should.equal false
 				@CollaboratorHandler.addUserIdToProject.called.should.equal false
 
 
-
-
-
+	describe "removeUserFromAllProjects", ->
+		beforeEach (done) ->
+			@CollaboratorHandler.getProjectsUserIsCollaboratorOf = sinon.stub()
+			@CollaboratorHandler.getProjectsUserIsCollaboratorOf.withArgs(@user_id, { _id: 1 }).yields(
+				null,
+				[ { _id: "read-and-write-0" }, { _id: "read-and-write-1" }, null ],
+				[ { _id: "read-only-0" }, { _id: "read-only-1" }, null ]
+			)
+			@CollaboratorHandler.removeUserFromProject = sinon.stub().yields()
+			@CollaboratorHandler.removeUserFromAllProjets @user_id, done
+		
+		it "should remove the user from each project", ->
+			for project_id in ["read-and-write-0", "read-and-write-1", "read-only-0", "read-only-1"]
+				@CollaboratorHandler.removeUserFromProject
+					.calledWith(project_id, @user_id)
+					.should.equal true

+ 1 - 5
services/web/test/UnitTests/coffee/Compile/CompileControllerTests.coffee

@@ -139,7 +139,7 @@ describe "CompileController", ->
 					.should.equal true
 					.should.equal true
 
 
 			it "should proxy the PDF from the CLSI", ->
 			it "should proxy the PDF from the CLSI", ->
-				@CompileController.proxyToClsi.calledWith(@project_id, "/project/#{@project_id}/output/output.pdf", @req, @res, @next).should.equal true
+				@CompileController.proxyToClsi.calledWith(@project_id, "/project/#{@project_id}/user/#{@user_id}/output/output.pdf", @req, @res, @next).should.equal true
 
 
 		describe "when the pdf is not going to be used in pdfjs viewer", ->
 		describe "when the pdf is not going to be used in pdfjs viewer", ->
 
 
@@ -338,8 +338,6 @@ describe "CompileController", ->
 			@req =
 			@req =
 				params:
 				params:
 					project_id:@project_id
 					project_id:@project_id
-				query:
-					isolated: "true"
 			@CompileManager.compile.callsArgWith(3)
 			@CompileManager.compile.callsArgWith(3)
 			@CompileController.proxyToClsi = sinon.stub()
 			@CompileController.proxyToClsi = sinon.stub()
 			@res = 
 			@res = 
@@ -362,8 +360,6 @@ describe "CompileController", ->
 			@CompileManager.wordCount = sinon.stub().callsArgWith(3, null, {content:"body"})
 			@CompileManager.wordCount = sinon.stub().callsArgWith(3, null, {content:"body"})
 			@req.params =
 			@req.params =
 				Project_id: @project_id
 				Project_id: @project_id
-			@req.query =
-				isolated: "true"
 			@res.send = sinon.stub()
 			@res.send = sinon.stub()
 			@res.contentType = sinon.stub()
 			@res.contentType = sinon.stub()
 			@CompileController.wordCount @req, @res, @next
 			@CompileController.wordCount @req, @res, @next

+ 1 - 1
services/web/test/UnitTests/coffee/Compile/CompileManagerTests.coffee

@@ -71,7 +71,7 @@ describe "CompileManager", ->
 
 
 			it "should run the compile with the compile limits", ->
 			it "should run the compile with the compile limits", ->
 				@ClsiManager.sendRequest
 				@ClsiManager.sendRequest
-					.calledWith(@project_id, undefined, {
+					.calledWith(@project_id, @user_id, {
 						timeout: @limits.timeout
 						timeout: @limits.timeout
 					})
 					})
 					.should.equal true
 					.should.equal true

+ 11 - 1
services/web/test/UnitTests/coffee/PasswordReset/PasswordResetControllerTests.coffee

@@ -17,6 +17,8 @@ describe "PasswordResetController", ->
 			setNewUserPassword:sinon.stub()
 			setNewUserPassword:sinon.stub()
 		@RateLimiter =
 		@RateLimiter =
 			addCount: sinon.stub()
 			addCount: sinon.stub()
+		@UserSessionsManager =
+			revokeAllUserSessions: sinon.stub().callsArgWith(2, null)
 		@PasswordResetController = SandboxedModule.require modulePath, requires:
 		@PasswordResetController = SandboxedModule.require modulePath, requires:
 			"settings-sharelatex":@settings
 			"settings-sharelatex":@settings
 			"./PasswordResetHandler":@PasswordResetHandler
 			"./PasswordResetHandler":@PasswordResetHandler
@@ -24,6 +26,7 @@ describe "PasswordResetController", ->
 			"../../infrastructure/RateLimiter":@RateLimiter
 			"../../infrastructure/RateLimiter":@RateLimiter
 			"../Authentication/AuthenticationController": @AuthenticationController = {}
 			"../Authentication/AuthenticationController": @AuthenticationController = {}
 			"../User/UserGetter": @UserGetter = {}
 			"../User/UserGetter": @UserGetter = {}
+			"../User/UserSessionsManager": @UserSessionsManager
 
 
 		@email = "bob@bob.com "
 		@email = "bob@bob.com "
 		@token = "my security token that was emailed to me"
 		@token = "my security token that was emailed to me"
@@ -134,7 +137,14 @@ describe "PasswordResetController", ->
 				@req.session.should.not.have.property 'resetToken'
 				@req.session.should.not.have.property 'resetToken'
 				done()
 				done()
 			@PasswordResetController.setNewUserPassword @req, @res
 			@PasswordResetController.setNewUserPassword @req, @res
-		
+
+		it 'should clear sessions', (done) ->
+			@PasswordResetHandler.setNewUserPassword.callsArgWith(2, null, true)
+			@res.sendStatus = (code)=>
+				@UserSessionsManager.revokeAllUserSessions.callCount.should.equal 1
+				done()
+			@PasswordResetController.setNewUserPassword @req, @res
+
 		it "should login user if login_after is set", (done) ->
 		it "should login user if login_after is set", (done) ->
 			@UserGetter.getUser = sinon.stub().callsArgWith(2, null, { email: "joe@example.com" })
 			@UserGetter.getUser = sinon.stub().callsArgWith(2, null, { email: "joe@example.com" })
 			@PasswordResetHandler.setNewUserPassword.callsArgWith(2, null, true, @user_id = "user-id-123")
 			@PasswordResetHandler.setNewUserPassword.callsArgWith(2, null, true, @user_id = "user-id-123")

+ 9 - 1
services/web/test/UnitTests/coffee/Project/ProjectDeleterTests.coffee

@@ -27,13 +27,15 @@ describe 'ProjectDeleter', ->
 			removeProjectFromAllTags: sinon.stub().callsArgWith(2)
 			removeProjectFromAllTags: sinon.stub().callsArgWith(2)
 		@ProjectGetter =
 		@ProjectGetter =
 			getProject:sinon.stub()
 			getProject:sinon.stub()
+		@CollaboratorsHandler =
+			removeUserFromAllProjets: sinon.stub().yields()
 		@deleter = SandboxedModule.require modulePath, requires:
 		@deleter = SandboxedModule.require modulePath, requires:
 			"../Editor/EditorController": @editorController
 			"../Editor/EditorController": @editorController
 			'../../models/Project':{Project:@Project}
 			'../../models/Project':{Project:@Project}
 			'../DocumentUpdater/DocumentUpdaterHandler': @documentUpdaterHandler
 			'../DocumentUpdater/DocumentUpdaterHandler': @documentUpdaterHandler
 			"../Tags/TagsHandler":@TagsHandler
 			"../Tags/TagsHandler":@TagsHandler
 			"../FileStore/FileStoreHandler": @FileStoreHandler = {}
 			"../FileStore/FileStoreHandler": @FileStoreHandler = {}
-			"../Collaborators/CollaboratorsHandler": @CollaboratorsHandler = {}
+			"../Collaborators/CollaboratorsHandler": @CollaboratorsHandler
 			"./ProjectGetter": @ProjectGetter
 			"./ProjectGetter": @ProjectGetter
 			'logger-sharelatex':
 			'logger-sharelatex':
 				log:->
 				log:->
@@ -74,6 +76,12 @@ describe 'ProjectDeleter', ->
 				@Project.remove.calledWith(owner_ref:user_id).should.equal true
 				@Project.remove.calledWith(owner_ref:user_id).should.equal true
 				done()
 				done()
 
 
+		it "should remove all the projects the user is a collaborator of", (done)->
+			user_id = 1234
+			@deleter.deleteUsersProjects user_id, =>
+				@CollaboratorsHandler.removeUserFromAllProjets.calledWith(user_id).should.equal true
+				done()
+
 	describe "deleteProject", ->
 	describe "deleteProject", ->
 		beforeEach (done) ->
 		beforeEach (done) ->
 			@project_id = "mock-project-id-123"
 			@project_id = "mock-project-id-123"

+ 15 - 12
services/web/test/UnitTests/coffee/User/UserControllerTests.coffee

@@ -19,9 +19,9 @@ describe "UserController", ->
 			save:sinon.stub().callsArgWith(0)
 			save:sinon.stub().callsArgWith(0)
 			ace:{}
 			ace:{}
 
 
-		@UserDeleter = 
+		@UserDeleter =
 			deleteUser: sinon.stub().callsArgWith(1)
 			deleteUser: sinon.stub().callsArgWith(1)
-		@UserLocator = 
+		@UserLocator =
 			findById: sinon.stub().callsArgWith(1, null, @user)
 			findById: sinon.stub().callsArgWith(1, null, @user)
 		@User =
 		@User =
 			findById: sinon.stub().callsArgWith(1, null, @user)
 			findById: sinon.stub().callsArgWith(1, null, @user)
@@ -36,14 +36,18 @@ describe "UserController", ->
 			setUserPassword: sinon.stub()
 			setUserPassword: sinon.stub()
 		@ReferalAllocator =
 		@ReferalAllocator =
 			allocate:sinon.stub()
 			allocate:sinon.stub()
-		@SubscriptionDomainHandler = 
+		@SubscriptionDomainHandler =
 			autoAllocate:sinon.stub()
 			autoAllocate:sinon.stub()
 		@UserUpdater =
 		@UserUpdater =
 			changeEmailAddress:sinon.stub()
 			changeEmailAddress:sinon.stub()
 		@settings =
 		@settings =
 			siteUrl: "sharelatex.example.com"
 			siteUrl: "sharelatex.example.com"
-		@UserHandler = 
+		@UserHandler =
 			populateGroupLicenceInvite:sinon.stub().callsArgWith(1)
 			populateGroupLicenceInvite:sinon.stub().callsArgWith(1)
+		@UserSessionsManager =
+			trackSession: sinon.stub()
+			untrackSession: sinon.stub()
+			revokeAllUserSessions: sinon.stub().callsArgWith(2, null)
 		@UserController = SandboxedModule.require modulePath, requires:
 		@UserController = SandboxedModule.require modulePath, requires:
 			"./UserLocator": @UserLocator
 			"./UserLocator": @UserLocator
 			"./UserDeleter": @UserDeleter
 			"./UserDeleter": @UserDeleter
@@ -56,14 +60,15 @@ describe "UserController", ->
 			"../Referal/ReferalAllocator":@ReferalAllocator
 			"../Referal/ReferalAllocator":@ReferalAllocator
 			"../Subscription/SubscriptionDomainHandler":@SubscriptionDomainHandler
 			"../Subscription/SubscriptionDomainHandler":@SubscriptionDomainHandler
 			"./UserHandler":@UserHandler
 			"./UserHandler":@UserHandler
+			"./UserSessionsManager": @UserSessionsManager
 			"settings-sharelatex": @settings
 			"settings-sharelatex": @settings
-			"logger-sharelatex": 
+			"logger-sharelatex":
 				log:->
 				log:->
 				err:->
 				err:->
 			"../../infrastructure/Metrics": inc:->
 			"../../infrastructure/Metrics": inc:->
 
 
-		@req = 
-			session: 
+		@req =
+			session:
 				destroy:->
 				destroy:->
 				user :
 				user :
 					_id : @user_id
 					_id : @user_id
@@ -198,12 +203,12 @@ describe "UserController", ->
 			@UserRegistrationHandler.registerNewUserAndSendActivationEmail = sinon.stub().callsArgWith(1, null, @user, @url = "mock/url")
 			@UserRegistrationHandler.registerNewUserAndSendActivationEmail = sinon.stub().callsArgWith(1, null, @user, @url = "mock/url")
 			@req.body.email = @user.email = @email = "email@example.com"
 			@req.body.email = @user.email = @email = "email@example.com"
 			@UserController.register @req, @res
 			@UserController.register @req, @res
-		
+
 		it "should register the user and send them an email", ->
 		it "should register the user and send them an email", ->
 			@UserRegistrationHandler.registerNewUserAndSendActivationEmail
 			@UserRegistrationHandler.registerNewUserAndSendActivationEmail
 				.calledWith(@email)
 				.calledWith(@email)
 				.should.equal true
 				.should.equal true
-		
+
 		it "should return the user and activation url", ->
 		it "should return the user and activation url", ->
 			@res.json
 			@res.json
 				.calledWith({
 				.calledWith({
@@ -233,7 +238,7 @@ describe "UserController", ->
 			@res.send = =>
 			@res.send = =>
 				@AuthenticationManager.setUserPassword.called.should.equal false
 				@AuthenticationManager.setUserPassword.called.should.equal false
 				done()
 				done()
-			@UserController.changePassword @req, @res			
+			@UserController.changePassword @req, @res
 
 
 		it "should set the new password if they do match", (done)->
 		it "should set the new password if they do match", (done)->
 			@AuthenticationManager.authenticate.callsArgWith(2, null, @user)
 			@AuthenticationManager.authenticate.callsArgWith(2, null, @user)
@@ -245,5 +250,3 @@ describe "UserController", ->
 				@AuthenticationManager.setUserPassword.calledWith(@user._id, "newpass").should.equal true
 				@AuthenticationManager.setUserPassword.calledWith(@user._id, "newpass").should.equal true
 				done()
 				done()
 			@UserController.changePassword @req, @res
 			@UserController.changePassword @req, @res
-
-

+ 484 - 0
services/web/test/UnitTests/coffee/User/UserSessionsManagerTests.coffee

@@ -0,0 +1,484 @@
+sinon = require('sinon')
+chai = require('chai')
+should = chai.should()
+expect = chai.expect
+modulePath = "../../../../app/js/Features/User/UserSessionsManager.js"
+SandboxedModule = require('sandboxed-module')
+
+describe 'UserSessionsManager', ->
+
+	beforeEach ->
+		@user =
+			_id: "abcd"
+			email: "user@example.com"
+		@sessionId = 'some_session_id'
+
+		@rclient =
+			multi:    sinon.stub()
+			exec:     sinon.stub()
+			get:      sinon.stub()
+			del:      sinon.stub()
+			sadd:     sinon.stub()
+			srem:     sinon.stub()
+			smembers: sinon.stub()
+			expire:   sinon.stub()
+		@rclient.multi.returns(@rclient)
+		@rclient.get.returns(@rclient)
+		@rclient.del.returns(@rclient)
+		@rclient.sadd.returns(@rclient)
+		@rclient.srem.returns(@rclient)
+		@rclient.smembers.returns(@rclient)
+		@rclient.expire.returns(@rclient)
+		@rclient.exec.callsArgWith(0, null)
+
+		@redis =
+			createClient: () => @rclient
+		@logger =
+			err:   sinon.stub()
+			error: sinon.stub()
+			log:   sinon.stub()
+		@settings =
+			redis:
+				web: {}
+		@UserSessionsManager = SandboxedModule.require modulePath, requires:
+			"redis-sharelatex":    @redis
+			"logger-sharelatex":   @logger
+			"settings-sharelatex": @settings
+
+	describe '_sessionSetKey', ->
+
+		it 'should build the correct key', ->
+			result = @UserSessionsManager._sessionSetKey(@user)
+			result.should.equal 'UserSessions:abcd'
+
+	describe '_sessionKey', ->
+
+		it 'should build the correct key', ->
+			result = @UserSessionsManager._sessionKey(@sessionId)
+			result.should.equal 'sess:some_session_id'
+
+	describe 'trackSession', ->
+
+		beforeEach ->
+			@call = (callback) =>
+				@UserSessionsManager.trackSession @user, @sessionId, callback
+			@rclient.exec.callsArgWith(0, null)
+			@_checkSessions = sinon.stub(@UserSessionsManager, '_checkSessions').returns(null)
+
+		afterEach ->
+			@_checkSessions.restore()
+
+		it 'should not produce an error', (done) ->
+			@call (err) =>
+				expect(err).to.not.be.instanceof Error
+				done()
+
+		it 'should call the appropriate redis methods', (done) ->
+			@call (err) =>
+				@rclient.multi.callCount.should.equal 1
+				@rclient.sadd.callCount.should.equal 1
+				@rclient.expire.callCount.should.equal 1
+				@rclient.exec.callCount.should.equal 1
+				done()
+
+		it 'should call _checkSessions', (done) ->
+			@call (err) =>
+				@_checkSessions.callCount.should.equal 1
+				done()
+
+		describe 'when rclient produces an error', ->
+
+			beforeEach ->
+				@rclient.exec.callsArgWith(0, new Error('woops'))
+
+			it 'should produce an error', (done) ->
+				@call (err) =>
+					expect(err).to.be.instanceof Error
+					done()
+
+			it 'should not call _checkSessions', (done) ->
+				@call (err) =>
+					@_checkSessions.callCount.should.equal 0
+					done()
+
+		describe 'when no user is supplied', ->
+
+			beforeEach ->
+				@call = (callback) =>
+					@UserSessionsManager.trackSession null, @sessionId, callback
+
+			it 'should not produce an error', (done) ->
+				@call (err) =>
+					expect(err).to.not.be.instanceof Error
+					expect(err).to.equal null
+					done()
+
+			it 'should not call the appropriate redis methods', (done) ->
+				@call (err) =>
+					@rclient.multi.callCount.should.equal 0
+					@rclient.sadd.callCount.should.equal 0
+					@rclient.expire.callCount.should.equal 0
+					@rclient.exec.callCount.should.equal 0
+					done()
+
+			it 'should not call _checkSessions', (done) ->
+				@call (err) =>
+					@_checkSessions.callCount.should.equal 0
+					done()
+
+		describe 'when no sessionId is supplied', ->
+
+			beforeEach ->
+				@call = (callback) =>
+					@UserSessionsManager.trackSession @user, null, callback
+
+			it 'should not produce an error', (done) ->
+				@call (err) =>
+					expect(err).to.not.be.instanceof Error
+					expect(err).to.equal null
+					done()
+
+			it 'should not call the appropriate redis methods', (done) ->
+				@call (err) =>
+					@rclient.multi.callCount.should.equal 0
+					@rclient.sadd.callCount.should.equal 0
+					@rclient.expire.callCount.should.equal 0
+					@rclient.exec.callCount.should.equal 0
+					done()
+
+			it 'should not call _checkSessions', (done) ->
+				@call (err) =>
+					@_checkSessions.callCount.should.equal 0
+					done()
+
+	describe 'untrackSession', ->
+
+		beforeEach ->
+			@call = (callback) =>
+				@UserSessionsManager.untrackSession @user, @sessionId, callback
+			@rclient.exec.callsArgWith(0, null)
+			@_checkSessions = sinon.stub(@UserSessionsManager, '_checkSessions').returns(null)
+
+		afterEach ->
+			@_checkSessions.restore()
+
+		it 'should not produce an error', (done) ->
+			@call (err) =>
+				expect(err).to.not.be.instanceof Error
+				expect(err).to.equal undefined
+				done()
+
+		it 'should call the appropriate redis methods', (done) ->
+			@call (err) =>
+				@rclient.multi.callCount.should.equal 1
+				@rclient.srem.callCount.should.equal 1
+				@rclient.expire.callCount.should.equal 1
+				@rclient.exec.callCount.should.equal 1
+				done()
+
+		it 'should call _checkSessions', (done) ->
+			@call (err) =>
+				@_checkSessions.callCount.should.equal 1
+				done()
+
+		describe 'when rclient produces an error', ->
+
+			beforeEach ->
+				@rclient.exec.callsArgWith(0, new Error('woops'))
+
+			it 'should produce an error', (done) ->
+				@call (err) =>
+					expect(err).to.be.instanceof Error
+					done()
+
+			it 'should not call _checkSessions', (done) ->
+				@call (err) =>
+					@_checkSessions.callCount.should.equal 0
+					done()
+
+		describe 'when no user is supplied', ->
+
+			beforeEach ->
+				@call = (callback) =>
+					@UserSessionsManager.untrackSession null, @sessionId, callback
+
+			it 'should not produce an error', (done) ->
+				@call (err) =>
+					expect(err).to.not.be.instanceof Error
+					expect(err).to.equal null
+					done()
+
+			it 'should not call the appropriate redis methods', (done) ->
+				@call (err) =>
+					@rclient.multi.callCount.should.equal 0
+					@rclient.srem.callCount.should.equal 0
+					@rclient.expire.callCount.should.equal 0
+					@rclient.exec.callCount.should.equal 0
+					done()
+
+			it 'should not call _checkSessions', (done) ->
+				@call (err) =>
+					@_checkSessions.callCount.should.equal 0
+					done()
+
+		describe 'when no sessionId is supplied', ->
+
+			beforeEach ->
+				@call = (callback) =>
+					@UserSessionsManager.untrackSession @user, null, callback
+
+			it 'should not produce an error', (done) ->
+				@call (err) =>
+					expect(err).to.not.be.instanceof Error
+					expect(err).to.equal null
+					done()
+
+			it 'should not call the appropriate redis methods', (done) ->
+				@call (err) =>
+					@rclient.multi.callCount.should.equal 0
+					@rclient.srem.callCount.should.equal 0
+					@rclient.expire.callCount.should.equal 0
+					@rclient.exec.callCount.should.equal 0
+					done()
+
+			it 'should not call _checkSessions', (done) ->
+				@call (err) =>
+					@_checkSessions.callCount.should.equal 0
+					done()
+
+	##
+	describe 'revokeAllUserSessions', ->
+
+		beforeEach ->
+			@sessionKeys = ['sess:one', 'sess:two']
+			@retain = []
+			@rclient.smembers.callsArgWith(1, null, @sessionKeys)
+			@rclient.exec.callsArgWith(0, null)
+			@call = (callback) =>
+				@UserSessionsManager.revokeAllUserSessions @user, @retain, callback
+
+		it 'should not produce an error', (done) ->
+			@call (err) =>
+				expect(err).to.not.be.instanceof Error
+				expect(err).to.equal null
+				done()
+
+		it 'should call the appropriate redis methods', (done) ->
+			@call (err) =>
+				@rclient.smembers.callCount.should.equal 1
+				@rclient.multi.callCount.should.equal 1
+
+				@rclient.del.callCount.should.equal 1
+				expect(@rclient.del.firstCall.args[0]).to.deep.equal @sessionKeys
+
+				@rclient.srem.callCount.should.equal 1
+				expect(@rclient.srem.firstCall.args[1]).to.deep.equal @sessionKeys
+
+				@rclient.exec.callCount.should.equal 1
+				done()
+
+		describe 'when a session is retained', ->
+
+			beforeEach ->
+				@sessionKeys = ['sess:one', 'sess:two', 'sess:three', 'sess:four']
+				@retain = ['two']
+				@rclient.smembers.callsArgWith(1, null, @sessionKeys)
+				@rclient.exec.callsArgWith(0, null)
+				@call = (callback) =>
+					@UserSessionsManager.revokeAllUserSessions @user, @retain, callback
+
+			it 'should not produce an error', (done) ->
+				@call (err) =>
+					expect(err).to.not.be.instanceof Error
+					expect(err).to.equal null
+					done()
+
+			it 'should call the appropriate redis methods', (done) ->
+				@call (err) =>
+					@rclient.smembers.callCount.should.equal 1
+					@rclient.multi.callCount.should.equal 1
+					@rclient.del.callCount.should.equal 1
+					@rclient.srem.callCount.should.equal 1
+					@rclient.exec.callCount.should.equal 1
+					done()
+
+			it 'should remove all sessions except for the retained one', (done) ->
+				@call (err) =>
+					expect(@rclient.del.firstCall.args[0]).to.deep.equal(['sess:one', 'sess:three', 'sess:four'])
+					expect(@rclient.srem.firstCall.args[1]).to.deep.equal(['sess:one', 'sess:three', 'sess:four'])
+					done()
+
+		describe 'when rclient produces an error', ->
+
+			beforeEach ->
+				@rclient.exec.callsArgWith(0, new Error('woops'))
+
+			it 'should produce an error', (done) ->
+				@call (err) =>
+					expect(err).to.be.instanceof Error
+					done()
+
+		describe 'when no user is supplied', ->
+
+			beforeEach ->
+				@call = (callback) =>
+					@UserSessionsManager.revokeAllUserSessions null, @retain, callback
+
+			it 'should not produce an error', (done) ->
+				@call (err) =>
+					expect(err).to.not.be.instanceof Error
+					expect(err).to.equal null
+					done()
+
+			it 'should not call the appropriate redis methods', (done) ->
+				@call (err) =>
+					@rclient.smembers.callCount.should.equal 0
+					@rclient.multi.callCount.should.equal 0
+					@rclient.del.callCount.should.equal 0
+					@rclient.srem.callCount.should.equal 0
+					@rclient.exec.callCount.should.equal 0
+					done()
+
+		describe 'when there are no keys to delete', ->
+
+			beforeEach ->
+				@rclient.smembers.callsArgWith(1, null, [])
+
+			it 'should not produce an error', (done) ->
+				@call (err) =>
+					expect(err).to.not.be.instanceof Error
+					expect(err).to.equal null
+					done()
+
+			it 'should not do the delete operation', (done) ->
+				@call (err) =>
+					@rclient.smembers.callCount.should.equal 1
+					@rclient.multi.callCount.should.equal 0
+					@rclient.del.callCount.should.equal 0
+					@rclient.srem.callCount.should.equal 0
+					@rclient.exec.callCount.should.equal 0
+					done()
+
+	describe 'touch', ->
+
+		beforeEach ->
+			@rclient.expire.callsArgWith(2, null)
+			@call = (callback) =>
+				@UserSessionsManager.touch @user, callback
+
+		it 'should not produce an error', (done) ->
+			@call (err) =>
+				expect(err).to.not.be.instanceof Error
+				expect(err).to.equal null
+				done()
+
+		it 'should call rclient.expire', (done) ->
+			@call (err) =>
+				@rclient.expire.callCount.should.equal 1
+				done()
+
+		describe 'when rclient produces an error', ->
+
+			beforeEach ->
+				@rclient.expire.callsArgWith(2, new Error('woops'))
+
+			it 'should produce an error', (done) ->
+				@call (err) =>
+					expect(err).to.be.instanceof Error
+					done()
+
+		describe 'when no user is supplied', ->
+
+			beforeEach ->
+				@call = (callback) =>
+					@UserSessionsManager.touch null, callback
+
+			it 'should not produce an error', (done) ->
+				@call (err) =>
+					expect(err).to.not.be.instanceof Error
+					expect(err).to.equal null
+					done()
+
+			it 'should not call expire', (done) ->
+				@call (err) =>
+					@rclient.expire.callCount.should.equal 0
+					done()
+
+	describe '_checkSessions', ->
+
+		beforeEach ->
+			@call = (callback) =>
+				@UserSessionsManager._checkSessions @user, callback
+			@sessionKeys = ['one', 'two']
+			@rclient.smembers.callsArgWith(1, null, @sessionKeys)
+			@rclient.get.callsArgWith(1, null, 'some-value')
+			@rclient.srem.callsArgWith(2, null, {})
+
+		it 'should not produce an error', (done) ->
+			@call (err) =>
+				expect(err).to.not.be.instanceof Error
+				expect(err).to.equal undefined
+				done()
+
+		it 'should call the appropriate redis methods', (done) ->
+			@call (err) =>
+				@rclient.smembers.callCount.should.equal 1
+				@rclient.get.callCount.should.equal 2
+				@rclient.srem.callCount.should.equal 0
+				done()
+
+		describe 'when one of the keys is not present in redis', ->
+
+			beforeEach ->
+				@rclient.get.onCall(0).callsArgWith(1, null, 'some-val')
+				@rclient.get.onCall(1).callsArgWith(1, null, null)
+
+			it 'should not produce an error', (done) ->
+				@call (err) =>
+					expect(err).to.not.be.instanceof Error
+					expect(err).to.equal undefined
+					done()
+
+			it 'should remove that key from the set', (done) ->
+				@call (err) =>
+					@rclient.smembers.callCount.should.equal 1
+					@rclient.get.callCount.should.equal 2
+					@rclient.srem.callCount.should.equal 1
+					@rclient.srem.firstCall.args[1].should.equal 'two'
+					done()
+
+		describe 'when no user is supplied', ->
+
+			beforeEach ->
+				@call = (callback) =>
+					@UserSessionsManager._checkSessions null, callback
+
+			it 'should not produce an error', (done) ->
+				@call (err) =>
+					expect(err).to.not.be.instanceof Error
+					expect(err).to.equal null
+					done()
+
+			it 'should not call redis methods', (done) ->
+				@call (err) =>
+					@rclient.smembers.callCount.should.equal 0
+					@rclient.get.callCount.should.equal 0
+					done()
+
+		describe 'when one of the get operations produces an error', ->
+
+			beforeEach ->
+				@rclient.get.onCall(0).callsArgWith(1, new Error('woops'), null)
+				@rclient.get.onCall(1).callsArgWith(1, null, null)
+
+			it 'should produce an error', (done) ->
+				@call (err) =>
+					expect(err).to.be.instanceof Error
+					done()
+
+			it 'should call the right redis methods, bailing out early', (done) ->
+				@call (err) =>
+					@rclient.smembers.callCount.should.equal 1
+					@rclient.get.callCount.should.equal 1
+					@rclient.srem.callCount.should.equal 0
+					done()

+ 253 - 0
services/web/test/acceptance/coffee/SessionTests.coffee

@@ -0,0 +1,253 @@
+expect = require("chai").expect
+async = require("async")
+User = require "./helpers/User"
+request = require "./helpers/request"
+settings = require "settings-sharelatex"
+redis = require "./helpers/redis"
+
+describe "Sessions", ->
+	before (done) ->
+		@timeout(20000)
+		@user1 = new User()
+		@site_admin = new User({email: "admin@example.com"})
+		async.series [
+			(cb) => @user1.login cb
+			(cb) => @user1.logout cb
+		], done
+
+	describe "one session", ->
+
+		it "should have one session in UserSessions set", (done) ->
+			async.series(
+				[
+					(next) =>
+						redis.clearUserSessions @user1, next
+
+					# login, should add session to set
+					, (next) =>
+						@user1.login (err) ->
+							next(err)
+
+					, (next) =>
+						redis.getUserSessions @user1, (err, sessions) =>
+							expect(sessions.length).to.equal 1
+							expect(sessions[0].slice(0, 5)).to.equal 'sess:'
+							next()
+
+					# should be able to access settings page
+					, (next) =>
+						@user1.getUserSettingsPage (err, statusCode) =>
+							expect(err).to.equal null
+							expect(statusCode).to.equal 200
+							next()
+
+					# logout, should remove session from set
+					, (next) =>
+						@user1.logout (err) ->
+							next(err)
+
+					, (next) =>
+						redis.getUserSessions @user1, (err, sessions) =>
+							expect(sessions.length).to.equal 0
+							next()
+
+				], (err, result) =>
+					if err
+						throw err
+					done()
+			)
+
+	describe "two sessions", ->
+
+		before ->
+			# set up second session for this user
+			@user2 = new User()
+			@user2.email = @user1.email
+			@user2.password = @user1.password
+
+		it "should have two sessions in UserSessions set", (done) ->
+			async.series(
+				[
+					(next) =>
+						redis.clearUserSessions @user1, next
+
+					# login, should add session to set
+					, (next) =>
+						@user1.login (err) ->
+							next(err)
+
+					, (next) =>
+						redis.getUserSessions @user1, (err, sessions) =>
+							expect(sessions.length).to.equal 1
+							expect(sessions[0].slice(0, 5)).to.equal 'sess:'
+							next()
+
+					# login again, should add the second session to set
+					, (next) =>
+						@user2.login (err) ->
+							next(err)
+
+					, (next) =>
+						redis.getUserSessions @user1, (err, sessions) =>
+							expect(sessions.length).to.equal 2
+							expect(sessions[0].slice(0, 5)).to.equal 'sess:'
+							expect(sessions[1].slice(0, 5)).to.equal 'sess:'
+							next()
+
+					# both should be able to access settings page
+					, (next) =>
+						@user1.getUserSettingsPage (err, statusCode) =>
+							expect(err).to.equal null
+							expect(statusCode).to.equal 200
+							next()
+
+					, (next) =>
+						@user2.getUserSettingsPage (err, statusCode) =>
+							expect(err).to.equal null
+							expect(statusCode).to.equal 200
+							next()
+
+					# logout first session, should remove session from set
+					, (next) =>
+						@user1.logout (err) ->
+							next(err)
+
+					, (next) =>
+						redis.getUserSessions @user1, (err, sessions) =>
+							expect(sessions.length).to.equal 1
+							next()
+
+					# first session should not have access to settings page
+					, (next) =>
+						@user1.getUserSettingsPage (err, statusCode) =>
+							expect(err).to.equal null
+							expect(statusCode).to.equal 302
+							next()
+
+					# second session should still have access to settings
+					, (next) =>
+						@user2.getUserSettingsPage (err, statusCode) =>
+							expect(err).to.equal null
+							expect(statusCode).to.equal 200
+							next()
+
+					# logout second session, should remove last session from set
+					, (next) =>
+						@user2.logout (err) ->
+							next(err)
+
+					, (next) =>
+						redis.getUserSessions @user1, (err, sessions) =>
+							expect(sessions.length).to.equal 0
+							next()
+
+					# second session should not have access to settings page
+					, (next) =>
+						@user2.getUserSettingsPage (err, statusCode) =>
+							expect(err).to.equal null
+							expect(statusCode).to.equal 302
+							next()
+
+				], (err, result) =>
+					if err
+						throw err
+					done()
+			)
+
+	describe 'three sessions, password reset', ->
+
+		before ->
+			# set up second session for this user
+			@user2 = new User()
+			@user2.email = @user1.email
+			@user2.password = @user1.password
+			@user3 = new User()
+			@user3.email = @user1.email
+			@user3.password = @user1.password
+
+		it "should erase both sessions when password is reset", (done) ->
+			async.series(
+				[
+					(next) =>
+						redis.clearUserSessions @user1, next
+
+					# login, should add session to set
+					, (next) =>
+						@user1.login (err) ->
+							next(err)
+
+					, (next) =>
+						redis.getUserSessions @user1, (err, sessions) =>
+							expect(sessions.length).to.equal 1
+							expect(sessions[0].slice(0, 5)).to.equal 'sess:'
+							next()
+
+					# login again, should add the second session to set
+					, (next) =>
+						@user2.login (err) ->
+							next(err)
+
+					, (next) =>
+						redis.getUserSessions @user1, (err, sessions) =>
+							expect(sessions.length).to.equal 2
+							expect(sessions[0].slice(0, 5)).to.equal 'sess:'
+							expect(sessions[1].slice(0, 5)).to.equal 'sess:'
+							next()
+
+					# login third session, should add the second session to set
+					, (next) =>
+						@user3.login (err) ->
+							next(err)
+
+					, (next) =>
+						redis.getUserSessions @user1, (err, sessions) =>
+							expect(sessions.length).to.equal 3
+							expect(sessions[0].slice(0, 5)).to.equal 'sess:'
+							expect(sessions[1].slice(0, 5)).to.equal 'sess:'
+							next()
+
+					# password reset from second session, should erase two of the three sessions
+					, (next) =>
+						@user2.changePassword (err) ->
+							next(err)
+
+					, (next) =>
+						redis.getUserSessions @user2, (err, sessions) =>
+							expect(sessions.length).to.equal 1
+							next()
+
+					# users one and three should not be able to access settings page
+					, (next) =>
+						@user1.getUserSettingsPage (err, statusCode) =>
+							expect(err).to.equal null
+							expect(statusCode).to.equal 302
+							next()
+
+					, (next) =>
+						@user3.getUserSettingsPage (err, statusCode) =>
+							expect(err).to.equal null
+							expect(statusCode).to.equal 302
+							next()
+
+					# user two should still be logged in, and able to access settings page
+					, (next) =>
+						@user2.getUserSettingsPage (err, statusCode) =>
+							expect(err).to.equal null
+							expect(statusCode).to.equal 200
+							next()
+
+					# logout second session, should remove last session from set
+					, (next) =>
+						@user2.logout (err) ->
+							next(err)
+
+					, (next) =>
+						redis.getUserSessions @user1, (err, sessions) =>
+							expect(sessions.length).to.equal 0
+							next()
+
+				], (err, result) =>
+					if err
+						throw err
+					done()
+			)

+ 46 - 3
services/web/test/acceptance/coffee/helpers/User.coffee

@@ -27,11 +27,28 @@ class User
 				db.users.findOne {email: @email}, (error, user) =>
 				db.users.findOne {email: @email}, (error, user) =>
 					return callback(error) if error?
 					return callback(error) if error?
 					@id = user?._id?.toString()
 					@id = user?._id?.toString()
+					@_id = user?._id?.toString()
 					callback()
 					callback()
-	
+
+	logout: (callback = (error) ->) ->
+		@getCsrfToken (error) =>
+			return callback(error) if error?
+			@request.get {
+				url: "/logout"
+				json:
+					email: @email
+					password: @password
+			}, (error, response, body) =>
+				return callback(error) if error?
+				db.users.findOne {email: @email}, (error, user) =>
+					return callback(error) if error?
+					@id = user?._id?.toString()
+					@_id = user?._id?.toString()
+					callback()
+
 	ensure_admin: (callback = (error) ->) ->
 	ensure_admin: (callback = (error) ->) ->
 		db.users.update {_id: ObjectId(@id)}, { $set: { isAdmin: true }}, callback
 		db.users.update {_id: ObjectId(@id)}, { $set: { isAdmin: true }}, callback
-	
+
 	createProject: (name, callback = (error, project_id) ->) ->
 	createProject: (name, callback = (error, project_id) ->) ->
 		@request.post {
 		@request.post {
 			url: "/project/new",
 			url: "/project/new",
@@ -74,4 +91,30 @@ class User
 			})
 			})
 			callback()
 			callback()
 
 
-module.exports = User
+	changePassword: (callback = (error) ->) ->
+		@getCsrfToken (error) =>
+			return callback(error) if error?
+			@request.post {
+				url: "/user/password/update"
+				json:
+					currentPassword: @password
+					newPassword1: @password
+					newPassword2: @password
+			}, (error, response, body) =>
+				return callback(error) if error?
+				db.users.findOne {email: @email}, (error, user) =>
+					return callback(error) if error?
+					callback()
+
+	getUserSettingsPage: (callback = (error, statusCode) ->) ->
+		@getCsrfToken (error) =>
+			return callback(error) if error?
+			@request.get {
+				url: "/user/settings"
+			}, (error, response, body) =>
+				return callback(error) if error?
+				callback(null, response.statusCode)
+
+
+
+module.exports = User

+ 27 - 0
services/web/test/acceptance/coffee/helpers/redis.coffee

@@ -0,0 +1,27 @@
+Settings = require('settings-sharelatex')
+redis = require('redis-sharelatex')
+logger = require("logger-sharelatex")
+Async = require('async')
+
+rclient = redis.createClient(Settings.redis.web)
+
+module.exports =
+
+	getUserSessions: (user, callback=(err, sessionsSet)->) ->
+		rclient.smembers "UserSessions:#{user._id}", (err, result) ->
+			return callback(err, result)
+
+	clearUserSessions: (user, callback=(err)->) ->
+		sessionSetKey = "UserSessions:#{user._id}"
+		rclient.smembers sessionSetKey, (err, sessionKeys) ->
+			if err
+				return callback(err)
+			if sessionKeys.length == 0
+				return callback(null)
+			rclient.multi()
+				.del(sessionKeys)
+				.srem(sessionSetKey, sessionKeys)
+				.exec (err, result) ->
+					if err
+						return callback(err)
+					callback(null)