Explorar el Código

Merge branch 'master' into pr-log-dropdown

Paulo Reis hace 10 años
padre
commit
b867c37d49
Se han modificado 46 ficheros con 1379 adiciones y 155 borrados
  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. 4 0
      services/web/app/coffee/Features/Email/EmailBuilder.coffee
  9. 5 5
      services/web/app/coffee/Features/Notifications/NotificationsHandler.coffee
  10. 9 6
      services/web/app/coffee/Features/PasswordReset/PasswordResetController.coffee
  11. 5 3
      services/web/app/coffee/Features/Project/ProjectDeleter.coffee
  12. 10 7
      services/web/app/coffee/Features/User/UserController.coffee
  13. 126 0
      services/web/app/coffee/Features/User/UserSessionsManager.coffee
  14. 4 1
      services/web/app/coffee/infrastructure/Server.coffee
  15. 43 10
      services/web/app/views/layout.jade
  16. 3 3
      services/web/app/views/layout/footer.jade
  17. 1 1
      services/web/app/views/project/editor/editor.jade
  18. 7 3
      services/web/app/views/project/editor/pdf.jade
  19. 4 0
      services/web/config/settings.defaults.coffee
  20. 0 1
      services/web/package.json
  21. 11 1
      services/web/public/coffee/ide.coffee
  22. 3 1
      services/web/public/coffee/ide/hotkeys/controllers/HotkeysController.coffee
  23. 2 1
      services/web/public/coffee/ide/human-readable-logs/HumanReadableLogs.coffee
  24. 22 12
      services/web/public/coffee/ide/pdf/controllers/PdfController.coffee
  25. 2 1
      services/web/public/coffee/ide/pdfng/directives/pdfRenderer.coffee
  26. 9 1
      services/web/public/coffee/ide/pdfng/directives/pdfViewer.coffee
  27. 25 1
      services/web/public/coffee/ide/settings/services/settings.coffee
  28. 3 1
      services/web/public/coffee/ide/share/controllers/ShareController.coffee
  29. 1 5
      services/web/public/coffee/ide/wordcount/controllers/WordCountModalController.coffee
  30. 15 2
      services/web/public/coffee/main/event.coffee
  31. BIN
      services/web/public/img/sprite.png
  32. 105 0
      services/web/public/stylesheets/app/sprites.less
  33. 3 8
      services/web/public/stylesheets/app/wiki.less
  34. 5 0
      services/web/public/stylesheets/components/footer.less
  35. 1 0
      services/web/public/stylesheets/style.less
  36. 32 25
      services/web/test/UnitTests/coffee/Authentication/AuthenticationControllerTests.coffee
  37. 19 4
      services/web/test/UnitTests/coffee/Collaborators/CollaboratorsHandlerTests.coffee
  38. 1 5
      services/web/test/UnitTests/coffee/Compile/CompileControllerTests.coffee
  39. 1 1
      services/web/test/UnitTests/coffee/Compile/CompileManagerTests.coffee
  40. 11 1
      services/web/test/UnitTests/coffee/PasswordReset/PasswordResetControllerTests.coffee
  41. 9 1
      services/web/test/UnitTests/coffee/Project/ProjectDeleterTests.coffee
  42. 15 12
      services/web/test/UnitTests/coffee/User/UserControllerTests.coffee
  43. 464 0
      services/web/test/UnitTests/coffee/User/UserSessionsManagerTests.coffee
  44. 253 0
      services/web/test/acceptance/coffee/SessionTests.coffee
  45. 46 3
      services/web/test/acceptance/coffee/helpers/User.coffee
  46. 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"

+ 4 - 0
services/web/app/coffee/Features/Email/EmailBuilder.coffee

@@ -3,6 +3,8 @@ PersonalEmailLayout = require("./Layouts/PersonalEmailLayout")
 NotificationEmailLayout = require("./Layouts/NotificationEmailLayout")
 NotificationEmailLayout = require("./Layouts/NotificationEmailLayout")
 settings = require("settings-sharelatex")
 settings = require("settings-sharelatex")
 
 
+
+
 templates = {}
 templates = {}
 
 
 templates.registered =	
 templates.registered =	
@@ -114,6 +116,8 @@ module.exports =
 		template = templates[templateName]
 		template = templates[templateName]
 		opts.siteUrl = settings.siteUrl
 		opts.siteUrl = settings.siteUrl
 		opts.body = template.compiledTemplate(opts)
 		opts.body = template.compiledTemplate(opts)
+		if settings.email?.templates?.customFooter?
+			opts.body += settings.email?.templates?.customFooter
 		return {
 		return {
 			subject : template.subject(opts)
 			subject : template.subject(opts)
 			html: template.layout(opts)
 			html: template.layout(opts)

+ 5 - 5
services/web/app/coffee/Features/Notifications/NotificationsHandler.coffee

@@ -6,7 +6,7 @@ oneSecond = 1000
 
 
 makeRequest = (opts, callback)->
 makeRequest = (opts, callback)->
 	if !settings.apis.notifications?.url?
 	if !settings.apis.notifications?.url?
-		return callback()
+		return callback(null, statusCode:200)
 	else
 	else
 		request(opts, callback)
 		request(opts, callback)
 
 
@@ -18,7 +18,7 @@ module.exports =
 			json: true
 			json: true
 			timeout: oneSecond
 			timeout: oneSecond
 			method: "GET"
 			method: "GET"
-		request opts, (err, res, unreadNotifications)->
+		makeRequest opts, (err, res, unreadNotifications)->
 			statusCode =  if res? then res.statusCode else 500
 			statusCode =  if res? then res.statusCode else 500
 			if err? or statusCode != 200
 			if err? or statusCode != 200
 				e = new Error("something went wrong getting notifications, #{err}, #{statusCode}")
 				e = new Error("something went wrong getting notifications, #{err}, #{statusCode}")
@@ -40,7 +40,7 @@ module.exports =
 				templateKey:templateKey
 				templateKey:templateKey
 			}
 			}
 		logger.log opts:opts, "creating notification for user"
 		logger.log opts:opts, "creating notification for user"
-		request opts, callback
+		makeRequest opts, callback
 
 
 	markAsReadWithKey: (user_id, key, callback)->
 	markAsReadWithKey: (user_id, key, callback)->
 		opts = 
 		opts = 
@@ -51,7 +51,7 @@ module.exports =
 				key:key
 				key:key
 			}
 			}
 		logger.log user_id:user_id, key:key, "sending mark notification as read with key to notifications api"
 		logger.log user_id:user_id, key:key, "sending mark notification as read with key to notifications api"
-		request opts, callback
+		makeRequest opts, callback
 	
 	
 
 
 	markAsRead: (user_id, notification_id, callback)->
 	markAsRead: (user_id, notification_id, callback)->
@@ -60,4 +60,4 @@ module.exports =
 			uri: "#{settings.apis.notifications?.url}/user/#{user_id}/notification/#{notification_id}"
 			uri: "#{settings.apis.notifications?.url}/user/#{user_id}/notification/#{notification_id}"
 			timeout:oneSecond
 			timeout:oneSecond
 		logger.log user_id:user_id, notification_id:notification_id, "sending mark notification as read to notifications api"
 		logger.log user_id:user_id, notification_id:notification_id, "sending mark notification as read to notifications api"
-		request opts, callback
+		makeRequest opts, callback

+ 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"
+		if !user
+			logger.log {sessionId}, "no user, for some reason"
+			return callback()
+		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)
+			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 == null
+									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

+ 7 - 3
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")

+ 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?
 	
 	

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

@@ -31,6 +31,17 @@ define [
 				$scope.$applyAsync () -> 
 				$scope.$applyAsync () -> 
 					$scope.shouldDropUp = getFilesDropdownTopCoordAsRatio() > 0.65
 					$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 "log-hints-feedback", { isPositive, 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
@@ -66,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
@@ -141,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)
@@ -163,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
@@ -253,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()
@@ -263,7 +267,10 @@ define [
 			return path
 			return path
 
 
 		$scope.recompile = (options = {}) ->
 		$scope.recompile = (options = {}) ->
+			event_tracking.sendCountly "editor-recompile", options
+
 			return if $scope.pdf.compiling
 			return if $scope.pdf.compiling
+
 			$scope.pdf.compiling = true
 			$scope.pdf.compiling = true
 
 
 			ide.$scope.$broadcast("flush-changes")
 			ide.$scope.$broadcast("flush-changes")
@@ -283,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 {
@@ -290,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"
@@ -304,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(
@@ -377,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) ->
@@ -423,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) ->
@@ -458,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')

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

@@ -6,10 +6,23 @@ 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 ]);
 		}
 		}
 
 
+	# 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


+ 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;
+}

+ 3 - 8
services/web/public/stylesheets/app/wiki.less

@@ -41,11 +41,6 @@
 	.example {
 	.example {
 		max-width: 100%;
 		max-width: 100%;
 
 
-		& > div {
-			display: block !important;
-			width: auto !important;
-		}
-
 		.code {
 		.code {
 			pre {
 			pre {
 				background-color: @gray-lightest;
 				background-color: @gray-lightest;
@@ -60,9 +55,9 @@
 			padding-top: 10px;
 			padding-top: 10px;
 			
 			
 			img {
 			img {
-				width: auto !important;
-				height: auto !important;
-				max-width: 100% !important;
+				width: auto;
+				height: auto;
+				max-width: 100%;
 				box-shadow: 0 1px 3px @gray-light;
 				box-shadow: 0 1px 3px @gray-light;
 				border-radius: 6px;
 				border-radius: 6px;
 			}
 			}

+ 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
-
-

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

@@ -0,0 +1,464 @@
+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 '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)