Просмотр исходного кода

Merge branch 'master' into ha-docker

# Conflicts:
#	app/coffee/Features/Email/EmailBuilder.coffee
Shane Kilkelly 9 лет назад
Родитель
Сommit
9a3c1c7d22
100 измененных файлов с 2886 добавлено и 1387 удалено
  1. 10 12
      services/web/.gitignore
  2. 11 7
      services/web/Gruntfile.coffee
  3. 1 1
      services/web/app/coffee/Features/Analytics/AnalyticsController.coffee
  4. 45 40
      services/web/app/coffee/Features/Analytics/AnalyticsManager.coffee
  5. 24 0
      services/web/app/coffee/Features/Announcements/AnnouncementsController.coffee
  6. 40 0
      services/web/app/coffee/Features/Announcements/AnnouncementsHandler.coffee
  7. 120 65
      services/web/app/coffee/Features/Authentication/AuthenticationController.coffee
  8. 3 0
      services/web/app/coffee/Features/Authentication/AuthenticationManager.coffee
  9. 19 18
      services/web/app/coffee/Features/Authorization/AuthorizationMiddlewear.coffee
  10. 6 5
      services/web/app/coffee/Features/BetaProgram/BetaProgramController.coffee
  11. 1 1
      services/web/app/coffee/Features/Blog/BlogController.coffee
  12. 24 0
      services/web/app/coffee/Features/Blog/BlogHandler.coffee
  13. 61 0
      services/web/app/coffee/Features/Chat/ChatApiHandler.coffee
  14. 27 22
      services/web/app/coffee/Features/Chat/ChatController.coffee
  15. 0 32
      services/web/app/coffee/Features/Chat/ChatHandler.coffee
  16. 2 22
      services/web/app/coffee/Features/Collaborators/CollaboratorsEmailHandler.coffee
  17. 53 16
      services/web/app/coffee/Features/Collaborators/CollaboratorsInviteController.coffee
  18. 9 7
      services/web/app/coffee/Features/Collaborators/CollaboratorsInviteHandler.coffee
  19. 8 2
      services/web/app/coffee/Features/Collaborators/CollaboratorsRouter.coffee
  20. 83 0
      services/web/app/coffee/Features/Comments/CommentsController.coffee
  21. 4 0
      services/web/app/coffee/Features/Compile/ClsiCookieManager.coffee
  22. 33 33
      services/web/app/coffee/Features/Compile/CompileController.coffee
  23. 22 23
      services/web/app/coffee/Features/Contacts/ContactController.coffee
  24. 20 3
      services/web/app/coffee/Features/Docstore/DocstoreManager.coffee
  25. 17 11
      services/web/app/coffee/Features/DocumentUpdater/DocumentUpdaterHandler.coffee
  26. 5 3
      services/web/app/coffee/Features/Documents/DocumentController.coffee
  27. 0 1
      services/web/app/coffee/Features/Editor/EditorController.coffee
  28. 49 0
      services/web/app/coffee/Features/Email/Bodies/SingleCTAEmailBody.coffee
  29. 117 70
      services/web/app/coffee/Features/Email/EmailBuilder.coffee
  30. 3 2
      services/web/app/coffee/Features/Email/EmailHandler.coffee
  31. 33 15
      services/web/app/coffee/Features/Email/EmailSender.coffee
  32. 380 0
      services/web/app/coffee/Features/Email/Layouts/BaseWithHeaderEmailLayout.coffee
  33. 6 4
      services/web/app/coffee/Features/Errors/ErrorController.coffee
  34. 3 2
      services/web/app/coffee/Features/HealthCheck/HealthCheckController.coffee
  35. 20 0
      services/web/app/coffee/Features/History/HistoryController.coffee
  36. 28 0
      services/web/app/coffee/Features/History/HistoryManager.coffee
  37. 0 3
      services/web/app/coffee/Features/InactiveData/InactiveProjectManager.coffee
  38. 3 3
      services/web/app/coffee/Features/Notifications/NotificationsBuilder.coffee
  39. 5 3
      services/web/app/coffee/Features/Notifications/NotificationsController.coffee
  40. 2 3
      services/web/app/coffee/Features/Notifications/NotificationsHandler.coffee
  41. 29 20
      services/web/app/coffee/Features/Project/ProjectController.coffee
  42. 6 0
      services/web/app/coffee/Features/Project/ProjectEditorHandler.coffee
  43. 3 3
      services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee
  44. 17 7
      services/web/app/coffee/Features/Project/ProjectLocator.coffee
  45. 4 2
      services/web/app/coffee/Features/Referal/ReferalController.coffee
  46. 0 11
      services/web/app/coffee/Features/Referal/ReferalMiddleware.coffee
  47. 10 9
      services/web/app/coffee/Features/Security/RateLimiterMiddlewear.coffee
  48. 0 26
      services/web/app/coffee/Features/Security/SessionInvalidator.coffee
  49. 3 1
      services/web/app/coffee/Features/Spelling/SpellingController.coffee
  50. 3 2
      services/web/app/coffee/Features/StaticPages/HomeController.coffee
  51. 4 63
      services/web/app/coffee/Features/StaticPages/UniversityController.coffee
  52. 9 1
      services/web/app/coffee/Features/Subscription/RecurlyWrapper.coffee
  53. 179 184
      services/web/app/coffee/Features/Subscription/SubscriptionController.coffee
  54. 19 14
      services/web/app/coffee/Features/Subscription/SubscriptionGroupController.coffee
  55. 3 0
      services/web/app/coffee/Features/Subscription/SubscriptionHandler.coffee
  56. 6 2
      services/web/app/coffee/Features/Subscription/SubscriptionLocator.coffee
  57. 7 3
      services/web/app/coffee/Features/Subscription/SubscriptionViewModelBuilder.coffee
  58. 12 11
      services/web/app/coffee/Features/Tags/TagsController.coffee
  59. 12 13
      services/web/app/coffee/Features/ThirdPartyDataStore/UpdateMerger.coffee
  60. 23 0
      services/web/app/coffee/Features/TrackChanges/RangesManager.coffee
  61. 38 17
      services/web/app/coffee/Features/TrackChanges/TrackChangesController.coffee
  62. 3 26
      services/web/app/coffee/Features/TrackChanges/TrackChangesManager.coffee
  63. 4 6
      services/web/app/coffee/Features/Uploads/ProjectUploadController.coffee
  64. 55 16
      services/web/app/coffee/Features/User/UserController.coffee
  65. 1 0
      services/web/app/coffee/Features/User/UserCreator.coffee
  66. 15 19
      services/web/app/coffee/Features/User/UserInfoController.coffee
  67. 5 0
      services/web/app/coffee/Features/User/UserInfoManager.coffee
  68. 25 6
      services/web/app/coffee/Features/User/UserPagesController.coffee
  69. 50 17
      services/web/app/coffee/Features/User/UserSessionsManager.coffee
  70. 21 0
      services/web/app/coffee/Features/User/UserSessionsRedis.coffee
  71. 67 45
      services/web/app/coffee/infrastructure/ExpressLocals.coffee
  72. 4 0
      services/web/app/coffee/infrastructure/LoggerSerializers.coffee
  73. 10 6
      services/web/app/coffee/infrastructure/Modules.coffee
  74. 15 0
      services/web/app/coffee/infrastructure/PackageVersions.coffee
  75. 34 4
      services/web/app/coffee/infrastructure/Server.coffee
  76. 1 0
      services/web/app/coffee/models/Project.coffee
  77. 2 1
      services/web/app/coffee/models/User.coffee
  78. 51 17
      services/web/app/coffee/router.coffee
  79. 5 2
      services/web/app/views/general/500.jade
  80. 1 1
      services/web/app/views/general/closed.jade
  81. 14 9
      services/web/app/views/layout.jade
  82. 37 6
      services/web/app/views/layout/navbar.jade
  83. 35 29
      services/web/app/views/project/editor.jade
  84. 36 4
      services/web/app/views/project/editor/editor.jade
  85. 54 0
      services/web/app/views/project/editor/feature-onboarding.jade
  86. 22 4
      services/web/app/views/project/editor/file-tree.jade
  87. 34 33
      services/web/app/views/project/editor/header.jade
  88. 97 58
      services/web/app/views/project/editor/history.jade
  89. 25 0
      services/web/app/views/project/editor/hotkeys.jade
  90. 12 0
      services/web/app/views/project/editor/left-menu.jade
  91. 2 2
      services/web/app/views/project/editor/pdf.jade
  92. 332 0
      services/web/app/views/project/editor/review-panel.jade
  93. 9 2
      services/web/app/views/project/editor/share.jade
  94. 1 1
      services/web/app/views/project/invite/show.jade
  95. 43 5
      services/web/app/views/project/list.jade
  96. 46 0
      services/web/app/views/project/list/empty-project-list.jade
  97. 22 5
      services/web/app/views/project/list/notifications.jade
  98. 0 198
      services/web/app/views/project/list/project-list-minimal.jade
  99. 2 2
      services/web/app/views/project/list/project-list.jade
  100. 10 45
      services/web/app/views/project/list/side-bar.jade

+ 10 - 12
services/web/.gitignore

@@ -47,23 +47,21 @@ TpdsWorker.js
 BackgroundJobsWorker.js
 BackgroundJobsWorker.js
 UserAndProjectPopulator.coffee
 UserAndProjectPopulator.coffee
 
 
-public/js/history/versiondetail.js
-!public/js/libs/
-public/js/*
-!public/js/ace/*
-!public/js/libs/
+public/js/*.js
 public/js/libs/sharejs.js
 public/js/libs/sharejs.js
-public/js/editor.js
-public/js/home.js
-public/js/forms.js
-public/js/gui.js
-public/js/admin.js
-public/js/history/*
+public/js/analytics/
+public/js/directives/
+public/js/filters/
+public/js/ide/
+public/js/main/
+public/js/modules/
+public/js/services/
+public/js/utils/
+
 public/stylesheets/style.css
 public/stylesheets/style.css
 public/brand/plans.css
 public/brand/plans.css
 public/minjs/
 public/minjs/
 
 
-public/js/main.js
 Gemfile.lock
 Gemfile.lock
 
 
 *.swp
 *.swp

+ 11 - 7
services/web/Gruntfile.coffee

@@ -1,4 +1,5 @@
 fs = require "fs"
 fs = require "fs"
+PackageVersions = require "./app/coffee/infrastructure/PackageVersions"
 
 
 module.exports = (grunt) ->
 module.exports = (grunt) ->
 	grunt.loadNpmTasks 'grunt-contrib-coffee'
 	grunt.loadNpmTasks 'grunt-contrib-coffee'
@@ -157,12 +158,13 @@ module.exports = (grunt) ->
 					inlineText: false
 					inlineText: false
 					preserveLicenseComments: false
 					preserveLicenseComments: false
 					paths:
 					paths:
-						"moment": "libs/moment-2.9.0"
+						"moment": "libs/#{PackageVersions.lib('moment')}"
 						"mathjax": "/js/libs/mathjax/MathJax.js?config=TeX-AMS_HTML"
 						"mathjax": "/js/libs/mathjax/MathJax.js?config=TeX-AMS_HTML"
-						"libs/pdf": "libs/pdfjs-1.3.91/pdf"
+						"pdfjs-dist/build/pdf": "libs/#{PackageVersions.lib('pdfjs')}/pdf"
+						"ace": "#{PackageVersions.lib('ace')}"
 					shim:
 					shim:
-						"libs/pdf":
-							deps: ["libs/pdfjs-1.3.91/compatibility"]
+						"pdfjs-dist/build/pdf":
+							deps: ["libs/#{PackageVersions.lib('pdfjs')}/compatibility"]
 
 
 					skipDirOptimize: true
 					skipDirOptimize: true
 					modules: [
 					modules: [
@@ -171,11 +173,13 @@ module.exports = (grunt) ->
 							exclude: ["libs"]
 							exclude: ["libs"]
 						}, {
 						}, {
 							name: "ide",
 							name: "ide",
-							exclude: ["libs", "libs/pdf"]
+							exclude: ["libs", "pdfjs-dist/build/pdf"]
 						}, {
 						}, {
 							name: "libs"
 							name: "libs"
 						},{
 						},{
 							name: "ace/mode-latex"
 							name: "ace/mode-latex"
+						},{
+							name: "ace/worker-latex"
 						}
 						}
 
 
 					]
 					]
@@ -380,8 +384,8 @@ module.exports = (grunt) ->
 	
 	
 	grunt.registerTask 'test:modules:unit', 'Run the unit tests for the modules', ['compile:modules:server', 'compile:modules:unit_tests'].concat(moduleUnitTestTasks)
 	grunt.registerTask 'test:modules:unit', 'Run the unit tests for the modules', ['compile:modules:server', 'compile:modules:unit_tests'].concat(moduleUnitTestTasks)
 
 
-	grunt.registerTask 'run', "Compile and run the web-sharelatex server", ['compile', 'env:run', 'parallel']
-	grunt.registerTask 'runq', "Compile and run the web-sharelatex server", ['compile', 'env:run', 'exec']
+	grunt.registerTask 'run:watch', "Compile and run the web-sharelatex server", ['compile', 'env:run', 'parallel']
+	grunt.registerTask 'run', "Compile and run the web-sharelatex server", ['compile', 'env:run', 'exec']
 
 
 	grunt.registerTask 'default', 'run'
 	grunt.registerTask 'default', 'run'
 
 

+ 1 - 1
services/web/app/coffee/Features/Analytics/AnalyticsController.coffee

@@ -4,4 +4,4 @@ module.exports = AnalyticsController =
 	recordEvent: (req, res, next) ->
 	recordEvent: (req, res, next) ->
 		AnalyticsManager.recordEvent req.session?.user?._id, req.params.event, req.body, (error) ->
 		AnalyticsManager.recordEvent req.session?.user?._id, req.params.event, req.body, (error) ->
 			return next(error) if error?
 			return next(error) if error?
-			res.send 204
+			res.send 204

+ 45 - 40
services/web/app/coffee/Features/Analytics/AnalyticsManager.coffee

@@ -1,43 +1,48 @@
-Settings = require "settings-sharelatex"
+settings = require "settings-sharelatex"
 logger = require "logger-sharelatex"
 logger = require "logger-sharelatex"
 _ = require "underscore"
 _ = require "underscore"
+request = require "request"
 
 
-if !Settings.analytics?.postgres?
-	module.exports =
-		recordEvent: (user_id, event, segmentation, callback = () ->) ->
-			logger.log {user_id, event, segmentation}, "no event tracking configured, logging event"
-			callback()
-else
-	Sequelize = require "sequelize"
-	options = _.extend {logging:false}, Settings.analytics.postgres
-
-	sequelize = new Sequelize(
-		Settings.analytics.postgres.database,
-		Settings.analytics.postgres.username,
-		Settings.analytics.postgres.password,
-		options
-	)
-	
-	Event = sequelize.define("Event", {
-		user_id: Sequelize.STRING,
-		event: Sequelize.STRING,
-		segmentation: Sequelize.JSONB
-	})
-
-	module.exports =
-		recordEvent: (user_id, event, segmentation = {}, callback = (error) ->) ->
-			if user_id? and typeof(user_id) != "string"
-				user_id = user_id.toString()
-			if user_id == Settings.smokeTest?.userId
-				# Don't record smoke tests analytics
-				return callback()
-			Event
-				.create({ user_id, event, segmentation })
-				.then(
-					(result) -> callback(),
-					(error) ->
-						logger.err {err: error, user_id, event, segmentation}, "error recording analytics event"
-						callback(error)
-				)
-			
-		sync: () -> sequelize.sync()
+
+makeRequest = (opts, callback)->
+	if settings.apis?.analytics?.url?
+		urlPath = opts.url
+		opts.url = "#{settings.apis.analytics.url}#{urlPath}"
+		request opts, callback
+	else
+		callback()
+
+
+
+module.exports =
+
+
+	recordEvent: (user_id, event, segmentation = {}, callback = (error) ->) ->
+		if user_id+"" == settings.smokeTest?.userId+""
+			return callback()
+		opts =
+			body:
+				event:event
+				segmentation:segmentation
+			json:true
+			method:"POST"
+			timeout:1000
+			url: "/user/#{user_id}/event"
+		makeRequest opts, callback
+
+
+	getLastOccurance: (user_id, event, callback = (error) ->) ->
+		opts =
+			body:
+				event:event
+			json:true
+			method:"POST"
+			timeout:1000
+			url: "/user/#{user_id}/event/last_occurnace"
+		makeRequest opts, (err, response, body)->
+			if err? 
+				console.log response, opts
+				logger.err {user_id, err}, "error getting last occurance of event"
+				return callback err
+			else
+				return callback null, body

+ 24 - 0
services/web/app/coffee/Features/Announcements/AnnouncementsController.coffee

@@ -0,0 +1,24 @@
+AnnouncementsHandler = require("./AnnouncementsHandler")
+AuthenticationController = require("../Authentication/AuthenticationController")
+logger = require("logger-sharelatex")
+settings = require("settings-sharelatex")
+
+module.exports =
+
+	getUndreadAnnouncements: (req, res, next)->
+		if !settings?.apis?.analytics?.url? or !settings.apis.blog.url?
+			return res.json []
+
+		user_id = AuthenticationController.getLoggedInUserId(req)
+		logger.log {user_id}, "getting unread announcements"
+		AnnouncementsHandler.getUnreadAnnouncements user_id, (err, announcements)->
+			if err?
+				logger.err {err, user_id}, "unable to get unread announcements"
+				next(err)
+			else
+				res.json announcements
+
+
+
+
+

+ 40 - 0
services/web/app/coffee/Features/Announcements/AnnouncementsHandler.coffee

@@ -0,0 +1,40 @@
+AnalyticsManager = require("../Analytics/AnalyticsManager")
+BlogHandler = require("../Blog/BlogHandler")
+async = require("async")
+_ = require("lodash")
+logger = require("logger-sharelatex")
+settings = require("settings-sharelatex")
+
+module.exports =
+
+	getUnreadAnnouncements : (user_id, callback = (err, announcements)->)->
+		async.parallel {
+			lastEvent: (cb)->
+				AnalyticsManager.getLastOccurance user_id, "announcement-alert-dismissed", cb
+			announcements: (cb)->
+				BlogHandler.getLatestAnnouncements cb
+		}, (err, results)->
+			if err?
+				logger.err err:err, user_id:user_id, "error getting unread announcements"
+				return callback(err)
+			
+			announcements = _.sortBy(results.announcements, "date").reverse()
+
+			lastSeenBlogId = results?.lastEvent?.segmentation?.blogPostId
+
+			announcementIndex = _.findIndex announcements, (announcement)-> 
+				announcement.id == lastSeenBlogId
+
+			announcements = _.map announcements, (announcement, index)->
+				if announcementIndex == -1
+					read = false
+				else if index >= announcementIndex
+					read = true
+				else
+					read = false
+				announcement.read = read
+				return announcement
+
+			logger.log announcementsLength:announcements?.length, user_id:user_id, "returning announcements"
+
+			callback null, announcements

+ 120 - 65
services/web/app/coffee/Features/Authentication/AuthenticationController.coffee

@@ -11,63 +11,126 @@ basicAuth = require('basic-auth-connect')
 UserHandler = require("../User/UserHandler")
 UserHandler = require("../User/UserHandler")
 UserSessionsManager = require("../User/UserSessionsManager")
 UserSessionsManager = require("../User/UserSessionsManager")
 Analytics = require "../Analytics/AnalyticsManager"
 Analytics = require "../Analytics/AnalyticsManager"
+passport = require 'passport'
 
 
 module.exports = AuthenticationController =
 module.exports = AuthenticationController =
-	login: (req, res, next = (error) ->) ->
-		AuthenticationController.doLogin req.body, req, res, next
 
 
-	doLogin: (options, req, res, next) ->
-		email = options.email?.toLowerCase()
-		password = options.password
-		redir = Url.parse(options.redir or "/project").path
+	serializeUser: (user, callback) ->
+		lightUser =
+			_id: user._id
+			first_name: user.first_name
+			last_name: user.last_name
+			isAdmin: user.isAdmin
+			email: user.email
+			referal_id: user.referal_id
+			session_created: (new Date()).toISOString()
+			ip_address: user._login_req_ip
+		callback(null, lightUser)
+
+	deserializeUser: (user, cb) ->
+		cb(null, user)
+
+	afterLoginSessionSetup: (req, user, callback=(err)->) ->
+		req.login user, (err) ->
+			if err?
+				logger.err {user_id: user._id, err}, "error from req.login"
+				return callback(err)
+			# Regenerate the session to get a new sessionID (cookie value) to
+			# protect against session fixation attacks
+			oldSession = req.session
+			req.session.destroy (err) ->
+				if err?
+					logger.err {user_id: user._id, err}, "error when trying to destroy old session"
+					return callback(err)
+				req.sessionStore.generate(req)
+				for key, value of oldSession
+					req.session[key] = value
+				# copy to the old `session.user` location, for backward-comptability
+				req.session.user = req.session.passport.user
+				req.session.save (err) ->
+					if err?
+						logger.err {user_id: user._id}, "error saving regenerated session after login"
+						return callback(err)
+					UserSessionsManager.trackSession(user, req.sessionID, () ->)
+					callback(null)
+
+	passportLogin: (req, res, next) ->
+		# This function is middleware which wraps the passport.authenticate middleware,
+		# so we can send back our custom `{message: {text: "", type: ""}}` responses on failure,
+		# and send a `{redir: ""}` response on success
+		passport.authenticate('local', (err, user, info) ->
+			if err?
+				return next(err)
+			if user # `user` is either a user object or false
+				redir = AuthenticationController._getRedirectFromSession(req) || "/project"
+				AuthenticationController.afterLoginSessionSetup req, user, (err) ->
+					if err?
+						return next(err)
+					AuthenticationController._clearRedirectFromSession(req)
+					res.json {redir: redir}
+			else
+				res.json message: info
+		)(req, res, next)
+
+	doPassportLogin: (req, username, password, done) ->
+		email = username.toLowerCase()
 		LoginRateLimiter.processLoginRequest email, (err, isAllowed)->
 		LoginRateLimiter.processLoginRequest email, (err, isAllowed)->
+			return done(err) if err?
 			if !isAllowed
 			if !isAllowed
 				logger.log email:email, "too many login requests"
 				logger.log email:email, "too many login requests"
-				res.statusCode = 429
-				return res.send
-					message:
-						text: req.i18n.translate("to_many_login_requests_2_mins"),
-						type: 'error'
+				return done(null, null, {text: req.i18n.translate("to_many_login_requests_2_mins"), type: 'error'})
 			AuthenticationManager.authenticate email: email, password, (error, user) ->
 			AuthenticationManager.authenticate email: email, password, (error, user) ->
-				return next(error) if error?
+				return done(error) if error?
 				if user?
 				if user?
-					UserHandler.setupLoginData user, ->
-					LoginRateLimiter.recordSuccessfulLogin email
-					AuthenticationController._recordSuccessfulLogin user._id
-					AuthenticationController.establishUserSession req, user, (error) ->
-						return next(error) if error?
-						req.session.justLoggedIn = true
-						logger.log email: email, user_id: user._id.toString(), "successful log in"
-						Analytics.recordEvent user._id, "user-logged-in"
-						res.json redir: redir
+					# async actions
+					UserHandler.setupLoginData(user, ()->)
+					LoginRateLimiter.recordSuccessfulLogin(email)
+					AuthenticationController._recordSuccessfulLogin(user._id)
+					Analytics.recordEvent(user._id, "user-logged-in", {ip:req.ip})
+					logger.log email: email, user_id: user._id.toString(), "successful log in"
+					req.session.justLoggedIn = true
+					# capture the request ip for use when creating the session
+					user._login_req_ip = req.ip
+					return done(null, user)
 				else
 				else
 					AuthenticationController._recordFailedLogin()
 					AuthenticationController._recordFailedLogin()
 					logger.log email: email, "failed log in"
 					logger.log email: email, "failed log in"
-					res.json message:
-						text: req.i18n.translate("email_or_password_wrong_try_again"),
-						type: 'error'
-
-	getLoggedInUserId: (req, callback = (error, user_id) ->) ->
-		if req?.session?.user?._id?
-			callback null, req.session.user._id.toString()
+					return done(null, false, {text: req.i18n.translate("email_or_password_wrong_try_again"), type: 'error'})
+
+	setInSessionUser: (req, props) ->
+		for key, value of props
+			if req?.session?.passport?.user?
+				req.session.passport.user[key] = value
+			if req?.session?.user?
+				req.session.user[key] = value
+
+	isUserLoggedIn: (req) ->
+		user_id = AuthenticationController.getLoggedInUserId(req)
+		return (user_id not in [null, undefined, false])
+
+	# TODO: perhaps should produce an error if the current user is not present
+	getLoggedInUserId: (req) ->
+		user = AuthenticationController.getSessionUser(req)
+		if user
+			return user._id
 		else
 		else
-			callback null, null
+			return null
 
 
-	getLoggedInUser: (req, callback = (error, user) ->) ->
-		if req.session?.user?._id?
-			query = req.session.user._id
+	getSessionUser: (req) ->
+		if req?.session?.user?
+			return req.session.user
+		else if req?.session?.passport?.user
+			return req.session.passport.user
 		else
 		else
-			return callback null, null
-
-		UserGetter.getUser query, callback
+			return null
 
 
 	requireLogin: () ->
 	requireLogin: () ->
 		doRequest = (req, res, next = (error) ->) ->
 		doRequest = (req, res, next = (error) ->) ->
-			if !req.session.user?
+			if !AuthenticationController.isUserLoggedIn(req)
 				AuthenticationController._redirectToLoginOrRegisterPage(req, res)
 				AuthenticationController._redirectToLoginOrRegisterPage(req, res)
 			else
 			else
-				req.user = req.session.user
-				return next()
+				req.user = AuthenticationController.getSessionUser(req)
+				next()
 
 
 		return doRequest
 		return doRequest
 
 
@@ -81,10 +144,11 @@ module.exports = AuthenticationController =
 
 
 		if req.headers['authorization']?
 		if req.headers['authorization']?
 			return AuthenticationController.httpAuth(req, res, next)
 			return AuthenticationController.httpAuth(req, res, next)
-		else if req.session.user?
+		else if AuthenticationController.isUserLoggedIn(req)
 			return next()
 			return next()
 		else
 		else
 			logger.log url:req.url, "user trying to access endpoint not in global whitelist"
 			logger.log url:req.url, "user trying to access endpoint not in global whitelist"
+			AuthenticationController._setRedirectInSession(req)
 			return res.redirect "/login"
 			return res.redirect "/login"
 
 
 	httpAuth: basicAuth (user, pass)->
 	httpAuth: basicAuth (user, pass)->
@@ -94,22 +158,23 @@ module.exports = AuthenticationController =
 		return isValid
 		return isValid
 
 
 	_redirectToLoginOrRegisterPage: (req, res)->
 	_redirectToLoginOrRegisterPage: (req, res)->
-		if req.query.zipUrl? or req.query.project_name?
+		if (req.query.zipUrl? or
+			  req.query.project_name? or
+			  req.path == '/user/subscription/new')
 			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) ->
 		logger.log url: req.url, "user not logged in so redirecting to login page"
 		logger.log url: req.url, "user not logged in so redirecting to login page"
-		req.query.redir = req.path
+		AuthenticationController._setRedirectInSession(req)
 		url = "/login?#{querystring.stringify(req.query)}"
 		url = "/login?#{querystring.stringify(req.query)}"
 		res.redirect url
 		res.redirect url
 		Metrics.inc "security.login-redirect"
 		Metrics.inc "security.login-redirect"
 
 
 	_redirectToRegisterPage: (req, res) ->
 	_redirectToRegisterPage: (req, res) ->
 		logger.log url: req.url, "user not logged in so redirecting to register page"
 		logger.log url: req.url, "user not logged in so redirecting to register page"
-		req.query.redir = req.path
+		AuthenticationController._setRedirectInSession(req)
 		url = "/register?#{querystring.stringify(req.query)}"
 		url = "/register?#{querystring.stringify(req.query)}"
 		res.redirect url
 		res.redirect url
 		Metrics.inc "security.login-redirect"
 		Metrics.inc "security.login-redirect"
@@ -127,25 +192,15 @@ module.exports = AuthenticationController =
 		Metrics.inc "user.login.failed"
 		Metrics.inc "user.login.failed"
 		callback()
 		callback()
 
 
-	establishUserSession: (req, user, callback = (error) ->) ->
-		lightUser =
-			_id: user._id
-			first_name: user.first_name
-			last_name: user.last_name
-			isAdmin: user.isAdmin
-			email: user.email
-			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
-		# protect against session fixation attacks
-		oldSession = req.session
-		req.session.destroy()
-		req.sessionStore.generate(req)
-		for key, value of oldSession
-			req.session[key] = value
-
-		req.session.user = lightUser
-
-		UserSessionsManager.trackSession(user, req.sessionID, () ->)
-		callback()
+	_setRedirectInSession: (req, value) ->
+		if !value?
+			value = if Object.keys(req.query).length > 0 then "#{req.path}?#{querystring.stringify(req.query)}" else "#{req.path}"
+		if req.session? && !value.match(new RegExp('^\/(socket.io|js|stylesheets|img)\/.*$'))
+			req.session.postLoginRedirect = value
+
+	_getRedirectFromSession: (req) ->
+		return req?.session?.postLoginRedirect || null
+
+	_clearRedirectFromSession: (req) ->
+		if req.session?
+			delete req.session.postLoginRedirect

+ 3 - 0
services/web/app/coffee/Features/Authentication/AuthenticationManager.coffee

@@ -29,6 +29,9 @@ module.exports = AuthenticationManager =
 				callback null, null
 				callback null, null
 
 
 	setUserPassword: (user_id, password, callback = (error) ->) ->
 	setUserPassword: (user_id, password, callback = (error) ->) ->
+		if Settings.passwordStrengthOptions?.length?.max? and Settings.passwordStrengthOptions?.length?.max < password.length
+			return callback("password is too long")
+
 		bcrypt.genSalt BCRYPT_ROUNDS, (error, salt) ->
 		bcrypt.genSalt BCRYPT_ROUNDS, (error, salt) ->
 			return callback(error) if error?
 			return callback(error) if error?
 			bcrypt.hash password, salt, (error, hash) ->
 			bcrypt.hash password, salt, (error, hash) ->

+ 19 - 18
services/web/app/coffee/Features/Authorization/AuthorizationMiddlewear.coffee

@@ -3,6 +3,7 @@ async = require "async"
 logger = require "logger-sharelatex"
 logger = require "logger-sharelatex"
 ObjectId = require("mongojs").ObjectId
 ObjectId = require("mongojs").ObjectId
 Errors = require "../Errors/Errors"
 Errors = require "../Errors/Errors"
+AuthenticationController = require "../Authentication/AuthenticationController"
 
 
 module.exports = AuthorizationMiddlewear =
 module.exports = AuthorizationMiddlewear =
 	ensureUserCanReadMultipleProjects: (req, res, next) ->
 	ensureUserCanReadMultipleProjects: (req, res, next) ->
@@ -20,7 +21,7 @@ module.exports = AuthorizationMiddlewear =
 					AuthorizationMiddlewear.redirectToRestricted req, res, next
 					AuthorizationMiddlewear.redirectToRestricted req, res, next
 				else
 				else
 					next()
 					next()
-		
+
 	ensureUserCanReadProject: (req, res, next) ->
 	ensureUserCanReadProject: (req, res, next) ->
 		AuthorizationMiddlewear._getUserAndProjectId req, (error, user_id, project_id) ->
 		AuthorizationMiddlewear._getUserAndProjectId req, (error, user_id, project_id) ->
 			return next(error) if error?
 			return next(error) if error?
@@ -32,7 +33,7 @@ module.exports = AuthorizationMiddlewear =
 				else
 				else
 					logger.log {user_id, project_id}, "denying user read access to project"
 					logger.log {user_id, project_id}, "denying user read access to project"
 					AuthorizationMiddlewear.redirectToRestricted req, res, next
 					AuthorizationMiddlewear.redirectToRestricted req, res, next
-		
+
 	ensureUserCanWriteProjectSettings: (req, res, next) ->
 	ensureUserCanWriteProjectSettings: (req, res, next) ->
 		AuthorizationMiddlewear._getUserAndProjectId req, (error, user_id, project_id) ->
 		AuthorizationMiddlewear._getUserAndProjectId req, (error, user_id, project_id) ->
 			return next(error) if error?
 			return next(error) if error?
@@ -44,7 +45,7 @@ module.exports = AuthorizationMiddlewear =
 				else
 				else
 					logger.log {user_id, project_id}, "denying user write access to project settings"
 					logger.log {user_id, project_id}, "denying user write access to project settings"
 					AuthorizationMiddlewear.redirectToRestricted req, res, next
 					AuthorizationMiddlewear.redirectToRestricted req, res, next
-		
+
 	ensureUserCanWriteProjectContent: (req, res, next) ->
 	ensureUserCanWriteProjectContent: (req, res, next) ->
 		AuthorizationMiddlewear._getUserAndProjectId req, (error, user_id, project_id) ->
 		AuthorizationMiddlewear._getUserAndProjectId req, (error, user_id, project_id) ->
 			return next(error) if error?
 			return next(error) if error?
@@ -56,7 +57,7 @@ module.exports = AuthorizationMiddlewear =
 				else
 				else
 					logger.log {user_id, project_id}, "denying user write access to project settings"
 					logger.log {user_id, project_id}, "denying user write access to project settings"
 					AuthorizationMiddlewear.redirectToRestricted req, res, next
 					AuthorizationMiddlewear.redirectToRestricted req, res, next
-		
+
 	ensureUserCanAdminProject: (req, res, next) ->
 	ensureUserCanAdminProject: (req, res, next) ->
 		AuthorizationMiddlewear._getUserAndProjectId req, (error, user_id, project_id) ->
 		AuthorizationMiddlewear._getUserAndProjectId req, (error, user_id, project_id) ->
 			return next(error) if error?
 			return next(error) if error?
@@ -68,7 +69,7 @@ module.exports = AuthorizationMiddlewear =
 				else
 				else
 					logger.log {user_id, project_id}, "denying user admin access to project"
 					logger.log {user_id, project_id}, "denying user admin access to project"
 					AuthorizationMiddlewear.redirectToRestricted req, res, next
 					AuthorizationMiddlewear.redirectToRestricted req, res, next
-		
+
 	ensureUserIsSiteAdmin: (req, res, next) ->
 	ensureUserIsSiteAdmin: (req, res, next) ->
 		AuthorizationMiddlewear._getUserId req, (error, user_id) ->
 		AuthorizationMiddlewear._getUserId req, (error, user_id) ->
 			return next(error) if error?
 			return next(error) if error?
@@ -90,22 +91,22 @@ module.exports = AuthorizationMiddlewear =
 		AuthorizationMiddlewear._getUserId req, (error, user_id) ->
 		AuthorizationMiddlewear._getUserId req, (error, user_id) ->
 			return callback(error) if error?
 			return callback(error) if error?
 			callback(null, user_id, project_id)
 			callback(null, user_id, project_id)
-	
+
 	_getUserId: (req, callback = (error, user_id) ->) ->
 	_getUserId: (req, callback = (error, user_id) ->) ->
-		if req.session?.user?._id?
-			user_id = req.session.user._id
-		else
-			user_id = null
-		callback null, user_id
-		
+		user_id = AuthenticationController.getLoggedInUserId(req)
+		return callback(null, user_id)
+
 	redirectToRestricted: (req, res, next) ->
 	redirectToRestricted: (req, res, next) ->
-		res.redirect "/restricted"
-	
+		res.redirect "/restricted?from=#{encodeURIComponent(req.url)}"
+
 	restricted : (req, res, next)->
 	restricted : (req, res, next)->
-		if req.session.user?
+		if AuthenticationController.isUserLoggedIn(req)
 			res.render 'user/restricted',
 			res.render 'user/restricted',
 				title:'restricted'
 				title:'restricted'
 		else
 		else
-			logger.log "user not logged in and trying to access #{req.url}, being redirected to login"
-			res.redirect '/register'
-		
+			from = req.query.from
+			logger.log {from: from}, "redirecting to login"
+			redirect_to = "/login"
+			if from?
+				AuthenticationController._setRedirectInSession(req, from)
+			res.redirect redirect_to

+ 6 - 5
services/web/app/coffee/Features/BetaProgram/BetaProgramController.coffee

@@ -2,14 +2,15 @@ BetaProgramHandler = require './BetaProgramHandler'
 UserLocator = require "../User/UserLocator"
 UserLocator = require "../User/UserLocator"
 Settings = require "settings-sharelatex"
 Settings = require "settings-sharelatex"
 logger = require 'logger-sharelatex'
 logger = require 'logger-sharelatex'
+AuthenticationController = require '../Authentication/AuthenticationController'
 
 
 
 
 module.exports = BetaProgramController =
 module.exports = BetaProgramController =
 
 
 	optIn: (req, res, next) ->
 	optIn: (req, res, next) ->
-		user_id = req?.session?.user?._id
+		user_id = AuthenticationController.getLoggedInUserId(req)
 		logger.log {user_id}, "user opting in to beta program"
 		logger.log {user_id}, "user opting in to beta program"
-		if !user_id
+		if !user_id?
 			return next(new Error("no user id in session"))
 			return next(new Error("no user id in session"))
 		BetaProgramHandler.optIn user_id, (err) ->
 		BetaProgramHandler.optIn user_id, (err) ->
 			if err
 			if err
@@ -17,9 +18,9 @@ module.exports = BetaProgramController =
 			return res.redirect "/beta/participate"
 			return res.redirect "/beta/participate"
 
 
 	optOut: (req, res, next) ->
 	optOut: (req, res, next) ->
-		user_id = req?.session?.user?._id
+		user_id = AuthenticationController.getLoggedInUserId(req)
 		logger.log {user_id}, "user opting out of beta program"
 		logger.log {user_id}, "user opting out of beta program"
-		if !user_id
+		if !user_id?
 			return next(new Error("no user id in session"))
 			return next(new Error("no user id in session"))
 		BetaProgramHandler.optOut user_id, (err) ->
 		BetaProgramHandler.optOut user_id, (err) ->
 			if err
 			if err
@@ -27,7 +28,7 @@ module.exports = BetaProgramController =
 			return res.redirect "/beta/participate"
 			return res.redirect "/beta/participate"
 
 
 	optInPage: (req, res, next)->
 	optInPage: (req, res, next)->
-		user_id = req.session?.user?._id
+		user_id = AuthenticationController.getLoggedInUserId(req)
 		logger.log {user_id}, "showing beta participation page for user"
 		logger.log {user_id}, "showing beta participation page for user"
 		UserLocator.findById user_id, (err, user)->
 		UserLocator.findById user_id, (err, user)->
 			if err
 			if err

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

@@ -28,7 +28,7 @@ module.exports = BlogController =
 			try
 			try
 				data = JSON.parse(data)
 				data = JSON.parse(data)
 				if settings.cdn?.web?.host?
 				if settings.cdn?.web?.host?
-					data?.content = data?.content?.replace(/src="([^"]+)"/g, "src='#{settings.cdn?.web?.host}$1'");
+					data?.content = data?.content?.replace(/src="(\/[^"]+)"/g, "src='#{settings.cdn?.web?.host}$1'");
 			catch err
 			catch err
 				logger.err err:err, data:data, "error parsing data from data"
 				logger.err err:err, data:data, "error parsing data from data"
 			res.render "blog/blog_holder", data
 			res.render "blog/blog_holder", data

+ 24 - 0
services/web/app/coffee/Features/Blog/BlogHandler.coffee

@@ -0,0 +1,24 @@
+request = require "request"
+settings = require "settings-sharelatex"
+_ = require("underscore")
+logger = require "logger-sharelatex"
+
+module.exports = BlogHandler =
+
+	getLatestAnnouncements: (callback)->
+		blogUrl = "#{settings.apis.blog.url}/blog/latestannouncements.json"
+		opts = 
+			url:blogUrl
+			json:true
+			timeout:500
+		request.get opts, (err, res, announcements)->
+			if err?
+				return callback err
+			if res.statusCode != 200
+				return callback("blog announcement returned non 200")
+			logger.log announcementsLength: announcements?.length, "announcements returned"
+			announcements = _.map announcements, (announcement)->
+				announcement.url = "/blog#{announcement.url}"
+				announcement.date = new Date(announcement.date)
+				return announcement
+			callback(err, announcements)

+ 61 - 0
services/web/app/coffee/Features/Chat/ChatApiHandler.coffee

@@ -0,0 +1,61 @@
+request = require("request")
+settings = require("settings-sharelatex")
+logger = require("logger-sharelatex")
+
+module.exports = ChatApiHandler =
+	_apiRequest: (opts, callback = (error, data) ->) ->
+		request opts, (error, response, data) ->
+			return callback(error) if error?
+			if 200 <= response.statusCode < 300
+				return callback null, data
+			else
+				error = new Error("chat api returned non-success code: #{response.statusCode}")
+				error.statusCode = response.statusCode
+				logger.error {err: error, opts}, "error sending request to chat api"
+				return callback error
+
+	sendGlobalMessage: (project_id, user_id, content, callback)->
+		ChatApiHandler._apiRequest {
+			url:    "#{settings.apis.chat.internal_url}/project/#{project_id}/messages"
+			method: "POST"
+			json:   {user_id, content}
+		}, callback
+
+	getGlobalMessages: (project_id, limit, before, callback)->
+		qs = {}
+		qs.limit = limit if limit?
+		qs.before = before if before?
+		
+		ChatApiHandler._apiRequest {
+			url:    "#{settings.apis.chat.internal_url}/project/#{project_id}/messages"
+			method: "GET"
+			qs:     qs
+			json:   true
+		}, callback
+	
+	sendComment: (project_id, thread_id, user_id, content, callback = (error) ->) ->
+		ChatApiHandler._apiRequest {
+			url:    "#{settings.apis.chat.internal_url}/project/#{project_id}/thread/#{thread_id}/messages"
+			method: "POST"
+			json:   {user_id, content}
+		}, callback
+	
+	getThreads: (project_id, callback = (error) ->) ->
+		ChatApiHandler._apiRequest {
+			url:    "#{settings.apis.chat.internal_url}/project/#{project_id}/threads"
+			method: "GET"
+			json:   true
+		}, callback
+	
+	resolveThread: (project_id, thread_id, user_id, callback = (error) ->) ->
+		ChatApiHandler._apiRequest {
+			url:    "#{settings.apis.chat.internal_url}/project/#{project_id}/thread/#{thread_id}/resolve"
+			method: "POST"
+			json:   {user_id}
+		}, callback
+	
+	reopenThread: (project_id, thread_id, callback = (error) ->) ->
+		ChatApiHandler._apiRequest {
+			url:    "#{settings.apis.chat.internal_url}/project/#{project_id}/thread/#{thread_id}/reopen"
+			method: "POST"
+		}, callback

+ 27 - 22
services/web/app/coffee/Features/Chat/ChatController.coffee

@@ -1,29 +1,34 @@
-ChatHandler = require("./ChatHandler")
+ChatApiHandler = require("./ChatApiHandler")
 EditorRealTimeController = require("../Editor/EditorRealTimeController")
 EditorRealTimeController = require("../Editor/EditorRealTimeController")
 logger = require("logger-sharelatex")
 logger = require("logger-sharelatex")
+AuthenticationController = require('../Authentication/AuthenticationController')
+UserInfoManager = require('../User/UserInfoManager')
+UserInfoController = require('../User/UserInfoController')
+CommentsController = require('../Comments/CommentsController')
 
 
 module.exports =
 module.exports =
+	sendMessage: (req, res, next)->
+		project_id = req.params.project_id
+		content = req.body.content
+		user_id = AuthenticationController.getLoggedInUserId(req)
+		if !user_id?
+			err = new Error('no logged-in user')
+			return next(err)
+		ChatApiHandler.sendGlobalMessage project_id, user_id, content, (err, message) ->
+			return next(err) if err?
+			UserInfoManager.getPersonalInfo message.user_id, (err, user) ->
+				return next(err) if err?
+				message.user = UserInfoController.formatPersonalInfo(user)
+				EditorRealTimeController.emitToRoom project_id, "new-chat-message", message, (err)->
+				res.send(204)
 
 
-
-	sendMessage: (req, res)->
-		project_id = req.params.Project_id
-		user_id = req.session.user._id
-		messageContent = req.body.content
-		ChatHandler.sendMessage project_id, user_id, messageContent, (err, builtMessge)->
-			if err?
-				logger.err err:err, project_id:project_id, user_id:user_id, messageContent:messageContent, "problem sending message to chat api"
-				return res.sendStatus(500)
-			EditorRealTimeController.emitToRoom project_id,  "new-chat-message", builtMessge, (err)->
-			res.send()
-
-	getMessages: (req, res)->
-		project_id = req.params.Project_id
+	getMessages: (req, res, next)->
+		project_id = req.params.project_id
 		query = req.query
 		query = req.query
 		logger.log project_id:project_id, query:query, "getting messages"
 		logger.log project_id:project_id, query:query, "getting messages"
-		ChatHandler.getMessages project_id, query, (err, messages)->
-			if err?
-				logger.err err:err, query:query, "problem getting messages from chat api"
-				return res.sendStatus 500
-			logger.log length:messages?.length, "sending messages to client"
-			res.set 'Content-Type', 'application/json'
-			res.send messages
+		ChatApiHandler.getGlobalMessages project_id, query.limit, query.before, (err, messages) ->
+			return next(err) if err?
+			CommentsController._injectUserInfoIntoThreads {global: { messages: messages }}, (err) ->
+				return next(err) if err?
+				logger.log length: messages?.length, "sending messages to client"
+				res.json messages

+ 0 - 32
services/web/app/coffee/Features/Chat/ChatHandler.coffee

@@ -1,32 +0,0 @@
-request = require("request")
-settings = require("settings-sharelatex")
-logger = require("logger-sharelatex")
-
-module.exports = 
-
-	sendMessage: (project_id, user_id, messageContent, callback)->
-		opts =
-			method:"post"
-			json:
-				content:messageContent
-				user_id:user_id
-			uri:"#{settings.apis.chat.internal_url}/room/#{project_id}/messages"
-		request opts, (err, response, body)->
-			if err?
-				logger.err err:err, "problem sending new message to chat"
-			callback(err, body)
-
-
-
-	getMessages: (project_id, query, callback)->
-		qs = {}
-		qs.limit = query.limit if query?.limit?
-		qs.before = query.before if query?.before?
-
-		opts =
-			uri:"#{settings.apis.chat.internal_url}/room/#{project_id}/messages"
-			method:"get"
-			qs: qs
-
-		request opts, (err, response, body)->
-			callback(err, body)

+ 2 - 22
services/web/app/coffee/Features/Collaborators/CollaboratorsEmailHandler.coffee

@@ -11,28 +11,7 @@ module.exports = CollaboratorsEmailHandler =
 			"user_first_name=#{encodeURIComponent(project.owner_ref.first_name)}"
 			"user_first_name=#{encodeURIComponent(project.owner_ref.first_name)}"
 		].join("&")
 		].join("&")
 
 
-	notifyUserOfProjectShare: (project_id, email, callback)->
-		Project
-			.findOne(_id: project_id )
-			.select("name owner_ref")
-			.populate('owner_ref')
-			.exec (err, project)->
-				emailOptions =
-					to: email
-					replyTo: project.owner_ref.email
-					project:
-						name: project.name
-						url: "#{Settings.siteUrl}/project/#{project._id}?" + [
-								"project_name=#{encodeURIComponent(project.name)}"
-								"user_first_name=#{encodeURIComponent(project.owner_ref.first_name)}"
-								"new_email=#{encodeURIComponent(email)}"
-								"r=#{project.owner_ref.referal_id}" # Referal
-								"rs=ci" # referral source = collaborator invite
-							].join("&")
-					owner: project.owner_ref
-				EmailHandler.sendEmail "projectSharedWithYou", emailOptions, callback
-
-	notifyUserOfProjectInvite: (project_id, email, invite, callback)->
+	notifyUserOfProjectInvite: (project_id, email, invite, sendingUser, callback)->
 		Project
 		Project
 			.findOne(_id: project_id )
 			.findOne(_id: project_id )
 			.select("name owner_ref")
 			.select("name owner_ref")
@@ -45,4 +24,5 @@ module.exports = CollaboratorsEmailHandler =
 						name: project.name
 						name: project.name
 					inviteUrl: CollaboratorsEmailHandler._buildInviteUrl(project, invite)
 					inviteUrl: CollaboratorsEmailHandler._buildInviteUrl(project, invite)
 					owner: project.owner_ref
 					owner: project.owner_ref
+					sendingUser_id: sendingUser._id
 				EmailHandler.sendEmail "projectInvite", emailOptions, callback
 				EmailHandler.sendEmail "projectInvite", emailOptions, callback

+ 53 - 16
services/web/app/coffee/Features/Collaborators/CollaboratorsInviteController.coffee

@@ -4,10 +4,13 @@ UserGetter = require "../User/UserGetter"
 CollaboratorsHandler = require('./CollaboratorsHandler')
 CollaboratorsHandler = require('./CollaboratorsHandler')
 CollaboratorsInviteHandler = require('./CollaboratorsInviteHandler')
 CollaboratorsInviteHandler = require('./CollaboratorsInviteHandler')
 logger = require('logger-sharelatex')
 logger = require('logger-sharelatex')
+Settings = require('settings-sharelatex')
 EmailHelper = require "../Helpers/EmailHelper"
 EmailHelper = require "../Helpers/EmailHelper"
 EditorRealTimeController = require("../Editor/EditorRealTimeController")
 EditorRealTimeController = require("../Editor/EditorRealTimeController")
 NotificationsBuilder = require("../Notifications/NotificationsBuilder")
 NotificationsBuilder = require("../Notifications/NotificationsBuilder")
 AnalyticsManger = require("../Analytics/AnalyticsManager")
 AnalyticsManger = require("../Analytics/AnalyticsManager")
+AuthenticationController = require("../Authentication/AuthenticationController")
+rateLimiter = require("../../infrastructure/RateLimiter")
 
 
 module.exports = CollaboratorsInviteController =
 module.exports = CollaboratorsInviteController =
 
 
@@ -20,11 +23,36 @@ module.exports = CollaboratorsInviteController =
 				return next(err)
 				return next(err)
 			res.json({invites: invites})
 			res.json({invites: invites})
 
 
+	_checkShouldInviteEmail: (sendingUser, email, callback=(err, shouldAllowInvite)->) ->
+		if Settings.restrictInvitesToExistingAccounts == true
+			logger.log {email}, "checking if user exists with this email"
+			UserGetter.getUser {email: email}, {_id: 1}, (err, user) ->
+				return callback(err) if err?
+				userExists = user? and user?._id?
+				callback(null, userExists)
+		else
+			UserGetter.getUser sendingUser._id, {features:1, _id:1}, (err, user)->
+				if err?
+					return callback(err)
+				collabLimit = user?.features?.collaborators || 1
+				if collabLimit == -1 
+					collabLimit = 20
+				collabLimit = collabLimit * 10
+				opts = 
+					endpointName: "invite_to_project"
+					timeInterval: 60 * 30
+					subjectName: sendingUser._id
+					throttle: collabLimit
+				rateLimiter.addCount opts, callback
+
 	inviteToProject: (req, res, next) ->
 	inviteToProject: (req, res, next) ->
 		projectId = req.params.Project_id
 		projectId = req.params.Project_id
 		email = req.body.email
 		email = req.body.email
-		sendingUser = req.session.user
+		sendingUser = AuthenticationController.getSessionUser(req)
 		sendingUserId = sendingUser._id
 		sendingUserId = sendingUser._id
+		if email == sendingUser.email
+			logger.log {projectId, email, sendingUserId}, "cannot invite yourself to project"
+			return res.json {invite: null, error: 'cannot_invite_self'}
 		logger.log {projectId, email, sendingUserId}, "inviting to project"
 		logger.log {projectId, email, sendingUserId}, "inviting to project"
 		LimitationsManager.canAddXCollaborators projectId, 1, (error, allowed) =>
 		LimitationsManager.canAddXCollaborators projectId, 1, (error, allowed) =>
 			return next(error) if error?
 			return next(error) if error?
@@ -36,13 +64,20 @@ module.exports = CollaboratorsInviteController =
 			if !email? or email == ""
 			if !email? or email == ""
 				logger.log {projectId, email, sendingUserId}, "invalid email address"
 				logger.log {projectId, email, sendingUserId}, "invalid email address"
 				return res.sendStatus(400)
 				return res.sendStatus(400)
-			CollaboratorsInviteHandler.inviteToProject projectId, sendingUser, email, privileges, (err, invite) ->
+			CollaboratorsInviteController._checkShouldInviteEmail sendingUser, email, (err, shouldAllowInvite)->
 				if err?
 				if err?
-					logger.err {projectId, email, sendingUserId}, "error creating project invite"
+					logger.err {err, email, projectId, sendingUserId}, "error checking if we can invite this email address"
 					return next(err)
 					return next(err)
-				logger.log {projectId, email, sendingUserId}, "invite created"
-				EditorRealTimeController.emitToRoom(projectId, 'project:membership:changed', {invites: true})
-				return res.json {invite: invite}
+				if !shouldAllowInvite
+					logger.log {email, projectId, sendingUserId}, "not allowed to send an invite to this email address"
+					return res.json {invite: null, error: 'cannot_invite_non_user'}
+				CollaboratorsInviteHandler.inviteToProject projectId, sendingUser, email, privileges, (err, invite) ->
+					if err?
+						logger.err {projectId, email, sendingUserId}, "error creating project invite"
+						return next(err)
+					logger.log {projectId, email, sendingUserId}, "invite created"
+					EditorRealTimeController.emitToRoom(projectId, 'project:membership:changed', {invites: true})
+					return res.json {invite: invite}
 
 
 	revokeInvite: (req, res, next) ->
 	revokeInvite: (req, res, next) ->
 		projectId = req.params.Project_id
 		projectId = req.params.Project_id
@@ -58,8 +93,8 @@ module.exports = CollaboratorsInviteController =
 	resendInvite: (req, res, next) ->
 	resendInvite: (req, res, next) ->
 		projectId = req.params.Project_id
 		projectId = req.params.Project_id
 		inviteId = req.params.invite_id
 		inviteId = req.params.invite_id
-		sendingUser = req.session.user
 		logger.log {projectId, inviteId}, "resending invite"
 		logger.log {projectId, inviteId}, "resending invite"
+		sendingUser = AuthenticationController.getSessionUser(req)
 		CollaboratorsInviteHandler.resendInvite projectId, sendingUser, inviteId, (err) ->
 		CollaboratorsInviteHandler.resendInvite projectId, sendingUser, inviteId, (err) ->
 			if err?
 			if err?
 				logger.err {projectId, inviteId}, "error resending invite"
 				logger.err {projectId, inviteId}, "error resending invite"
@@ -69,11 +104,11 @@ module.exports = CollaboratorsInviteController =
 	viewInvite: (req, res, next) ->
 	viewInvite: (req, res, next) ->
 		projectId = req.params.Project_id
 		projectId = req.params.Project_id
 		token = req.params.token
 		token = req.params.token
-		currentUser = req.session.user
 		_renderInvalidPage = () ->
 		_renderInvalidPage = () ->
 			logger.log {projectId, token}, "invite not valid, rendering not-valid page"
 			logger.log {projectId, token}, "invite not valid, rendering not-valid page"
 			res.render "project/invite/not-valid", {title: "Invalid Invite"}
 			res.render "project/invite/not-valid", {title: "Invalid Invite"}
 		# check if the user is already a member of the project
 		# check if the user is already a member of the project
+		currentUser = AuthenticationController.getSessionUser(req)
 		CollaboratorsHandler.isUserMemberOfProject currentUser._id, projectId, (err, isMember, _privilegeLevel) ->
 		CollaboratorsHandler.isUserMemberOfProject currentUser._id, projectId, (err, isMember, _privilegeLevel) ->
 			if err?
 			if err?
 				logger.err {err, projectId}, "error checking if user is member of project"
 				logger.err {err, projectId}, "error checking if user is member of project"
@@ -111,14 +146,16 @@ module.exports = CollaboratorsInviteController =
 
 
 	acceptInvite: (req, res, next) ->
 	acceptInvite: (req, res, next) ->
 		projectId = req.params.Project_id
 		projectId = req.params.Project_id
-		inviteId = req.params.invite_id
-		{token} = req.body
-		currentUser = req.session.user
-		logger.log {projectId, inviteId, userId: currentUser._id}, "accepting invite"
-		CollaboratorsInviteHandler.acceptInvite projectId, inviteId, token, currentUser, (err) ->
+		token = req.params.token
+		currentUser = AuthenticationController.getSessionUser(req)
+		logger.log {projectId, userId: currentUser._id, token}, "got request to accept invite"
+		CollaboratorsInviteHandler.acceptInvite projectId, token, currentUser, (err) ->
 			if err?
 			if err?
-				logger.err {projectId, inviteId}, "error accepting invite by token"
+				logger.err {projectId, token}, "error accepting invite by token"
 				return next(err)
 				return next(err)
 			EditorRealTimeController.emitToRoom projectId, 'project:membership:changed', {invites: true, members: true}
 			EditorRealTimeController.emitToRoom projectId, 'project:membership:changed', {invites: true, members: true}
-			AnalyticsManger.recordEvent(currentUser._id, "project-invite-accept", {inviteId:inviteId, projectId:projectId})
-			res.redirect "/project/#{projectId}"
+			AnalyticsManger.recordEvent(currentUser._id, "project-invite-accept", {projectId:projectId, userId:currentUser._id})
+			if req.xhr
+				res.sendStatus 204 #  Done async via project page notification
+			else
+				res.redirect "/project/#{projectId}"

+ 9 - 7
services/web/app/coffee/Features/Collaborators/CollaboratorsInviteHandler.coffee

@@ -53,7 +53,7 @@ module.exports = CollaboratorsInviteHandler =
 
 
 	_sendMessages: (projectId, sendingUser, invite, callback=(err)->) ->
 	_sendMessages: (projectId, sendingUser, invite, callback=(err)->) ->
 		logger.log {projectId, inviteId: invite._id}, "sending notification and email for invite"
 		logger.log {projectId, inviteId: invite._id}, "sending notification and email for invite"
-		CollaboratorsEmailHandler.notifyUserOfProjectInvite projectId, invite.email, invite, (err)->
+		CollaboratorsEmailHandler.notifyUserOfProjectInvite projectId, invite.email, invite, sendingUser, (err)->
 			return callback(err) if err?
 			return callback(err) if err?
 			CollaboratorsInviteHandler._trySendInviteNotification projectId, sendingUser, invite, (err)->
 			CollaboratorsInviteHandler._trySendInviteNotification projectId, sendingUser, invite, (err)->
 				return callback(err) if err?
 				return callback(err) if err?
@@ -77,10 +77,12 @@ module.exports = CollaboratorsInviteHandler =
 				if err?
 				if err?
 					logger.err {err, projectId, sendingUserId: sendingUser._id, email}, "error saving token"
 					logger.err {err, projectId, sendingUserId: sendingUser._id, email}, "error saving token"
 					return callback(err)
 					return callback(err)
+				# Send email and notification in background
 				CollaboratorsInviteHandler._sendMessages projectId, sendingUser, invite, (err) ->
 				CollaboratorsInviteHandler._sendMessages projectId, sendingUser, invite, (err) ->
 					if err?
 					if err?
 						logger.err {projectId, email}, "error sending messages for invite"
 						logger.err {projectId, email}, "error sending messages for invite"
-					callback(err, invite)
+				callback(null, invite)
+
 
 
 	revokeInvite: (projectId, inviteId, callback=(err)->) ->
 	revokeInvite: (projectId, inviteId, callback=(err)->) ->
 		logger.log {projectId, inviteId}, "removing invite"
 		logger.log {projectId, inviteId}, "removing invite"
@@ -102,7 +104,7 @@ module.exports = CollaboratorsInviteHandler =
 				return callback(null)
 				return callback(null)
 			CollaboratorsInviteHandler._sendMessages projectId, sendingUser, invite, (err) ->
 			CollaboratorsInviteHandler._sendMessages projectId, sendingUser, invite, (err) ->
 				if err?
 				if err?
-					logger.err {projectid, inviteId}, "error resending invite messages"
+					logger.err {projectId, inviteId}, "error resending invite messages"
 					return callback(err)
 					return callback(err)
 				callback(null)
 				callback(null)
 
 
@@ -117,15 +119,15 @@ module.exports = CollaboratorsInviteHandler =
 				return callback(null, null)
 				return callback(null, null)
 			callback(null, invite)
 			callback(null, invite)
 
 
-	acceptInvite: (projectId, inviteId, tokenString, user, callback=(err)->) ->
-		logger.log {projectId, inviteId, userId: user._id}, "accepting invite"
+	acceptInvite: (projectId, tokenString, user, callback=(err)->) ->
+		logger.log {projectId, userId: user._id, tokenString}, "accepting invite"
 		CollaboratorsInviteHandler.getInviteByToken projectId, tokenString, (err, invite) ->
 		CollaboratorsInviteHandler.getInviteByToken projectId, tokenString, (err, invite) ->
 			if err?
 			if err?
-				logger.err {err, projectId, inviteId}, "error finding invite"
+				logger.err {err, projectId, tokenString}, "error finding invite"
 				return callback(err)
 				return callback(err)
 			if !invite
 			if !invite
 				err = new Errors.NotFoundError("no matching invite found")
 				err = new Errors.NotFoundError("no matching invite found")
-				logger.log {err, projectId, inviteId, tokenString}, "no matching invite found"
+				logger.log {err, projectId, tokenString}, "no matching invite found"
 				return callback(err)
 				return callback(err)
 			inviteId = invite._id
 			inviteId = invite._id
 			CollaboratorsHandler.addUserIdToProject projectId, invite.sendingUserId, user._id, invite.privileges, (err) ->
 			CollaboratorsHandler.addUserIdToProject projectId, invite.sendingUserId, user._id, invite.privileges, (err) ->

+ 8 - 2
services/web/app/coffee/Features/Collaborators/CollaboratorsRouter.coffee

@@ -24,7 +24,13 @@ module.exports =
 			RateLimiterMiddlewear.rateLimit({
 			RateLimiterMiddlewear.rateLimit({
 				endpointName: "invite-to-project"
 				endpointName: "invite-to-project"
 				params: ["Project_id"]
 				params: ["Project_id"]
-				maxRequests: 200
+				maxRequests: 100
+				timeInterval: 60 * 10
+			}),
+			RateLimiterMiddlewear.rateLimit({
+				endpointName: "invite-to-project-ip"
+				ipOnly:true
+				maxRequests: 100
 				timeInterval: 60 * 10
 				timeInterval: 60 * 10
 			}),
 			}),
 			AuthenticationController.requireLogin(),
 			AuthenticationController.requireLogin(),
@@ -66,7 +72,7 @@ module.exports =
 		)
 		)
 
 
 		webRouter.post(
 		webRouter.post(
-			'/project/:Project_id/invite/:invite_id/accept',
+			'/project/:Project_id/invite/token/:token/accept',
 			AuthenticationController.requireLogin(),
 			AuthenticationController.requireLogin(),
 			CollaboratorsInviteController.acceptInvite
 			CollaboratorsInviteController.acceptInvite
 		)
 		)

+ 83 - 0
services/web/app/coffee/Features/Comments/CommentsController.coffee

@@ -0,0 +1,83 @@
+ChatApiHandler = require("../Chat/ChatApiHandler")
+EditorRealTimeController = require("../Editor/EditorRealTimeController")
+logger = require("logger-sharelatex")
+AuthenticationController = require('../Authentication/AuthenticationController')
+UserInfoManager = require('../User/UserInfoManager')
+UserInfoController = require('../User/UserInfoController')
+async = require "async"
+
+module.exports = CommentsController =
+	sendComment: (req, res, next) ->
+		{project_id, thread_id} = req.params
+		content = req.body.content
+		user_id = AuthenticationController.getLoggedInUserId(req)
+		if !user_id?
+			err = new Error('no logged-in user')
+			return next(err)
+		logger.log {project_id, thread_id, user_id, content}, "sending comment"
+		ChatApiHandler.sendComment project_id, thread_id, user_id, content, (err, comment) ->
+			return next(err) if err?
+			UserInfoManager.getPersonalInfo comment.user_id, (err, user) ->
+				return next(err) if err?
+				comment.user = UserInfoController.formatPersonalInfo(user)
+				EditorRealTimeController.emitToRoom project_id, "new-comment", thread_id, comment, (err) ->
+				res.send 204
+
+	getThreads: (req, res, next) ->
+		{project_id} = req.params
+		logger.log {project_id}, "getting comment threads for project"
+		ChatApiHandler.getThreads project_id, (err, threads) ->
+			return next(err) if err?
+			CommentsController._injectUserInfoIntoThreads threads, (error, threads) ->
+				return next(err) if err?
+				res.json threads
+
+	resolveThread: (req, res, next) ->
+		{project_id, thread_id} = req.params
+		user_id = AuthenticationController.getLoggedInUserId(req)
+		logger.log {project_id, thread_id, user_id}, "resolving comment thread"
+		ChatApiHandler.resolveThread project_id, thread_id, user_id, (err) ->
+			return next(err) if err?
+			UserInfoManager.getPersonalInfo user_id, (err, user) ->
+				return next(err) if err?
+				EditorRealTimeController.emitToRoom project_id, "resolve-thread", thread_id, UserInfoController.formatPersonalInfo(user), (err)->
+				res.send 204
+
+	reopenThread: (req, res, next) ->
+		{project_id, thread_id} = req.params
+		logger.log {project_id, thread_id}, "reopening comment thread"
+		ChatApiHandler.reopenThread project_id, thread_id, (err, threads) ->
+			return next(err) if err?
+			EditorRealTimeController.emitToRoom project_id, "reopen-thread", thread_id, (err)->
+			res.send 204
+
+	_injectUserInfoIntoThreads: (threads, callback = (error, threads) ->) ->
+		userCache = {}
+		getUserDetails = (user_id, callback = (error, user) ->) ->
+			return callback(null, userCache[user_id]) if userCache[user_id]?
+			UserInfoManager.getPersonalInfo user_id, (err, user) ->
+				return callback(error) if error?
+				user = UserInfoController.formatPersonalInfo user
+				userCache[user_id] = user
+				callback null, user
+		
+		jobs = []
+		for thread_id, thread of threads
+			do (thread) ->
+				if thread.resolved
+					jobs.push (cb) ->
+						getUserDetails thread.resolved_by_user_id, (error, user) ->
+							cb(error) if error?
+							thread.resolved_by_user = user
+							cb()
+				for message in thread.messages
+					do (message) ->
+						jobs.push (cb) ->
+							getUserDetails message.user_id, (error, user) ->
+								cb(error) if error?
+								message.user = user
+								cb()
+		
+		async.series jobs, (error) ->
+			return callback(error) if error?
+			return callback null, threads

+ 4 - 0
services/web/app/coffee/Features/Compile/ClsiCookieManager.coffee

@@ -48,6 +48,10 @@ module.exports = ClsiCookieManager =
 		multi.exec (err)->
 		multi.exec (err)->
 			callback(err, serverId)
 			callback(err, serverId)
 
 
+	clearServerId: (project_id, callback = (err)->)->
+		if !clsiCookiesEnabled
+			return callback()
+		rclient.del buildKey(project_id), callback
 
 
 	getCookieJar: (project_id, callback = (err, jar)->)->
 	getCookieJar: (project_id, callback = (err, jar)->)->
 		if !clsiCookiesEnabled
 		if !clsiCookiesEnabled

+ 33 - 33
services/web/app/coffee/Features/Compile/CompileController.coffee

@@ -16,53 +16,53 @@ module.exports = CompileController =
 		res.setTimeout(5 * 60 * 1000)
 		res.setTimeout(5 * 60 * 1000)
 		project_id = req.params.Project_id
 		project_id = req.params.Project_id
 		isAutoCompile = !!req.query?.auto_compile
 		isAutoCompile = !!req.query?.auto_compile
-		AuthenticationController.getLoggedInUserId req, (error, user_id) ->
+		user_id = AuthenticationController.getLoggedInUserId req
+		options = {
+			isAutoCompile: isAutoCompile
+		}
+		if req.body?.rootDoc_id?
+			options.rootDoc_id = req.body.rootDoc_id
+		else if req.body?.settingsOverride?.rootDoc_id? # Can be removed after deploy
+			options.rootDoc_id = req.body.settingsOverride.rootDoc_id
+		if req.body?.compiler
+			options.compiler = req.body.compiler
+		if req.body?.draft
+			options.draft = req.body.draft
+		if req.body?.check in ['validate', 'error', 'silent']
+			options.check = req.body.check
+		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) ->
 			return next(error) if error?
 			return next(error) if error?
-			options = {
-				isAutoCompile: isAutoCompile
+			res.contentType("application/json")
+			res.status(200).send JSON.stringify {
+				status: status
+				outputFiles: outputFiles
+				compileGroup: limits?.compileGroup
+				clsiServerId:clsiServerId
+				validationProblems:validationProblems
 			}
 			}
-			if req.body?.rootDoc_id?
-				options.rootDoc_id = req.body.rootDoc_id
-			else if req.body?.settingsOverride?.rootDoc_id? # Can be removed after deploy
-				options.rootDoc_id = req.body.settingsOverride.rootDoc_id
-			if req.body?.compiler
-				options.compiler = req.body.compiler
-			if req.body?.draft
-				options.draft = req.body.draft
-			if req.body?.check in ['validate', 'error', 'silent']
-				options.check = req.body.check
-			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) ->
-				return next(error) if error?
-				res.contentType("application/json")
-				res.status(200).send JSON.stringify {
-					status: status
-					outputFiles: outputFiles
-					compileGroup: limits?.compileGroup
-					clsiServerId:clsiServerId
-					validationProblems:validationProblems
-				}
 
 
 	stopCompile: (req, res, next = (error) ->) ->
 	stopCompile: (req, res, next = (error) ->) ->
 		project_id = req.params.Project_id
 		project_id = req.params.Project_id
-		AuthenticationController.getLoggedInUserId req, (error, user_id) ->
+		user_id = AuthenticationController.getLoggedInUserId req
+		logger.log {project_id:project_id, user_id:user_id}, "stop compile request"
+		CompileManager.stopCompile project_id, user_id, (error) ->
 			return next(error) if error?
 			return next(error) if error?
-			logger.log {project_id:project_id, user_id:user_id}, "stop compile request"
-			CompileManager.stopCompile project_id, user_id, (error) ->
-				return next(error) if error?
-				res.status(200).send()
+			res.status(200).send()
 
 
 	_compileAsUser: (req, callback) ->
 	_compileAsUser: (req, callback) ->
 		# callback with user_id if per-user, undefined otherwise
 		# callback with user_id if per-user, undefined otherwise
 		if not Settings.disablePerUserCompiles
 		if not Settings.disablePerUserCompiles
-			AuthenticationController.getLoggedInUserId req, callback # -> (error, user_id)
+			user_id = AuthenticationController.getLoggedInUserId req
+			return callback(null, 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 per-user, undefined otherwise
 		# callback with user_id if per-user, undefined otherwise
 		if not Settings.disablePerUserCompiles
 		if not Settings.disablePerUserCompiles
-			AuthenticationController.getLoggedInUserId req, callback # -> (error, user_id)
+			user_id = AuthenticationController.getLoggedInUserId req
+			return callback(null, user_id)
 		else
 		else
 			callback() # do a per-project compile, not per-user
 			callback() # do a per-project compile, not per-user
 
 
@@ -151,9 +151,9 @@ module.exports = CompileController =
 		{page, h, v} = req.query
 		{page, h, v} = req.query
 		if not page?.match(/^\d+$/)
 		if not page?.match(/^\d+$/)
 			return next(new Error("invalid page parameter"))
 			return next(new Error("invalid page parameter"))
-		if not h?.match(/^\d+\.\d+$/)
+		if not h?.match(/^-?\d+\.\d+$/)
 			return next(new Error("invalid h parameter"))
 			return next(new Error("invalid h parameter"))
-		if not v?.match(/^\d+\.\d+$/)
+		if not v?.match(/^-?\d+\.\d+$/)
 			return next(new Error("invalid v parameter"))
 			return next(new Error("invalid v parameter"))
 		# whether this request is going to a per-user container
 		# whether this request is going to a per-user container
 		CompileController._compileAsUser req, (error, user_id) ->
 		CompileController._compileAsUser req, (error, user_id) ->

+ 22 - 23
services/web/app/coffee/Features/Contacts/ContactController.coffee

@@ -6,33 +6,32 @@ Modules = require "../../infrastructure/Modules"
 
 
 module.exports = ContactsController =
 module.exports = ContactsController =
 	getContacts: (req, res, next) ->
 	getContacts: (req, res, next) ->
-		AuthenticationController.getLoggedInUserId req, (error, user_id) ->
+		user_id = AuthenticationController.getLoggedInUserId req
+		ContactManager.getContactIds user_id, {limit: 50}, (error, contact_ids) ->
 			return next(error) if error?
 			return next(error) if error?
-			ContactManager.getContactIds user_id, {limit: 50}, (error, contact_ids) ->
+			UserGetter.getUsers contact_ids, {
+				email: 1, first_name: 1, last_name: 1, holdingAccount: 1
+			}, (error, contacts) ->
 				return next(error) if error?
 				return next(error) if error?
-				UserGetter.getUsers contact_ids, {
-					email: 1, first_name: 1, last_name: 1, holdingAccount: 1
-				}, (error, contacts) ->
+
+				# UserGetter.getUsers may not preserve order so put them back in order
+				positions = {}
+				for contact_id, i in contact_ids
+					positions[contact_id] = i
+				contacts.sort (a,b) -> positions[a._id?.toString()] - positions[b._id?.toString()]
+
+				# Don't count holding accounts to discourage users from repeating mistakes (mistyped or wrong emails, etc)
+				contacts = contacts.filter (c) -> !c.holdingAccount
+
+				contacts = contacts.map(ContactsController._formatContact)
+
+				Modules.hooks.fire "getContacts", user_id, contacts, (error, additional_contacts) ->
 					return next(error) if error?
 					return next(error) if error?
-					
-					# UserGetter.getUsers may not preserve order so put them back in order
-					positions = {}
-					for contact_id, i in contact_ids
-						positions[contact_id] = i
-					contacts.sort (a,b) -> positions[a._id?.toString()] - positions[b._id?.toString()]
+					contacts = contacts.concat(additional_contacts...)
+					res.send({
+						contacts: contacts
+					})
 
 
-					# Don't count holding accounts to discourage users from repeating mistakes (mistyped or wrong emails, etc)
-					contacts = contacts.filter (c) -> !c.holdingAccount
-					
-					contacts = contacts.map(ContactsController._formatContact)
-					
-					Modules.hooks.fire "getContacts", user_id, contacts, (error, additional_contacts) ->
-						return next(error) if error?
-						contacts = contacts.concat(additional_contacts...)
-						res.send({
-							contacts: contacts
-						})
-	
 	_formatContact: (contact) ->
 	_formatContact: (contact) ->
 		return  {
 		return  {
 			id: contact._id?.toString()
 			id: contact._id?.toString()

+ 20 - 3
services/web/app/coffee/Features/Docstore/DocstoreManager.coffee

@@ -29,8 +29,23 @@ module.exports = DocstoreManager =
 				error = new Error("docstore api responded with non-success code: #{res.statusCode}")
 				error = new Error("docstore api responded with non-success code: #{res.statusCode}")
 				logger.error err: error, project_id: project_id, "error getting all docs from docstore"
 				logger.error err: error, project_id: project_id, "error getting all docs from docstore"
 				callback(error)
 				callback(error)
+	
+	getAllRanges: (project_id, callback = (error) ->) ->
+		logger.log { project_id }, "getting all doc ranges for project in docstore api"
+		url = "#{settings.apis.docstore.url}/project/#{project_id}/ranges"
+		request.get {
+			url: url
+			json: true
+		}, (error, res, docs) ->
+			return callback(error) if error?
+			if 200 <= res.statusCode < 300
+				callback(null, docs)
+			else
+				error = new Error("docstore api responded with non-success code: #{res.statusCode}")
+				logger.error err: error, project_id: project_id, "error getting all doc ranges from docstore"
+				callback(error)
 
 
-	getDoc: (project_id, doc_id, options = {}, callback = (error, lines, rev) ->) ->
+	getDoc: (project_id, doc_id, options = {}, callback = (error, lines, rev, version) ->) ->
 		if typeof(options) == "function"
 		if typeof(options) == "function"
 			callback = options
 			callback = options
 			options = {}
 			options = {}
@@ -45,19 +60,21 @@ module.exports = DocstoreManager =
 			return callback(error) if error?
 			return callback(error) if error?
 			if 200 <= res.statusCode < 300
 			if 200 <= res.statusCode < 300
 				logger.log doc_id: doc_id, project_id: project_id, version: doc.version, rev: doc.rev, "got doc from docstore api"
 				logger.log doc_id: doc_id, project_id: project_id, version: doc.version, rev: doc.rev, "got doc from docstore api"
-				callback(null, doc.lines, doc.rev)
+				callback(null, doc.lines, doc.rev, doc.version, doc.ranges)
 			else
 			else
 				error = new Error("docstore api responded with non-success code: #{res.statusCode}")
 				error = new Error("docstore api responded with non-success code: #{res.statusCode}")
 				logger.error err: error, project_id: project_id, doc_id: doc_id, "error getting doc from docstore"
 				logger.error err: error, project_id: project_id, doc_id: doc_id, "error getting doc from docstore"
 				callback(error)
 				callback(error)
 
 
-	updateDoc: (project_id, doc_id, lines, callback = (error, modified, rev) ->) ->
+	updateDoc: (project_id, doc_id, lines, version, ranges, callback = (error, modified, rev) ->) ->
 		logger.log project_id: project_id, doc_id: doc_id, "updating doc in docstore api"
 		logger.log project_id: project_id, doc_id: doc_id, "updating doc in docstore api"
 		url = "#{settings.apis.docstore.url}/project/#{project_id}/doc/#{doc_id}"
 		url = "#{settings.apis.docstore.url}/project/#{project_id}/doc/#{doc_id}"
 		request.post {
 		request.post {
 			url: url
 			url: url
 			json:
 			json:
 				lines: lines
 				lines: lines
+				version: version
+				ranges: ranges
 		}, (error, res, result) ->
 		}, (error, res, result) ->
 			return callback(error) if error?
 			return callback(error) if error?
 			if 200 <= res.statusCode < 300
 			if 200 <= res.statusCode < 300

+ 17 - 11
services/web/app/coffee/Features/DocumentUpdater/DocumentUpdaterHandler.coffee

@@ -95,7 +95,7 @@ module.exports = DocumentUpdaterHandler =
 				logger.error err: error, project_id: project_id, doc_id: doc_id, "document updater returned failure status code: #{res.statusCode}"
 				logger.error err: error, project_id: project_id, doc_id: doc_id, "document updater returned failure status code: #{res.statusCode}"
 				return callback(error)
 				return callback(error)
 
 
-	getDocument: (project_id, doc_id, fromVersion, callback = (error, exists, doclines, version) ->) ->
+	getDocument: (project_id, doc_id, fromVersion, callback = (error, doclines, version, ranges, ops) ->) ->
 		timer = new metrics.Timer("get-document")
 		timer = new metrics.Timer("get-document")
 		url = "#{settings.apis.documentupdater.url}/project/#{project_id}/doc/#{doc_id}?fromVersion=#{fromVersion}"
 		url = "#{settings.apis.documentupdater.url}/project/#{project_id}/doc/#{doc_id}?fromVersion=#{fromVersion}"
 		logger.log project_id:project_id, doc_id: doc_id, "getting doc from document updater"
 		logger.log project_id:project_id, doc_id: doc_id, "getting doc from document updater"
@@ -110,7 +110,7 @@ module.exports = DocumentUpdaterHandler =
 					body = JSON.parse(body)
 					body = JSON.parse(body)
 				catch error
 				catch error
 					return callback(error)
 					return callback(error)
-				callback null, body.lines, body.version, body.ops
+				callback null, body.lines, body.version, body.ranges, body.ops
 			else
 			else
 				logger.error project_id:project_id, doc_id:doc_id, url: url, "doc updater returned a non-success status code: #{res.statusCode}"
 				logger.error project_id:project_id, doc_id:doc_id, url: url, "doc updater returned a non-success status code: #{res.statusCode}"
 				callback new Error("doc updater returned a non-success status code: #{res.statusCode}")
 				callback new Error("doc updater returned a non-success status code: #{res.statusCode}")
@@ -137,15 +137,21 @@ module.exports = DocumentUpdaterHandler =
 				logger.error project_id:project_id, doc_id:doc_id, url: url, "doc updater returned a non-success status code: #{res.statusCode}"
 				logger.error project_id:project_id, doc_id:doc_id, url: url, "doc updater returned a non-success status code: #{res.statusCode}"
 				callback new Error("doc updater returned a non-success status code: #{res.statusCode}")
 				callback new Error("doc updater returned a non-success status code: #{res.statusCode}")
 
 
-	getNumberOfDocsInMemory : (callback)->
-		request.get "#{settings.apis.documentupdater.url}/total", (err, req, body)->
-			try
-				body = JSON.parse body
-			catch err
-				logger.err err:err, "error parsing response from doc updater about the total number of docs"
-			callback(err, body?.total)
-
-
+	acceptChange: (project_id, doc_id, change_id, callback = (error) ->) ->
+		timer = new metrics.Timer("accept-change")
+		url = "#{settings.apis.documentupdater.url}/project/#{project_id}/doc/#{doc_id}/change/#{change_id}/accept"
+		logger.log {project_id, doc_id, change_id}, "accepting change in document updater"
+		request.post url, (error, res, body)->
+			timer.done()
+			if error?
+				logger.error {err:error, project_id, doc_id, change_id}, "error accepting change in doc updater"
+				return callback(error)
+			if res.statusCode >= 200 and res.statusCode < 300
+				logger.log {project_id, doc_id, change_id}, "accepted change in document updater"
+				return callback(null)
+			else
+				logger.error {project_id, doc_id, change_id}, "doc updater returned a non-success status code: #{res.statusCode}"
+				callback new Error("doc updater returned a non-success status code: #{res.statusCode}")
 
 
 PENDINGUPDATESKEY = "PendingUpdates"
 PENDINGUPDATESKEY = "PendingUpdates"
 DOCLINESKEY = "doclines"
 DOCLINESKEY = "doclines"

+ 5 - 3
services/web/app/coffee/Features/Documents/DocumentController.coffee

@@ -7,7 +7,7 @@ module.exports =
 		doc_id = req.params.doc_id
 		doc_id = req.params.doc_id
 		plain = req?.query?.plain == 'true'
 		plain = req?.query?.plain == 'true'
 		logger.log doc_id:doc_id, project_id:project_id, "receiving get document request from api (docupdater)"
 		logger.log doc_id:doc_id, project_id:project_id, "receiving get document request from api (docupdater)"
-		ProjectEntityHandler.getDoc project_id, doc_id, (error, lines, rev) ->
+		ProjectEntityHandler.getDoc project_id, doc_id, (error, lines, rev, version, ranges) ->
 			if error?
 			if error?
 				logger.err err:error, doc_id:doc_id, project_id:project_id, "error finding element for getDocument"
 				logger.err err:error, doc_id:doc_id, project_id:project_id, "error finding element for getDocument"
 				return next(error)
 				return next(error)
@@ -18,14 +18,16 @@ module.exports =
 				res.type "json"
 				res.type "json"
 				res.send JSON.stringify {
 				res.send JSON.stringify {
 					lines: lines
 					lines: lines
+					version: version
+					ranges: ranges
 				}
 				}
 
 
 	setDocument: (req, res, next = (error) ->) ->
 	setDocument: (req, res, next = (error) ->) ->
 		project_id = req.params.Project_id
 		project_id = req.params.Project_id
 		doc_id = req.params.doc_id
 		doc_id = req.params.doc_id
-		lines = req.body.lines
+		{lines, version, ranges} = req.body
 		logger.log doc_id:doc_id, project_id:project_id, "receiving set document request from api (docupdater)"
 		logger.log doc_id:doc_id, project_id:project_id, "receiving set document request from api (docupdater)"
-		ProjectEntityHandler.updateDocLines project_id, doc_id, lines, (error) ->
+		ProjectEntityHandler.updateDocLines project_id, doc_id, lines, version, ranges, (error) ->
 			if error?
 			if error?
 				logger.err err:error, doc_id:doc_id, project_id:project_id, "error finding element for getDocument"
 				logger.err err:error, doc_id:doc_id, project_id:project_id, "error finding element for getDocument"
 				return next(error)
 				return next(error)

+ 0 - 1
services/web/app/coffee/Features/Editor/EditorController.coffee

@@ -7,7 +7,6 @@ ProjectDetailsHandler = require('../Project/ProjectDetailsHandler')
 ProjectDeleter = require("../Project/ProjectDeleter")
 ProjectDeleter = require("../Project/ProjectDeleter")
 DocumentUpdaterHandler = require('../DocumentUpdater/DocumentUpdaterHandler')
 DocumentUpdaterHandler = require('../DocumentUpdater/DocumentUpdaterHandler')
 EditorRealTimeController = require("./EditorRealTimeController")
 EditorRealTimeController = require("./EditorRealTimeController")
-TrackChangesManager = require("../TrackChanges/TrackChangesManager")
 async = require('async')
 async = require('async')
 LockManager = require("../../infrastructure/LockManager")
 LockManager = require("../../infrastructure/LockManager")
 _ = require('underscore')
 _ = require('underscore')

+ 49 - 0
services/web/app/coffee/Features/Email/Bodies/SingleCTAEmailBody.coffee

@@ -0,0 +1,49 @@
+_ = require("underscore")
+settings = require "settings-sharelatex"
+
+module.exports = _.template """
+<table class="row" style="border-collapse: collapse; border-spacing: 0; display: table; padding: 0; position: relative; text-align: left; vertical-align: top; width: 100%;"><tbody><tr style="padding: 0; text-align: left; vertical-align: top;">
+	<th class="small-12 large-12 columns first last" style="Margin: 0 auto; color: #0a0a0a; font-family: Helvetica, Arial, sans-serif; font-size: 16px; font-weight: normal; line-height: 1.3; margin: 0 auto; padding: 0; padding-bottom: 16px; padding-left: 16px; padding-right: 16px; text-align: left; width: 564px;"><table style="border-collapse: collapse; border-spacing: 0; padding: 0; text-align: left; vertical-align: top; width: 100%;"><tr style="padding: 0; text-align: left; vertical-align: top;"><th style="Margin: 0; color: #0a0a0a; font-family: Helvetica, Arial, sans-serif; font-size: 16px; font-weight: normal; line-height: 1.3; margin: 0; padding: 0; text-align: left;">
+		<h3 class="avoid-auto-linking" style="Margin: 0; Margin-bottom: px; color: inherit; font-family: Baskerville, 'Baskerville Old Face', Georgia, serif; font-size: 24px; font-weight: normal; line-height: 1.3; margin: 0; margin-bottom: px; padding: 0; text-align: left; word-wrap: normal;">
+			<%= title %>
+		</h3>
+		<table class="spacer" style="border-collapse: collapse; border-spacing: 0; padding: 0; text-align: left; vertical-align: top; width: 100%;"><tbody><tr style="padding: 0; text-align: left; vertical-align: top;"><td height="20px" style="-moz-hyphens: auto; -webkit-hyphens: auto; Margin: 0; border-collapse: collapse !important; color: #0a0a0a; font-family: Helvetica, Arial, sans-serif; font-size: 20px; font-weight: normal; hyphens: auto; line-height: 20px; margin: 0; mso-line-height-rule: exactly; padding: 0; text-align: left; vertical-align: top; word-wrap: break-word;">&#xA0;</td></tr></tbody></table> 
+		<p style="Margin: 0; Margin-bottom: 10px; color: #0a0a0a; font-family: Helvetica, Arial, sans-serif; font-size: 16px; font-weight: normal; line-height: 1.3; margin: 0; margin-bottom: 10px; padding: 0; text-align: left;">
+			<%= greeting %>
+		</p>
+		<p class="avoid-auto-linking" style="Margin: 0; Margin-bottom: 10px; color: #0a0a0a; font-family: Helvetica, Arial, sans-serif; font-size: 16px; font-weight: normal; line-height: 1.3; margin: 0; margin-bottom: 10px; padding: 0; text-align: left;">
+			<%= message %>
+		</p>
+		<table class="spacer" style="border-collapse: collapse; border-spacing: 0; padding: 0; text-align: left; vertical-align: top; width: 100%;"><tbody><tr style="padding: 0; text-align: left; vertical-align: top;"><td height="20px" style="-moz-hyphens: auto; -webkit-hyphens: auto; Margin: 0; border-collapse: collapse !important; color: #0a0a0a; font-family: Helvetica, Arial, sans-serif; font-size: 20px; font-weight: normal; hyphens: auto; line-height: 20px; margin: 0; mso-line-height-rule: exactly; padding: 0; text-align: left; vertical-align: top; word-wrap: break-word;">&#xA0;</td></tr></tbody></table> 
+		<center data-parsed="" style="min-width: 532px; width: 100%;">
+			<table class="button float-center" style="Margin: 0 0 16px 0; border-collapse: collapse; border-spacing: 0; float: none; margin: 0 0 16px 0; padding: 0; text-align: center; vertical-align: top; width: auto;"><tr style="padding: 0; text-align: left; vertical-align: top;"><td style="-moz-hyphens: auto; -webkit-hyphens: auto; Margin: 0; border-collapse: collapse !important; color: #0a0a0a; font-family: Helvetica, Arial, sans-serif; font-size: 16px; font-weight: normal; hyphens: auto; line-height: 1.3; margin: 0; padding: 0; text-align: left; vertical-align: top; word-wrap: break-word;"><table style="border-collapse: collapse; border-spacing: 0; padding: 0; text-align: left; vertical-align: top; width: 100%;"><tr style="padding: 0; text-align: left; vertical-align: top;"><td style="-moz-hyphens: auto; -webkit-hyphens: auto; Margin: 0; background: #a93529; border: 2px solid #a93529; border-collapse: collapse !important; color: #fefefe; font-family: Helvetica, Arial, sans-serif; font-size: 16px; font-weight: normal; hyphens: auto; line-height: 1.3; margin: 0; padding: 0; text-align: left; vertical-align: top; word-wrap: break-word;">
+				<a href="<%= ctaURL %>" style="Margin: 0; border: 0 solid #a93529; border-radius: 3px; color: #fefefe; display: inline-block; font-family: Helvetica, Arial, sans-serif; font-size: 16px; font-weight: bold; line-height: 1.3; margin: 0; padding: 8px 16px 8px 16px; text-align: left; text-decoration: none;">
+					<%= ctaText %>
+				</a>
+			</td></tr></table></td></tr></table>
+		</center>
+		<% if (secondaryMessage) { %>
+			<table class="spacer" style="border-collapse: collapse; border-spacing: 0; padding: 0; text-align: left; vertical-align: top; width: 100%;"><tbody><tr style="padding: 0; text-align: left; vertical-align: top;"><td height="20px" style="-moz-hyphens: auto; -webkit-hyphens: auto; Margin: 0; border-collapse: collapse !important; color: #0a0a0a; font-family: Helvetica, Arial, sans-serif; font-size: 20px; font-weight: normal; hyphens: auto; line-height: 20px; margin: 0; mso-line-height-rule: exactly; padding: 0; text-align: left; vertical-align: top; word-wrap: break-word;">&#xA0;</td></tr></tbody></table> 
+			<p class="avoid-auto-linking" style="Margin: 0; color: #0a0a0a; font-family: Helvetica, Arial, sans-serif; font-size: 16px; font-weight: normal; line-height: 1.3; margin: 0; padding: 0; text-align: left;">
+				<%= secondaryMessage %>
+			</p>
+		<% } %>
+	</th>
+	<th class="expander" style="Margin: 0; color: #0a0a0a; font-family: Helvetica, Arial, sans-serif; font-size: 16px; font-weight: normal; line-height: 1.3; margin: 0; padding: 0 !important; text-align: left; visibility: hidden; width: 0;"></th></tr></table></th>
+</tr></tbody></table>
+<% if (gmailGoToAction) { %>
+	<script type="application/ld+json">
+		{
+			"@context": "http://schema.org",
+			"@type": "EmailMessage",
+			"potentialAction": {
+				"@type": "ViewAction",
+				"target": "<%= gmailGoToAction.target %>",
+				"url": "<%= gmailGoToAction.target %>",
+				"name": "<%= gmailGoToAction.name %>"
+			},
+			"description": "<%= gmailGoToAction.description %>"
+		}
+	</script>
+<% } %>
+"""

+ 117 - 70
services/web/app/coffee/Features/Email/EmailBuilder.coffee

@@ -1,16 +1,30 @@
 _ = require('underscore')
 _ = require('underscore')
+
 PersonalEmailLayout = require("./Layouts/PersonalEmailLayout")
 PersonalEmailLayout = require("./Layouts/PersonalEmailLayout")
 NotificationEmailLayout = require("./Layouts/NotificationEmailLayout")
 NotificationEmailLayout = require("./Layouts/NotificationEmailLayout")
+BaseWithHeaderEmailLayout = require("./Layouts/BaseWithHeaderEmailLayout") 
+
+SingleCTAEmailBody = require("./Bodies/SingleCTAEmailBody")
+
+
 settings = require("settings-sharelatex")
 settings = require("settings-sharelatex")
 
 
 
 
 
 
 templates = {}
 templates = {}
 
 
+
 templates.registered =
 templates.registered =
 	subject:  _.template "Activate your #{settings.appName} Account"
 	subject:  _.template "Activate your #{settings.appName} Account"
 	layout: PersonalEmailLayout
 	layout: PersonalEmailLayout
 	type: "notification"
 	type: "notification"
+	plainTextTemplate: _.template """
+Congratulations, you've just had an account created for you on #{settings.appName} with the email address "<%= to %>".
+
+Click here to set your password and log in: <%= setNewPasswordUrl %>
+
+If you have any questions or problems, please contact #{settings.adminEmail}
+"""
 	compiledTemplate: _.template """
 	compiledTemplate: _.template """
 <p>Congratulations, you've just had an account created for you on #{settings.appName} with the email address "<%= to %>".</p>
 <p>Congratulations, you've just had an account created for you on #{settings.appName} with the email address "<%= to %>".</p>
 
 
@@ -19,14 +33,28 @@ templates.registered =
 <p>If you have any questions or problems, please contact <a href="mailto:#{settings.adminEmail}">#{settings.adminEmail}</a>.</p>
 <p>If you have any questions or problems, please contact <a href="mailto:#{settings.adminEmail}">#{settings.adminEmail}</a>.</p>
 """
 """
 
 
+
 templates.canceledSubscription =
 templates.canceledSubscription =
 	subject:  _.template "ShareLaTeX thoughts"
 	subject:  _.template "ShareLaTeX thoughts"
 	layout: PersonalEmailLayout
 	layout: PersonalEmailLayout
 	type:"lifecycle"
 	type:"lifecycle"
+	plainTextTemplate: _.template """
+Hi <%= first_name %>,
+
+I'm sorry to see you cancelled your ShareLaTeX premium account. Would you mind giving me some advice on what the site is lacking at the moment via this survey?:
+
+https://sharelatex.typeform.com/to/f5lBiZ
+
+Thank you in advance.
+
+Henry
+
+ShareLaTeX Co-founder
+"""
 	compiledTemplate: _.template '''
 	compiledTemplate: _.template '''
 <p>Hi <%= first_name %>,</p>
 <p>Hi <%= first_name %>,</p>
 
 
-<p>I'm sorry to see you cancelled your ShareLaTeX premium account. Would you mind giving me some advice on what the site is lacking at the moment via <a href="https://sharelatex.typeform.com/to/F7OzIY">this survey</a>?</p>
+<p>I'm sorry to see you cancelled your ShareLaTeX premium account. Would you mind giving me some advice on what the site is lacking at the moment via <a href="https://sharelatex.typeform.com/to/f5lBiZ">this survey</a>?</p>
 
 
 <p>Thank you in advance.</p>
 <p>Thank you in advance.</p>
 
 
@@ -36,94 +64,112 @@ ShareLaTeX Co-founder
 </p>
 </p>
 '''
 '''
 
 
+
 templates.passwordResetRequested =
 templates.passwordResetRequested =
 	subject:  _.template "Password Reset - #{settings.appName}"
 	subject:  _.template "Password Reset - #{settings.appName}"
-	layout: NotificationEmailLayout
+	layout: BaseWithHeaderEmailLayout
 	type:"notification"
 	type:"notification"
-	compiledTemplate: _.template """
-<h2>Password Reset</h2>
-<p>
+	plainTextTemplate: _.template """
+Password Reset
+
 We got a request to reset your #{settings.appName} password.
 We got a request to reset your #{settings.appName} password.
-<p>
-<center>
-	<div style="width:200px;background-color:#a93629;border:1px solid #e24b3b;border-radius:3px;padding:15px; margin:24px;">
-		<div style="padding-right:10px;padding-left:10px">
-			<a href="<%= setNewPasswordUrl %>" style="text-decoration:none" target="_blank">
-				<span style= "font-size:16px;font-family:Arial;font-weight:bold;color:#fff;white-space:nowrap;display:block; text-align:center">
-		  			Reset password
-				</span>
-			</a>
-		</div>
-	</div>
-</center>
+
+Click this link to reset your password: <%= setNewPasswordUrl %>
 
 
 If you ignore this message, your password won't be changed.
 If you ignore this message, your password won't be changed.
-<p>
+
 If you didn't request a password reset, let us know.
 If you didn't request a password reset, let us know.
 
 
-</p>
-<p>Thank you</p>
-<p> <a href="<%= siteUrl %>">#{settings.appName}</a></p>
-"""
+Thank you
 
 
-templates.projectSharedWithYou =
-	subject: _.template "<%= owner.email %> wants to share <%= project.name %> with you"
-	layout: NotificationEmailLayout
-	type:"notification"
-	compiledTemplate: _.template """
-<p>Hi, <%= owner.email %> wants to share <a href="<%= project.url %>">'<%= project.name %>'</a> with you</p>
-<center>
-	<div style="width:200px;background-color:#a93629;border:1px solid #e24b3b;border-radius:3px;padding:15px; margin:24px;">
-		<div style="padding-right:10px;padding-left:10px">
-			<a href="<%= project.url %>" style="text-decoration:none" target="_blank">
-				<span style= "font-size:16px;font-family:Helvetica,Arial;font-weight:400;color:#fff;white-space:nowrap;display:block; text-align:center">
-		  			View Project
-				</span>
-			</a>
-		</div>
-	</div>
-</center>
-<p> Thank you</p>
-<p> <a href="<%= siteUrl %>">#{settings.appName}</a></p>
+#{settings.appName} - <%= siteUrl %>
 """
 """
+	compiledTemplate: (opts) -> 
+		SingleCTAEmailBody({
+			title: "Password Reset"
+			greeting: "Hi,"
+			message: "We got a request to reset your #{settings.appName} password."
+			secondaryMessage: "If you ignore this message, your password won't be changed.<br>If you didn't request a password reset, let us know."
+			ctaText: "Reset password"
+			ctaURL: opts.setNewPasswordUrl
+			gmailGoToAction: null
+		})
+
 
 
 templates.projectInvite =
 templates.projectInvite =
-	subject: _.template "<%= project.name %> - shared by <%= owner.email %>"
-	layout: NotificationEmailLayout
+	subject: _.template "<%= project.name.slice(0, 40) %> - shared by <%= owner.email %>"
+	layout: BaseWithHeaderEmailLayout
 	type:"notification"
 	type:"notification"
-	compiledTemplate: _.template """
-<p>Hi, <%= owner.email %> wants to share <a href="<%= inviteUrl %>">'<%= project.name %>'</a> with you</p>
-<center>
-			<a style="text-decoration: none; width: 200px; background-color: #a93629; border: 1px solid #e24b3b; border-radius: 3px; padding: 15px; margin: 24px; display: block;" href="<%= inviteUrl %>" style="text-decoration:none" target="_blank">
-				<span style= "font-size:16px;font-family:Helvetica,Arial;font-weight:400;color:#fff;white-space:nowrap;display:block; text-align:center">
-					View Project
-				</span>
-			</a>
-</center>
-<p> Thank you</p>
-<p> <a href="<%= siteUrl %>">#{settings.appName}</a></p>
+	plainTextTemplate: _.template """
+Hi, <%= owner.email %> wants to share '<%= project.name %>' with you.
+
+Follow this link to view the project: <%= inviteUrl %>
+
+Thank you
+
+#{settings.appName} - <%= siteUrl %>
 """
 """
+	compiledTemplate: (opts) -> 
+		SingleCTAEmailBody({
+			title: "#{ opts.project.name.slice(0, 40) } &ndash; shared by #{ opts.owner.email }"
+			greeting: "Hi,"
+			message: "#{ opts.owner.email } wants to share &ldquo;#{ opts.project.name.slice(0, 40) }&rdquo; with you."
+			secondaryMessage: null
+			ctaText: "View project"
+			ctaURL: opts.inviteUrl
+			gmailGoToAction: 
+				target: opts.inviteUrl
+				name: "View project"
+				description: "Join #{ opts.project.name.slice(0, 40) } at ShareLaTeX"
+		})
 
 
 templates.completeJoinGroupAccount =
 templates.completeJoinGroupAccount =
 	subject: _.template "Verify Email to join <%= group_name %> group"
 	subject: _.template "Verify Email to join <%= group_name %> group"
-	layout: NotificationEmailLayout
+	layout: BaseWithHeaderEmailLayout
 	type:"notification"
 	type:"notification"
-	compiledTemplate: _.template """
-<p>Hi, please verify your email to join the <%= group_name %> and get your free premium account</p>
-<center>
-	<div style="width:200px;background-color:#a93629;border:1px solid #e24b3b;border-radius:3px;padding:15px; margin:24px;">
-		<div style="padding-right:10px;padding-left:10px">
-			<a href="<%= completeJoinUrl %>" style="text-decoration:none" target="_blank">
-				<span style= "font-size:16px;font-family:Helvetica,Arial;font-weight:400;color:#fff;white-space:nowrap;display:block; text-align:center">
-		  			Verify now
-				</span>
-			</a>
-		</div>
-	</div>
-</center>
-<p> Thank you</p>
-<p> <a href="<%= siteUrl %>">#{settings.appName}</a></p>
+	plainTextTemplate: _.template """
+Hi, please verify your email to join the <%= group_name %> and get your free premium account
+
+Click this link to verify now: <%= completeJoinUrl %>
+
+Thank You
+
+#{settings.appName} - <%= siteUrl %>
 """
 """
+	compiledTemplate: (opts) -> 
+		SingleCTAEmailBody({
+			title: "Verify Email to join #{ opts.group_name } group"
+			greeting: "Hi,"
+			message: "please verify your email to join the #{ opts.group_name } group and get your free premium account."
+			secondaryMessage: null
+			ctaText: "Verify now"
+			ctaURL: opts.completeJoinUrl
+			gmailGoToAction: null
+		})
+
+
+templates.testEmail =
+	subject: _.template "A Test Email from ShareLaTeX"
+	layout: BaseWithHeaderEmailLayout
+	type:"notification"
+	plainTextTemplate: _.template """
+Hi,
+
+This is a test email sent from ShareLaTeX.
+
+#{settings.appName} - <%= siteUrl %>
+"""
+	compiledTemplate: (opts) -> 
+		SingleCTAEmailBody({
+			title: "A Test Email from ShareLaTeX"
+			greeting: "Hi,"
+			message: "This is a test email sent from ShareLaTeX"
+			secondaryMessage: null
+			ctaText: "Open ShareLaTeX"
+			ctaURL: "/"
+			gmailGoToAction: null
+		})
+
 
 
 module.exports =
 module.exports =
 	templates: templates
 	templates: templates
@@ -137,5 +183,6 @@ module.exports =
 		return {
 		return {
 			subject : template.subject(opts)
 			subject : template.subject(opts)
 			html: template.layout(opts)
 			html: template.layout(opts)
+			text: template?.plainTextTemplate?(opts)
 			type:template.type
 			type:template.type
 		}
 		}

+ 3 - 2
services/web/app/coffee/Features/Email/EmailHandler.coffee

@@ -3,7 +3,7 @@ EmailBuilder = require "./EmailBuilder"
 EmailSender = require "./EmailSender"
 EmailSender = require "./EmailSender"
 
 
 if !settings.email?
 if !settings.email?
-	settings.email = 
+	settings.email =
 		lifecycleEnabled:false
 		lifecycleEnabled:false
 
 
 
 
@@ -14,6 +14,7 @@ module.exports =
 		if email.type == "lifecycle" and !settings.email.lifecycle
 		if email.type == "lifecycle" and !settings.email.lifecycle
 			return callback()
 			return callback()
 		opts.html = email.html
 		opts.html = email.html
+		opts.text = email.text
 		opts.subject = email.subject
 		opts.subject = email.subject
 		EmailSender.sendEmail opts, (err)->
 		EmailSender.sendEmail opts, (err)->
-			callback(err)
+			callback(err)

+ 33 - 15
services/web/app/coffee/Features/Email/EmailSender.coffee

@@ -4,7 +4,7 @@ Settings = require('settings-sharelatex')
 nodemailer = require("nodemailer")
 nodemailer = require("nodemailer")
 sesTransport = require('nodemailer-ses-transport')
 sesTransport = require('nodemailer-ses-transport')
 sgTransport = require('nodemailer-sendgrid-transport')
 sgTransport = require('nodemailer-sendgrid-transport')
-
+rateLimiter = require('../../infrastructure/RateLimiter')
 _ = require("underscore")
 _ = require("underscore")
 
 
 if Settings.email? and Settings.email.fromAddress?
 if Settings.email? and Settings.email.fromAddress?
@@ -25,7 +25,7 @@ else if Settings?.email?.parameters?.sendgridApiKey?
 	logger.log "using sendgrid for email"
 	logger.log "using sendgrid for email"
 	nm_client = nodemailer.createTransport(sgTransport({auth:{api_key:Settings?.email?.parameters?.sendgridApiKey}}))
 	nm_client = nodemailer.createTransport(sgTransport({auth:{api_key:Settings?.email?.parameters?.sendgridApiKey}}))
 else if Settings?.email?.parameters?
 else if Settings?.email?.parameters?
-	smtp = _.pick(Settings?.email?.parameters, "host", "port", "secure", "auth")
+	smtp = _.pick(Settings?.email?.parameters, "host", "port", "secure", "auth", "ignoreTLS")
 
 
 
 
 	logger.log "using smtp for email"
 	logger.log "using smtp for email"
@@ -39,21 +39,39 @@ if nm_client?
 else
 else
 	logger.warn "Failed to create email transport. Please check your settings. No email will be sent."
 	logger.warn "Failed to create email transport. Please check your settings. No email will be sent."
 
 
+checkCanSendEmail = (options, callback)->
+	if !options.sendingUser_id? #email not sent from user, not rate limited
+		return callback(null, true)
+	opts = 
+		endpointName: "send_email"
+		timeInterval: 60 * 60 * 3
+		subjectName: options.sendingUser_id
+		throttle: 100
+	rateLimiter.addCount opts, callback
 
 
 module.exports =
 module.exports =
 	sendEmail : (options, callback = (error) ->)->
 	sendEmail : (options, callback = (error) ->)->
 		logger.log receiver:options.to, subject:options.subject, "sending email"
 		logger.log receiver:options.to, subject:options.subject, "sending email"
-		metrics.inc "email"
-		options =
-			to: options.to
-			from: defaultFromAddress
-			subject: options.subject
-			html: options.html
-			replyTo: options.replyTo || Settings.email.replyToAddress
-			socketTimeout: 30 * 1000
-		client.sendMail options, (err, res)->
+		checkCanSendEmail options, (err, canContinue)->
 			if err?
 			if err?
-				logger.err err:err, "error sending message"
-			else
-				logger.log "Message sent to #{options.to}"
-			callback(err)
+				return callback(err)
+			if !canContinue
+				logger.log sendingUser_id:options.sendingUser_id, to:options.to, subject:options.subject, canContinue:canContinue, "rate limit hit for sending email, not sending"
+				return callback("rate limit hit sending email")
+			metrics.inc "email"
+			options =
+				to: options.to
+				from: defaultFromAddress
+				subject: options.subject
+				html: options.html
+				text: options.text
+				replyTo: options.replyTo || Settings.email.replyToAddress
+				socketTimeout: 30 * 1000
+			if Settings.email.textEncoding?
+				opts.textEncoding = textEncoding
+			client.sendMail options, (err, res)->
+				if err?
+					logger.err err:err, "error sending message"
+				else
+					logger.log "Message sent to #{options.to}"
+				callback(err)

+ 380 - 0
services/web/app/coffee/Features/Email/Layouts/BaseWithHeaderEmailLayout.coffee

@@ -0,0 +1,380 @@
+_ = require("underscore")
+settings = require "settings-sharelatex"
+
+module.exports = _.template """
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
+"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+
+<html xmlns="http://www.w3.org/1999/xhtml" lang="en" xml:lang="en" style="Margin: 0; background: #f6f6f6 !important; margin: 0; min-height: 100%; padding: 0;">
+	<head>
+		
+		
+		<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
+		<meta name="viewport" content="width=device-width">
+		<title>Project invite</title>
+		<style>.avoid-auto-linking a,
+.avoid-auto-linking a[href] {
+	color: #a93529 !important;
+	text-decoration: none !important;
+	-moz-hyphens: none;
+	-ms-hyphens: none;
+	-webkit-hyphens: none;
+	hyphens: none; }
+	.avoid-auto-linking a:visited,
+	.avoid-auto-linking a[href]:visited {
+		color: #a93529; }
+	.avoid-auto-linking a:hover,
+	.avoid-auto-linking a[href]:hover {
+		color: #80281f; }
+	.avoid-auto-linking a:active,
+	.avoid-auto-linking a[href]:active {
+		color: #80281f; }
+@media only screen {
+	html {
+		min-height: 100%;
+		background: #f6f6f6;
+	}
+}
+
+@media only screen and (max-width: 596px) {
+	.small-float-center {
+		margin: 0 auto !important;
+		float: none !important;
+		text-align: center !important;
+	}
+
+	.small-text-center {
+		text-align: center !important;
+	}
+
+	.small-text-left {
+		text-align: left !important;
+	}
+
+	.small-text-right {
+		text-align: right !important;
+	}
+}
+
+@media only screen and (max-width: 596px) {
+	.hide-for-large {
+		display: block !important;
+		width: auto !important;
+		overflow: visible !important;
+		max-height: none !important;
+		font-size: inherit !important;
+		line-height: inherit !important;
+	}
+}
+
+@media only screen and (max-width: 596px) {
+	table.body table.container .hide-for-large,
+	table.body table.container .row.hide-for-large {
+		display: table !important;
+		width: 100% !important;
+	}
+}
+
+@media only screen and (max-width: 596px) {
+	table.body table.container .callout-inner.hide-for-large {
+		display: table-cell !important;
+		width: 100% !important;
+	}
+}
+
+@media only screen and (max-width: 596px) {
+	table.body table.container .show-for-large {
+		display: none !important;
+		width: 0;
+		mso-hide: all;
+		overflow: hidden;
+	}
+}
+
+@media only screen and (max-width: 596px) {
+	table.body img {
+		width: auto;
+		height: auto;
+	}
+
+	table.body center {
+		min-width: 0 !important;
+	}
+
+	table.body .container {
+		width: 95% !important;
+	}
+
+	table.body .columns,
+	table.body .column {
+		height: auto !important;
+		-moz-box-sizing: border-box;
+		-webkit-box-sizing: border-box;
+		box-sizing: border-box;
+		padding-left: 16px !important;
+		padding-right: 16px !important;
+	}
+
+	table.body .columns .column,
+	table.body .columns .columns,
+	table.body .column .column,
+	table.body .column .columns {
+		padding-left: 0 !important;
+		padding-right: 0 !important;
+	}
+
+	table.body .collapse .columns,
+	table.body .collapse .column {
+		padding-left: 0 !important;
+		padding-right: 0 !important;
+	}
+
+	td.small-1,
+	th.small-1 {
+		display: inline-block !important;
+		width: 8.33333% !important;
+	}
+
+	td.small-2,
+	th.small-2 {
+		display: inline-block !important;
+		width: 16.66667% !important;
+	}
+
+	td.small-3,
+	th.small-3 {
+		display: inline-block !important;
+		width: 25% !important;
+	}
+
+	td.small-4,
+	th.small-4 {
+		display: inline-block !important;
+		width: 33.33333% !important;
+	}
+
+	td.small-5,
+	th.small-5 {
+		display: inline-block !important;
+		width: 41.66667% !important;
+	}
+
+	td.small-6,
+	th.small-6 {
+		display: inline-block !important;
+		width: 50% !important;
+	}
+
+	td.small-7,
+	th.small-7 {
+		display: inline-block !important;
+		width: 58.33333% !important;
+	}
+
+	td.small-8,
+	th.small-8 {
+		display: inline-block !important;
+		width: 66.66667% !important;
+	}
+
+	td.small-9,
+	th.small-9 {
+		display: inline-block !important;
+		width: 75% !important;
+	}
+
+	td.small-10,
+	th.small-10 {
+		display: inline-block !important;
+		width: 83.33333% !important;
+	}
+
+	td.small-11,
+	th.small-11 {
+		display: inline-block !important;
+		width: 91.66667% !important;
+	}
+
+	td.small-12,
+	th.small-12 {
+		display: inline-block !important;
+		width: 100% !important;
+	}
+
+	.columns td.small-12,
+	.column td.small-12,
+	.columns th.small-12,
+	.column th.small-12 {
+		display: block !important;
+		width: 100% !important;
+	}
+
+	table.body td.small-offset-1,
+	table.body th.small-offset-1 {
+		margin-left: 8.33333% !important;
+		Margin-left: 8.33333% !important;
+	}
+
+	table.body td.small-offset-2,
+	table.body th.small-offset-2 {
+		margin-left: 16.66667% !important;
+		Margin-left: 16.66667% !important;
+	}
+
+	table.body td.small-offset-3,
+	table.body th.small-offset-3 {
+		margin-left: 25% !important;
+		Margin-left: 25% !important;
+	}
+
+	table.body td.small-offset-4,
+	table.body th.small-offset-4 {
+		margin-left: 33.33333% !important;
+		Margin-left: 33.33333% !important;
+	}
+
+	table.body td.small-offset-5,
+	table.body th.small-offset-5 {
+		margin-left: 41.66667% !important;
+		Margin-left: 41.66667% !important;
+	}
+
+	table.body td.small-offset-6,
+	table.body th.small-offset-6 {
+		margin-left: 50% !important;
+		Margin-left: 50% !important;
+	}
+
+	table.body td.small-offset-7,
+	table.body th.small-offset-7 {
+		margin-left: 58.33333% !important;
+		Margin-left: 58.33333% !important;
+	}
+
+	table.body td.small-offset-8,
+	table.body th.small-offset-8 {
+		margin-left: 66.66667% !important;
+		Margin-left: 66.66667% !important;
+	}
+
+	table.body td.small-offset-9,
+	table.body th.small-offset-9 {
+		margin-left: 75% !important;
+		Margin-left: 75% !important;
+	}
+
+	table.body td.small-offset-10,
+	table.body th.small-offset-10 {
+		margin-left: 83.33333% !important;
+		Margin-left: 83.33333% !important;
+	}
+
+	table.body td.small-offset-11,
+	table.body th.small-offset-11 {
+		margin-left: 91.66667% !important;
+		Margin-left: 91.66667% !important;
+	}
+
+	table.body table.columns td.expander,
+	table.body table.columns th.expander {
+		display: none !important;
+	}
+
+	table.body .right-text-pad,
+	table.body .text-pad-right {
+		padding-left: 10px !important;
+	}
+
+	table.body .left-text-pad,
+	table.body .text-pad-left {
+		padding-right: 10px !important;
+	}
+
+	table.menu {
+		width: 100% !important;
+	}
+
+	table.menu td,
+	table.menu th {
+		width: auto !important;
+		display: inline-block !important;
+	}
+
+	table.menu.vertical td,
+	table.menu.vertical th,
+	table.menu.small-vertical td,
+	table.menu.small-vertical th {
+		display: block !important;
+	}
+
+	table.menu[align="center"] {
+		width: auto !important;
+	}
+
+	table.button.small-expand,
+	table.button.small-expanded {
+		width: 100% !important;
+	}
+
+	table.button.small-expand table,
+	table.button.small-expanded table {
+		width: 100%;
+	}
+
+	table.button.small-expand table a,
+	table.button.small-expanded table a {
+		text-align: center !important;
+		width: 100% !important;
+		padding-left: 0 !important;
+		padding-right: 0 !important;
+	}
+
+	table.button.small-expand center,
+	table.button.small-expanded center {
+		min-width: 0;
+	}
+}</style>
+	</head>
+	<body leftmargin="0" topmargin="0" marginwidth="0" marginheight="0" bgcolor="#F6F6F6" style="-moz-box-sizing: border-box; -ms-text-size-adjust: 100%; -webkit-box-sizing: border-box; -webkit-text-size-adjust: 100%; Margin: 0; background: #f6f6f6 !important; box-sizing: border-box; color: #0a0a0a; font-family: Helvetica, Arial, sans-serif; font-size: 16px; font-weight: normal; line-height: 1.3; margin: 0; min-height: 100%; min-width: 100%; padding: 0; text-align: left; width: 100% !important;">
+		<!-- <span class="preheader"></span> -->
+		<table class="body" border="0" cellspacing="0" cellpadding="0" width="100%" height="100%" style="Margin: 0; background: #f6f6f6 !important; border-collapse: collapse; border-spacing: 0; color: #0a0a0a; font-family: Helvetica, Arial, sans-serif; font-size: 16px; font-weight: normal; height: 100%; line-height: 1.3; margin: 0; min-height: 100%; padding: 0; text-align: left; vertical-align: top; width: 100%;">
+			<tr style="padding: 0; text-align: left; vertical-align: top;">
+				<td class="body-cell" align="center" valign="top" bgcolor="#F6F6F6" style="-moz-hyphens: auto; -webkit-hyphens: auto; Margin: 0; background: #f6f6f6 !important; border-collapse: collapse !important; color: #0a0a0a; font-family: Helvetica, Arial, sans-serif; font-size: 16px; font-weight: normal; hyphens: auto; line-height: 1.3; margin: 0; padding: 0; padding-bottom: 20px; text-align: left; vertical-align: top; word-wrap: break-word;">
+					<center data-parsed="" style="min-width: 580px; width: 100%;">
+						
+						<table align="center" class="wrapper header float-center" style="Margin: 0 auto; background: #fefefe; border-bottom: solid 1px #cfcfcf; border-collapse: collapse; border-spacing: 0; float: none; margin: 0 auto; padding: 0; text-align: center; vertical-align: top; width: 100%;"><tr style="padding: 0; text-align: left; vertical-align: top;"><td class="wrapper-inner" style="-moz-hyphens: auto; -webkit-hyphens: auto; Margin: 0; border-collapse: collapse !important; color: #0a0a0a; font-family: Helvetica, Arial, sans-serif; font-size: 16px; font-weight: normal; hyphens: auto; line-height: 1.3; margin: 0; padding: 20px; text-align: left; vertical-align: top; word-wrap: break-word;">
+							<table align="center" class="container" style="Margin: 0 auto; background: transparent; border-collapse: collapse; border-spacing: 0; margin: 0 auto; padding: 0; text-align: inherit; vertical-align: top; width: 580px;"><tbody><tr style="padding: 0; text-align: left; vertical-align: top;"><td style="-moz-hyphens: auto; -webkit-hyphens: auto; Margin: 0; border-collapse: collapse !important; color: #0a0a0a; font-family: Helvetica, Arial, sans-serif; font-size: 16px; font-weight: normal; hyphens: auto; line-height: 1.3; margin: 0; padding: 0; text-align: left; vertical-align: top; word-wrap: break-word;">
+							<table class="row collapse" style="border-collapse: collapse; border-spacing: 0; display: table; padding: 0; position: relative; text-align: left; vertical-align: top; width: 100%;"><tbody><tr style="padding: 0; text-align: left; vertical-align: top;">
+								<th class="small-12 large-12 columns first last" style="Margin: 0 auto; color: #0a0a0a; font-family: Helvetica, Arial, sans-serif; font-size: 16px; font-weight: normal; line-height: 1.3; margin: 0 auto; padding: 0; padding-bottom: 0; padding-left: 0; padding-right: 0; text-align: left; width: 588px;"><table style="border-collapse: collapse; border-spacing: 0; padding: 0; text-align: left; vertical-align: top; width: 100%;"><tr style="padding: 0; text-align: left; vertical-align: top;"><th style="Margin: 0; color: #0a0a0a; font-family: Helvetica, Arial, sans-serif; font-size: 16px; font-weight: normal; line-height: 1.3; margin: 0; padding: 0; text-align: left;">
+								<h1 class="sl-logotype" style="Margin: 0; Margin-bottom: 0; color: #333333; font-family: Baskerville, 'Baskerville Old Face', Georgia, serif; font-size: 26px; font-weight: normal; line-height: 1.3; margin: 0; margin-bottom: 0; padding: 0; text-align: left; word-wrap: normal;">
+									<span>S</span><span class="sl-logotype-small" style="font-size: 80%;">HARE</span><span>L</span><span class="sl-logotype-small" style="font-size: 80%;">A</span><span>T</span><span class="sl-logotype-small" style="font-size: 80%;">E</span><span>X</span>
+								</h1>
+								</th>
+								<th class="expander" style="Margin: 0; color: #0a0a0a; font-family: Helvetica, Arial, sans-serif; font-size: 16px; font-weight: normal; line-height: 1.3; margin: 0; padding: 0 !important; text-align: left; visibility: hidden; width: 0;"></th></tr></table></th>
+							</tr></tbody></table>
+							</td></tr></tbody></table>
+						</td></tr></table>
+						<table class="spacer float-center" style="Margin: 0 auto; border-collapse: collapse; border-spacing: 0; float: none; margin: 0 auto; padding: 0; text-align: center; vertical-align: top; width: 100%;"><tbody><tr style="padding: 0; text-align: left; vertical-align: top;"><td height="20px" style="-moz-hyphens: auto; -webkit-hyphens: auto; Margin: 0; border-collapse: collapse !important; color: #0a0a0a; font-family: Helvetica, Arial, sans-serif; font-size: 20px; font-weight: normal; hyphens: auto; line-height: 20px; margin: 0; mso-line-height-rule: exactly; padding: 0; text-align: left; vertical-align: top; word-wrap: break-word;">&#xA0;</td></tr></tbody></table> 
+						<table align="center" class="container main float-center" style="Margin: 0 auto; Margin-top: 10px; background: #fefefe; border-collapse: collapse; border-spacing: 0; float: none; margin: 0 auto; margin-top: 10px; padding: 0; text-align: center; vertical-align: top; width: 580px;"><tbody><tr style="padding: 0; text-align: left; vertical-align: top;"><td style="-moz-hyphens: auto; -webkit-hyphens: auto; Margin: 0; border-collapse: collapse !important; color: #0a0a0a; font-family: Helvetica, Arial, sans-serif; font-size: 16px; font-weight: normal; hyphens: auto; line-height: 1.3; margin: 0; padding: 0; text-align: left; vertical-align: top; word-wrap: break-word;">
+							<table class="spacer" style="border-collapse: collapse; border-spacing: 0; padding: 0; text-align: left; vertical-align: top; width: 100%;"><tbody><tr style="padding: 0; text-align: left; vertical-align: top;"><td height="20px" style="-moz-hyphens: auto; -webkit-hyphens: auto; Margin: 0; border-collapse: collapse !important; color: #0a0a0a; font-family: Helvetica, Arial, sans-serif; font-size: 20px; font-weight: normal; hyphens: auto; line-height: 20px; margin: 0; mso-line-height-rule: exactly; padding: 0; text-align: left; vertical-align: top; word-wrap: break-word;">&#xA0;</td></tr></tbody></table> 
+							
+							<%= body %>
+							
+							<table class="wrapper secondary" align="center" style="background: #f6f6f6; border-collapse: collapse; border-spacing: 0; padding: 0; text-align: left; vertical-align: top; width: 100%;"><tr style="padding: 0; text-align: left; vertical-align: top;"><td class="wrapper-inner" style="-moz-hyphens: auto; -webkit-hyphens: auto; Margin: 0; border-collapse: collapse !important; color: #0a0a0a; font-family: Helvetica, Arial, sans-serif; font-size: 16px; font-weight: normal; hyphens: auto; line-height: 1.3; margin: 0; padding: 0; text-align: left; vertical-align: top; word-wrap: break-word;">
+								<table class="spacer" style="border-collapse: collapse; border-spacing: 0; padding: 0; text-align: left; vertical-align: top; width: 100%;"><tbody><tr style="padding: 0; text-align: left; vertical-align: top;"><td height="10px" style="-moz-hyphens: auto; -webkit-hyphens: auto; Margin: 0; border-collapse: collapse !important; color: #0a0a0a; font-family: Helvetica, Arial, sans-serif; font-size: 10px; font-weight: normal; hyphens: auto; line-height: 10px; margin: 0; mso-line-height-rule: exactly; padding: 0; text-align: left; vertical-align: top; word-wrap: break-word;">&#xA0;</td></tr></tbody></table> 
+								<p style="Margin: 0; Margin-bottom: 10px; color: #0a0a0a; font-family: Helvetica, Arial, sans-serif; font-size: 16px; font-weight: normal; line-height: 1.3; margin: 0; margin-bottom: 10px; padding: 0; text-align: left;"><small style="color: #7a7a7a; font-size: 80%;">
+									#{ settings.appName} &bull; <a href="#{ settings.siteUrl }" style="Margin: 0; color: #a93529; font-family: Helvetica, Arial, sans-serif; font-weight: normal; line-height: 1.3; margin: 0; padding: 0; text-align: left; text-decoration: none;">#{ settings.siteUrl }</a>
+								</small></p>
+							</td></tr></table>
+						</td></tr></tbody></table>
+						
+					</center>
+				</td>
+			</tr>
+		</table>
+		<!-- prevent Gmail on iOS font size manipulation -->
+	 <div style="display:none; white-space:nowrap; font:15px courier; line-height:0;"> &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </div>
+	</body>
+</html>
+
+"""

+ 6 - 4
services/web/app/coffee/Features/Errors/ErrorController.coffee

@@ -1,5 +1,6 @@
 Errors = require "./Errors"
 Errors = require "./Errors"
 logger = require "logger-sharelatex"
 logger = require "logger-sharelatex"
+AuthenticationController = require '../Authentication/AuthenticationController'
 
 
 module.exports = ErrorController =
 module.exports = ErrorController =
 	notFound: (req, res)->
 	notFound: (req, res)->
@@ -11,15 +12,16 @@ module.exports = ErrorController =
 		res.status(500)
 		res.status(500)
 		res.render 'general/500',
 		res.render 'general/500',
 			title: "Server Error"
 			title: "Server Error"
-	
+
 	handleError: (error, req, res, next) ->
 	handleError: (error, req, res, next) ->
+		user = AuthenticationController.getSessionUser(req)
 		if error?.code is 'EBADCSRFTOKEN'
 		if error?.code is 'EBADCSRFTOKEN'
-			logger.warn err: error,url:req.url, method:req.method, user:req?.sesson?.user, "invalid csrf"
+			logger.warn err: error,url:req.url, method:req.method, user:user, "invalid csrf"
 			res.sendStatus(403)
 			res.sendStatus(403)
 			return
 			return
 		if error instanceof Errors.NotFoundError
 		if error instanceof Errors.NotFoundError
 			logger.warn {err: error, url: req.url}, "not found error"
 			logger.warn {err: error, url: req.url}, "not found error"
 			ErrorController.notFound req, res
 			ErrorController.notFound req, res
 		else
 		else
-			logger.error err: error, url:req.url, method:req.method, user:req?.sesson?.user, "error passed to top level next middlewear"
-			ErrorController.serverError req, res
+			logger.error err: error, url:req.url, method:req.method, user:user, "error passed to top level next middlewear"
+			ErrorController.serverError req, res

+ 3 - 2
services/web/app/coffee/Features/HealthCheck/HealthCheckController.coffee

@@ -63,7 +63,8 @@ Reporter = (res) ->
 
 
 			res.contentType("application/json")
 			res.contentType("application/json")
 			if failures.length > 0
 			if failures.length > 0
-				res.send 500, JSON.stringify(results, null, 2)
+				logger.err failures:failures, "health check failed"
+				res.status(500).send(JSON.stringify(results, null, 2))
 			else
 			else
-				res.send 200, JSON.stringify(results, null, 2)
+				res.status(200).send(JSON.stringify(results, null, 2))
 
 

+ 20 - 0
services/web/app/coffee/Features/History/HistoryController.coffee

@@ -0,0 +1,20 @@
+logger = require "logger-sharelatex"
+request = require "request"
+settings = require "settings-sharelatex"
+AuthenticationController = require "../Authentication/AuthenticationController"
+
+module.exports = HistoryController =
+	proxyToHistoryApi: (req, res, next = (error) ->) ->
+		user_id = AuthenticationController.getLoggedInUserId req
+		url = settings.apis.trackchanges.url + req.url
+		logger.log url: url, "proxying to track-changes api"
+		getReq = request(
+			url: url
+			method: req.method
+			headers:
+				"X-User-Id": user_id
+		)
+		getReq.pipe(res)
+		getReq.on "error", (error) ->
+			logger.error err: error, "track-changes API error"
+			next(error)

+ 28 - 0
services/web/app/coffee/Features/History/HistoryManager.coffee

@@ -0,0 +1,28 @@
+settings = require "settings-sharelatex"
+request = require "request"
+logger = require "logger-sharelatex"
+
+module.exports = HistoryManager =
+	flushProject: (project_id, callback = (error) ->) ->
+		logger.log project_id: project_id, "flushing project in track-changes api"
+		url = "#{settings.apis.trackchanges.url}/project/#{project_id}/flush"
+		request.post url, (error, res, body) ->
+			return callback(error) if error?
+			if 200 <= res.statusCode < 300
+				callback(null)
+			else
+				error = new Error("track-changes api responded with non-success code: #{res.statusCode} #{url}")
+				logger.error err: error, project_id: project_id, "error flushing project in track-changes api"
+				callback(error)
+
+	archiveProject: (project_id, callback = ()->)->
+		logger.log project_id: project_id, "archving project in track-changes api"
+		url = "#{settings.apis.trackchanges.url}/project/#{project_id}/archive"
+		request.post url, (error, res, body) ->
+			return callback(error) if error?
+			if 200 <= res.statusCode < 300
+				callback(null)
+			else
+				error = new Error("track-changes api responded with non-success code: #{res.statusCode} #{url}")
+				logger.error err: error, project_id: project_id, "error archving project in track-changes api"
+				callback(error)

+ 0 - 3
services/web/app/coffee/Features/InactiveData/InactiveProjectManager.coffee

@@ -5,8 +5,6 @@ DocstoreManager = require("../Docstore/DocstoreManager")
 ProjectGetter = require("../Project/ProjectGetter")
 ProjectGetter = require("../Project/ProjectGetter")
 ProjectUpdateHandler = require("../Project/ProjectUpdateHandler")
 ProjectUpdateHandler = require("../Project/ProjectUpdateHandler")
 Project = require("../../models/Project").Project
 Project = require("../../models/Project").Project
-TrackChangesManager = require("../TrackChanges/TrackChangesManager")
-
 
 
 MILISECONDS_IN_DAY = 86400000
 MILISECONDS_IN_DAY = 86400000
 module.exports = InactiveProjectManager =
 module.exports = InactiveProjectManager =
@@ -52,7 +50,6 @@ module.exports = InactiveProjectManager =
 		logger.log project_id:project_id, "deactivating inactive project"
 		logger.log project_id:project_id, "deactivating inactive project"
 		jobs = [
 		jobs = [
 			(cb)-> DocstoreManager.archiveProject project_id, cb
 			(cb)-> DocstoreManager.archiveProject project_id, cb
-			# (cb)-> TrackChangesManager.archiveProject project_id, cb
 			(cb)-> ProjectUpdateHandler.markAsInactive project_id, cb
 			(cb)-> ProjectUpdateHandler.markAsInactive project_id, cb
 		]
 		]
 		async.series jobs, (err)->
 		async.series jobs, (err)->

+ 3 - 3
services/web/app/coffee/Features/Notifications/NotificationsBuilder.coffee

@@ -11,8 +11,8 @@ module.exports =
 			messageOpts =
 			messageOpts =
 				groupName: licence.name
 				groupName: licence.name
 				subscription_id: licence.subscription_id
 				subscription_id: licence.subscription_id
-			logger.log user_id:user._id, key:key, "creating notification key for user"
-			NotificationsHandler.createNotification user._id, @key, "notification_group_invite", messageOpts, null, false, callback
+			logger.log user_id:user._id, key:@key, "creating notification key for user"
+			NotificationsHandler.createNotification user._id, @key, "notification_group_invite", messageOpts, null, callback
 
 
 		read: (callback = ->)->
 		read: (callback = ->)->
 			NotificationsHandler.markAsReadWithKey user._id, @key, callback
 			NotificationsHandler.markAsReadWithKey user._id, @key, callback
@@ -26,6 +26,6 @@ module.exports =
 				projectId: project._id.toString()
 				projectId: project._id.toString()
 				token: invite.token
 				token: invite.token
 			logger.log {user_id: user._id, project_id: project._id, invite_id: invite._id, key: @key}, "creating project invite notification for user"
 			logger.log {user_id: user._id, project_id: project._id, invite_id: invite._id, key: @key}, "creating project invite notification for user"
-			NotificationsHandler.createNotification user._id, @key, "notification_project_invite", messageOpts, invite.expires, true, callback
+			NotificationsHandler.createNotification user._id, @key, "notification_project_invite", messageOpts, invite.expires, callback
 		read:  (callback=()->) ->
 		read:  (callback=()->) ->
 			NotificationsHandler.markAsReadByKeyOnly @key, callback
 			NotificationsHandler.markAsReadByKeyOnly @key, callback

+ 5 - 3
services/web/app/coffee/Features/Notifications/NotificationsController.coffee

@@ -1,18 +1,20 @@
 NotificationsHandler = require("./NotificationsHandler")
 NotificationsHandler = require("./NotificationsHandler")
+AuthenticationController = require("../Authentication/AuthenticationController")
 logger = require("logger-sharelatex")
 logger = require("logger-sharelatex")
 _ = require("underscore")
 _ = require("underscore")
 
 
 module.exports =
 module.exports =
 
 
 	getAllUnreadNotifications: (req, res)->
 	getAllUnreadNotifications: (req, res)->
-		NotificationsHandler.getUserNotifications req.session.user._id, (err, unreadNotifications)->
-			unreadNotifications = _.map unreadNotifications, (notification)-> 
+		user_id = AuthenticationController.getLoggedInUserId(req)
+		NotificationsHandler.getUserNotifications user_id, (err, unreadNotifications)->
+			unreadNotifications = _.map unreadNotifications, (notification)->
 				notification.html = req.i18n.translate(notification.templateKey, notification.messageOpts)
 				notification.html = req.i18n.translate(notification.templateKey, notification.messageOpts)
 				return notification
 				return notification
 			res.send(unreadNotifications)
 			res.send(unreadNotifications)
 
 
 	markNotificationAsRead: (req, res)->
 	markNotificationAsRead: (req, res)->
-		user_id = req.session.user._id
+		user_id = AuthenticationController.getLoggedInUserId(req)
 		notification_id = req.params.notification_id
 		notification_id = req.params.notification_id
 		NotificationsHandler.markAsRead user_id, notification_id, ->
 		NotificationsHandler.markAsRead user_id, notification_id, ->
 			res.send()
 			res.send()

+ 2 - 3
services/web/app/coffee/Features/Notifications/NotificationsHandler.coffee

@@ -29,16 +29,15 @@ module.exports =
 					unreadNotifications = []
 					unreadNotifications = []
 				callback(null, unreadNotifications)
 				callback(null, unreadNotifications)
 
 
-	createNotification: (user_id, key, templateKey, messageOpts, expiryDateTime, forceCreate, callback)->
+	createNotification: (user_id, key, templateKey, messageOpts, expiryDateTime, callback)->
 		payload = {
 		payload = {
 				key:key
 				key:key
 				messageOpts:messageOpts
 				messageOpts:messageOpts
 				templateKey:templateKey
 				templateKey:templateKey
+				forceCreate: true
 		}
 		}
 		if expiryDateTime?
 		if expiryDateTime?
 			payload.expires = expiryDateTime
 			payload.expires = expiryDateTime
-		if forceCreate
-			payload.forceCreate = true
 		opts =
 		opts =
 			uri: "#{settings.apis.notifications?.url}/user/#{user_id}"
 			uri: "#{settings.apis.notifications?.url}/user/#{user_id}"
 			timeout: oneSecond
 			timeout: oneSecond

+ 29 - 20
services/web/app/coffee/Features/Project/ProjectController.coffee

@@ -18,6 +18,8 @@ InactiveProjectManager = require("../InactiveData/InactiveProjectManager")
 ProjectUpdateHandler = require("./ProjectUpdateHandler")
 ProjectUpdateHandler = require("./ProjectUpdateHandler")
 ProjectGetter = require("./ProjectGetter")
 ProjectGetter = require("./ProjectGetter")
 PrivilegeLevels = require("../Authorization/PrivilegeLevels")
 PrivilegeLevels = require("../Authorization/PrivilegeLevels")
+AuthenticationController = require("../Authentication/AuthenticationController")
+PackageVersions = require("../../infrastructure/PackageVersions")
 
 
 module.exports = ProjectController =
 module.exports = ProjectController =
 
 
@@ -45,10 +47,10 @@ module.exports = ProjectController =
 		async.series jobs, (error) ->
 		async.series jobs, (error) ->
 			return next(error) if error?
 			return next(error) if error?
 			res.sendStatus(204)
 			res.sendStatus(204)
-			
+
 	updateProjectAdminSettings: (req, res, next) ->
 	updateProjectAdminSettings: (req, res, next) ->
 		project_id = req.params.Project_id
 		project_id = req.params.Project_id
-		
+
 		jobs = []
 		jobs = []
 		if req.body.publicAccessLevel?
 		if req.body.publicAccessLevel?
 			jobs.push (callback) ->
 			jobs.push (callback) ->
@@ -88,32 +90,33 @@ module.exports = ProjectController =
 		project_id = req.params.Project_id
 		project_id = req.params.Project_id
 		projectName = req.body.projectName
 		projectName = req.body.projectName
 		logger.log project_id:project_id, projectName:projectName, "cloning project"
 		logger.log project_id:project_id, projectName:projectName, "cloning project"
-		if !req.session.user?
+		if !AuthenticationController.isUserLoggedIn(req)
 			return res.send redir:"/register"
 			return res.send redir:"/register"
-		projectDuplicator.duplicate req.session.user, project_id, projectName, (err, project)->
+		currentUser = AuthenticationController.getSessionUser(req)
+		projectDuplicator.duplicate currentUser, project_id, projectName, (err, project)->
 			if err?
 			if err?
-				logger.error err:err, project_id: project_id, user_id: req.session.user._id, "error cloning project"
+				logger.error err:err, project_id: project_id, user_id: currentUser._id, "error cloning project"
 				return next(err)
 				return next(err)
 			res.send(project_id:project._id)
 			res.send(project_id:project._id)
 
 
 
 
 	newProject: (req, res)->
 	newProject: (req, res)->
-		user = req.session.user
+		user_id = AuthenticationController.getLoggedInUserId(req)
 		projectName = req.body.projectName?.trim()
 		projectName = req.body.projectName?.trim()
 		template = req.body.template
 		template = req.body.template
-		logger.log user: user, projectType: template, name: projectName, "creating project"
+		logger.log user: user_id, projectType: template, name: projectName, "creating project"
 		async.waterfall [
 		async.waterfall [
 			(cb)->
 			(cb)->
 				if template == 'example'
 				if template == 'example'
-					projectCreationHandler.createExampleProject user._id, projectName, cb
+					projectCreationHandler.createExampleProject user_id, projectName, cb
 				else
 				else
-					projectCreationHandler.createBasicProject user._id, projectName, cb
+					projectCreationHandler.createBasicProject user_id, projectName, cb
 		], (err, project)->
 		], (err, project)->
 			if err?
 			if err?
-				logger.error err: err, project: project, user: user, name: projectName, templateType: template, "error creating project"
+				logger.error err: err, project: project, user: user_id, name: projectName, templateType: template, "error creating project"
 				res.sendStatus 500
 				res.sendStatus 500
 			else
 			else
-				logger.log project: project, user: user, name: projectName, templateType: template, "created project"
+				logger.log project: project, user: user_id, name: projectName, templateType: template, "created project"
 				res.send {project_id:project._id}
 				res.send {project_id:project._id}
 
 
 
 
@@ -131,7 +134,8 @@ module.exports = ProjectController =
 
 
 	projectListPage: (req, res, next)->
 	projectListPage: (req, res, next)->
 		timer = new metrics.Timer("project-list")
 		timer = new metrics.Timer("project-list")
-		user_id = req.session.user._id
+		user_id = AuthenticationController.getLoggedInUserId(req)
+		currentUser = AuthenticationController.getSessionUser(req)
 		async.parallel {
 		async.parallel {
 			tags: (cb)->
 			tags: (cb)->
 				TagsHandler.getAllTags user_id, cb
 				TagsHandler.getAllTags user_id, cb
@@ -140,7 +144,7 @@ module.exports = ProjectController =
 			projects: (cb)->
 			projects: (cb)->
 				ProjectGetter.findAllUsersProjects user_id, 'name lastUpdated publicAccesLevel archived owner_ref', cb
 				ProjectGetter.findAllUsersProjects user_id, 'name lastUpdated publicAccesLevel archived owner_ref', cb
 			hasSubscription: (cb)->
 			hasSubscription: (cb)->
-				LimitationsManager.userHasSubscriptionOrIsGroupMember req.session.user, cb
+				LimitationsManager.userHasSubscriptionOrIsGroupMember currentUser, cb
 			user: (cb) ->
 			user: (cb) ->
 				User.findById user_id, "featureSwitches", cb
 				User.findById user_id, "featureSwitches", cb
 			}, (err, results)->
 			}, (err, results)->
@@ -149,7 +153,9 @@ module.exports = ProjectController =
 					return next(err)
 					return next(err)
 				logger.log results:results, user_id:user_id, "rendering project list"
 				logger.log results:results, user_id:user_id, "rendering project list"
 				tags = results.tags[0]
 				tags = results.tags[0]
-				notifications = require("underscore").map results.notifications, (notification)-> 
+				
+
+				notifications = require("underscore").map results.notifications, (notification)->
 					notification.html = req.i18n.translate(notification.templateKey, notification.messageOpts)
 					notification.html = req.i18n.translate(notification.templateKey, notification.messageOpts)
 					return notification
 					return notification
 				projects = ProjectController._buildProjectList results.projects[0], results.projects[1], results.projects[2]
 				projects = ProjectController._buildProjectList results.projects[0], results.projects[1], results.projects[2]
@@ -183,19 +189,19 @@ module.exports = ProjectController =
 		if !Settings.editorIsOpen
 		if !Settings.editorIsOpen
 			return res.render("general/closed", {title:"updating_site"})
 			return res.render("general/closed", {title:"updating_site"})
 
 
-		if req.session.user?
-			user_id = req.session.user._id
+		if AuthenticationController.isUserLoggedIn(req)
+			user_id = AuthenticationController.getLoggedInUserId(req)
 			anonymous = false
 			anonymous = false
 		else
 		else
 			anonymous = true
 			anonymous = true
 			user_id = null
 			user_id = null
 
 
 		project_id = req.params.Project_id
 		project_id = req.params.Project_id
-		logger.log project_id:project_id, "loading editor"
+		logger.log project_id:project_id, anonymous:anonymous, user_id:user_id, "loading editor"
 
 
 		async.parallel {
 		async.parallel {
 			project: (cb)->
 			project: (cb)->
-				ProjectGetter.getProject project_id, { name: 1, lastUpdated: 1}, cb
+				ProjectGetter.getProject project_id, { name: 1, lastUpdated: 1, track_changes: 1 }, cb
 			user: (cb)->
 			user: (cb)->
 				if !user_id?
 				if !user_id?
 					cb null, defaultSettingsForAnonymousUser(user_id)
 					cb null, defaultSettingsForAnonymousUser(user_id)
@@ -259,7 +265,9 @@ module.exports = ProjectController =
 						fontSize : user.ace.fontSize
 						fontSize : user.ace.fontSize
 						autoComplete: user.ace.autoComplete
 						autoComplete: user.ace.autoComplete
 						pdfViewer : user.ace.pdfViewer
 						pdfViewer : user.ace.pdfViewer
+						syntaxValidation: user.ace.syntaxValidation
 					}
 					}
+					trackChangesEnabled: !!project.track_changes
 					privilegeLevel: privilegeLevel
 					privilegeLevel: privilegeLevel
 					chatUrl: Settings.apis.chat.url
 					chatUrl: Settings.apis.chat.url
 					anonymous: anonymous
 					anonymous: anonymous
@@ -320,6 +328,7 @@ defaultSettingsForAnonymousUser = (user_id)->
 		autoComplete: true
 		autoComplete: true
 		spellCheckLanguage: ""
 		spellCheckLanguage: ""
 		pdfViewer: ""
 		pdfViewer: ""
+		syntaxValidation: true
 	subscription:
 	subscription:
 		freeTrial:
 		freeTrial:
 			allowed: true
 			allowed: true
@@ -328,8 +337,8 @@ defaultSettingsForAnonymousUser = (user_id)->
 
 
 THEME_LIST = []
 THEME_LIST = []
 do generateThemeList = () ->
 do generateThemeList = () ->
-	files = fs.readdirSync __dirname + '/../../../../public/js/ace'
+	files = fs.readdirSync __dirname + '/../../../../public/js/' + PackageVersions.lib('ace')
 	for file in files
 	for file in files
 		if file.slice(-2) == "js" and file.match(/^theme-/)
 		if file.slice(-2) == "js" and file.match(/^theme-/)
 			cleanName = file.slice(0,-3).slice(6)
 			cleanName = file.slice(0,-3).slice(6)
-			THEME_LIST.push cleanName
+			THEME_LIST.push cleanName

+ 6 - 0
services/web/app/coffee/Features/Project/ProjectEditorHandler.coffee

@@ -19,6 +19,11 @@ module.exports = ProjectEditorHandler =
 
 
 		if !result.invites?
 		if !result.invites?
 			result.invites = []
 			result.invites = []
+			
+		hasTrackChanges = false
+		for member in members
+			if member.privilegeLevel == "owner" and member.user?.featureSwitches?.track_changes
+				hasTrackChanges = true
 
 
 		{owner, ownerFeatures, members} = @buildOwnerAndMembersViews(members)
 		{owner, ownerFeatures, members} = @buildOwnerAndMembersViews(members)
 		result.owner = owner
 		result.owner = owner
@@ -32,6 +37,7 @@ module.exports = ProjectEditorHandler =
 			compileGroup:"standard"
 			compileGroup:"standard"
 			templates: false
 			templates: false
 			references: false
 			references: false
+			trackChanges: hasTrackChanges
 		})
 		})
 
 
 		return result
 		return result

+ 3 - 3
services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee

@@ -126,7 +126,7 @@ module.exports = ProjectEntityHandler =
 			doc = new Doc name: docName
 			doc = new Doc name: docName
 			# Put doc in docstore first, so that if it errors, we don't have a doc_id in the project
 			# Put doc in docstore first, so that if it errors, we don't have a doc_id in the project
 			# which hasn't been created in docstore.
 			# which hasn't been created in docstore.
-			DocstoreManager.updateDoc project_id.toString(), doc._id.toString(), docLines, (err, modified, rev) ->
+			DocstoreManager.updateDoc project_id.toString(), doc._id.toString(), docLines, 0, {}, (err, modified, rev) ->
 				return callback(err) if err? 
 				return callback(err) if err? 
 
 
 				ProjectEntityHandler._putElement project, folder_id, doc, "doc", (err, result)=>
 				ProjectEntityHandler._putElement project, folder_id, doc, "doc", (err, result)=>
@@ -292,7 +292,7 @@ module.exports = ProjectEntityHandler =
 					return callback(err)
 					return callback(err)
 				callback(err, folder, parentFolder_id)
 				callback(err, folder, parentFolder_id)
 
 
-	updateDocLines : (project_id, doc_id, lines, callback = (error) ->)->
+	updateDocLines : (project_id, doc_id, lines, version, ranges, callback = (error) ->)->
 		ProjectGetter.getProjectWithoutDocLines project_id, (err, project)->
 		ProjectGetter.getProjectWithoutDocLines project_id, (err, project)->
 			return callback(err) if err?
 			return callback(err) if err?
 			return callback(new Errors.NotFoundError("project not found")) if !project?
 			return callback(new Errors.NotFoundError("project not found")) if !project?
@@ -307,7 +307,7 @@ module.exports = ProjectEntityHandler =
 					return callback(error)
 					return callback(error)
 
 
 				logger.log project_id: project_id, doc_id: doc_id, "telling docstore manager to update doc"
 				logger.log project_id: project_id, doc_id: doc_id, "telling docstore manager to update doc"
-				DocstoreManager.updateDoc project_id, doc_id, lines, (err, modified, rev) ->
+				DocstoreManager.updateDoc project_id, doc_id, lines, version, ranges, (err, modified, rev) ->
 					if err?
 					if err?
 						logger.error err: err, doc_id: doc_id, project_id:project_id, lines: lines, "error sending doc to docstore"
 						logger.error err: err, doc_id: doc_id, project_id:project_id, lines: lines, "error sending doc to docstore"
 						return callback(err)
 						return callback(err)

+ 17 - 7
services/web/app/coffee/Features/Project/ProjectLocator.coffee

@@ -81,7 +81,7 @@ module.exports = ProjectLocator =
 				else
 				else
 					getRootDoc project
 					getRootDoc project
 
 
-	findElementByPath: (project_or_id, needlePath, callback = (err, foundEntity)->)->
+	findElementByPath: (project_or_id, needlePath, callback = (err, foundEntity, type)->)->
 
 
 		getParentFolder = (haystackFolder, foldersList, level, cb)->
 		getParentFolder = (haystackFolder, foldersList, level, cb)->
 			if foldersList.length == 0
 			if foldersList.length == 0
@@ -100,12 +100,22 @@ module.exports = ProjectLocator =
 
 
 		getEntity = (folder, entityName, cb)->
 		getEntity = (folder, entityName, cb)->
 			if !entityName?
 			if !entityName?
-				return cb null, folder
-			enteties = _.union folder.fileRefs, folder.docs, folder.folders
-			result = _.find enteties, (entity)->
-				entity?.name.toLowerCase() == entityName.toLowerCase()
+				return cb null, folder, "folder"
+			for file in folder.fileRefs or []
+				if file?.name.toLowerCase() == entityName.toLowerCase()
+					result = file
+					type = "file"
+			for doc in folder.docs or []
+				if doc?.name.toLowerCase() == entityName.toLowerCase()
+					result = doc
+					type = "doc"
+			for childFolder in folder.folders or []
+				if childFolder?.name.toLowerCase() == entityName.toLowerCase()
+					result = childFolder
+					type = "folder"
+
 			if result?
 			if result?
-				cb null, result
+				cb null, result, type
 			else
 			else
 				cb("not found project_or_id: #{project_or_id} search path: #{needlePath}, entity #{entityName} could not be found")
 				cb("not found project_or_id: #{project_or_id} search path: #{needlePath}, entity #{entityName} could not be found")
 
 
@@ -117,7 +127,7 @@ module.exports = ProjectLocator =
 			if !project?
 			if !project?
 				return callback("project could not be found for finding a element #{project_or_id}")
 				return callback("project could not be found for finding a element #{project_or_id}")
 			if needlePath == '' || needlePath == '/'
 			if needlePath == '' || needlePath == '/'
-				return callback(null, project.rootFolder[0])
+				return callback(null, project.rootFolder[0], "folder")
 
 
 			if needlePath.indexOf('/') == 0
 			if needlePath.indexOf('/') == 0
 				needlePath = needlePath.substring(1)
 				needlePath = needlePath.substring(1)

+ 4 - 2
services/web/app/coffee/Features/Referal/ReferalController.coffee

@@ -1,9 +1,11 @@
 logger = require('logger-sharelatex')
 logger = require('logger-sharelatex')
 ReferalHandler = require('./ReferalHandler')
 ReferalHandler = require('./ReferalHandler')
+AuthenticationController = require('../Authentication/AuthenticationController')
 
 
-module.exports = 
+module.exports =
 	bonus: (req, res)->
 	bonus: (req, res)->
-		ReferalHandler.getReferedUserIds req.session.user._id, (err, refered_users)->
+		user_id = AuthenticationController.getLoggedInUserId(req)
+		ReferalHandler.getReferedUserIds user_id, (err, refered_users)->
 			res.render "referal/bonus",
 			res.render "referal/bonus",
 				title: "bonus_please_recommend_us"
 				title: "bonus_please_recommend_us"
 				refered_users: refered_users
 				refered_users: refered_users

+ 0 - 11
services/web/app/coffee/Features/Referal/ReferalMiddleware.coffee

@@ -1,11 +0,0 @@
-User = require("../../models/User").User
-
-module.exports = RefererMiddleware =
-	getUserReferalId: (req, res, next) ->
-		if req.session? and req.session.user?
-			User.findById req.session.user._id, (error, user) ->
-				return next(error) if error?
-				req.session.user.referal_id = user.referal_id
-				next()
-		else
-			next()

+ 10 - 9
services/web/app/coffee/Features/Security/RateLimiterMiddlewear.coffee

@@ -1,32 +1,33 @@
 RateLimiter = require "../../infrastructure/RateLimiter"
 RateLimiter = require "../../infrastructure/RateLimiter"
 logger = require "logger-sharelatex"
 logger = require "logger-sharelatex"
+AuthenticationController = require('../Authentication/AuthenticationController')
 
 
 module.exports = RateLimiterMiddlewear =
 module.exports = RateLimiterMiddlewear =
 	###
 	###
 	Do not allow more than opts.maxRequests from a single client in
 	Do not allow more than opts.maxRequests from a single client in
 	opts.timeInterval. Pass an array of opts.params to segment this based on
 	opts.timeInterval. Pass an array of opts.params to segment this based on
 	parameters in the request URL, e.g.:
 	parameters in the request URL, e.g.:
-	
+
 	    app.get "/project/:project_id", RateLimiterMiddlewear.rateLimit(endpointName: "open-editor", params: ["project_id"])
 	    app.get "/project/:project_id", RateLimiterMiddlewear.rateLimit(endpointName: "open-editor", params: ["project_id"])
-	
+
 	will rate limit each project_id separately.
 	will rate limit each project_id separately.
-	
+
 	Unique clients are identified by user_id if logged in, and IP address if not.
 	Unique clients are identified by user_id if logged in, and IP address if not.
 	###
 	###
 	rateLimit: (opts) ->
 	rateLimit: (opts) ->
 		return (req, res, next) ->
 		return (req, res, next) ->
-			if req.session?.user?
-				user_id = req.session.user._id
-			else
-				user_id = req.ip
+			user_id = AuthenticationController.getLoggedInUserId(req) || req.ip
 			params = (opts.params or []).map (p) -> req.params[p]
 			params = (opts.params or []).map (p) -> req.params[p]
 			params.push user_id
 			params.push user_id
+			subjectName = params.join(":")
+			if opts.ipOnly
+				subjectName = req.ip
 			if !opts.endpointName?
 			if !opts.endpointName?
 				throw new Error("no endpointName provided")
 				throw new Error("no endpointName provided")
 			options = {
 			options = {
 				endpointName: opts.endpointName
 				endpointName: opts.endpointName
 				timeInterval: opts.timeInterval or 60
 				timeInterval: opts.timeInterval or 60
-				subjectName:  params.join(":")
+				subjectName:  subjectName
 				throttle:     opts.maxRequests or 6
 				throttle:     opts.maxRequests or 6
 			}
 			}
 			RateLimiter.addCount options, (error, canContinue)->
 			RateLimiter.addCount options, (error, canContinue)->
@@ -37,4 +38,4 @@ module.exports = RateLimiterMiddlewear =
 					logger.warn options, "rate limit exceeded"
 					logger.warn options, "rate limit exceeded"
 					res.status(429) # Too many requests
 					res.status(429) # Too many requests
 					res.write("Rate limit reached, please try again later")
 					res.write("Rate limit reached, please try again later")
-					res.end()
+					res.end()

+ 0 - 26
services/web/app/coffee/Features/Security/SessionInvalidator.coffee

@@ -1,26 +0,0 @@
-Settings = require("settings-sharelatex")
-redis = require("redis-sharelatex")
-rclient = redis.createClient(Settings.redis.web)
-crypto = require("crypto")
-async = require("async")
-
-
-module.exports = 
-
-	_getEmailKey : (email)->
-		hash = crypto.createHash("md5").update(email).digest("hex")
-		return "e_sess:#{hash}"
-
-	tracksession:(sessionId, email, callback = ->)->
-		session_lookup_key = @_getEmailKey(email)
-		rclient.set session_lookup_key, sessionId, callback
-
-	invalidateSession:(email, callback = ->)->
-		session_lookup_key = @_getEmailKey(email)
-		rclient.get session_lookup_key, (err, sessionId)->
-			async.series [
-				(cb)-> rclient.del sessionId, cb
-				(cb)-> rclient.del session_lookup_key, cb
-			], callback
-
-

+ 3 - 1
services/web/app/coffee/Features/Spelling/SpellingController.coffee

@@ -1,13 +1,15 @@
 request = require 'request'
 request = require 'request'
 Settings = require 'settings-sharelatex'
 Settings = require 'settings-sharelatex'
 logger = require 'logger-sharelatex'
 logger = require 'logger-sharelatex'
+AuthenticationController = require('../Authentication/AuthenticationController')
 
 
 TEN_SECONDS = 1000 * 10
 TEN_SECONDS = 1000 * 10
 
 
 module.exports = SpellingController =
 module.exports = SpellingController =
 	proxyRequestToSpellingApi: (req, res, next) ->
 	proxyRequestToSpellingApi: (req, res, next) ->
+		user_id = AuthenticationController.getLoggedInUserId(req)
 		url = req.url.slice("/spelling".length)
 		url = req.url.slice("/spelling".length)
-		url = "/user/#{req.session.user._id}#{url}"
+		url = "/user/#{user_id}#{url}"
 		req.headers["Host"] = Settings.apis.spelling.host
 		req.headers["Host"] = Settings.apis.spelling.host
 		request(url: Settings.apis.spelling.url + url, method: req.method, headers: req.headers, json: req.body, timeout:TEN_SECONDS)
 		request(url: Settings.apis.spelling.url + url, method: req.method, headers: req.headers, json: req.body, timeout:TEN_SECONDS)
 		.on "error", (error) ->
 		.on "error", (error) ->

+ 3 - 2
services/web/app/coffee/Features/StaticPages/HomeController.coffee

@@ -5,12 +5,13 @@ Path = require "path"
 fs = require "fs"
 fs = require "fs"
 
 
 ErrorController = require "../Errors/ErrorController"
 ErrorController = require "../Errors/ErrorController"
+AuthenticationController = require('../Authentication/AuthenticationController')
 
 
 homepageExists = fs.existsSync Path.resolve(__dirname + "/../../../views/external/home.jade")
 homepageExists = fs.existsSync Path.resolve(__dirname + "/../../../views/external/home.jade")
 
 
 module.exports = HomeController =
 module.exports = HomeController =
 	index : (req,res)->
 	index : (req,res)->
-		if req.session.user
+		if AuthenticationController.isUserLoggedIn(req)
 			if req.query.scribtex_path?
 			if req.query.scribtex_path?
 				res.redirect "/project?scribtex_path=#{req.query.scribtex_path}"
 				res.redirect "/project?scribtex_path=#{req.query.scribtex_path}"
 			else
 			else
@@ -33,4 +34,4 @@ module.exports = HomeController =
 					res.render "external/#{page}.jade",
 					res.render "external/#{page}.jade",
 						title: title
 						title: title
 				else
 				else
-					ErrorController.notFound(req, res, next)
+					ErrorController.notFound(req, res, next)

+ 4 - 63
services/web/app/coffee/Features/StaticPages/UniversityController.coffee

@@ -1,75 +1,16 @@
-request = require("request")
 settings = require("settings-sharelatex")
 settings = require("settings-sharelatex")
 logger = require("logger-sharelatex")
 logger = require("logger-sharelatex")
-_ = require("underscore")
-ErrorController = require "../Errors/ErrorController"
-StaticPageHelpers = require("./StaticPageHelpers")
-sanitize = require('sanitizer')
 Settings = require("settings-sharelatex")
 Settings = require("settings-sharelatex")
-contentful = require('contentful')
-marked = require("marked")
 sixpack = require("../../infrastructure/Sixpack")
 sixpack = require("../../infrastructure/Sixpack")
 
 
 
 
 
 
-module.exports = UniversityController = 
+module.exports = UniversityController =
 
 
 	getPage: (req, res, next)->
 	getPage: (req, res, next)->
-		url = req.url?.toLowerCase()
-		universityUrl = "#{settings.apis.university.url}#{url}"
-		if StaticPageHelpers.shouldProxy(url) 
-			return UniversityController._directProxy universityUrl, res
-
-		logger.log url:url, "proxying request to university api"
-		request.get universityUrl, (err, r, data)->
-			if r?.statusCode == 404
-				return UniversityController.getContentfulPage(req, res, next)
-			if err?
-				return res.send 500
-			data = data.trim()
-			try
-				data = JSON.parse(data)
-				data.content = data.content.replace(/__ref__/g, sanitize.escape(req.query.ref))
-			catch err
-				logger.err err:err, data:data, "error parsing data from data"
-			res.render "university/university_holder", data
-
+		url = req.url?.toLowerCase().replace(".html","")
+		return res.redirect("/i#{url}")
 
 
 	getIndexPage: (req, res)->
 	getIndexPage: (req, res)->
-		client = sixpack.client(req?.session?.user?._id?.toString() || req.ip)
-		client.participate 'instapage-pages', ['default', 'instapage'], (err, response)->
-			if response?.alternative?.name == "instapage"
-				return res.redirect("/i/university")
-			else
-				req.url = "/university/index.html"
-				UniversityController.getPage req, res
-
-	_directProxy: (originUrl, res)->
-		upstream = request.get(originUrl)
-		upstream.on "error", (error) ->
-			logger.error err: error, "university proxy error"
-		upstream.pipe res
-
-	getContentfulPage: (req, res, next)->
-		console.log Settings.contentful
-		if !Settings.contentful?.uni?.space? and !Settings.contentful?.uni?.accessToken?
-			return ErrorController.notFound(req, res, next)
-
-		client = contentful.createClient({
-			space: Settings.contentful?.uni?.space
-			accessToken: Settings.contentful?.uni?.accessToken
-		})
-
-		url = req.url?.toLowerCase().replace("/university/","")
-		client.getEntries({content_type: 'caseStudy', 'fields.slug':url})
-			.catch (e)->
-				return res.send 500
-			.then (entry)->
-				if !entry? or !entry.items? or entry.items.length == 0
-					return ErrorController.notFound(req, res, next)
-				viewData = entry.items[0].fields
-				viewData.html = marked(viewData.content)
-				res.render "university/case_study", viewData:viewData
-
-
+		return res.redirect("/i/university")
 
 

+ 9 - 1
services/web/app/coffee/Features/Subscription/RecurlyWrapper.coffee

@@ -418,7 +418,15 @@ module.exports = RecurlyWrapper =
 			url: "subscriptions/#{subscriptionId}/cancel",
 			url: "subscriptions/#{subscriptionId}/cancel",
 			method: "put"
 			method: "put"
 		}, (error, response, body) ->
 		}, (error, response, body) ->
-			callback(error)
+			if error?
+				RecurlyWrapper._parseXml body, (_err, parsed) ->
+					if parsed?.error?.description == "A canceled subscription can't transition to canceled"
+						logger.log {subscriptionId, error, body}, "subscription already cancelled, not really an error, proceeding"
+						callback(null)
+					else
+						callback(error)
+			else
+				callback(null)
 		)
 		)
 
 
 	reactivateSubscription: (subscriptionId, callback) ->
 	reactivateSubscription: (subscriptionId, callback) ->

+ 179 - 184
services/web/app/coffee/Features/Subscription/SubscriptionController.coffee

@@ -8,192 +8,190 @@ Settings   = require 'settings-sharelatex'
 logger     = require('logger-sharelatex')
 logger     = require('logger-sharelatex')
 GeoIpLookup = require("../../infrastructure/GeoIpLookup")
 GeoIpLookup = require("../../infrastructure/GeoIpLookup")
 SubscriptionDomainHandler = require("./SubscriptionDomainHandler")
 SubscriptionDomainHandler = require("./SubscriptionDomainHandler")
+UserGetter = require "../User/UserGetter"
 
 
 module.exports = SubscriptionController =
 module.exports = SubscriptionController =
 
 
 	plansPage: (req, res, next) ->
 	plansPage: (req, res, next) ->
 		plans = SubscriptionViewModelBuilder.buildViewModel()
 		plans = SubscriptionViewModelBuilder.buildViewModel()
-		if !req.session.user?
-			baseUrl = "/register?redir="
-		else
-			baseUrl = ""
 		viewName = "subscriptions/plans"
 		viewName = "subscriptions/plans"
 		if req.query.v?
 		if req.query.v?
 			viewName = "#{viewName}_#{req.query.v}"
 			viewName = "#{viewName}_#{req.query.v}"
 		logger.log viewName:viewName, "showing plans page"
 		logger.log viewName:viewName, "showing plans page"
+		currentUser = null
 		GeoIpLookup.getCurrencyCode req.query?.ip || req.ip, (err, recomendedCurrency)->
 		GeoIpLookup.getCurrencyCode req.query?.ip || req.ip, (err, recomendedCurrency)->
 			return next(err) if err?
 			return next(err) if err?
-			res.render viewName,
-				title: "plans_and_pricing"
-				plans: plans
-				baseUrl: baseUrl
-				gaExperiments: Settings.gaExperiments.plansPage
-				recomendedCurrency:recomendedCurrency
+			render = () ->
+				res.render viewName,
+					title: "plans_and_pricing"
+					plans: plans
+					gaExperiments: Settings.gaExperiments.plansPage
+					recomendedCurrency:recomendedCurrency
+					shouldABTestPlans: currentUser == null or (currentUser?.signUpDate? and currentUser.signUpDate >= (new Date('2016-10-27')))
+			user_id = AuthenticationController.getLoggedInUserId(req)
+			if user_id?
+				UserGetter.getUser user_id, {signUpDate: 1}, (err, user) ->
+					return next(err) if err?
+					currentUser = user
+					render()
+			else
+				render()
 
 
 	#get to show the recurly.js page
 	#get to show the recurly.js page
 	paymentPage: (req, res, next) ->
 	paymentPage: (req, res, next) ->
-		AuthenticationController.getLoggedInUser req, (error, user) =>
-			return next(error) if error?
-			plan = PlansLocator.findLocalPlanInSettings(req.query.planCode)
-			LimitationsManager.userHasSubscription user, (err, hasSubscription)->
-				return next(err) if err?
-				if hasSubscription or !plan?
-					res.redirect "/user/subscription"
-				else
-					currency = req.query.currency?.toUpperCase()
-					GeoIpLookup.getCurrencyCode req.query?.ip || req.ip, (err, recomendedCurrency, countryCode)->
-						return next(err) if err?
-						if recomendedCurrency? and !currency?
-							currency = recomendedCurrency
-						RecurlyWrapper.sign {
-							subscription:
-								plan_code : req.query.planCode
-								currency: currency
-							account_code: user._id
-						}, (error, signature) ->
-							return next(error) if error?
-							res.render "subscriptions/new",
-								title      : "subscribe"
-								plan_code: req.query.planCode
+		user = AuthenticationController.getSessionUser(req)
+		plan = PlansLocator.findLocalPlanInSettings(req.query.planCode)
+		LimitationsManager.userHasSubscription user, (err, hasSubscription)->
+			return next(err) if err?
+			if hasSubscription or !plan?
+				res.redirect "/user/subscription"
+			else
+				currency = req.query.currency?.toUpperCase()
+				GeoIpLookup.getCurrencyCode req.query?.ip || req.ip, (err, recomendedCurrency, countryCode)->
+					return next(err) if err?
+					if recomendedCurrency? and !currency?
+						currency = recomendedCurrency
+					RecurlyWrapper.sign {
+						subscription:
+							plan_code : req.query.planCode
+							currency: currency
+						account_code: user._id
+					}, (error, signature) ->
+						return next(error) if error?
+						res.render "subscriptions/new",
+							title      : "subscribe"
+							plan_code: req.query.planCode
+							currency: currency
+							countryCode:countryCode
+							plan:plan
+							showStudentPlan: req.query.ssp
+							recurlyConfig: JSON.stringify
 								currency: currency
 								currency: currency
-								countryCode:countryCode
-								plan:plan
-								showStudentPlan: req.query.ssp
-								recurlyConfig: JSON.stringify
-									currency: currency
-									subdomain: Settings.apis.recurly.subdomain
-								showCouponField: req.query.scf
-								showVatField: req.query.svf
-								couponCode:      req.query.cc or ""
+								subdomain: Settings.apis.recurly.subdomain
+							showCouponField: req.query.scf
+							showVatField: req.query.svf
+							couponCode:      req.query.cc or ""
 
 
 
 
 
 
 	userSubscriptionPage: (req, res, next) ->
 	userSubscriptionPage: (req, res, next) ->
-		AuthenticationController.getLoggedInUser req, (error, user) =>
-			return next(error) if error?
-			LimitationsManager.userHasSubscriptionOrIsGroupMember user, (err, hasSubOrIsGroupMember, subscription)->
-				return next(err) if err?
-				groupLicenceInviteUrl = SubscriptionDomainHandler.getDomainLicencePage(user)
-				if subscription?.customAccount
-					logger.log user: user, "redirecting to custom account page"
-					res.redirect "/user/subscription/custom_account"
-				else if groupLicenceInviteUrl? and !hasSubOrIsGroupMember
-					logger.log user:user, "redirecting to group subscription invite page"
-					res.redirect groupLicenceInviteUrl
-				else if !hasSubOrIsGroupMember
-					logger.log user: user, "redirecting to plans"
-					res.redirect "/user/subscription/plans"
-				else
-					SubscriptionViewModelBuilder.buildUsersSubscriptionViewModel user, (error, subscription, groupSubscriptions) ->
-						return next(error) if error?
-						logger.log user: user, subscription:subscription, hasSubOrIsGroupMember:hasSubOrIsGroupMember, groupSubscriptions:groupSubscriptions, "showing subscription dashboard"
-						plans = SubscriptionViewModelBuilder.buildViewModel()
-						res.render "subscriptions/dashboard",
-							title: "your_subscription"
-							recomendedCurrency: subscription?.currency
-							taxRate:subscription?.taxRate
-							plans: plans
-							subscription: subscription || {}
-							groupSubscriptions: groupSubscriptions
-							subscriptionTabActive: true
-							user:user
-							saved_billing_details: req.query.saved_billing_details?
+		user = AuthenticationController.getSessionUser(req)
+		LimitationsManager.userHasSubscriptionOrIsGroupMember user, (err, hasSubOrIsGroupMember, subscription)->
+			return next(err) if err?
+			groupLicenceInviteUrl = SubscriptionDomainHandler.getDomainLicencePage(user)
+			if subscription?.customAccount
+				logger.log user: user, "redirecting to custom account page"
+				res.redirect "/user/subscription/custom_account"
+			else if groupLicenceInviteUrl? and !hasSubOrIsGroupMember
+				logger.log user:user, "redirecting to group subscription invite page"
+				res.redirect groupLicenceInviteUrl
+			else if !hasSubOrIsGroupMember
+				logger.log user: user, "redirecting to plans"
+				res.redirect "/user/subscription/plans"
+			else
+				SubscriptionViewModelBuilder.buildUsersSubscriptionViewModel user, (error, subscription, groupSubscriptions) ->
+					return next(error) if error?
+					logger.log user: user, subscription:subscription, hasSubOrIsGroupMember:hasSubOrIsGroupMember, groupSubscriptions:groupSubscriptions, "showing subscription dashboard"
+					plans = SubscriptionViewModelBuilder.buildViewModel()
+					res.render "subscriptions/dashboard",
+						title: "your_subscription"
+						recomendedCurrency: subscription?.currency
+						taxRate:subscription?.taxRate
+						plans: plans
+						subscription: subscription || {}
+						groupSubscriptions: groupSubscriptions
+						subscriptionTabActive: true
+						user:user
+						saved_billing_details: req.query.saved_billing_details?
 
 
 	userCustomSubscriptionPage: (req, res, next)->
 	userCustomSubscriptionPage: (req, res, next)->
-		AuthenticationController.getLoggedInUser req, (error, user) ->
-			return next(error) if error?
-			LimitationsManager.userHasSubscriptionOrIsGroupMember user, (err, hasSubOrIsGroupMember, subscription)->
-				return next(err) if err?
-				if !subscription?
-					err = new Error("subscription null for custom account, user:#{user?._id}")
-					logger.warn err:err, "subscription is null for custom accounts page"
-					return next(err)
-				res.render "subscriptions/custom_account",
-					title: "your_subscription"
-					subscription: subscription
+		user = AuthenticationController.getSessionUser(req)
+		LimitationsManager.userHasSubscriptionOrIsGroupMember user, (err, hasSubOrIsGroupMember, subscription)->
+			return next(err) if err?
+			if !subscription?
+				err = new Error("subscription null for custom account, user:#{user?._id}")
+				logger.warn err:err, "subscription is null for custom accounts page"
+				return next(err)
+			res.render "subscriptions/custom_account",
+				title: "your_subscription"
+				subscription: subscription
 
 
 
 
 	editBillingDetailsPage: (req, res, next) ->
 	editBillingDetailsPage: (req, res, next) ->
-		AuthenticationController.getLoggedInUser req, (error, user) ->
-			return next(error) if error?
-			LimitationsManager.userHasSubscription user, (err, hasSubscription)->
-				return next(err) if err?
-				if !hasSubscription
-					res.redirect "/user/subscription"
-				else
-					RecurlyWrapper.sign {
-						account_code: user._id
-					}, (error, signature) ->
-						return next(error) if error?
-						res.render "subscriptions/edit-billing-details",
-							title      : "update_billing_details"
-							recurlyConfig: JSON.stringify
-								currency: "USD"
-								subdomain: Settings.apis.recurly.subdomain
-							signature  : signature
-							successURL : "#{Settings.siteUrl}/user/subscription/billing-details/update"
-							user       :
-								id : user._id
-	
+		user = AuthenticationController.getSessionUser(req)
+		LimitationsManager.userHasSubscription user, (err, hasSubscription)->
+			return next(err) if err?
+			if !hasSubscription
+				res.redirect "/user/subscription"
+			else
+				RecurlyWrapper.sign {
+					account_code: user._id
+				}, (error, signature) ->
+					return next(error) if error?
+					res.render "subscriptions/edit-billing-details",
+						title      : "update_billing_details"
+						recurlyConfig: JSON.stringify
+							currency: "USD"
+							subdomain: Settings.apis.recurly.subdomain
+						signature  : signature
+						successURL : "#{Settings.siteUrl}/user/subscription/billing-details/update"
+						user       :
+							id : user._id
+
 	updateBillingDetails: (req, res, next) ->
 	updateBillingDetails: (req, res, next) ->
 		res.redirect "/user/subscription?saved_billing_details=true"
 		res.redirect "/user/subscription?saved_billing_details=true"
 
 
 	createSubscription: (req, res, next)->
 	createSubscription: (req, res, next)->
-		AuthenticationController.getLoggedInUser req, (error, user) ->
-			return callback(error) if error?
-			recurly_token_id = req.body.recurly_token_id
-			subscriptionDetails = req.body.subscriptionDetails
-			logger.log recurly_token_id: recurly_token_id, user_id:user._id, subscriptionDetails:subscriptionDetails, "creating subscription"
-			SubscriptionHandler.createSubscription user, subscriptionDetails, recurly_token_id, (err)->
-				if err?
-					logger.err err:err, user_id:user._id, "something went wrong creating subscription"
-					return res.sendStatus 500
-				res.sendStatus 201
+		user = AuthenticationController.getSessionUser(req)
+		recurly_token_id = req.body.recurly_token_id
+		subscriptionDetails = req.body.subscriptionDetails
+		logger.log recurly_token_id: recurly_token_id, user_id:user._id, subscriptionDetails:subscriptionDetails, "creating subscription"
+		SubscriptionHandler.createSubscription user, subscriptionDetails, recurly_token_id, (err)->
+			if err?
+				logger.err err:err, user_id:user._id, "something went wrong creating subscription"
+				return res.sendStatus 500
+			res.sendStatus 201
 
 
 	successful_subscription: (req, res, next)->
 	successful_subscription: (req, res, next)->
-		AuthenticationController.getLoggedInUser req, (error, user) =>
+		user = AuthenticationController.getSessionUser(req)
+		SubscriptionViewModelBuilder.buildUsersSubscriptionViewModel user, (error, subscription) ->
 			return next(error) if error?
 			return next(error) if error?
-			SubscriptionViewModelBuilder.buildUsersSubscriptionViewModel user, (error, subscription) ->
-				return next(error) if error?
-				res.render "subscriptions/successful_subscription",
-					title: "thank_you"
-					subscription:subscription
+			res.render "subscriptions/successful_subscription",
+				title: "thank_you"
+				subscription:subscription
 
 
 	cancelSubscription: (req, res, next) ->
 	cancelSubscription: (req, res, next) ->
-		AuthenticationController.getLoggedInUser req, (error, user) ->
-			return next(error) if error?
-			logger.log user_id:user._id, "canceling subscription"
-			SubscriptionHandler.cancelSubscription user, (err)->
-				if err?
-					logger.err err:err, user_id:user._id, "something went wrong canceling subscription"
-					return next(err)
-				res.redirect "/user/subscription"
+		user = AuthenticationController.getSessionUser(req)
+		logger.log user_id:user._id, "canceling subscription"
+		SubscriptionHandler.cancelSubscription user, (err)->
+			if err?
+				logger.err err:err, user_id:user._id, "something went wrong canceling subscription"
+				return next(err)
+			res.redirect "/user/subscription"
 
 
 	updateSubscription: (req, res, next)->
 	updateSubscription: (req, res, next)->
 		_origin = req?.query?.origin || null
 		_origin = req?.query?.origin || null
-		AuthenticationController.getLoggedInUser req, (error, user) ->
-			return next(error) if error?
-			planCode = req.body.plan_code
-			if !planCode?
-				err = new Error('plan_code is not defined')
-				logger.err {user_id: user._id, err, planCode, origin: _origin, body: req.body}, "[Subscription] error in updateSubscription form"
+		user = AuthenticationController.getSessionUser(req)
+		planCode = req.body.plan_code
+		if !planCode?
+			err = new Error('plan_code is not defined')
+			logger.err {user_id: user._id, err, planCode, origin: _origin, body: req.body}, "[Subscription] error in updateSubscription form"
+			return next(err)
+		logger.log planCode: planCode, user_id:user._id, "updating subscription"
+		SubscriptionHandler.updateSubscription user, planCode, null, (err)->
+			if err?
+				logger.err err:err, user_id:user._id, "something went wrong updating subscription"
 				return next(err)
 				return next(err)
-			logger.log planCode: planCode, user_id:user._id, "updating subscription"
-			SubscriptionHandler.updateSubscription user, planCode, null, (err)->
-				if err?
-					logger.err err:err, user_id:user._id, "something went wrong updating subscription"
-					return next(err)
-				res.redirect "/user/subscription"
+			res.redirect "/user/subscription"
 
 
 	reactivateSubscription: (req, res, next)->
 	reactivateSubscription: (req, res, next)->
-		AuthenticationController.getLoggedInUser req, (error, user) ->
-			return next(error) if error?
-			logger.log user_id:user._id, "reactivating subscription"
-			SubscriptionHandler.reactivateSubscription user, (err)->
-				if err?
-					logger.err err:err, user_id:user._id, "something went wrong reactivating subscription"
-					return next(err)
-				res.redirect "/user/subscription"
+		user = AuthenticationController.getSessionUser(req)
+		logger.log user_id:user._id, "reactivating subscription"
+		SubscriptionHandler.reactivateSubscription user, (err)->
+			if err?
+				logger.err err:err, user_id:user._id, "something went wrong reactivating subscription"
+				return next(err)
+			res.redirect "/user/subscription"
 
 
 	recurlyCallback: (req, res, next)->
 	recurlyCallback: (req, res, next)->
 		logger.log data: req.body, "received recurly callback"
 		logger.log data: req.body, "received recurly callback"
@@ -207,47 +205,44 @@ module.exports = SubscriptionController =
 			res.sendStatus 200
 			res.sendStatus 200
 
 
 	renderUpgradeToAnnualPlanPage: (req, res, next)->
 	renderUpgradeToAnnualPlanPage: (req, res, next)->
-		AuthenticationController.getLoggedInUser req, (error, user) ->
-			return next(error) if error?
-			LimitationsManager.userHasSubscription user, (err, hasSubscription, subscription)->
-				return next(err) if err?
-				planCode = subscription?.planCode.toLowerCase()
-				if planCode?.indexOf("annual") != -1
-					planName = "annual"
-				else if planCode?.indexOf("student") != -1
-					planName = "student"
-				else if planCode?.indexOf("collaborator") != -1
-					planName = "collaborator"
-				if !hasSubscription
-					return res.redirect("/user/subscription/plans")
-				logger.log planName:planName, user_id:user._id, "rendering upgrade to annual page"
-				res.render "subscriptions/upgradeToAnnual",
-					title: "Upgrade to annual"
-					planName: planName
+		user = AuthenticationController.getSessionUser(req)
+		LimitationsManager.userHasSubscription user, (err, hasSubscription, subscription)->
+			return next(err) if err?
+			planCode = subscription?.planCode.toLowerCase()
+			if planCode?.indexOf("annual") != -1
+				planName = "annual"
+			else if planCode?.indexOf("student") != -1
+				planName = "student"
+			else if planCode?.indexOf("collaborator") != -1
+				planName = "collaborator"
+			if !hasSubscription
+				return res.redirect("/user/subscription/plans")
+			logger.log planName:planName, user_id:user._id, "rendering upgrade to annual page"
+			res.render "subscriptions/upgradeToAnnual",
+				title: "Upgrade to annual"
+				planName: planName
 
 
 	processUpgradeToAnnualPlan: (req, res, next)->
 	processUpgradeToAnnualPlan: (req, res, next)->
-		AuthenticationController.getLoggedInUser req, (error, user) ->
-			return next(error) if error?
-			{planName} = req.body
-			coupon_code = Settings.coupon_codes.upgradeToAnnualPromo[planName]
-			annualPlanName = "#{planName}-annual"
-			logger.log user_id:user._id, planName:annualPlanName, "user is upgrading to annual billing with discount"
-			SubscriptionHandler.updateSubscription user, annualPlanName, coupon_code, (err)->
-				if err?
-					logger.err err:err, user_id:user._id, "error updating subscription"
-					return next(err)
-				res.sendStatus 200
+		user = AuthenticationController.getSessionUser(req)
+		{planName} = req.body
+		coupon_code = Settings.coupon_codes.upgradeToAnnualPromo[planName]
+		annualPlanName = "#{planName}-annual"
+		logger.log user_id:user._id, planName:annualPlanName, "user is upgrading to annual billing with discount"
+		SubscriptionHandler.updateSubscription user, annualPlanName, coupon_code, (err)->
+			if err?
+				logger.err err:err, user_id:user._id, "error updating subscription"
+				return next(err)
+			res.sendStatus 200
 
 
 	extendTrial: (req, res, next)->
 	extendTrial: (req, res, next)->
-		AuthenticationController.getLoggedInUser req, (error, user) ->
-			return next(error) if error?
-			LimitationsManager.userHasSubscription user, (err, hasSubscription, subscription)->
-				return next(err) if err?
-				SubscriptionHandler.extendTrial subscription, 14, (err)->
-					if err?
-						res.send 500
-					else
-						res.send 200
+		user = AuthenticationController.getSessionUser(req)
+		LimitationsManager.userHasSubscription user, (err, hasSubscription, subscription)->
+			return next(err) if err?
+			SubscriptionHandler.extendTrial subscription, 14, (err)->
+				if err?
+					res.send 500
+				else
+					res.send 200
 
 
 	recurlyNotificationParser: (req, res, next) ->
 	recurlyNotificationParser: (req, res, next) ->
 		xml = ""
 		xml = ""

+ 19 - 14
services/web/app/coffee/Features/Subscription/SubscriptionGroupController.coffee

@@ -3,27 +3,28 @@ logger = require("logger-sharelatex")
 SubscriptionLocator = require("./SubscriptionLocator")
 SubscriptionLocator = require("./SubscriptionLocator")
 ErrorsController = require("../Errors/ErrorController")
 ErrorsController = require("../Errors/ErrorController")
 SubscriptionDomainHandler = require("./SubscriptionDomainHandler")
 SubscriptionDomainHandler = require("./SubscriptionDomainHandler")
+AuthenticationController = require('../Authentication/AuthenticationController')
 _ = require("underscore")
 _ = require("underscore")
 async = require("async")
 async = require("async")
 
 
 module.exports =
 module.exports =
 
 
 	addUserToGroup: (req, res)->
 	addUserToGroup: (req, res)->
-		adminUserId = req.session.user._id
+		adminUserId = AuthenticationController.getLoggedInUserId(req)
 		newEmail = req.body?.email?.toLowerCase()?.trim()
 		newEmail = req.body?.email?.toLowerCase()?.trim()
 		logger.log adminUserId:adminUserId, newEmail:newEmail, "adding user to group subscription"
 		logger.log adminUserId:adminUserId, newEmail:newEmail, "adding user to group subscription"
 		SubscriptionGroupHandler.addUserToGroup adminUserId, newEmail, (err, user)->
 		SubscriptionGroupHandler.addUserToGroup adminUserId, newEmail, (err, user)->
 			if err?
 			if err?
 				logger.err err:err, newEmail:newEmail, adminUserId:adminUserId, "error adding user from group"
 				logger.err err:err, newEmail:newEmail, adminUserId:adminUserId, "error adding user from group"
 				return res.sendStatus 500
 				return res.sendStatus 500
-			result = 
+			result =
 				user:user
 				user:user
 			if err and err.limitReached
 			if err and err.limitReached
 				result.limitReached = true
 				result.limitReached = true
 			res.json(result)
 			res.json(result)
 
 
 	removeUserFromGroup: (req, res)->
 	removeUserFromGroup: (req, res)->
-		adminUserId = req.session.user._id
+		adminUserId = AuthenticationController.getLoggedInUserId(req)
 		userToRemove_id = req.params.user_id
 		userToRemove_id = req.params.user_id
 		logger.log adminUserId:adminUserId, userToRemove_id:userToRemove_id, "removing user from group subscription"
 		logger.log adminUserId:adminUserId, userToRemove_id:userToRemove_id, "removing user from group subscription"
 		SubscriptionGroupHandler.removeUserFromGroup adminUserId, userToRemove_id, (err)->
 		SubscriptionGroupHandler.removeUserFromGroup adminUserId, userToRemove_id, (err)->
@@ -31,10 +32,10 @@ module.exports =
 				logger.err err:err, adminUserId:adminUserId, userToRemove_id:userToRemove_id, "error removing user from group"
 				logger.err err:err, adminUserId:adminUserId, userToRemove_id:userToRemove_id, "error removing user from group"
 				return res.sendStatus 500
 				return res.sendStatus 500
 			res.send()
 			res.send()
-			
+
 	removeSelfFromGroup: (req, res)->
 	removeSelfFromGroup: (req, res)->
 		adminUserId = req.query.admin_user_id
 		adminUserId = req.query.admin_user_id
-		userToRemove_id = req.session.user._id
+		userToRemove_id = AuthenticationController.getLoggedInUserId(req)
 		logger.log adminUserId:adminUserId, userToRemove_id:userToRemove_id, "removing user from group subscription after self request"
 		logger.log adminUserId:adminUserId, userToRemove_id:userToRemove_id, "removing user from group subscription after self request"
 		SubscriptionGroupHandler.removeUserFromGroup adminUserId, userToRemove_id, (err)->
 		SubscriptionGroupHandler.removeUserFromGroup adminUserId, userToRemove_id, (err)->
 			if err?
 			if err?
@@ -43,7 +44,7 @@ module.exports =
 			res.send()
 			res.send()
 
 
 	renderSubscriptionGroupAdminPage: (req, res)->
 	renderSubscriptionGroupAdminPage: (req, res)->
-		user_id = req.session.user._id
+		user_id = AuthenticationController.getLoggedInUserId(req)
 		SubscriptionLocator.getUsersSubscription user_id, (err, subscription)->
 		SubscriptionLocator.getUsersSubscription user_id, (err, subscription)->
 			if !subscription.groupPlan
 			if !subscription.groupPlan
 				return res.redirect("/")
 				return res.redirect("/")
@@ -55,11 +56,11 @@ module.exports =
 
 
 	renderGroupInvitePage: (req, res)->
 	renderGroupInvitePage: (req, res)->
 		group_subscription_id = req.params.subscription_id
 		group_subscription_id = req.params.subscription_id
-		user_id = req.session.user._id
+		user_id = AuthenticationController.getLoggedInUserId(req)
 		licence = SubscriptionDomainHandler.findDomainLicenceBySubscriptionId(group_subscription_id)
 		licence = SubscriptionDomainHandler.findDomainLicenceBySubscriptionId(group_subscription_id)
 		if !licence?
 		if !licence?
 			return ErrorsController.notFound(req, res)
 			return ErrorsController.notFound(req, res)
-		jobs = 
+		jobs =
 			partOfGroup: (cb)->
 			partOfGroup: (cb)->
 				SubscriptionGroupHandler.isUserPartOfGroup user_id, licence.group_subscription_id, cb
 				SubscriptionGroupHandler.isUserPartOfGroup user_id, licence.group_subscription_id, cb
 			subscription: (cb)->
 			subscription: (cb)->
@@ -77,22 +78,26 @@ module.exports =
 
 
 	beginJoinGroup: (req, res)->
 	beginJoinGroup: (req, res)->
 		subscription_id = req.params.subscription_id
 		subscription_id = req.params.subscription_id
-		user_id = req.session.user._id
+		currentUser = AuthenticationController.getSessionUser(req)
+		if !currentUser?
+			logger.err {subscription_id}, "error getting current user"
+			return res.sendStatus 500
 		licence = SubscriptionDomainHandler.findDomainLicenceBySubscriptionId(subscription_id)
 		licence = SubscriptionDomainHandler.findDomainLicenceBySubscriptionId(subscription_id)
 		if !licence?
 		if !licence?
 			return ErrorsController.notFound(req, res)
 			return ErrorsController.notFound(req, res)
-		SubscriptionGroupHandler.sendVerificationEmail subscription_id, licence.name, req.session.user.email, (err)->
+		SubscriptionGroupHandler.sendVerificationEmail subscription_id, licence.name, currentUser.email, (err)->
 			if err?
 			if err?
 				res.sendStatus 500
 				res.sendStatus 500
 			else
 			else
 				res.sendStatus 200
 				res.sendStatus 200
 
 
 	completeJoin: (req, res)->
 	completeJoin: (req, res)->
+		currentUser = AuthenticationController.getSessionUser(req)
 		subscription_id = req.params.subscription_id
 		subscription_id = req.params.subscription_id
 		if !SubscriptionDomainHandler.findDomainLicenceBySubscriptionId(subscription_id)?
 		if !SubscriptionDomainHandler.findDomainLicenceBySubscriptionId(subscription_id)?
 			return ErrorsController.notFound(req, res)
 			return ErrorsController.notFound(req, res)
-		email = req?.session?.user?.email
-		logger.log subscription_id:subscription_id, user_id:req?.session?.user?._id, email:email, "starting the completion of joining group"
+		email = currentUser?.email
+		logger.log subscription_id:subscription_id, user_id:currentUser?._id, email:email, "starting the completion of joining group"
 		SubscriptionGroupHandler.processGroupVerification email, subscription_id, req.query?.token, (err)->
 		SubscriptionGroupHandler.processGroupVerification email, subscription_id, req.query?.token, (err)->
 			if err? and err == "token_not_found"
 			if err? and err == "token_not_found"
 				return res.redirect "/user/subscription/#{subscription_id}/group/invited?expired=true"
 				return res.redirect "/user/subscription/#{subscription_id}/group/invited?expired=true"
@@ -109,10 +114,10 @@ module.exports =
 			return ErrorsController.notFound(req, res)
 			return ErrorsController.notFound(req, res)
 		res.render "subscriptions/group/successful_join",
 		res.render "subscriptions/group/successful_join",
 			title: "Sucessfully joined group"
 			title: "Sucessfully joined group"
-			licenceName:licence.name	
+			licenceName:licence.name
 
 
 	exportGroupCsv: (req, res)->
 	exportGroupCsv: (req, res)->
-		user_id = req.session.user._id
+		user_id = AuthenticationController.getLoggedInUserId(req)
 		logger.log user_id: user_id, "exporting group csv"
 		logger.log user_id: user_id, "exporting group csv"
 		SubscriptionLocator.getUsersSubscription user_id, (err, subscription)->
 		SubscriptionLocator.getUsersSubscription user_id, (err, subscription)->
 			if !subscription.groupPlan
 			if !subscription.groupPlan

+ 3 - 0
services/web/app/coffee/Features/Subscription/SubscriptionHandler.coffee

@@ -7,6 +7,8 @@ SubscriptionUpdater = require("./SubscriptionUpdater")
 LimitationsManager = require('./LimitationsManager')
 LimitationsManager = require('./LimitationsManager')
 EmailHandler = require("../Email/EmailHandler")
 EmailHandler = require("../Email/EmailHandler")
 Events = require "../../infrastructure/Events"
 Events = require "../../infrastructure/Events"
+Analytics = require("../Analytics/AnalyticsManager")
+
 
 
 module.exports =
 module.exports =
 
 
@@ -52,6 +54,7 @@ module.exports =
 					setTimeout (-> EmailHandler.sendEmail "canceledSubscription", emailOpts
 					setTimeout (-> EmailHandler.sendEmail "canceledSubscription", emailOpts
 					), ONE_HOUR_IN_MS
 					), ONE_HOUR_IN_MS
 					Events.emit "cancelSubscription", user._id
 					Events.emit "cancelSubscription", user._id
+					Analytics.recordEvent user._id, "subscription-canceled"
 					callback()
 					callback()
 			else
 			else
 				callback()
 				callback()

+ 6 - 2
services/web/app/coffee/Features/Subscription/SubscriptionLocator.coffee

@@ -14,7 +14,11 @@ module.exports =
 			logger.log user_id:user_id, "got users subscription"
 			logger.log user_id:user_id, "got users subscription"
 			callback(err, subscription)
 			callback(err, subscription)
 
 
-	getMemberSubscriptions: (user_id, callback) ->
+	getMemberSubscriptions: (user_or_id, callback) ->
+		if user_or_id? and user_or_id._id?
+			user_id = user_or_id._id
+		else if user_or_id?
+			user_id = user_or_id
 		logger.log user_id: user_id, "getting users group subscriptions"
 		logger.log user_id: user_id, "getting users group subscriptions"
 		Subscription.find(member_ids: user_id).populate("admin_id").exec callback
 		Subscription.find(member_ids: user_id).populate("admin_id").exec callback
 
 
@@ -25,4 +29,4 @@ module.exports =
 		Subscription.findOne {member_ids: user_id, _id:subscription_id}, {_id:1}, callback
 		Subscription.findOne {member_ids: user_id, _id:subscription_id}, {_id:1}, callback
 
 
 	getGroupSubscriptionMemberOf: (user_id, callback)->
 	getGroupSubscriptionMemberOf: (user_id, callback)->
-		Subscription.findOne {member_ids: user_id}, {_id:1, planCode:1}, callback
+		Subscription.findOne {member_ids: user_id}, {_id:1, planCode:1}, callback

+ 7 - 3
services/web/app/coffee/Features/Subscription/SubscriptionViewModelBuilder.coffee

@@ -4,6 +4,7 @@ PlansLocator = require("./PlansLocator")
 SubscriptionFormatters = require("./SubscriptionFormatters")
 SubscriptionFormatters = require("./SubscriptionFormatters")
 LimitationsManager = require("./LimitationsManager")
 LimitationsManager = require("./LimitationsManager")
 SubscriptionLocator = require("./SubscriptionLocator")
 SubscriptionLocator = require("./SubscriptionLocator")
+logger = require('logger-sharelatex')
 _ = require("underscore")
 _ = require("underscore")
 
 
 module.exports =
 module.exports =
@@ -16,6 +17,10 @@ module.exports =
 				if subscription?
 				if subscription?
 					return callback(error) if error?
 					return callback(error) if error?
 					plan = PlansLocator.findLocalPlanInSettings(subscription.planCode)
 					plan = PlansLocator.findLocalPlanInSettings(subscription.planCode)
+					if !plan?
+						err = new Error("No plan found for planCode '#{subscription.planCode}'")
+						logger.error {user_id: user._id, err}, "error getting subscription plan for user"
+						return callback(err)
 					RecurlyWrapper.getSubscription subscription.recurlySubscription_id, (err, recurlySubscription)->
 					RecurlyWrapper.getSubscription subscription.recurlySubscription_id, (err, recurlySubscription)->
 						tax = recurlySubscription?.tax_in_cents || 0
 						tax = recurlySubscription?.tax_in_cents || 0
 						callback null, {
 						callback null, {
@@ -39,7 +44,7 @@ module.exports =
 		allPlans = {}
 		allPlans = {}
 		plans.forEach (plan)->
 		plans.forEach (plan)->
 			allPlans[plan.planCode] = plan
 			allPlans[plan.planCode] = plan
-			
+
 		result =
 		result =
 			allPlans: allPlans
 			allPlans: allPlans
 
 
@@ -49,7 +54,7 @@ module.exports =
 
 
 		result.studentAccounts = _.filter plans, (plan)->
 		result.studentAccounts = _.filter plans, (plan)->
 			plan.planCode.indexOf("student") != -1
 			plan.planCode.indexOf("student") != -1
-			
+
 		result.groupMonthlyPlans = _.filter plans, (plan)->
 		result.groupMonthlyPlans = _.filter plans, (plan)->
 			plan.groupPlan and !plan.annual
 			plan.groupPlan and !plan.annual
 
 
@@ -63,4 +68,3 @@ module.exports =
 			!plan.groupPlan and plan.annual and plan.planCode.indexOf("student") == -1
 			!plan.groupPlan and plan.annual and plan.planCode.indexOf("student") == -1
 
 
 		return result
 		return result
-

+ 12 - 11
services/web/app/coffee/Features/Tags/TagsController.coffee

@@ -1,48 +1,49 @@
 TagsHandler = require("./TagsHandler")
 TagsHandler = require("./TagsHandler")
 logger = require("logger-sharelatex")
 logger = require("logger-sharelatex")
+AuthenticationController = require('../Authentication/AuthenticationController')
 
 
 module.exports =
 module.exports =
 	getAllTags: (req, res, next)->
 	getAllTags: (req, res, next)->
-		user_id = req.session.user._id
+		user_id = AuthenticationController.getLoggedInUserId(req)
 		logger.log {user_id}, "getting tags"
 		logger.log {user_id}, "getting tags"
 		TagsHandler.getAllTags user_id, (error, allTags)->
 		TagsHandler.getAllTags user_id, (error, allTags)->
 			return next(error) if error?
 			return next(error) if error?
 			res.json(allTags)
 			res.json(allTags)
-	
+
 	createTag: (req, res, next) ->
 	createTag: (req, res, next) ->
-		user_id = req.session.user._id
+		user_id = AuthenticationController.getLoggedInUserId(req)
 		name = req.body.name
 		name = req.body.name
 		logger.log {user_id, name}, "creating tag"
 		logger.log {user_id, name}, "creating tag"
 		TagsHandler.createTag user_id, name, (error, tag) ->
 		TagsHandler.createTag user_id, name, (error, tag) ->
 			return next(error) if error?
 			return next(error) if error?
 			res.json(tag)
 			res.json(tag)
-	
+
 	addProjectToTag: (req, res, next) ->
 	addProjectToTag: (req, res, next) ->
-		user_id = req.session.user._id
+		user_id = AuthenticationController.getLoggedInUserId(req)
 		{tag_id, project_id} = req.params
 		{tag_id, project_id} = req.params
 		logger.log {user_id, tag_id, project_id}, "adding tag to project"
 		logger.log {user_id, tag_id, project_id}, "adding tag to project"
 		TagsHandler.addProjectToTag user_id, tag_id, project_id, (error) ->
 		TagsHandler.addProjectToTag user_id, tag_id, project_id, (error) ->
 			return next(error) if error?
 			return next(error) if error?
 			res.status(204).end()
 			res.status(204).end()
-	
+
 	removeProjectFromTag: (req, res, next) ->
 	removeProjectFromTag: (req, res, next) ->
-		user_id = req.session.user._id
+		user_id = AuthenticationController.getLoggedInUserId(req)
 		{tag_id, project_id} = req.params
 		{tag_id, project_id} = req.params
 		logger.log {user_id, tag_id, project_id}, "removing tag from project"
 		logger.log {user_id, tag_id, project_id}, "removing tag from project"
 		TagsHandler.removeProjectFromTag user_id, tag_id, project_id, (error) ->
 		TagsHandler.removeProjectFromTag user_id, tag_id, project_id, (error) ->
 			return next(error) if error?
 			return next(error) if error?
 			res.status(204).end()
 			res.status(204).end()
-	
+
 	deleteTag: (req, res, next) ->
 	deleteTag: (req, res, next) ->
-		user_id = req.session.user._id
+		user_id = AuthenticationController.getLoggedInUserId(req)
 		tag_id = req.params.tag_id
 		tag_id = req.params.tag_id
 		logger.log {user_id, tag_id}, "deleting tag"
 		logger.log {user_id, tag_id}, "deleting tag"
 		TagsHandler.deleteTag user_id, tag_id, (error) ->
 		TagsHandler.deleteTag user_id, tag_id, (error) ->
 			return next(error) if error?
 			return next(error) if error?
 			res.status(204).end()
 			res.status(204).end()
-	
+
 	renameTag: (req, res, next) ->
 	renameTag: (req, res, next) ->
-		user_id = req.session.user._id
+		user_id = AuthenticationController.getLoggedInUserId(req)
 		tag_id = req.params.tag_id
 		tag_id = req.params.tag_id
 		name = req.body?.name
 		name = req.body?.name
 		if !name?
 		if !name?

+ 12 - 13
services/web/app/coffee/Features/ThirdPartyDataStore/UpdateMerger.coffee

@@ -33,16 +33,11 @@ module.exports =
 						self.p.processDoc project_id, elementId, user_id, fsPath, path, source, callback
 						self.p.processDoc project_id, elementId, user_id, fsPath, path, source, callback
 
 
 	deleteUpdate: (project_id, path, source, callback)->
 	deleteUpdate: (project_id, path, source, callback)->
-		projectLocator.findElementByPath project_id, path, (err, element)->
-			type = 'file'
+		projectLocator.findElementByPath project_id, path, (err, element, type)->
 			if  err? || !element?
 			if  err? || !element?
 				logger.log element:element, project_id:project_id, path:path, "could not find entity for deleting, assuming it was already deleted"
 				logger.log element:element, project_id:project_id, path:path, "could not find entity for deleting, assuming it was already deleted"
 				return callback()
 				return callback()
-			if element.lines?
-				type = 'doc'
-			else if element.folders?
-				type = 'folder'
-			logger.log project_id:project_id, updateType:path, updateType:type, element:element, "processing update to delete entity from tpds"
+			logger.log project_id:project_id, path:path, type:type, element:element, "processing update to delete entity from tpds"
 			editorController.deleteEntity project_id, element._id, type, source, (err)->
 			editorController.deleteEntity project_id, element._id, type, source, (err)->
 				logger.log project_id:project_id, path:path, "finished processing update to delete entity from tpds"
 				logger.log project_id:project_id, path:path, "finished processing update to delete entity from tpds"
 				callback()
 				callback()
@@ -56,12 +51,13 @@ module.exports =
 					return callback(err)
 					return callback(err)
 				logger.log docLines:docLines, doc_id:doc_id, project_id:project_id, "processing doc update from tpds"
 				logger.log docLines:docLines, doc_id:doc_id, project_id:project_id, "processing doc update from tpds"
 				if doc_id?
 				if doc_id?
-					editorController.setDoc project_id, doc_id, user_id, docLines, source, (err)->
-						callback()
+					editorController.setDoc project_id, doc_id, user_id, docLines, source, callback
 				else
 				else
 					setupNewEntity project_id, path, (err, folder, fileName)->
 					setupNewEntity project_id, path, (err, folder, fileName)->
-						editorController.addDoc project_id, folder._id, fileName, docLines, source, (err)->
-							callback()
+						if err?
+							logger.err err:err, project_id:project_id, doc_id:doc_id, path:path, "error processing file"
+							return callback(err)
+						editorController.addDoc project_id, folder._id, fileName, docLines, source, callback
 
 
 		processFile: (project_id, file_id, fsPath, path, source, callback)->
 		processFile: (project_id, file_id, fsPath, path, source, callback)->
 			finish = (err)->
 			finish = (err)->
@@ -69,10 +65,13 @@ module.exports =
 				callback(err)
 				callback(err)
 			logger.log project_id:project_id, file_id:file_id, path:path, "processing file update from tpds"
 			logger.log project_id:project_id, file_id:file_id, path:path, "processing file update from tpds"
 			setupNewEntity project_id, path, (err, folder, fileName) =>
 			setupNewEntity project_id, path, (err, folder, fileName) =>
-				if file_id?
+				if err?
+					logger.err err:err, project_id:project_id, file_id:file_id, path:path, "error processing file"
+					return callback(err)
+				else if file_id?
 					editorController.replaceFile project_id, file_id, fsPath, source, finish
 					editorController.replaceFile project_id, file_id, fsPath, source, finish
 				else
 				else
-					editorController.addFile project_id, folder._id, fileName, fsPath, source, finish
+					editorController.addFile project_id, folder?._id, fileName, fsPath, source, finish
 
 
 		writeStreamToDisk: (project_id, file_id, stream, callback = (err, fsPath)->)->
 		writeStreamToDisk: (project_id, file_id, stream, callback = (err, fsPath)->)->
 			if !file_id?
 			if !file_id?

+ 23 - 0
services/web/app/coffee/Features/TrackChanges/RangesManager.coffee

@@ -0,0 +1,23 @@
+DocumentUpdaterHandler = require "../DocumentUpdater/DocumentUpdaterHandler"
+DocstoreManager = require "../Docstore/DocstoreManager"
+UserInfoManager = require "../User/UserInfoManager"
+async = require "async"
+
+module.exports = RangesManager =
+	getAllRanges: (project_id, callback = (error, docs) ->) ->
+		DocumentUpdaterHandler.flushProjectToMongo project_id, (error) ->
+			return callback(error) if error?
+			DocstoreManager.getAllRanges project_id, callback
+	
+	getAllChangesUsers: (project_id, callback = (error, users) ->) ->
+		user_ids = {}
+		RangesManager.getAllRanges project_id, (error, docs) ->
+			return callback(error) if error?
+			jobs = []
+			for doc in docs
+				for change in doc.ranges?.changes or []
+					user_ids[change.metadata.user_id] = true
+			
+			async.mapSeries Object.keys(user_ids), (user_id, cb) ->
+				UserInfoManager.getPersonalInfo user_id, cb
+			, callback

+ 38 - 17
services/web/app/coffee/Features/TrackChanges/TrackChangesController.coffee

@@ -1,21 +1,42 @@
+RangesManager = require "./RangesManager"
 logger = require "logger-sharelatex"
 logger = require "logger-sharelatex"
-request = require "request"
-settings = require "settings-sharelatex"
-AuthenticationController = require "../Authentication/AuthenticationController"
+UserInfoController = require "../User/UserInfoController"
+DocumentUpdaterHandler = require "../DocumentUpdater/DocumentUpdaterHandler"
+EditorRealTimeController = require("../Editor/EditorRealTimeController")
+TrackChangesManager = require "./TrackChangesManager"
 
 
 module.exports = TrackChangesController =
 module.exports = TrackChangesController =
-	proxyToTrackChangesApi: (req, res, next = (error) ->) ->
-		AuthenticationController.getLoggedInUserId req, (error, user_id) ->
+	getAllRanges: (req, res, next) ->
+		project_id = req.params.project_id
+		logger.log {project_id}, "request for project ranges"
+		RangesManager.getAllRanges project_id, (error, docs = []) ->
 			return next(error) if error?
 			return next(error) if error?
-			url = settings.apis.trackchanges.url + req.url
-			logger.log url: url, "proxying to track-changes api"
-			getReq = request(
-				url: url
-				method: req.method
-				headers:
-					"X-User-Id": user_id
-			)
-			getReq.pipe(res)
-			getReq.on "error", (error) ->
-				logger.error err: error, "track-changes API error"
-				next(error)
+			docs = ({id: d._id, ranges: d.ranges} for d in docs)
+			res.json docs
+	
+	getAllChangesUsers: (req, res, next) ->
+		project_id = req.params.project_id
+		logger.log {project_id}, "request for project range users"
+		RangesManager.getAllChangesUsers project_id, (error, users) ->
+			return next(error) if error?
+			users = (UserInfoController.formatPersonalInfo(user) for user in users)
+			# Get rid of any anonymous/deleted user objects
+			users = users.filter (u) -> u?.id?
+			res.json users
+	
+	acceptChange: (req, res, next) ->
+		{project_id, doc_id, change_id} = req.params
+		logger.log {project_id, doc_id, change_id}, "request to accept change"
+		DocumentUpdaterHandler.acceptChange project_id, doc_id, change_id, (error) ->
+			return next(error) if error?
+			EditorRealTimeController.emitToRoom project_id, "accept-change", doc_id, change_id, (err)->
+			res.send 204
+
+	toggleTrackChanges: (req, res, next) ->
+		{project_id} = req.params
+		track_changes_on = !!req.body.on
+		logger.log {project_id, track_changes_on}, "request to toggle track changes"
+		TrackChangesManager.toggleTrackChanges project_id, track_changes_on, (error) ->
+			return next(error) if error?
+			EditorRealTimeController.emitToRoom project_id, "toggle-track-changes", track_changes_on, (err)->
+			res.send 204

+ 3 - 26
services/web/app/coffee/Features/TrackChanges/TrackChangesManager.coffee

@@ -1,28 +1,5 @@
-settings = require "settings-sharelatex"
-request = require "request"
-logger = require "logger-sharelatex"
+Project = require("../../models/Project").Project
 
 
 module.exports = TrackChangesManager =
 module.exports = TrackChangesManager =
-	flushProject: (project_id, callback = (error) ->) ->
-		logger.log project_id: project_id, "flushing project in track-changes api"
-		url = "#{settings.apis.trackchanges.url}/project/#{project_id}/flush"
-		request.post url, (error, res, body) ->
-			return callback(error) if error?
-			if 200 <= res.statusCode < 300
-				callback(null)
-			else
-				error = new Error("track-changes api responded with non-success code: #{res.statusCode} #{url}")
-				logger.error err: error, project_id: project_id, "error flushing project in track-changes api"
-				callback(error)
-
-	archiveProject: (project_id, callback = ()->)->
-		logger.log project_id: project_id, "archving project in track-changes api"
-		url = "#{settings.apis.trackchanges.url}/project/#{project_id}/archive"
-		request.post url, (error, res, body) ->
-			return callback(error) if error?
-			if 200 <= res.statusCode < 300
-				callback(null)
-			else
-				error = new Error("track-changes api responded with non-success code: #{res.statusCode} #{url}")
-				logger.error err: error, project_id: project_id, "error archving project in track-changes api"
-				callback(error)
+	toggleTrackChanges: (project_id, track_changes_on, callback = (error) ->) ->
+		Project.update {_id: project_id}, {track_changes: track_changes_on}, callback

+ 4 - 6
services/web/app/coffee/Features/Uploads/ProjectUploadController.coffee

@@ -4,11 +4,12 @@ fs      = require "fs"
 Path    = require "path"
 Path    = require "path"
 FileSystemImportManager = require "./FileSystemImportManager"
 FileSystemImportManager = require "./FileSystemImportManager"
 ProjectUploadManager    = require "./ProjectUploadManager"
 ProjectUploadManager    = require "./ProjectUploadManager"
+AuthenticationController = require('../Authentication/AuthenticationController')
 
 
 module.exports = ProjectUploadController =
 module.exports = ProjectUploadController =
 	uploadProject: (req, res, next) ->
 	uploadProject: (req, res, next) ->
 		timer = new metrics.Timer("project-upload")
 		timer = new metrics.Timer("project-upload")
-		user_id = req.session.user._id
+		user_id = AuthenticationController.getLoggedInUserId(req)
 		{originalname, path} = req.files.qqfile
 		{originalname, path} = req.files.qqfile
 		name = Path.basename(originalname, ".zip")
 		name = Path.basename(originalname, ".zip")
 		ProjectUploadManager.createProjectFromZipArchive user_id, name, path, (error, project) ->
 		ProjectUploadManager.createProjectFromZipArchive user_id, name, path, (error, project) ->
@@ -24,7 +25,7 @@ module.exports = ProjectUploadController =
 					project: project._id, file_path: path, file_name: name,
 					project: project._id, file_path: path, file_name: name,
 					"uploaded project"
 					"uploaded project"
 				res.send success: true, project_id: project._id
 				res.send success: true, project_id: project._id
-	
+
 	uploadFile: (req, res, next) ->
 	uploadFile: (req, res, next) ->
 		timer = new metrics.Timer("file-upload")
 		timer = new metrics.Timer("file-upload")
 		name = req.files.qqfile?.originalname
 		name = req.files.qqfile?.originalname
@@ -35,7 +36,7 @@ module.exports = ProjectUploadController =
 			logger.err project_id:project_id, name:name, "bad name when trying to upload file"
 			logger.err project_id:project_id, name:name, "bad name when trying to upload file"
 			return res.send success: false
 			return res.send success: false
 		logger.log folder_id:folder_id, project_id:project_id, "getting upload file request"
 		logger.log folder_id:folder_id, project_id:project_id, "getting upload file request"
-		user_id = req.session.user._id
+		user_id = AuthenticationController.getLoggedInUserId(req)
 		FileSystemImportManager.addEntity user_id, project_id, folder_id, name, path, true, (error, entity) ->
 		FileSystemImportManager.addEntity user_id, project_id, folder_id, name, path, true, (error, entity) ->
 			fs.unlink path, ->
 			fs.unlink path, ->
 			timer.done()
 			timer.done()
@@ -50,6 +51,3 @@ module.exports = ProjectUploadController =
 					project_id: project_id, file_path: path, file_name: name, folder_id: folder_id
 					project_id: project_id, file_path: path, file_name: name, folder_id: folder_id
 					"uploaded file"
 					"uploaded file"
 				res.send success: true, entity_id: entity?._id
 				res.send success: true, entity_id: entity?._id
-
-
-

+ 55 - 16
services/web/app/coffee/Features/User/UserController.coffee

@@ -8,27 +8,50 @@ 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")
+AuthenticationController = require('../Authentication/AuthenticationController')
 UserSessionsManager = require("./UserSessionsManager")
 UserSessionsManager = require("./UserSessionsManager")
 UserUpdater = require("./UserUpdater")
 UserUpdater = require("./UserUpdater")
 settings = require "settings-sharelatex"
 settings = require "settings-sharelatex"
 
 
 module.exports = UserController =
 module.exports = UserController =
 
 
-	deleteUser: (req, res)->
-		user_id = req.session.user._id
-		UserDeleter.deleteUser user_id, (err)->
-			if !err?
-				req.session?.destroy()
-			res.sendStatus(200)
+	tryDeleteUser: (req, res, next) ->
+		user_id = AuthenticationController.getLoggedInUserId(req)
+		password = req.body.password
+		logger.log {user_id}, "trying to delete user account"
+		if !password? or password == ''
+			logger.err {user_id}, 'no password supplied for attempt to delete account'
+			return res.sendStatus(403)
+		AuthenticationManager.authenticate {_id: user_id}, password, (err, user) ->
+			if err?
+				logger.err {user_id}, 'error authenticating during attempt to delete account'
+				return next(err)
+			if !user
+				logger.err {user_id}, 'auth failed during attempt to delete account'
+				return res.sendStatus(403)
+			UserDeleter.deleteUser user_id, (err) ->
+				if err?
+					logger.err {user_id}, "error while deleting user account"
+					return next(err)
+				sessionId = req.sessionID
+				req.logout?()
+				req.session.destroy (err) ->
+					if err?
+						logger.err err: err, 'error destorying session'
+						return next(err)
+					UserSessionsManager.untrackSession(user, sessionId)
+					res.sendStatus(200)
 
 
 	unsubscribe: (req, res)->
 	unsubscribe: (req, res)->
-		UserLocator.findById req.session.user._id, (err, user)->
+		user_id = AuthenticationController.getLoggedInUserId(req)
+		UserLocator.findById user_id, (err, user)->
 			newsLetterManager.unsubscribe user, ->
 			newsLetterManager.unsubscribe user, ->
 				res.send()
 				res.send()
 
 
 	updateUserSettings : (req, res)->
 	updateUserSettings : (req, res)->
-		logger.log user: req.session.user, "updating account settings"
-		user_id = req.session.user._id
+		user_id = AuthenticationController.getLoggedInUserId(req)
+		usingExternalAuth = settings.ldap? or settings.saml?
+		logger.log user_id: user_id, "updating account settings"
 		User.findById user_id, (err, user)->
 		User.findById user_id, (err, user)->
 			if err? or !user?
 			if err? or !user?
 				logger.err err:err, user_id:user_id, "problem updaing user settings"
 				logger.err err:err, user_id:user_id, "problem updaing user settings"
@@ -54,13 +77,19 @@ module.exports = UserController =
 				user.ace.spellCheckLanguage = req.body.spellCheckLanguage
 				user.ace.spellCheckLanguage = req.body.spellCheckLanguage
 			if req.body.pdfViewer?
 			if req.body.pdfViewer?
 				user.ace.pdfViewer = req.body.pdfViewer
 				user.ace.pdfViewer = req.body.pdfViewer
+			if req.body.syntaxValidation?
+				user.ace.syntaxValidation = req.body.syntaxValidation
 			user.save (err)->
 			user.save (err)->
 				newEmail = req.body.email?.trim().toLowerCase()
 				newEmail = req.body.email?.trim().toLowerCase()
-				if !newEmail? or newEmail == user.email
+				if !newEmail? or newEmail == user.email or usingExternalAuth
+					# end here, don't update email
+					AuthenticationController.setInSessionUser(req, {first_name: user.first_name, last_name: user.last_name})
 					return res.sendStatus 200
 					return res.sendStatus 200
 				else if newEmail.indexOf("@") == -1
 				else if newEmail.indexOf("@") == -1
+					# email invalid
 					return res.sendStatus(400)
 					return res.sendStatus(400)
 				else
 				else
+					# update the user email
 					UserUpdater.changeEmailAddress user_id, newEmail, (err)->
 					UserUpdater.changeEmailAddress user_id, newEmail, (err)->
 						if err?
 						if err?
 							logger.err err:err, user_id:user_id, newEmail:newEmail, "problem updaing users email address"
 							logger.err err:err, user_id:user_id, newEmail:newEmail, "problem updaing users email address"
@@ -73,7 +102,7 @@ module.exports = UserController =
 							if err?
 							if err?
 								logger.err err:err, user_id:user_id, "error getting user for email update"
 								logger.err err:err, user_id:user_id, "error getting user for email update"
 								return res.send 500
 								return res.send 500
-							req.session.user.email = user.email
+							AuthenticationController.setInSessionUser(req, {email: user.email, first_name: user.first_name, last_name: user.last_name})
 							UserHandler.populateGroupLicenceInvite user, (err)-> #need to refresh this in the background
 							UserHandler.populateGroupLicenceInvite user, (err)-> #need to refresh this in the background
 								if err?
 								if err?
 									logger.err err:err, "error populateGroupLicenceInvite"
 									logger.err err:err, "error populateGroupLicenceInvite"
@@ -81,9 +110,10 @@ 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"
+		user = AuthenticationController.getSessionUser(req)
+		logger.log user: user, "logging out"
 		sessionId = req.sessionID
 		sessionId = req.sessionID
-		user = req?.session?.user
+		req.logout?()  # passport logout
 		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'
@@ -102,13 +132,22 @@ module.exports = UserController =
 				setNewPasswordUrl: setNewPasswordUrl
 				setNewPasswordUrl: setNewPasswordUrl
 			}
 			}
 
 
+	clearSessions: (req, res, next = (error) ->) ->
+		metrics.inc "user.clear-sessions"
+		user = AuthenticationController.getSessionUser(req)
+		logger.log {user_id: user._id}, "clearing sessions for user"
+		UserSessionsManager.revokeAllUserSessions user, [req.sessionID], (err) ->
+			return next(err) if err?
+			res.sendStatus 201
+
 	changePassword : (req, res, next = (error) ->)->
 	changePassword : (req, res, next = (error) ->)->
 		metrics.inc "user.password-change"
 		metrics.inc "user.password-change"
 		oldPass = req.body.currentPassword
 		oldPass = req.body.currentPassword
-		AuthenticationManager.authenticate {_id:req.session.user._id}, oldPass, (err, user)->
+		user_id = AuthenticationController.getLoggedInUserId(req)
+		AuthenticationManager.authenticate {_id:user_id}, oldPass, (err, user)->
 			return next(err) if err?
 			return next(err) if err?
 			if(user)
 			if(user)
-				logger.log user: req.session.user, "changing password"
+				logger.log user: user._id, "changing password"
 				newPassword1 = req.body.newPassword1
 				newPassword1 = req.body.newPassword1
 				newPassword2 = req.body.newPassword2
 				newPassword2 = req.body.newPassword2
 				if newPassword1 != newPassword2
 				if newPassword1 != newPassword2
@@ -128,7 +167,7 @@ module.exports = UserController =
 									type:'success'
 									type:'success'
 									text:'Your password has been changed'
 									text:'Your password has been changed'
 			else
 			else
-				logger.log user: user, "current password wrong"
+				logger.log user_id: user_id, "current password wrong"
 				res.send
 				res.send
 					message:
 					message:
 					  type:'error'
 					  type:'error'

+ 1 - 0
services/web/app/coffee/Features/User/UserCreator.coffee

@@ -17,6 +17,7 @@ module.exports =
 		user = new User()
 		user = new User()
 		user.email = opts.email
 		user.email = opts.email
 		user.holdingAccount = opts.holdingAccount
 		user.holdingAccount = opts.holdingAccount
+		user.ace.syntaxValidation = true
 
 
 		username = opts.email.match(/^[^@]*/)
 		username = opts.email.match(/^[^@]*/)
 		if opts.first_name? and opts.first_name.length != 0
 		if opts.first_name? and opts.first_name.length != 0

+ 15 - 19
services/web/app/coffee/Features/User/UserInfoController.coffee

@@ -3,12 +3,14 @@ logger = require("logger-sharelatex")
 UserDeleter = require("./UserDeleter")
 UserDeleter = require("./UserDeleter")
 UserUpdater = require("./UserUpdater")
 UserUpdater = require("./UserUpdater")
 sanitize = require('sanitizer')
 sanitize = require('sanitizer')
+AuthenticationController = require('../Authentication/AuthenticationController')
 
 
 module.exports = UserController =
 module.exports = UserController =
 	getLoggedInUsersPersonalInfo: (req, res, next = (error) ->) ->
 	getLoggedInUsersPersonalInfo: (req, res, next = (error) ->) ->
-		logger.log user: req.user, "reciving request for getting logged in users personal info"
-		return next(new Error("User is not logged in")) if !req.user?
-		UserGetter.getUser req.user._id, {
+		user_id = AuthenticationController.getLoggedInUserId(req)
+		logger.log user_id: user_id, "reciving request for getting logged in users personal info"
+		return next(new Error("User is not logged in")) if !user_id?
+		UserGetter.getUser user_id, {
 			first_name: true, last_name: true,
 			first_name: true, last_name: true,
 			role:true, institution:true,
 			role:true, institution:true,
 			email: true, signUpDate: true
 			email: true, signUpDate: true
@@ -24,20 +26,14 @@ module.exports = UserController =
 			UserController.sendFormattedPersonalInfo(user, res, next)
 			UserController.sendFormattedPersonalInfo(user, res, next)
 
 
 	sendFormattedPersonalInfo: (user, res, next = (error) ->) ->
 	sendFormattedPersonalInfo: (user, res, next = (error) ->) ->
-		UserController._formatPersonalInfo user, (error, info) ->
-			return next(error) if error?
-			res.send JSON.stringify(info)
-
-	_formatPersonalInfo: (user, callback = (error, info) ->) ->
-		callback null, {
-			id: user._id.toString()
-			first_name: user.first_name
-			last_name: user.last_name
-			email: user.email
-			signUpDate: user.signUpDate
-			role: user.role
-			institution: user.institution
-		}
-		
+		info = UserController.formatPersonalInfo(user)
+		res.send JSON.stringify(info)
 
 
-	
+	formatPersonalInfo: (user, callback = (error, info) ->) ->
+		if !user?
+			return {}
+		formatted_user = { id: user._id.toString() }
+		for key in ["first_name", "last_name", "email", "signUpDate", "role", "institution"]
+			if user[key]?
+				formatted_user[key] = user[key]
+		return formatted_user

+ 5 - 0
services/web/app/coffee/Features/User/UserInfoManager.coffee

@@ -0,0 +1,5 @@
+UserGetter = require "./UserGetter"
+
+module.exports = UserInfoManager =
+	getPersonalInfo: (user_id, callback = (error) ->) ->
+		UserGetter.getUser user_id, { _id: true, first_name: true, last_name: true, email: true }, callback

+ 25 - 6
services/web/app/coffee/Features/User/UserPagesController.coffee

@@ -1,9 +1,11 @@
 UserLocator = require("./UserLocator")
 UserLocator = require("./UserLocator")
 UserGetter = require("./UserGetter")
 UserGetter = require("./UserGetter")
+UserSessionsManager = require("./UserSessionsManager")
 ErrorController = require("../Errors/ErrorController")
 ErrorController = require("../Errors/ErrorController")
 logger = require("logger-sharelatex")
 logger = require("logger-sharelatex")
 Settings = require("settings-sharelatex")
 Settings = require("settings-sharelatex")
 fs = require('fs')
 fs = require('fs')
+AuthenticationController = require('../Authentication/AuthenticationController')
 
 
 module.exports =
 module.exports =
 
 
@@ -18,18 +20,17 @@ module.exports =
 
 
 		res.render 'user/register',
 		res.render 'user/register',
 			title: 'register'
 			title: 'register'
-			redir: req.query.redir
 			sharedProjectData: sharedProjectData
 			sharedProjectData: sharedProjectData
 			newTemplateData: newTemplateData
 			newTemplateData: newTemplateData
 			new_email:req.query.new_email || ""
 			new_email:req.query.new_email || ""
-	
+
 	activateAccountPage: (req, res) ->
 	activateAccountPage: (req, res) ->
 		# An 'activation' is actually just a password reset on an account that
 		# An 'activation' is actually just a password reset on an account that
 		# was set with a random password originally.
 		# was set with a random password originally.
 		logger.log query:req.query, "activiate account page called"
 		logger.log query:req.query, "activiate account page called"
 		if !req.query?.user_id? or !req.query?.token?
 		if !req.query?.user_id? or !req.query?.token?
 			return ErrorController.notFound(req, res)
 			return ErrorController.notFound(req, res)
-			
+
 		UserGetter.getUser req.query.user_id, {email: 1, loginCount: 1}, (error, user) ->
 		UserGetter.getUser req.query.user_id, {email: 1, loginCount: 1}, (error, user) ->
 			return next(error) if error?
 			return next(error) if error?
 			if !user
 			if !user
@@ -47,17 +48,35 @@ module.exports =
 					token: req.query.token
 					token: req.query.token
 
 
 	loginPage : (req, res)->
 	loginPage : (req, res)->
+		# if user is being sent to /login with explicit redirect (redir=/foo),
+		# such as being sent from the editor to /login, then set the redirect explicitly
+		if req.query.redir? and !AuthenticationController._getRedirectFromSession(req)?
+			logger.log {redir: req.query.redir}, "setting explicit redirect from login page"
+			AuthenticationController._setRedirectInSession(req, req.query.redir)
 		res.render 'user/login',
 		res.render 'user/login',
 			title: 'login',
 			title: 'login',
-			redir: req.query.redir,
 			email: req.query.email
 			email: req.query.email
 
 
 	settingsPage : (req, res, next)->
 	settingsPage : (req, res, next)->
-		logger.log user: req.session.user, "loading settings page"
-		UserLocator.findById req.session.user._id, (err, user)->
+		user_id = AuthenticationController.getLoggedInUserId(req)
+		logger.log user: user_id, "loading settings page"
+		shouldAllowEditingDetails = !(Settings?.ldap?.updateUserDetailsOnLogin) and !(Settings?.saml?.updateUserDetailsOnLogin)
+		UserLocator.findById user_id, (err, user)->
 			return next(err) if err?
 			return next(err) if err?
 			res.render 'user/settings',
 			res.render 'user/settings',
 				title:'account_settings'
 				title:'account_settings'
 				user: user,
 				user: user,
+				shouldAllowEditingDetails: shouldAllowEditingDetails
 				languages: Settings.languages,
 				languages: Settings.languages,
 				accountSettingsTabActive: true
 				accountSettingsTabActive: true
+
+	sessionsPage: (req, res, next) ->
+		user = AuthenticationController.getSessionUser(req)
+		logger.log user_id: user._id, "loading sessions page"
+		UserSessionsManager.getAllUserSessions user, [req.sessionID], (err, sessions) ->
+			if err?
+				logger.err {user_id: user._id}, "error getting all user sessions"
+				return next(err)
+			res.render 'user/sessions',
+				title: "sessions"
+				sessions: sessions

+ 50 - 17
services/web/app/coffee/Features/User/UserSessionsManager.coffee

@@ -3,14 +3,11 @@ redis = require('redis-sharelatex')
 logger = require("logger-sharelatex")
 logger = require("logger-sharelatex")
 Async = require('async')
 Async = require('async')
 _ = require('underscore')
 _ = require('underscore')
+UserSessionsRedis = require('./UserSessionsRedis')
 
 
-rclient = redis.createClient(Settings.redis.web)
+rclient = UserSessionsRedis.client()
 
 
 module.exports = UserSessionsManager =
 module.exports = UserSessionsManager =
-
-	_sessionSetKey: (user) ->
-		return "UserSessions:#{user._id}"
-
 	# mimic the key used by the express sessions
 	# mimic the key used by the express sessions
 	_sessionKey: (sessionId) ->
 	_sessionKey: (sessionId) ->
 		return "sess:#{sessionId}"
 		return "sess:#{sessionId}"
@@ -23,7 +20,7 @@ module.exports = UserSessionsManager =
 			logger.log {user_id: user._id}, "no sessionId to track, returning"
 			logger.log {user_id: user._id}, "no sessionId to track, returning"
 			return callback(null)
 			return callback(null)
 		logger.log {user_id: user._id, sessionId}, "onLogin handler"
 		logger.log {user_id: user._id, sessionId}, "onLogin handler"
-		sessionSetKey = UserSessionsManager._sessionSetKey(user)
+		sessionSetKey = UserSessionsRedis.sessionSetKey(user)
 		value = UserSessionsManager._sessionKey sessionId
 		value = UserSessionsManager._sessionKey sessionId
 		rclient.multi()
 		rclient.multi()
 			.sadd(sessionSetKey, value)
 			.sadd(sessionSetKey, value)
@@ -43,7 +40,7 @@ module.exports = UserSessionsManager =
 			logger.log {user_id: user._id}, "no sessionId to untrack, returning"
 			logger.log {user_id: user._id}, "no sessionId to untrack, returning"
 			return callback(null)
 			return callback(null)
 		logger.log {user_id: user._id, sessionId}, "onLogout handler"
 		logger.log {user_id: user._id, sessionId}, "onLogout handler"
-		sessionSetKey = UserSessionsManager._sessionSetKey(user)
+		sessionSetKey = UserSessionsRedis.sessionSetKey(user)
 		value = UserSessionsManager._sessionKey sessionId
 		value = UserSessionsManager._sessionKey sessionId
 		rclient.multi()
 		rclient.multi()
 			.srem(sessionSetKey, value)
 			.srem(sessionSetKey, value)
@@ -55,15 +52,45 @@ module.exports = UserSessionsManager =
 				UserSessionsManager._checkSessions(user, () ->)
 				UserSessionsManager._checkSessions(user, () ->)
 				callback()
 				callback()
 
 
+	getAllUserSessions: (user, exclude, callback=(err, sessionKeys)->) ->
+		exclude = _.map(exclude, UserSessionsManager._sessionKey)
+		sessionSetKey = UserSessionsRedis.sessionSetKey(user)
+		rclient.smembers sessionSetKey, (err, sessionKeys) ->
+			if err?
+				logger.err user_id: user._id, "error getting all session keys for user from redis"
+				return callback(err)
+			sessionKeys = _.filter sessionKeys, (k) -> !(_.contains(exclude, k))
+			if sessionKeys.length == 0
+				logger.log {user_id: user._id}, "no other sessions found, returning"
+				return callback(null, [])
+
+			Async.mapSeries sessionKeys, ((k, cb) -> rclient.get(k, cb)), (err, sessions) ->
+				if err?
+					logger.err {user_id: user._id}, "error getting all sessions for user from redis"
+					return callback(err)
+
+				result = []
+				for session in sessions
+					if session is null
+						continue
+					session = JSON.parse(session)
+					session_user = session?.user or session?.passport?.user
+					result.push {
+						ip_address: session_user.ip_address,
+						session_created: session_user.session_created
+					}
+
+				return callback(null, result)
+
 	revokeAllUserSessions: (user, retain, callback=(err)->) ->
 	revokeAllUserSessions: (user, retain, callback=(err)->) ->
-		if !retain
+		if !retain?
 			retain = []
 			retain = []
 		retain = retain.map((i) -> UserSessionsManager._sessionKey(i))
 		retain = retain.map((i) -> UserSessionsManager._sessionKey(i))
-		if !user
+		if !user?
 			logger.log {}, "no user to revoke sessions for, returning"
 			logger.log {}, "no user to revoke sessions for, returning"
 			return callback(null)
 			return callback(null)
 		logger.log {user_id: user._id}, "revoking all existing sessions for user"
 		logger.log {user_id: user._id}, "revoking all existing sessions for user"
-		sessionSetKey = UserSessionsManager._sessionSetKey(user)
+		sessionSetKey = UserSessionsRedis.sessionSetKey(user)
 		rclient.smembers sessionSetKey, (err, sessionKeys) ->
 		rclient.smembers sessionSetKey, (err, sessionKeys) ->
 			if err?
 			if err?
 				logger.err {err, user_id: user._id, sessionSetKey}, "error getting contents of UserSessions set"
 				logger.err {err, user_id: user._id, sessionSetKey}, "error getting contents of UserSessions set"
@@ -73,12 +100,18 @@ module.exports = UserSessionsManager =
 				logger.log {user_id: user._id}, "no sessions in UserSessions set to delete, returning"
 				logger.log {user_id: user._id}, "no sessions in UserSessions set to delete, returning"
 				return callback(null)
 				return callback(null)
 			logger.log {user_id: user._id, count: keysToDelete.length}, "deleting sessions for user"
 			logger.log {user_id: user._id, count: keysToDelete.length}, "deleting sessions for user"
-			rclient.multi()
-				.del(keysToDelete)
-				.srem(sessionSetKey, keysToDelete)
-				.exec (err, result) ->
+
+			deletions = keysToDelete.map (k) ->
+				(cb) ->
+					rclient.del k, cb
+
+			Async.series deletions, (err, _result) ->
+				if err?
+					logger.err {err, user_id: user._id, sessionSetKey}, "errror revoking all sessions for user"
+					return callback(err)
+				rclient.srem sessionSetKey, keysToDelete, (err) ->
 					if err?
 					if err?
-						logger.err {err, user_id: user._id, sessionSetKey}, "error revoking all sessions for user"
+						logger.err {err, user_id: user._id, sessionSetKey}, "error removing session set for user"
 						return callback(err)
 						return callback(err)
 					callback(null)
 					callback(null)
 
 
@@ -86,7 +119,7 @@ module.exports = UserSessionsManager =
 		if !user
 		if !user
 			logger.log {}, "no user to touch sessions for, returning"
 			logger.log {}, "no user to touch sessions for, returning"
 			return callback(null)
 			return callback(null)
-		sessionSetKey = UserSessionsManager._sessionSetKey(user)
+		sessionSetKey = UserSessionsRedis.sessionSetKey(user)
 		rclient.expire sessionSetKey, "#{Settings.cookieSessionLength}", (err, response) ->
 		rclient.expire sessionSetKey, "#{Settings.cookieSessionLength}", (err, response) ->
 			if err?
 			if err?
 				logger.err {err, user_id: user._id}, "error while updating ttl on UserSessions set"
 				logger.err {err, user_id: user._id}, "error while updating ttl on UserSessions set"
@@ -98,7 +131,7 @@ module.exports = UserSessionsManager =
 			logger.log {}, "no user, returning"
 			logger.log {}, "no user, returning"
 			return callback(null)
 			return callback(null)
 		logger.log {user_id: user._id}, "checking sessions for user"
 		logger.log {user_id: user._id}, "checking sessions for user"
-		sessionSetKey = UserSessionsManager._sessionSetKey(user)
+		sessionSetKey = UserSessionsRedis.sessionSetKey(user)
 		rclient.smembers sessionSetKey, (err, sessionKeys) ->
 		rclient.smembers sessionSetKey, (err, sessionKeys) ->
 			if err?
 			if err?
 				logger.err {err, user_id: user._id, sessionSetKey}, "error getting contents of UserSessions set"
 				logger.err {err, user_id: user._id, sessionSetKey}, "error getting contents of UserSessions set"

+ 21 - 0
services/web/app/coffee/Features/User/UserSessionsRedis.coffee

@@ -0,0 +1,21 @@
+Settings = require 'settings-sharelatex'
+redis = require 'redis-sharelatex'
+ioredis = require 'ioredis'
+logger = require 'logger-sharelatex'
+
+redisSessionsSettings = Settings.redis.websessions or Settings.redis.web
+
+module.exports = Redis =
+	client: () ->
+		if redisSessionsSettings?.cluster?
+			logger.log {}, "using redis cluster for web sessions"
+			rclient = new ioredis.Cluster(redisSessionsSettings.cluster)
+		else
+			rclient = redis.createClient(redisSessionsSettings)
+		return rclient
+
+	sessionSetKey: (user) ->
+		if redisSessionsSettings?.cluster?
+			return "UserSessions:{#{user._id}}"
+		else
+			return "UserSessions:#{user._id}"

+ 67 - 45
services/web/app/coffee/infrastructure/ExpressLocals.coffee

@@ -5,42 +5,54 @@ Settings = require('settings-sharelatex')
 SubscriptionFormatters = require('../Features/Subscription/SubscriptionFormatters')
 SubscriptionFormatters = require('../Features/Subscription/SubscriptionFormatters')
 querystring = require('querystring')
 querystring = require('querystring')
 SystemMessageManager = require("../Features/SystemMessages/SystemMessageManager")
 SystemMessageManager = require("../Features/SystemMessages/SystemMessageManager")
+AuthenticationController = require("../Features/Authentication/AuthenticationController")
 _ = require("underscore")
 _ = require("underscore")
+async = require("async")
 Modules = require "./Modules"
 Modules = require "./Modules"
 Url = require "url"
 Url = require "url"
-
+PackageVersions = require "./PackageVersions"
 fingerprints = {}
 fingerprints = {}
 Path = require 'path'
 Path = require 'path'
 
 
-
 jsPath =
 jsPath =
 	if Settings.useMinifiedJs
 	if Settings.useMinifiedJs
 		"/minjs/"
 		"/minjs/"
 	else
 	else
 		"/js/"
 		"/js/"
 
 
+ace = PackageVersions.lib('ace')
+pdfjs = PackageVersions.lib('pdfjs')
 
 
-logger.log "Generating file fingerprints..."
-for path in [
-	"#{jsPath}libs/require.js",
-	"#{jsPath}ide.js",
-	"#{jsPath}main.js",
-	"#{jsPath}libs.js",
-	"#{jsPath}ace/ace.js",
-	"#{jsPath}libs/pdfjs-1.3.91/pdf.js",
-	"#{jsPath}libs/pdfjs-1.3.91/pdf.worker.js",
-	"#{jsPath}libs/pdfjs-1.3.91/compatibility.js",
-	"/stylesheets/style.css"
-]
-	filePath = Path.join __dirname, "../../../", "public#{path}"
+getFileContent = (filePath)->
+	filePath = Path.join __dirname, "../../../", "public#{filePath}"
 	exists = fs.existsSync filePath
 	exists = fs.existsSync filePath
 	if exists
 	if exists
 		content = fs.readFileSync filePath
 		content = fs.readFileSync filePath
-		hash = crypto.createHash("md5").update(content).digest("hex")
-		logger.log "#{filePath}: #{hash}"
-		fingerprints[path] = hash
+		return content
 	else
 	else
 		logger.log filePath:filePath, "file does not exist for fingerprints"
 		logger.log filePath:filePath, "file does not exist for fingerprints"
+		return ""
+
+logger.log "Generating file fingerprints..."
+pathList = [
+	["#{jsPath}libs/require.js"]
+	["#{jsPath}ide.js"]
+	["#{jsPath}main.js"]
+	["#{jsPath}libs.js"]
+	["#{jsPath}#{ace}/ace.js","#{jsPath}#{ace}/mode-latex.js","#{jsPath}#{ace}/worker-latex.js","#{jsPath}#{ace}/snippets/latex.js"]
+	["#{jsPath}libs/#{pdfjs}/pdf.js"]
+	["#{jsPath}libs/#{pdfjs}/pdf.worker.js"]
+	["#{jsPath}libs/#{pdfjs}/compatibility.js"]
+	["/stylesheets/style.css"]
+]
+
+for paths in pathList
+	contentList = _.map(paths, getFileContent)
+	content = contentList.join("")
+	hash = crypto.createHash("md5").update(content).digest("hex")
+	_.each paths, (filePath)-> 
+		logger.log "#{filePath}: #{hash}"
+		fingerprints[filePath] = hash
 
 
 getFingerprint = (path) ->
 getFingerprint = (path) ->
 	if fingerprints[path]?
 	if fingerprints[path]?
@@ -59,12 +71,13 @@ module.exports = (app, webRouter, apiRouter)->
 		res.locals.session = req.session
 		res.locals.session = req.session
 		next()
 		next()
 
 
-	webRouter.use (req, res, next)-> 
+	webRouter.use (req, res, next)->
 
 
 		cdnBlocked = req.query.nocdn == 'true' or req.session.cdnBlocked
 		cdnBlocked = req.query.nocdn == 'true' or req.session.cdnBlocked
+		user_id = AuthenticationController.getLoggedInUserId(req)
 
 
 		if cdnBlocked and !req.session.cdnBlocked?
 		if cdnBlocked and !req.session.cdnBlocked?
-			logger.log user_id:req?.session?.user?._id, ip:req?.ip, "cdnBlocked for user, not using it and turning it off for future requets"
+			logger.log user_id:user_id, ip:req?.ip, "cdnBlocked for user, not using it and turning it off for future requets"
 			req.session.cdnBlocked = true
 			req.session.cdnBlocked = true
 
 
 		isDark = req.headers?.host?.slice(0,4)?.toLowerCase() == "dark"
 		isDark = req.headers?.host?.slice(0,4)?.toLowerCase() == "dark"
@@ -77,16 +90,16 @@ module.exports = (app, webRouter, apiRouter)->
 			staticFilesBase = Settings.cdn?.web?.darkHost
 			staticFilesBase = Settings.cdn?.web?.darkHost
 		else
 		else
 			staticFilesBase = ""
 			staticFilesBase = ""
-		
+
 		res.locals.jsPath = jsPath
 		res.locals.jsPath = jsPath
 		res.locals.fullJsPath = Url.resolve(staticFilesBase, jsPath)
 		res.locals.fullJsPath = Url.resolve(staticFilesBase, jsPath)
-
+		res.locals.lib = PackageVersions.lib
 
 
 		res.locals.buildJsPath = (jsFile, opts = {})->
 		res.locals.buildJsPath = (jsFile, opts = {})->
 			path = Path.join(jsPath, jsFile)
 			path = Path.join(jsPath, jsFile)
 
 
 			doFingerPrint = opts.fingerprint != false
 			doFingerPrint = opts.fingerprint != false
-			
+
 			if !opts.qs?
 			if !opts.qs?
 				opts.qs = {}
 				opts.qs = {}
 
 
@@ -95,14 +108,13 @@ module.exports = (app, webRouter, apiRouter)->
 
 
 			if opts.cdn != false
 			if opts.cdn != false
 				path = Url.resolve(staticFilesBase, path)
 				path = Url.resolve(staticFilesBase, path)
-				
+
 			qs = querystring.stringify(opts.qs)
 			qs = querystring.stringify(opts.qs)
 
 
 			if qs? and qs.length > 0
 			if qs? and qs.length > 0
 				path = path + "?" + qs
 				path = path + "?" + qs
 			return path
 			return path
 
 
-
 		res.locals.buildCssPath = (cssFile)->
 		res.locals.buildCssPath = (cssFile)->
 			path = Path.join("/stylesheets/", cssFile)
 			path = Path.join("/stylesheets/", cssFile)
 			return Url.resolve(staticFilesBase, path) + "?fingerprint=" + getFingerprint(path)
 			return Url.resolve(staticFilesBase, path) + "?fingerprint=" + getFingerprint(path)
@@ -115,7 +127,7 @@ module.exports = (app, webRouter, apiRouter)->
 
 
 
 
 
 
-	webRouter.use (req, res, next)-> 
+	webRouter.use (req, res, next)->
 		res.locals.settings = Settings
 		res.locals.settings = Settings
 		next()
 		next()
 
 
@@ -123,7 +135,9 @@ module.exports = (app, webRouter, apiRouter)->
 		res.locals.translate = (key, vars = {}) ->
 		res.locals.translate = (key, vars = {}) ->
 			vars.appName = Settings.appName
 			vars.appName = Settings.appName
 			req.i18n.translate(key, vars)
 			req.i18n.translate(key, vars)
-		res.locals.currentUrl = req.originalUrl
+		# Don't include the query string parameters, otherwise Google
+		# treats ?nocdn=true as the canonical version
+		res.locals.currentUrl = Url.parse(req.originalUrl).pathname
 		next()
 		next()
 
 
 	webRouter.use (req, res, next)->
 	webRouter.use (req, res, next)->
@@ -131,9 +145,10 @@ module.exports = (app, webRouter, apiRouter)->
 			Settings.siteUrl.substring(Settings.siteUrl.indexOf("//")+2)
 			Settings.siteUrl.substring(Settings.siteUrl.indexOf("//")+2)
 		next()
 		next()
 
 
-	webRouter.use (req, res, next)->
+	webRouter.use (req, res, next) ->
 		res.locals.getUserEmail = ->
 		res.locals.getUserEmail = ->
-			email = req?.session?.user?.email or ""
+			user = AuthenticationController.getSessionUser(req)
+			email = user?.email or ""
 			return email
 			return email
 		next()
 		next()
 
 
@@ -143,15 +158,17 @@ module.exports = (app, webRouter, apiRouter)->
 			return formatedPrivileges[privilegeLevel] || "Private"
 			return formatedPrivileges[privilegeLevel] || "Private"
 		next()
 		next()
 
 
-	webRouter.use (req, res, next)-> 
+	webRouter.use (req, res, next)->
 		res.locals.buildReferalUrl = (referal_medium) ->
 		res.locals.buildReferalUrl = (referal_medium) ->
 			url = Settings.siteUrl
 			url = Settings.siteUrl
-			if req.session? and req.session.user? and req.session.user.referal_id?
-				url+="?r=#{req.session.user.referal_id}&rm=#{referal_medium}&rs=b" # Referal source = bonus
+			currentUser = AuthenticationController.getSessionUser(req)
+			if currentUser? and currentUser?.referal_id?
+				url+="?r=#{currentUser.referal_id}&rm=#{referal_medium}&rs=b" # Referal source = bonus
 			return url
 			return url
 		res.locals.getReferalId = ->
 		res.locals.getReferalId = ->
-			if req.session? and req.session.user? and req.session.user.referal_id
-				return req.session.user.referal_id
+			currentUser = AuthenticationController.getSessionUser(req)
+			if currentUser? and currentUser?.referal_id?
+				return currentUser.referal_id
 		res.locals.getReferalTagLine = ->
 		res.locals.getReferalTagLine = ->
 			tagLines = [
 			tagLines = [
 				"Roar!"
 				"Roar!"
@@ -167,7 +184,11 @@ module.exports = (app, webRouter, apiRouter)->
 			return ""
 			return ""
 
 
 		res.locals.getLoggedInUserId = ->
 		res.locals.getLoggedInUserId = ->
-			return req.session.user?._id
+			return AuthenticationController.getLoggedInUserId(req)
+		res.locals.isUserLoggedIn = ->
+			return AuthenticationController.isUserLoggedIn(req)
+		res.locals.getSessionUser = ->
+			return AuthenticationController.getSessionUser(req)
 		next()
 		next()
 
 
 	webRouter.use (req, res, next) ->
 	webRouter.use (req, res, next) ->
@@ -179,25 +200,26 @@ module.exports = (app, webRouter, apiRouter)->
 			return req.query?[field]
 			return req.query?[field]
 		next()
 		next()
 
 
-	webRouter.use (req, res, next)-> 
+	webRouter.use (req, res, next)->
 		res.locals.fingerprint = getFingerprint
 		res.locals.fingerprint = getFingerprint
 		next()
 		next()
 
 
-	webRouter.use (req, res, next)-> 
+	webRouter.use (req, res, next)->
 		res.locals.formatPrice = SubscriptionFormatters.formatPrice
 		res.locals.formatPrice = SubscriptionFormatters.formatPrice
 		next()
 		next()
 
 
 	webRouter.use (req, res, next)->
 	webRouter.use (req, res, next)->
 		res.locals.externalAuthenticationSystemUsed = ->
 		res.locals.externalAuthenticationSystemUsed = ->
-			Settings.ldap?
+			Settings.ldap? or Settings.saml?
 		next()
 		next()
 
 
 	webRouter.use (req, res, next)->
 	webRouter.use (req, res, next)->
-		if req.session.user?
+		currentUser = AuthenticationController.getSessionUser(req)
+		if currentUser?
 			res.locals.user =
 			res.locals.user =
-				email: req.session.user.email
-				first_name: req.session.user.first_name
-				last_name: req.session.user.last_name
+				email: currentUser.email
+				first_name: currentUser.first_name
+				last_name: currentUser.last_name
 			if req.session.justRegistered
 			if req.session.justRegistered
 				res.locals.justRegistered = true
 				res.locals.justRegistered = true
 				delete req.session.justRegistered
 				delete req.session.justRegistered
@@ -222,8 +244,10 @@ module.exports = (app, webRouter, apiRouter)->
 		for key, value of Settings.nav
 		for key, value of Settings.nav
 			res.locals.nav[key] = _.clone(Settings.nav[key])
 			res.locals.nav[key] = _.clone(Settings.nav[key])
 		res.locals.templates = Settings.templateLinks
 		res.locals.templates = Settings.templateLinks
+		if res.locals.nav.header
+			console.error {}, "The `nav.header` setting is no longer supported, use `nav.header_extras` instead"
 		next()
 		next()
-		
+
 	webRouter.use (req, res, next) ->
 	webRouter.use (req, res, next) ->
 		SystemMessageManager.getMessages (error, messages = []) ->
 		SystemMessageManager.getMessages (error, messages = []) ->
 			res.locals.systemMessages = messages
 			res.locals.systemMessages = messages
@@ -246,5 +270,3 @@ module.exports = (app, webRouter, apiRouter)->
 		res.locals.moduleIncludes = Modules.moduleIncludes
 		res.locals.moduleIncludes = Modules.moduleIncludes
 		res.locals.moduleIncludesAvailable = Modules.moduleIncludesAvailable
 		res.locals.moduleIncludesAvailable = Modules.moduleIncludesAvailable
 		next()
 		next()
-
-

+ 4 - 0
services/web/app/coffee/infrastructure/LoggerSerializers.coffee

@@ -1,5 +1,7 @@
 module.exports =
 module.exports =
 	user: (user) ->
 	user: (user) ->
+		if !user?
+			return null
 		if !user._id?
 		if !user._id?
 			user = {_id : user}
 			user = {_id : user}
 		return {
 		return {
@@ -10,6 +12,8 @@ module.exports =
 		}
 		}
 	
 	
 	project: (project) ->
 	project: (project) ->
+		if !project?
+			return null
 		if !project._id?
 		if !project._id?
 			project = {_id: project}
 			project = {_id: project}
 		return {
 		return {

+ 10 - 6
services/web/app/coffee/infrastructure/Modules.coffee

@@ -18,6 +18,10 @@ module.exports = Modules =
 	applyRouter: (webRouter, apiRouter) ->
 	applyRouter: (webRouter, apiRouter) ->
 		for module in @modules
 		for module in @modules
 			module.router?.apply(webRouter, apiRouter)
 			module.router?.apply(webRouter, apiRouter)
+
+	applyNonCsrfRouter: (webRouter, apiRouter) ->
+		for module in @modules
+			module.nonCsrfRouter?.apply(webRouter, apiRouter)
 			
 			
 	viewIncludes: {}
 	viewIncludes: {}
 	loadViewIncludes: (app) ->
 	loadViewIncludes: (app) ->
@@ -25,14 +29,14 @@ module.exports = Modules =
 		for module in @modules
 		for module in @modules
 			for view, partial of module.viewIncludes or {}
 			for view, partial of module.viewIncludes or {}
 				@viewIncludes[view] ||= []
 				@viewIncludes[view] ||= []
-				@viewIncludes[view].push fs.readFileSync(Path.join(MODULE_BASE_PATH, module.name, "app/views", partial + ".jade"))
+				@viewIncludes[view].push jade.compile(fs.readFileSync(Path.join(MODULE_BASE_PATH, module.name, "app/views", partial + ".jade")), doctype: "html")
 			
 			
 	moduleIncludes: (view, locals) ->
 	moduleIncludes: (view, locals) ->
-		partials = Modules.viewIncludes[view] or []
+		compiledPartials = Modules.viewIncludes[view] or []
 		html = ""
 		html = ""
-		for partial in partials
-			compiler = jade.compile(partial, doctype: "html")
-			html += compiler(locals)
+		for compiledPartial in compiledPartials
+			d = new Date()
+			html += compiledPartial(locals)
 		return html
 		return html
 
 
 	moduleIncludesAvailable: (view) ->
 	moduleIncludesAvailable: (view) ->
@@ -58,4 +62,4 @@ module.exports = Modules =
 				return callback(error) if error?
 				return callback(error) if error?
 				return callback null, results
 				return callback null, results
 		
 		
-Modules.loadModules()
+Modules.loadModules()

+ 15 - 0
services/web/app/coffee/infrastructure/PackageVersions.coffee

@@ -0,0 +1,15 @@
+version = {
+	"pdfjs": "1.6.210p2"
+	"moment": "2.9.0"
+	"ace": "1.2.5"
+}
+
+module.exports = {
+	version: version
+
+	lib: (name) ->
+		if version[name]?
+			return "#{name}-#{version[name]}"
+		else
+			return "#{name}"
+}

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

@@ -8,7 +8,9 @@ expressLocals = require('./ExpressLocals')
 Router = require('../router')
 Router = require('../router')
 metrics.inc("startup")
 metrics.inc("startup")
 redis = require("redis-sharelatex")
 redis = require("redis-sharelatex")
-rclient = redis.createClient(Settings.redis.web)
+UserSessionsRedis = require('../Features/User/UserSessionsRedis')
+
+sessionsRedisClient = UserSessionsRedis.client()
 
 
 session = require("express-session")
 session = require("express-session")
 RedisStore = require('connect-redis')(session)
 RedisStore = require('connect-redis')(session)
@@ -19,7 +21,11 @@ csrf = require('csurf')
 csrfProtection = csrf()
 csrfProtection = csrf()
 cookieParser = require('cookie-parser')
 cookieParser = require('cookie-parser')
 
 
-sessionStore = new RedisStore(client:rclient)
+# Init the session store
+sessionStore = new RedisStore(client:sessionsRedisClient)
+
+passport = require('passport')
+LocalStrategy = require('passport-local').Strategy
 
 
 Mongoose = require("./Mongoose")
 Mongoose = require("./Mongoose")
 
 
@@ -32,6 +38,7 @@ Modules = require "./Modules"
 
 
 ErrorController = require "../Features/Errors/ErrorController"
 ErrorController = require "../Features/Errors/ErrorController"
 UserSessionsManager = require "../Features/User/UserSessionsManager"
 UserSessionsManager = require "../Features/User/UserSessionsManager"
+AuthenticationController = require "../Features/Authentication/AuthenticationController"
 
 
 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)
@@ -83,6 +90,29 @@ webRouter.use session
 		secure: Settings.secureCookie
 		secure: Settings.secureCookie
 	store: sessionStore
 	store: sessionStore
 	key: Settings.cookieName
 	key: Settings.cookieName
+	rolling: true
+
+# passport
+webRouter.use passport.initialize()
+webRouter.use passport.session()
+
+passport.use(new LocalStrategy(
+	{
+		passReqToCallback: true,
+		usernameField: 'email',
+		passwordField: 'password'
+	},
+	AuthenticationController.doPassportLogin
+))
+passport.serializeUser(AuthenticationController.serializeUser)
+passport.deserializeUser(AuthenticationController.deserializeUser)
+
+Modules.hooks.fire 'passportSetup', passport, (err) ->
+	if err?
+		logger.err {err}, "error setting up passport in modules"
+
+Modules.applyNonCsrfRouter(webRouter, apiRouter)
+
 webRouter.use csrfProtection
 webRouter.use csrfProtection
 webRouter.use translations.expressMiddlewear
 webRouter.use translations.expressMiddlewear
 webRouter.use translations.setLangBasedOnDomainMiddlewear
 webRouter.use translations.setLangBasedOnDomainMiddlewear
@@ -90,8 +120,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)->)
+	if AuthenticationController.isUserLoggedIn(req)
+		UserSessionsManager.touch(AuthenticationController.getSessionUser(req), (err)->)
 	next()
 	next()
 
 
 webRouter.use ReferalConnect.use
 webRouter.use ReferalConnect.use

+ 1 - 0
services/web/app/coffee/models/Project.coffee

@@ -32,6 +32,7 @@ ProjectSchema = new Schema
 	archived          : { type: Boolean }
 	archived          : { type: Boolean }
 	deletedDocs       : [DeletedDocSchema]
 	deletedDocs       : [DeletedDocSchema]
 	imageName         : { type: String }
 	imageName         : { type: String }
+	track_changes     : { type: Boolean }
 
 
 ProjectSchema.statics.getProject = (project_or_id, fields, callback)->
 ProjectSchema.statics.getProject = (project_or_id, fields, callback)->
 	if project_or_id._id?
 	if project_or_id._id?

+ 2 - 1
services/web/app/coffee/models/User.coffee

@@ -26,6 +26,7 @@ UserSchema = new Schema
 							autoComplete:   {type : Boolean, default: true}
 							autoComplete:   {type : Boolean, default: true}
 							spellCheckLanguage :   {type : String, default: "en"}
 							spellCheckLanguage :   {type : String, default: "en"}
 							pdfViewer   :   {type : String, default: "pdfjs"}
 							pdfViewer   :   {type : String, default: "pdfjs"}
+							syntaxValidation   :   {type : Boolean}
 						}
 						}
 	features		  : {
 	features		  : {
 							collaborators: { type:Number,  default: Settings.defaultFeatures.collaborators }
 							collaborators: { type:Number,  default: Settings.defaultFeatures.collaborators }
@@ -38,7 +39,7 @@ UserSchema = new Schema
 							references:    { type:Boolean, default: Settings.defaultFeatures.references }
 							references:    { type:Boolean, default: Settings.defaultFeatures.references }
 						}
 						}
 	featureSwitches	  : {
 	featureSwitches	  : {
-		pdfng: { type: Boolean }
+		track_changes: { type: Boolean }
 	}
 	}
 	referal_id : {type:String, default:() -> uuid.v4().split("-")[0]}
 	referal_id : {type:String, default:() -> uuid.v4().split("-")[0]}
 	refered_users: [ type:ObjectId, ref:'User' ]
 	refered_users: [ type:ObjectId, ref:'User' ]

+ 51 - 17
services/web/app/coffee/router.coffee

@@ -11,7 +11,6 @@ SubscriptionRouter = require './Features/Subscription/SubscriptionRouter'
 UploadsRouter = require './Features/Uploads/UploadsRouter'
 UploadsRouter = require './Features/Uploads/UploadsRouter'
 metrics = require('./infrastructure/Metrics')
 metrics = require('./infrastructure/Metrics')
 ReferalController = require('./Features/Referal/ReferalController')
 ReferalController = require('./Features/Referal/ReferalController')
-ReferalMiddleware = require('./Features/Referal/ReferalMiddleware')
 AuthenticationController = require('./Features/Authentication/AuthenticationController')
 AuthenticationController = require('./Features/Authentication/AuthenticationController')
 TagsController = require("./Features/Tags/TagsController")
 TagsController = require("./Features/Tags/TagsController")
 NotificationsController = require("./Features/Notifications/NotificationsController")
 NotificationsController = require("./Features/Notifications/NotificationsController")
@@ -22,10 +21,11 @@ UserPagesController = require('./Features/User/UserPagesController')
 DocumentController = require('./Features/Documents/DocumentController')
 DocumentController = require('./Features/Documents/DocumentController')
 CompileManager = require("./Features/Compile/CompileManager")
 CompileManager = require("./Features/Compile/CompileManager")
 CompileController = require("./Features/Compile/CompileController")
 CompileController = require("./Features/Compile/CompileController")
+ClsiCookieManager = require("./Features/Compile/ClsiCookieManager")
 HealthCheckController = require("./Features/HealthCheck/HealthCheckController")
 HealthCheckController = require("./Features/HealthCheck/HealthCheckController")
 ProjectDownloadsController = require "./Features/Downloads/ProjectDownloadsController"
 ProjectDownloadsController = require "./Features/Downloads/ProjectDownloadsController"
 FileStoreController = require("./Features/FileStore/FileStoreController")
 FileStoreController = require("./Features/FileStore/FileStoreController")
-TrackChangesController = require("./Features/TrackChanges/TrackChangesController")
+HistoryController = require("./Features/History/HistoryController")
 PasswordResetRouter = require("./Features/PasswordReset/PasswordResetRouter")
 PasswordResetRouter = require("./Features/PasswordReset/PasswordResetRouter")
 StaticPagesRouter = require("./Features/StaticPages/StaticPagesRouter")
 StaticPagesRouter = require("./Features/StaticPages/StaticPagesRouter")
 ChatController = require("./Features/Chat/ChatController")
 ChatController = require("./Features/Chat/ChatController")
@@ -39,6 +39,9 @@ ReferencesController = require('./Features/References/ReferencesController')
 AuthorizationMiddlewear = require('./Features/Authorization/AuthorizationMiddlewear')
 AuthorizationMiddlewear = require('./Features/Authorization/AuthorizationMiddlewear')
 BetaProgramController = require('./Features/BetaProgram/BetaProgramController')
 BetaProgramController = require('./Features/BetaProgram/BetaProgramController')
 AnalyticsRouter = require('./Features/Analytics/AnalyticsRouter')
 AnalyticsRouter = require('./Features/Analytics/AnalyticsRouter')
+AnnouncementsController = require("./Features/Announcements/AnnouncementsController")
+TrackChangesController = require("./Features/TrackChanges/TrackChangesController")
+CommentsController = require "./Features/Comments/CommentsController"
 
 
 logger = require("logger-sharelatex")
 logger = require("logger-sharelatex")
 _ = require("underscore")
 _ = require("underscore")
@@ -52,7 +55,8 @@ module.exports = class Router
 		webRouter.get  '/login', UserPagesController.loginPage
 		webRouter.get  '/login', UserPagesController.loginPage
 		AuthenticationController.addEndpointToLoginWhitelist '/login'
 		AuthenticationController.addEndpointToLoginWhitelist '/login'
 
 
-		webRouter.post '/login', AuthenticationController.login
+		webRouter.post '/login', AuthenticationController.passportLogin
+
 		webRouter.get  '/logout', UserController.logout
 		webRouter.get  '/logout', UserController.logout
 		webRouter.get  '/restricted', AuthorizationMiddlewear.restricted
 		webRouter.get  '/restricted', AuthorizationMiddlewear.restricted
 
 
@@ -70,12 +74,12 @@ module.exports = class Router
 		RealTimeProxyRouter.apply(webRouter, apiRouter)
 		RealTimeProxyRouter.apply(webRouter, apiRouter)
 		ContactRouter.apply(webRouter, apiRouter)
 		ContactRouter.apply(webRouter, apiRouter)
 		AnalyticsRouter.apply(webRouter, apiRouter)
 		AnalyticsRouter.apply(webRouter, apiRouter)
-		
+
 		Modules.applyRouter(webRouter, apiRouter)
 		Modules.applyRouter(webRouter, apiRouter)
 
 
 
 
 		if Settings.enableSubscriptions
 		if Settings.enableSubscriptions
-			webRouter.get  '/user/bonus', AuthenticationController.requireLogin(), ReferalMiddleware.getUserReferalId, ReferalController.bonus
+			webRouter.get  '/user/bonus', AuthenticationController.requireLogin(), ReferalController.bonus
 
 
 		webRouter.get '/blog', BlogController.getIndexPage
 		webRouter.get '/blog', BlogController.getIndexPage
 		webRouter.get '/blog/*', BlogController.getPage
 		webRouter.get '/blog/*', BlogController.getPage
@@ -87,8 +91,11 @@ module.exports = class Router
 		webRouter.post '/user/settings', AuthenticationController.requireLogin(), UserController.updateUserSettings
 		webRouter.post '/user/settings', AuthenticationController.requireLogin(), UserController.updateUserSettings
 		webRouter.post '/user/password/update', AuthenticationController.requireLogin(), UserController.changePassword
 		webRouter.post '/user/password/update', AuthenticationController.requireLogin(), UserController.changePassword
 
 
+		webRouter.get  '/user/sessions', AuthenticationController.requireLogin(), UserPagesController.sessionsPage
+		webRouter.post '/user/sessions/clear', AuthenticationController.requireLogin(), UserController.clearSessions
+
 		webRouter.delete '/user/newsletter/unsubscribe', AuthenticationController.requireLogin(), UserController.unsubscribe
 		webRouter.delete '/user/newsletter/unsubscribe', AuthenticationController.requireLogin(), UserController.unsubscribe
-		webRouter.delete '/user', AuthenticationController.requireLogin(), UserController.deleteUser
+		webRouter.post '/user/delete', AuthenticationController.requireLogin(), UserController.tryDeleteUser
 
 
 		webRouter.get  '/user/personal_info', AuthenticationController.requireLogin(), UserInfoController.getLoggedInUsersPersonalInfo
 		webRouter.get  '/user/personal_info', AuthenticationController.requireLogin(), UserInfoController.getLoggedInUsersPersonalInfo
 		apiRouter.get  '/user/:user_id/personal_info', AuthenticationController.httpAuth, UserInfoController.getPersonalInfo
 		apiRouter.get  '/user/:user_id/personal_info', AuthenticationController.httpAuth, UserInfoController.getPersonalInfo
@@ -166,9 +173,14 @@ module.exports = class Router
 
 
 		webRouter.post '/project/:Project_id/rename', AuthorizationMiddlewear.ensureUserCanAdminProject, ProjectController.renameProject
 		webRouter.post '/project/:Project_id/rename', AuthorizationMiddlewear.ensureUserCanAdminProject, ProjectController.renameProject
 
 
-		webRouter.get  "/project/:Project_id/updates", AuthorizationMiddlewear.ensureUserCanReadProject, TrackChangesController.proxyToTrackChangesApi
-		webRouter.get  "/project/:Project_id/doc/:doc_id/diff", AuthorizationMiddlewear.ensureUserCanReadProject, TrackChangesController.proxyToTrackChangesApi
-		webRouter.post "/project/:Project_id/doc/:doc_id/version/:version_id/restore", AuthorizationMiddlewear.ensureUserCanReadProject, TrackChangesController.proxyToTrackChangesApi
+		webRouter.get  "/project/:Project_id/updates", AuthorizationMiddlewear.ensureUserCanReadProject, HistoryController.proxyToHistoryApi
+		webRouter.get  "/project/:Project_id/doc/:doc_id/diff", AuthorizationMiddlewear.ensureUserCanReadProject, HistoryController.proxyToHistoryApi
+		webRouter.post "/project/:Project_id/doc/:doc_id/version/:version_id/restore", AuthorizationMiddlewear.ensureUserCanReadProject, HistoryController.proxyToHistoryApi
+
+		webRouter.get "/project/:project_id/ranges", AuthorizationMiddlewear.ensureUserCanReadProject, TrackChangesController.getAllRanges
+		webRouter.get "/project/:project_id/changes/users", AuthorizationMiddlewear.ensureUserCanReadProject, TrackChangesController.getAllChangesUsers
+		webRouter.post "/project/:project_id/doc/:doc_id/changes/:change_id/accept", AuthorizationMiddlewear.ensureUserCanWriteProjectContent, TrackChangesController.acceptChange
+		webRouter.post "/project/:project_id/track_changes", AuthorizationMiddlewear.ensureUserCanWriteProjectContent, TrackChangesController.toggleTrackChanges
 
 
 		webRouter.get  '/Project/:Project_id/download/zip', AuthorizationMiddlewear.ensureUserCanReadProject, ProjectDownloadsController.downloadProject
 		webRouter.get  '/Project/:Project_id/download/zip', AuthorizationMiddlewear.ensureUserCanReadProject, ProjectDownloadsController.downloadProject
 		webRouter.get  '/project/download/zip', AuthorizationMiddlewear.ensureUserCanReadMultipleProjects, ProjectDownloadsController.downloadMultipleProjects
 		webRouter.get  '/project/download/zip', AuthorizationMiddlewear.ensureUserCanReadMultipleProjects, ProjectDownloadsController.downloadMultipleProjects
@@ -181,7 +193,10 @@ module.exports = class Router
 		webRouter.delete '/tag/:tag_id/project/:project_id', AuthenticationController.requireLogin(), TagsController.removeProjectFromTag
 		webRouter.delete '/tag/:tag_id/project/:project_id', AuthenticationController.requireLogin(), TagsController.removeProjectFromTag
 
 
 		webRouter.get '/notifications', AuthenticationController.requireLogin(), NotificationsController.getAllUnreadNotifications
 		webRouter.get '/notifications', AuthenticationController.requireLogin(), NotificationsController.getAllUnreadNotifications
-		webRouter.delete '/notifications/:notification_id', AuthenticationController.requireLogin(), NotificationsController.markNotificationAsRead	
+		webRouter.delete '/notifications/:notification_id', AuthenticationController.requireLogin(), NotificationsController.markNotificationAsRead
+
+		webRouter.get '/announcements', AuthenticationController.requireLogin(), AnnouncementsController.getUndreadAnnouncements
+
 
 
 		# Deprecated in favour of /internal/project/:project_id but still used by versioning
 		# Deprecated in favour of /internal/project/:project_id but still used by versioning
 		apiRouter.get  '/project/:project_id/details', AuthenticationController.httpAuth, ProjectApiController.getProjectDetails
 		apiRouter.get  '/project/:project_id/details', AuthenticationController.httpAuth, ProjectApiController.getProjectDetails
@@ -215,8 +230,14 @@ module.exports = class Router
 		webRouter.post "/spelling/check", AuthenticationController.requireLogin(), SpellingController.proxyRequestToSpellingApi
 		webRouter.post "/spelling/check", AuthenticationController.requireLogin(), SpellingController.proxyRequestToSpellingApi
 		webRouter.post "/spelling/learn", AuthenticationController.requireLogin(), SpellingController.proxyRequestToSpellingApi
 		webRouter.post "/spelling/learn", AuthenticationController.requireLogin(), SpellingController.proxyRequestToSpellingApi
 
 
-		webRouter.get  "/project/:Project_id/messages", AuthorizationMiddlewear.ensureUserCanReadProject, ChatController.getMessages
-		webRouter.post "/project/:Project_id/messages", AuthorizationMiddlewear.ensureUserCanReadProject, ChatController.sendMessage
+		webRouter.get  "/project/:project_id/messages", AuthorizationMiddlewear.ensureUserCanReadProject, ChatController.getMessages
+		webRouter.post "/project/:project_id/messages", AuthorizationMiddlewear.ensureUserCanReadProject, ChatController.sendMessage
+		
+		# Note: Read only users can still comment
+		webRouter.post "/project/:project_id/thread/:thread_id/messages", AuthorizationMiddlewear.ensureUserCanReadProject, CommentsController.sendComment
+		webRouter.get  "/project/:project_id/threads", AuthorizationMiddlewear.ensureUserCanReadProject, CommentsController.getThreads
+		webRouter.post "/project/:project_id/thread/:thread_id/resolve", AuthorizationMiddlewear.ensureUserCanWriteProjectContent, CommentsController.resolveThread
+		webRouter.post "/project/:project_id/thread/:thread_id/reopen", AuthorizationMiddlewear.ensureUserCanWriteProjectContent, CommentsController.reopenThread
 
 
 		webRouter.post "/project/:Project_id/references/index", AuthorizationMiddlewear.ensureUserCanReadProject, ReferencesController.index
 		webRouter.post "/project/:Project_id/references/index", AuthorizationMiddlewear.ensureUserCanReadProject, ReferencesController.index
 		webRouter.post "/project/:Project_id/references/indexAll", AuthorizationMiddlewear.ensureUserCanReadProject, ReferencesController.indexAll
 		webRouter.post "/project/:Project_id/references/indexAll", AuthorizationMiddlewear.ensureUserCanReadProject, ReferencesController.indexAll
@@ -251,14 +272,27 @@ module.exports = class Router
 		apiRouter.get '/health_check/redis', HealthCheckController.checkRedis
 		apiRouter.get '/health_check/redis', HealthCheckController.checkRedis
 
 
 		apiRouter.get "/status/compiler/:Project_id", AuthorizationMiddlewear.ensureUserCanReadProject, (req, res) ->
 		apiRouter.get "/status/compiler/:Project_id", AuthorizationMiddlewear.ensureUserCanReadProject, (req, res) ->
+			project_id = req.params.Project_id
 			sendRes = _.once (statusCode, message)->
 			sendRes = _.once (statusCode, message)->
-				res.writeHead statusCode
-				res.end message
-			CompileManager.compile req.params.Project_id, "test-compile", {}, () ->
-				sendRes 200, "Compiler returned in less than 10 seconds"
-			setTimeout (() ->
+				res.status statusCode
+				res.send message
+				ClsiCookieManager.clearServerId project_id # force every compile to a new server
+			# set a timeout
+			handler = setTimeout (() ->
 				sendRes 500, "Compiler timed out"
 				sendRes 500, "Compiler timed out"
+				handler = null
 			), 10000
 			), 10000
+			# use a valid user id for testing
+			test_user_id = "123456789012345678901234"
+			# run the compile
+			CompileManager.compile project_id, test_user_id, {}, (error, status) ->
+				clearTimeout handler if handler?
+				if error?
+					sendRes 500, "Compiler returned error #{error.message}"
+				else if status is "success"
+					sendRes 200, "Compiler returned in less than 10 seconds"
+				else
+					sendRes 500, "Compiler returned failure #{status}"
 
 
 		apiRouter.get "/ip", (req, res, next) ->
 		apiRouter.get "/ip", (req, res, next) ->
 			res.send({
 			res.send({

+ 5 - 2
services/web/app/views/general/500.jade

@@ -3,7 +3,8 @@ html(itemscope, itemtype='http://schema.org/Product')
 	head
 	head
 		title Something went wrong
 		title Something went wrong
 		link(rel="icon", href="/favicon.ico")
 		link(rel="icon", href="/favicon.ico")
-		link(rel='stylesheet', href=buildCssPath('/style.css'))
+		if buildCssPath
+			link(rel='stylesheet', href=buildCssPath('/style.css'))
 		link(href="//netdna.bootstrapcdn.com/font-awesome/4.2.0/css/font-awesome.min.css",rel="stylesheet")
 		link(href="//netdna.bootstrapcdn.com/font-awesome/4.2.0/css/font-awesome.min.css",rel="stylesheet")
 	body
 	body
 		.content
 		.content
@@ -12,7 +13,9 @@ html(itemscope, itemtype='http://schema.org/Product')
 					.col-md-8.col-md-offset-2.text-center
 					.col-md-8.col-md-offset-2.text-center
 						.page-header
 						.page-header
 							h2 Oh dear, something went wrong.
 							h2 Oh dear, something went wrong.
-						p: img(src=buildImgPath("lion-sad-128.png"), alt="Sad Lion")
+						if buildImgPath
+							p
+								img(src=buildImgPath("lion-sad-128.png"), alt="Sad Lion")
 						p
 						p
 							| Something went wrong with your request, sorry. Our staff are probably looking into this, but if it continues, please contact us at #{settings.adminEmail}
 							| Something went wrong with your request, sorry. Our staff are probably looking into this, but if it continues, please contact us at #{settings.adminEmail}
 						p
 						p

+ 1 - 1
services/web/app/views/general/closed.jade

@@ -11,4 +11,4 @@ block content
 						| Sorry, ShareLaTeX is briefly down for maintenance.
 						| Sorry, ShareLaTeX is briefly down for maintenance.
 						| We should be back within minutes, but if not, or you have
 						| We should be back within minutes, but if not, or you have
 						| an urgent request, please contact us at
 						| an urgent request, please contact us at
-						| support@sharelatex.com
+						| #{settings.adminEmail}

+ 14 - 9
services/web/app/views/layout.jade

@@ -51,15 +51,17 @@ html(itemscope, itemtype='http://schema.org/Product')
 		script(type="text/javascript").
 		script(type="text/javascript").
 			window.csrfToken = "#{csrfToken}";
 			window.csrfToken = "#{csrfToken}";
 			
 			
-		block scripts
 		script(src=buildJsPath("libs/jquery-1.11.1.min.js", {fingerprint:false}))
 		script(src=buildJsPath("libs/jquery-1.11.1.min.js", {fingerprint:false}))
 		script(type="text/javascript").
 		script(type="text/javascript").
 			var noCdnKey = "nocdn=true"
 			var noCdnKey = "nocdn=true"
 			var cdnBlocked = typeof jQuery === 'undefined'
 			var cdnBlocked = typeof jQuery === 'undefined'
 			var noCdnAlreadyInUrl = window.location.href.indexOf(noCdnKey) != -1 //prevent loops
 			var noCdnAlreadyInUrl = window.location.href.indexOf(noCdnKey) != -1 //prevent loops
-			if (cdnBlocked && !noCdnAlreadyInUrl) {
+			if (cdnBlocked && !noCdnAlreadyInUrl && navigator.userAgent.indexOf("Googlebot") == -1) {
 				window.location.search += '&'+noCdnKey;
 				window.location.search += '&'+noCdnKey;
 			}
 			}
+
+		block scripts
+
 		script(src=buildJsPath("libs/angular-1.3.15.min.js", {fingerprint:false}))
 		script(src=buildJsPath("libs/angular-1.3.15.min.js", {fingerprint:false}))
 
 
 		script.
 		script.
@@ -110,23 +112,27 @@ html(itemscope, itemtype='http://schema.org/Product')
 			include layout/navbar
 			include layout/navbar
 
 
 		block content
 		block content
+
 		div(ng-controller="AbTestController")
 		div(ng-controller="AbTestController")
 		- if(typeof(suppressFooter) == "undefined")
 		- if(typeof(suppressFooter) == "undefined")
 			include layout/footer
 			include layout/footer
 
 
-
-
 		- if (typeof(lookingForScribtex) != "undefined" && lookingForScribtex)
 		- if (typeof(lookingForScribtex) != "undefined" && lookingForScribtex)
 			span(ng-controller="ScribtexPopupController")
 			span(ng-controller="ScribtexPopupController")
 			include scribtex-modal
 			include scribtex-modal
 
 
-
-		- if(typeof(suppressFooter) == "undefined")
+		block requirejs
 			script(type='text/javascript').
 			script(type='text/javascript').
+				// minimal requirejs configuration (can be extended/overridden)
 				window.requirejs = {
 				window.requirejs = {
-					"urlArgs" : "fingerprint=#{fingerprint(jsPath + 'main.js')}-#{fingerprint(jsPath + 'libs.js')}",
 					"paths" : {
 					"paths" : {
-						"moment": "libs/moment-2.7.0"
+						"moment": "libs/#{lib('moment')}"
+					},
+					"urlArgs": "fingerprint=#{fingerprint(jsPath + 'main.js')}-#{fingerprint(jsPath + 'libs.js')}",
+					"config":{
+						"moment":{
+							"noGlobal": true
+						}
 					}
 					}
 				};
 				};
 			script(
 			script(
@@ -135,7 +141,6 @@ html(itemscope, itemtype='http://schema.org/Product')
 				src=buildJsPath('libs/require.js')
 				src=buildJsPath('libs/require.js')
 			)
 			)
 
 
-
 		include contact-us-modal
 		include contact-us-modal
 		include sentry
 		include sentry
 		
 		

+ 37 - 6
services/web/app/views/layout/navbar.jade

@@ -13,7 +13,7 @@ nav.navbar.navbar-default
 		.navbar-collapse.collapse(collapse="navCollapsed")
 		.navbar-collapse.collapse(collapse="navCollapsed")
 
 
 			ul.nav.navbar-nav.navbar-right
 			ul.nav.navbar-nav.navbar-right
-				if (session && session.user && session.user.isAdmin)
+				if (getSessionUser() && getSessionUser().isAdmin)
 					li.dropdown(class="subdued", dropdown)
 					li.dropdown(class="subdued", dropdown)
 						a.dropdown-toggle(href, dropdown-toggle)
 						a.dropdown-toggle(href, dropdown-toggle)
 							| Admin
 							| Admin
@@ -24,8 +24,11 @@ nav.navbar.navbar-default
 							li
 							li
 								a(href="/admin/user") Manage Users
 								a(href="/admin/user") Manage Users
 
 
-				each item in nav.header
-					if ((item.only_when_logged_in && session && session.user) || (item.only_when_logged_out && (!session || !session.user)) || (!item.only_when_logged_out && !item.only_when_logged_in))
+
+				// loop over header_extras
+				each item in nav.header_extras
+
+					if ((item.only_when_logged_in && getSessionUser()) || (item.only_when_logged_out && (!getSessionUser())) || (!item.only_when_logged_out && !item.only_when_logged_in))
 						if item.dropdown
 						if item.dropdown
 							li.dropdown(class=item.class, dropdown)
 							li.dropdown(class=item.class, dropdown)
 								a.dropdown-toggle(href, dropdown-toggle)
 								a.dropdown-toggle(href, dropdown-toggle)
@@ -47,7 +50,35 @@ nav.navbar.navbar-default
 									a(href=item.url, class=item.class) !{translate(item.text)}
 									a(href=item.url, class=item.class) !{translate(item.text)}
 								else
 								else
 									| !{translate(item.text)}
 									| !{translate(item.text)}
-				
-				
-				
 
 
+				// logged out
+				if !getSessionUser()
+					// register link
+					if !externalAuthenticationSystemUsed()
+						li
+							a(href="/register") #{translate('register')}
+
+					// login link
+					li
+						a(href="/login") #{translate('log_in')}
+
+				// projects link and account menu
+				if getSessionUser()
+					li
+						a(href="/project") #{translate('Projects')}
+					li.dropdown(dropdown)
+						a.dropbodw-toggle(href, dropdown-toggle)
+							| #{translate('Account')}
+							b.caret
+						ul.dropdown-menu
+							li
+								div.subdued #{getUserEmail()}
+							li.divider
+							li
+								a(href="/user/settings") #{translate('Account Settings')}
+							if nav.showSubscriptionLink
+								li
+									a(href="/user/subscription") #{translate('subscription')}
+							li.divider
+							li
+								a(href="/logout") #{translate('log_out')}

+ 35 - 29
services/web/app/views/project/editor.jade

@@ -3,7 +3,6 @@ extends ../layout
 block vars
 block vars
 	- var suppressNavbar = true
 	- var suppressNavbar = true
 	- var suppressFooter = true
 	- var suppressFooter = true
-	- var suppressDefaultJs = true
 	- var suppressSystemMessages = true
 	- var suppressSystemMessages = true
 
 
 block content
 block content
@@ -16,7 +15,8 @@ block content
 				p.text-center.text-danger(ng-if="state.error").ng-cloak 
 				p.text-center.text-danger(ng-if="state.error").ng-cloak 
 					span(ng-bind-html="state.error")
 					span(ng-bind-html="state.error")
 
 
-
+		include ./editor/feature-onboarding
+		
 		.global-alerts(ng-cloak)
 		.global-alerts(ng-cloak)
 			.alert.alert-danger.small(ng-if="connection.forced_disconnect")
 			.alert.alert-danger.small(ng-if="connection.forced_disconnect")
 				strong #{translate("disconnected")}
 				strong #{translate("disconnected")}
@@ -38,7 +38,7 @@ block content
 
 
 		include ./editor/left-menu
 		include ./editor/left-menu
 
 
-		#chat-wrapper(
+		#chat-wrapper.full-size(
 			layout="chat",
 			layout="chat",
 			spacing-open="12",
 			spacing-open="12",
 			spacing-closed="0",
 			spacing-closed="0",
@@ -66,7 +66,7 @@ block content
 					.ui-layout-center
 					.ui-layout-center
 						include ./editor/editor
 						include ./editor/editor
 						include ./editor/binary-file
 						include ./editor/binary-file
-						include ./editor/track-changes
+						include ./editor/history
 						include ./editor/publish-template
 						include ./editor/publish-template
 
 
 			.ui-layout-east
 			.ui-layout-east
@@ -86,7 +86,17 @@ block content
 		.modal-footer
 		.modal-footer
 			button.btn.btn-info(ng-click="done()") #{translate("ok")}
 			button.btn.btn-info(ng-click="done()") #{translate("ok")}
 
 
-	script(src='/socket.io/socket.io.js')
+	script(type="text/ng-template", id="lockEditorModalTemplate")
+		.modal-header
+			h3 {{ title }}
+		.modal-body(ng-bind-html="message")
+
+block requirejs
+	script(type="text/javascript" src='/socket.io/socket.io.js')
+
+	//- don't use cdn for workers
+	- var aceWorkerPath = buildJsPath(lib('ace'), {cdn:false,fingerprint:false})
+	- var pdfWorkerPath = buildJsPath('/libs/' + lib('pdfjs') + '/pdf.worker', {cdn:false,fingerprint:false})
 
 
 	//- We need to do .replace(/\//g, '\\/') do that '</script>' -> '<\/script>'
 	//- We need to do .replace(/\//g, '\\/') do that '</script>' -> '<\/script>'
 	//- and doesn't prematurely end the script tag.
 	//- and doesn't prematurely end the script tag.
@@ -97,50 +107,46 @@ block content
 		window.csrfToken = "!{csrfToken}";
 		window.csrfToken = "!{csrfToken}";
 		window.anonymous = #{anonymous};
 		window.anonymous = #{anonymous};
 		window.maxDocLength = #{maxDocLength};
 		window.maxDocLength = #{maxDocLength};
+		window.trackChangesEnabled = #{trackChangesEnabled};
 		window.wikiEnabled = #{!!(settings.apis.wiki && settings.apis.wiki.url)};
 		window.wikiEnabled = #{!!(settings.apis.wiki && settings.apis.wiki.url)};
 		window.requirejs = {
 		window.requirejs = {
 			"paths" : {
 			"paths" : {
-				"mathjax": "#{buildJsPath('/libs/mathjax/MathJax.js', {qs:{config:'TeX-AMS_HTML', fingerprint:false}})}",
-				"moment": "libs/moment-2.7.0",
-				"libs/pdf": "libs/pdfjs-1.3.91/pdf"
+				"mathjax": "#{buildJsPath('/libs/mathjax/MathJax.js', {cdn:false, fingerprint:false, qs:{config:'TeX-AMS_HTML'}})}",
+				"moment": "libs/#{lib('moment')}",
+				"pdfjs-dist/build/pdf": "libs/#{lib('pdfjs')}/pdf",
+				"pdfjs-dist/build/pdf.worker": "#{pdfWorkerPath}",
+				"ace": "#{lib('ace')}"
 			},
 			},
 			"urlArgs" : "fingerprint=#{fingerprint(jsPath + 'ide.js')}-#{fingerprint(jsPath + 'libs.js')}",
 			"urlArgs" : "fingerprint=#{fingerprint(jsPath + 'ide.js')}-#{fingerprint(jsPath + 'libs.js')}",
 			"waitSeconds": 0,
 			"waitSeconds": 0,
 			"shim": {
 			"shim": {
-				"libs/pdf": {
-					deps: ["libs/pdfjs-1.3.91/compatibility"]
+				"pdfjs-dist/build/pdf": {
+					"deps": ["libs/#{lib('pdfjs')}/compatibility"]
 				},
 				},
 				"ace/ext-searchbox": {
 				"ace/ext-searchbox": {
-					deps: ["ace/ace"]
+					"deps": ["ace/ace"]
+				},
+				"ace/ext-modelist": {
+					"deps": ["ace/ace"]
 				},
 				},
 				"ace/ext-language_tools": {
 				"ace/ext-language_tools": {
-					deps: ["ace/ace"]
+					"deps": ["ace/ace"]
 				}
 				}
 			},
 			},
-			config:{
-				moment:{
-					noGlobal: true
+			"config":{
+				"moment":{
+					"noGlobal": true
 				}
 				}
 			}
 			}
 		};
 		};
-		window.aceFingerprint = "#{fingerprint(jsPath + 'ace/ace.js')}"
-
-	- locals.suppressDefaultJs = true
-
-	- var pdfPath = 'libs/pdfjs-1.3.91/pdf.worker.js'
-	- var fingerprintedPath = fingerprint(jsPath+pdfPath)
-	- var pdfJsWorkerPath = buildJsPath(pdfPath, {cdn:false,qs:{fingerprint:fingerprintedPath}}) // don't use worker for cdn
-
-
-	script(type='text/javascript').
-		window.pdfJsWorkerPath = "#{pdfJsWorkerPath}";
+		window.aceFingerprint = "#{fingerprint(jsPath + lib('ace') + '/ace.js')}"
+		window.aceWorkerPath = "#{aceWorkerPath}";
 
 
 	script(
 	script(
 		data-main=buildJsPath("ide.js", {fingerprint:false}),
 		data-main=buildJsPath("ide.js", {fingerprint:false}),
 		baseurl=fullJsPath,
 		baseurl=fullJsPath,
-		data-ace-base=buildJsPath('ace', {fingerprint:false}),
+		data-ace-base=buildJsPath(lib('ace'), {fingerprint:false}),
 		src=buildJsPath('libs/require.js')
 		src=buildJsPath('libs/require.js')
 	)
 	)
 
 
-		
-
+	

+ 36 - 4
services/web/app/views/project/editor/editor.jade

@@ -7,8 +7,21 @@ div.full-size(
 	resize-proportionally="true"
 	resize-proportionally="true"
 	initial-size-east="'50%'"
 	initial-size-east="'50%'"
 	minimum-restore-size-east="300"
 	minimum-restore-size-east="300"
+	allow-overflow-on="'center'"
 )
 )
-	.ui-layout-center
+	.ui-layout-center(
+		ng-controller="ReviewPanelController",
+		ng-class="{\
+			'rp-state-current-file': (reviewPanel.subView === SubViews.CUR_FILE),\
+			'rp-state-current-file-expanded': (reviewPanel.subView === SubViews.CUR_FILE && ui.reviewPanelOpen),\
+			'rp-state-current-file-mini': (reviewPanel.subView === SubViews.CUR_FILE && !ui.reviewPanelOpen),\
+			'rp-state-overview': (reviewPanel.subView === SubViews.OVERVIEW),\
+			'rp-size-mini': (!ui.reviewPanelOpen && reviewPanel.hasEntries),\
+			'rp-size-expanded': ui.reviewPanelOpen,\
+			'rp-layout-left': reviewPanel.layoutToLeft,\
+			'rp-loading-threads': reviewPanel.loadingThreads\
+		}"
+	)
 		.loading-panel(ng-show="!editor.sharejs_doc || editor.opening")
 		.loading-panel(ng-show="!editor.sharejs_doc || editor.opening")
 			span(ng-show="editor.open_doc_id")
 			span(ng-show="editor.open_doc_id")
 				i.fa.fa-spin.fa-refresh
 				i.fa.fa-spin.fa-refresh
@@ -24,7 +37,7 @@ div.full-size(
 			keybindings="settings.mode",
 			keybindings="settings.mode",
 			font-size="settings.fontSize",
 			font-size="settings.fontSize",
 			auto-complete="settings.autoComplete",
 			auto-complete="settings.autoComplete",
-			spell-check="true",
+			spell-check="!anonymous",
 			spell-check-language="project.spellCheckLanguage",
 			spell-check-language="project.spellCheckLanguage",
 			highlights="onlineUserCursorHighlights[editor.open_doc_id]"
 			highlights="onlineUserCursorHighlights[editor.open_doc_id]"
 			show-print-margin="false",
 			show-print-margin="false",
@@ -32,12 +45,31 @@ div.full-size(
 			last-updated="editor.last_updated",
 			last-updated="editor.last_updated",
 			cursor-position="editor.cursorPosition",
 			cursor-position="editor.cursorPosition",
 			goto-line="editor.gotoLine",
 			goto-line="editor.gotoLine",
-			resize-on="layout:main:resize,layout:pdf:resize",
+			resize-on="layout:main:resize,layout:pdf:resize,layout:review:resize,review-panel:toggle",
 			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="recompileViaKey"
+			file-name="editor.open_doc_name",
+			on-ctrl-enter="recompileViaKey",
+			syntax-validation="settings.syntaxValidation",
+			review-panel="reviewPanel",
+			events-bridge="reviewPanelEventsBridge"
+			track-changes-enabled="project.features.trackChanges",
+			track-changes= "editor.trackChanges",
+			doc-id="editor.open_doc_id"
+			renderer-data="reviewPanel.rendererData"
 		)
 		)
 
 
+		a.rp-track-changes-indicator(
+			href
+			ng-if="editor.wantTrackChanges"
+			ng-click="toggleReviewPanel();"
+			ng-class="{ 'rp-track-changes-indicator-on-dark' : darkTheme }"
+		) Track changes is 
+			strong on
+
+		
+		include ./review-panel
+
 	.ui-layout-east
 	.ui-layout-east
 		div(ng-if="ui.pdfLayout == 'sideBySide'")
 		div(ng-if="ui.pdfLayout == 'sideBySide'")
 			include ./pdf
 			include ./pdf

+ 54 - 0
services/web/app/views/project/editor/feature-onboarding.jade

@@ -0,0 +1,54 @@
+.feat-onboard(
+	ng-controller="FeatureOnboardingController"
+	ng-class="('feat-onboard-step' + innerStep)"
+	ng-if="!state.loading && ui.showCodeCheckerOnboarding"
+	ng-cloak
+)
+	.feat-onboard-wrapper
+		h1.feat-onboard-title
+			| Introducing&nbsp;
+			span.feat-onboard-title-name Code check
+		div(ng-if="innerStep === 1;")
+			p.feat-onboard-description
+				span.feat-onboard-description-name Code check&nbsp;
+				| will highlight potential problems in your LaTeX code, allowing you to handle errors earlier and become more productive.
+			.row
+				video.feat-onboard-video(autoplay, loop)
+					source(src="/img/teasers/code-checker/code-checker.mp4", type="video/mp4")
+					img(src="/img/teasers/code-checker/code-checker.gif")
+			.row.feat-onboard-adv-wrapper
+				.col-xs-4
+					h2.feat-onboard-adv-title 
+						| Missing&nbsp;
+						span.feat-onboard-adv-title-highlight brackets
+					p Forgot to place a closing bracket? We'll warn you.
+				.col-xs-4
+					h2.feat-onboard-adv-title 
+						| Unclosed&nbsp;
+						span.feat-onboard-adv-title-highlight environments
+					p 
+						| Know when you are missing an&nbsp;
+						code \end{...}
+						| &nbsp;command.
+				.col-xs-4
+					h2.feat-onboard-adv-title 
+						| Incorrect&nbsp;
+						span.feat-onboard-adv-title-highlight nesting
+					p 
+						| Order matters. Get notified when you use an&nbsp;
+						code \end{...}
+						| &nbsp; too soon.
+			.feat-onboard-btn-wrapper
+				button.btn.btn-primary(ng-click="turnCodeCheckOn();") Yes, turn Code check on
+			.feat-onboard-btn-wrapper
+				button.btn.btn-default(ng-click="turnCodeCheckOff();") No, disable it for now
+		div(ng-if="innerStep === 2;")
+			p.feat-onboard-description
+				| Remember: you can always turn&nbsp;
+				span.feat-onboard-description-name Code check&nbsp;
+				em on&nbsp;
+				| or&nbsp;
+				em off&nbsp;
+				|, in the settings menu.
+			.feat-onboard-btn-wrapper
+				button.btn.btn-primary(ng-click="dismiss();") OK, got it

+ 22 - 4
services/web/app/views/project/editor/file-tree.jade

@@ -70,13 +70,13 @@ aside#file-tree(ng-controller="FileTreeController", ng-class="{ 'multi-selected'
 				ng-repeat="entity in rootFolder.children | orderBy:[orderByFoldersFirst, 'name']"
 				ng-repeat="entity in rootFolder.children | orderBy:[orderByFoldersFirst, 'name']"
 			)
 			)
 
 
-			li(ng-show="deletedDocs.length > 0 && ui.view == 'track-changes'")
+			li(ng-show="deletedDocs.length > 0 && ui.view == 'history'")
 				h3 #{translate("deleted_files")}
 				h3 #{translate("deleted_files")}
 			li(
 			li(
 				ng-class="{ 'selected': entity.selected }",
 				ng-class="{ 'selected': entity.selected }",
 				ng-repeat="entity in deletedDocs | orderBy:'name'",
 				ng-repeat="entity in deletedDocs | orderBy:'name'",
 				ng-controller="FileTreeEntityController",
 				ng-controller="FileTreeEntityController",
-				ng-show="ui.view == 'track-changes'"
+				ng-show="ui.view == 'history'"
 			)
 			)
 				.entity
 				.entity
 					.entity-name(
 					.entity-name(
@@ -315,8 +315,12 @@ script(type='text/ng-template', id='newDocModalTemplate')
 				required,
 				required,
 				ng-model="inputs.name",
 				ng-model="inputs.name",
 				on-enter="create()",
 				on-enter="create()",
-				select-name-on="open"
+				select-name-on="open",
+				ng-pattern="validFileRegex",
+				name="name"
 			)
 			)
+			.text-danger.row-spaced-small(ng-show="newDocForm.name.$error.pattern")
+				| #{translate('files_cannot_include_invalid_characters')}
 	.modal-footer
 	.modal-footer
 		button.btn.btn-default(
 		button.btn.btn-default(
 			ng-disabled="state.inflight"
 			ng-disabled="state.inflight"
@@ -341,8 +345,12 @@ script(type='text/ng-template', id='newFolderModalTemplate')
 				required,
 				required,
 				ng-model="inputs.name",
 				ng-model="inputs.name",
 				on-enter="create()",
 				on-enter="create()",
-				select-name-on="open"
+				select-name-on="open",
+				ng-pattern="validFileRegex",
+				name="name"
 			)
 			)
+			.text-danger.row-spaced-small(ng-show="newFolderForm.name.$error.pattern")
+				| #{translate('files_cannot_include_invalid_characters')}
 	.modal-footer
 	.modal-footer
 		button.btn.btn-default(
 		button.btn.btn-default(
 			ng-disabled="state.inflight"
 			ng-disabled="state.inflight"
@@ -414,3 +422,13 @@ script(type='text/ng-template', id='deleteEntityModalTemplate')
 		)
 		)
 			span(ng-hide="state.inflight") #{translate("delete")}
 			span(ng-hide="state.inflight") #{translate("delete")}
 			span(ng-show="state.inflight") #{translate("deleting")}...
 			span(ng-show="state.inflight") #{translate("deleting")}...
+
+script(type='text/ng-template', id='invalidFileNameModalTemplate')
+	.modal-header
+		h3 #{translate('invalid_file_name')}
+	.modal-body
+		p #{translate('files_cannot_include_invalid_characters')}
+	.modal-footer
+		button.btn.btn-default(
+			ng-click="$close()"
+		) #{translate('ok')}

+ 34 - 33
services/web/app/views/project/editor/header.jade

@@ -1,19 +1,19 @@
-header.toolbar.toolbar-header(ng-cloak, ng-hide="state.loading")
-	a.btn.btn-full-height(
-		href,
-		ng-click="ui.leftMenuShown = true"
-		tooltip='#{translate("menu")}',
-		tooltip-placement="bottom",
-		tooltip-append-to-body="true"
-	)
-		i.fa.fa-fw.fa-bars
-	a(
-		href="/project"
-		tooltip="#{translate('back_to_projects')}",
-		tooltip-placement="bottom",
-		tooltip-append-to-body="true"
-	)
-		i.fa.fa-fw.fa-level-up
+header.toolbar.toolbar-header.toolbar-with-labels(
+	ng-cloak,
+	ng-hide="state.loading"
+)
+	.toolbar-left
+		a.btn.btn-full-height(
+			href,
+			ng-click="ui.leftMenuShown = true;",
+		)
+			i.fa.fa-fw.fa-bars
+			p.toolbar-label #{translate("menu")}
+		a(
+			href="/project"
+		)
+			i.fa.fa-fw.fa-level-up
+
 	span(ng-controller="PdfViewToggleController")
 	span(ng-controller="PdfViewToggleController")
 		a(
 		a(
 			href,
 			href,
@@ -85,39 +85,40 @@ header.toolbar.toolbar-header(ng-cloak, ng-hide="state.loading")
 							) {{ user.name.slice(0,1) }}
 							) {{ user.name.slice(0,1) }}
 							| {{ user.name }}
 							| {{ user.name }}
 
 
-	
+		a.btn.btn-full-height(
+			href,
+			ng-if="project.features.trackChanges",
+			ng-class="{ active: ui.reviewPanelOpen }"
+			ng-click="toggleReviewPanel()"
+		)
+			i.review-icon
+			p.toolbar-label Review
 		a.btn.btn-full-height(
 		a.btn.btn-full-height(
 			href,
 			href,
 			ng-if="permissions.admin",
 			ng-if="permissions.admin",
-			tooltip="#{translate('share')}",
-			tooltip-placement="bottom",
-			ng-mouseenter="trackHover('share')"
-			ng-click="openShareProjectModal()",
-			ng-controller="ShareController"
+			ng-click="openShareProjectModal();",
+			ng-controller="ShareController",
 		)
 		)
 			i.fa.fa-fw.fa-group
 			i.fa.fa-fw.fa-group
+			p.toolbar-label #{translate("share")}
 		a.btn.btn-full-height(
 		a.btn.btn-full-height(
 			href,
 			href,
-			ng-mouseenter="trackHover('track-changes')"
-			ng-click="toggleTrackChanges()",
-			ng-class="{ active: (ui.view == 'track-changes') }"
-			tooltip="#{translate('recent_changes')}",
-			tooltip-placement="bottom"
+			ng-click="toggleHistory();",
+			ng-class="{ active: (ui.view == 'history') }",
 		)
 		)
 			i.fa.fa-fw.fa-history
 			i.fa.fa-fw.fa-history
+			p.toolbar-label #{translate("history")}
 		a.btn.btn-full-height(
 		a.btn.btn-full-height(
 			href,
 			href,
-			tooltip="#{translate('chat')}",
-			tooltip-placement="bottom",
 			ng-class="{ active: ui.chatOpen }",
 			ng-class="{ active: ui.chatOpen }",
-			ng-mouseenter="trackHover('chat')"
-			ng-click="toggleChat()",
+			ng-click="toggleChat();",
 			ng-controller="ChatButtonController",
 			ng-controller="ChatButtonController",
-			ng-show="!anonymous"
+			ng-show="!anonymous",
 		)
 		)
 			i.fa.fa-fw.fa-comment(
 			i.fa.fa-fw.fa-comment(
 				ng-class="{ 'bounce': unreadMessages > 0 }"
 				ng-class="{ 'bounce': unreadMessages > 0 }"
 			)
 			)
 			span.label.label-info(
 			span.label.label-info(
 				ng-show="unreadMessages > 0"
 				ng-show="unreadMessages > 0"
-			) {{ unreadMessages }}
+			) {{ unreadMessages }}
+			p.toolbar-label #{translate("chat")}

+ 97 - 58
services/web/app/views/project/editor/track-changes.jade → services/web/app/views/project/editor/history.jade

@@ -1,61 +1,100 @@
-div#trackChanges(ng-show="ui.view == 'track-changes'")
-	span(ng-controller="TrackChangesPremiumPopup")
-		.upgrade-prompt(ng-show="!project.features.versioning")
-			.message(ng-show="project.owner._id == user.id")
-				p.text-center: strong #{translate("upgrade_to_get_feature", {feature:"full Project History"})}
-				p.text-center.small(ng-show="startedFreeTrial") #{translate("refresh_page_after_starting_free_trial")}
-				ul.list-unstyled
-					li 
-						i.fa.fa-check &nbsp;
-						| #{translate("unlimited_projects")}
-					
-					li
-						i.fa.fa-check &nbsp;
-						| #{translate("collabs_per_proj", {collabcount:'Multiple'})}
-					
-					li 
-						i.fa.fa-check &nbsp;
-						| #{translate("full_doc_history")}
-					
-					li 
-						i.fa.fa-check &nbsp;
-						| #{translate("sync_to_dropbox")}
+div#history(ng-show="ui.view == 'history'")
+	span(ng-controller="HistoryPremiumPopup")
+		.upgrade-prompt(ng-if="project.features.versioning === false && ui.view === 'history'")
+			
+			div(ng-if="project.owner._id == user.id")
+				div(sixpack-switch="teaser-history")
+					.message(sixpack-default)
+						p.text-center: strong #{translate("upgrade_to_get_feature", {feature:"full Project History"})}
+						p.text-center.small(ng-show="startedFreeTrial") #{translate("refresh_page_after_starting_free_trial")}
+						ul.list-unstyled
+							li 
+								i.fa.fa-check &nbsp;
+								| #{translate("unlimited_projects")}
+							
+							li
+								i.fa.fa-check &nbsp;
+								| #{translate("collabs_per_proj", {collabcount:'Multiple'})}
+							
+							li 
+								i.fa.fa-check &nbsp;
+								| #{translate("full_doc_history")}
+							
+							li 
+								i.fa.fa-check &nbsp;
+								| #{translate("sync_to_dropbox")}
 
 
-					li 
-						i.fa.fa-check &nbsp;
-						| #{translate("sync_to_github")}
+							li 
+								i.fa.fa-check &nbsp;
+								| #{translate("sync_to_github")}
 
 
-					li  
-						i.fa.fa-check &nbsp;
-						|#{translate("compile_larger_projects")}
+							li  
+								i.fa.fa-check &nbsp;
+								|#{translate("compile_larger_projects")}
+						p.text-center(ng-controller="FreeTrialModalController")
+							a.btn.btn-success(
+								href
+								ng-class="buttonClass"
+								ng-click="startFreeTrial('history')"
+								sixpack-convert="teaser-history"
+							) #{translate("start_free_trial")}
 
 
-				p.text-center(ng-controller="FreeTrialModalController")
-					a.btn.btn-success(
-						href
-						ng-class="buttonClass"
-						ng-click="startFreeTrial('track-changes')"
-					) #{translate("start_free_trial")}
+					.message.message-wider(sixpack-when="focused")
+						header.message-header
+							h3 History
 
 
+						.message-body
+							h4.teaser-title See who changed what. Go back to previous versions.
+							img.teaser-img(
+								src="/img/teasers/history/teaser-history.png"
+								alt="History"
+							)
+							p.text-center.small(ng-show="startedFreeTrial") #{translate("refresh_page_after_starting_free_trial")}
+							.row
+								.col-md-8.col-md-offset-2
+									ul.list-unstyled
+										li 
+											i.fa.fa-check &nbsp;
+											| Catch up with your collaborators changes
+										
+										li
+											i.fa.fa-check &nbsp;
+											| See changes over any time period
+										
+										li 
+											i.fa.fa-check &nbsp;
+											| Revert your documents to previous versions
+										
+										li 
+											i.fa.fa-check &nbsp;
+											| Restore deleted files
+							p.text-center(ng-controller="FreeTrialModalController")
+								a.btn.btn-success(
+									href
+									ng-class="buttonClass"
+									ng-click="startFreeTrial('history')"
+									sixpack-convert="teaser-history"
+								) Try it for free
 
 
 			.message(ng-show="project.owner._id != user.id")
 			.message(ng-show="project.owner._id != user.id")
 				p #{translate("ask_proj_owner_to_upgrade_for_history")}
 				p #{translate("ask_proj_owner_to_upgrade_for_history")}
 				p
 				p
-					a.small(href, ng-click="toggleTrackChanges()") #{translate("cancel")}
+					a.small(href, ng-click="toggleHistory()") #{translate("cancel")}
 
 
 	aside.change-list(
 	aside.change-list(
-		ng-controller="TrackChangesListController"
+		ng-controller="HistoryListController"
 		infinite-scroll="loadMore()"
 		infinite-scroll="loadMore()"
-		infinite-scroll-disabled="trackChanges.loading || trackChanges.atEnd"
-		infinite-scroll-initialize="ui.view == 'track-changes'"
+		infinite-scroll-disabled="history.loading || history.atEnd"
+		infinite-scroll-initialize="ui.view == 'history'"
 	)
 	)
 		.infinite-scroll-inner
 		.infinite-scroll-inner
 			ul.list-unstyled(
 			ul.list-unstyled(
 				ng-class="{\
 				ng-class="{\
-					'hover-state': trackChanges.hoveringOverListSelectors\
+					'hover-state': history.hoveringOverListSelectors\
 				}"
 				}"
 			)
 			)
 				li.change(
 				li.change(
-					ng-repeat="update in trackChanges.updates"
+					ng-repeat="update in history.updates"
 					ng-class="{\
 					ng-class="{\
 						'first-in-day': update.meta.first_in_day,\
 						'first-in-day': update.meta.first_in_day,\
 						'selected': update.inSelection,\
 						'selected': update.inSelection,\
@@ -65,7 +104,7 @@ div#trackChanges(ng-show="ui.view == 'track-changes'")
 						'hover-selected-to': update.hoverSelectedTo,\
 						'hover-selected-to': update.hoverSelectedTo,\
 						'hover-selected-from': update.hoverSelectedFrom,\
 						'hover-selected-from': update.hoverSelectedFrom,\
 					}"
 					}"
-					ng-controller="TrackChangesListItemController"
+					ng-controller="HistoryListItemController"
 				)
 				)
 
 
 					div.day(ng-show="update.meta.first_in_day") {{ update.meta.end_ts | relativeDate }}
 					div.day(ng-show="update.meta.first_in_day") {{ update.meta.end_ts | relativeDate }}
@@ -108,55 +147,55 @@ div#trackChanges(ng-show="ui.view == 'track-changes'")
 								.color-square(style="background-color: hsl(100, 100%, 50%)")
 								.color-square(style="background-color: hsl(100, 100%, 50%)")
 								span #{translate("anonymous")}
 								span #{translate("anonymous")}
 
 
-			.loading(ng-show="trackChanges.loading")
+			.loading(ng-show="history.loading")
 				i.fa.fa-spin.fa-refresh
 				i.fa.fa-spin.fa-refresh
 				| &nbsp;&nbsp; #{translate("loading")}...
 				| &nbsp;&nbsp; #{translate("loading")}...
 
 
-	.diff-panel.full-size(ng-controller="TrackChangesDiffController")
+	.diff-panel.full-size(ng-controller="HistoryDiffController")
 		.diff(
 		.diff(
-			ng-show="!!trackChanges.diff && !trackChanges.diff.loading && !trackChanges.diff.deleted && !trackChanges.diff.error"
+			ng-show="!!history.diff && !history.diff.loading && !history.diff.deleted && !history.diff.error"
 		)
 		)
 			.toolbar.toolbar-alt
 			.toolbar.toolbar-alt
 				span.name
 				span.name
-					| <strong>{{trackChanges.diff.highlights.length}} </strong>
+					| <strong>{{history.diff.highlights.length}} </strong>
 					ng-pluralize(
 					ng-pluralize(
-						count="trackChanges.diff.highlights.length",
+						count="history.diff.highlights.length",
 						when="{\
 						when="{\
 							'one': 'change',\
 							'one': 'change',\
 							'other': 'changes'\
 							'other': 'changes'\
 						}"
 						}"
 					)
 					)
-					|  in <strong>{{trackChanges.diff.doc.name}}</strong>
+					|  in <strong>{{history.diff.doc.name}}</strong>
 				.toolbar-right
 				.toolbar-right
 					a.btn.btn-danger.btn-sm(
 					a.btn.btn-danger.btn-sm(
 						href,
 						href,
 						ng-click="openRestoreDiffModal()"
 						ng-click="openRestoreDiffModal()"
 					) #{translate("restore_to_before_these_changes")}
 					) #{translate("restore_to_before_these_changes")}
 			.diff-editor.hide-ace-cursor(
 			.diff-editor.hide-ace-cursor(
-				ace-editor="track-changes",
+				ace-editor="history",
 				theme="settings.theme",
 				theme="settings.theme",
 				font-size="settings.fontSize",
 				font-size="settings.fontSize",
-				text="trackChanges.diff.text",
-				highlights="trackChanges.diff.highlights",
+				text="history.diff.text",
+				highlights="history.diff.highlights",
 				read-only="true",
 				read-only="true",
 				resize-on="layout:main:resize",
 				resize-on="layout:main:resize",
 				navigate-highlights="true"
 				navigate-highlights="true"
 			)
 			)
 		.diff-deleted.text-centered(
 		.diff-deleted.text-centered(
-			ng-show="trackChanges.diff.deleted && !trackChanges.diff.restoreDeletedSuccess"
+			ng-show="history.diff.deleted && !history.diff.restoreDeletedSuccess"
 		)
 		)
-			p.text-serif #{translate("file_has_been_deleted", {filename:"{{ trackChanges.diff.doc.name }} "})}
+			p.text-serif #{translate("file_has_been_deleted", {filename:"{{ history.diff.doc.name }} "})}
 			p
 			p
 				a.btn.btn-primary.btn-lg(
 				a.btn.btn-primary.btn-lg(
 					href,
 					href,
 					ng-click="restoreDeletedDoc()",
 					ng-click="restoreDeletedDoc()",
-					ng-disabled="trackChanges.diff.restoreInProgress"
+					ng-disabled="history.diff.restoreInProgress"
 				) #{translate("restore")}
 				) #{translate("restore")}
 
 
 		.diff-deleted.text-centered(
 		.diff-deleted.text-centered(
-			ng-show="trackChanges.diff.deleted && trackChanges.diff.restoreDeletedSuccess"
+			ng-show="history.diff.deleted && history.diff.restoreDeletedSuccess"
 		)
 		)
-			p.text-serif  #{translate("file_restored", {filename:"{{ trackChanges.diff.doc.name }} "})}
+			p.text-serif  #{translate("file_restored", {filename:"{{ history.diff.doc.name }} "})}
 			p.text-serif  #{translate("file_restored_back_to_editor")}
 			p.text-serif  #{translate("file_restored_back_to_editor")}
 			p
 			p
 				a.btn.btn-default(
 				a.btn.btn-default(
@@ -164,13 +203,13 @@ div#trackChanges(ng-show="ui.view == 'track-changes'")
 					ng-click="backToEditorAfterRestore()",
 					ng-click="backToEditorAfterRestore()",
 				) #{translate("file_restored_back_to_editor_btn")}
 				) #{translate("file_restored_back_to_editor_btn")}
 
 
-		.loading-panel(ng-show="trackChanges.diff.loading")
+		.loading-panel(ng-show="history.diff.loading")
 			i.fa.fa-spin.fa-refresh
 			i.fa.fa-spin.fa-refresh
 			| &nbsp;&nbsp;#{translate("loading")}...
 			| &nbsp;&nbsp;#{translate("loading")}...
-		.error-panel(ng-show="trackChanges.diff.error")
+		.error-panel(ng-show="history.diff.error")
 			.alert.alert-danger #{translate("generic_something_went_wrong")}
 			.alert.alert-danger #{translate("generic_something_went_wrong")}
 
 
-script(type="text/ng-template", id="trackChangesRestoreDiffModalTemplate")
+script(type="text/ng-template", id="historyRestoreDiffModalTemplate")
 	.modal-header
 	.modal-header
 		button.close(
 		button.close(
 			type="button"
 			type="button"

+ 25 - 0
services/web/app/views/project/editor/hotkeys.jade

@@ -66,6 +66,31 @@ script(type="text/ng-template", id="hotkeysModalTemplate")
 				.hotkey
 				.hotkey
 					span.combination {{ctrl}} + I
 					span.combination {{ctrl}} + I
 					span.description Italic Text
 					span.description Italic Text
+	
+		h3 #{translate("autocomplete")}
+		.row
+			.col-xs-6
+				.hotkey
+					span.combination Ctrl + Space
+					span.description Autocomplete Menu
+
+			.col-xs-6
+				.hotkey
+					span.combination Tab / Up / Down
+					span.description Select Candidate
+
+				.hotkey
+					span.combination Enter
+					span.description Insert Candidate
+	
+		h3 !{translate("autocomplete_references")}
+		.row
+			.col-xs-6
+				.hotkey
+					span.combination Ctrl + Space 
+					span.description Search References
+		
+					
 	.modal-footer
 	.modal-footer
 		button.btn.btn-default(
 		button.btn.btn-default(
 			ng-click="cancel()"
 			ng-click="cancel()"

+ 12 - 0
services/web/app/views/project/editor/left-menu.jade

@@ -105,6 +105,14 @@ aside#left-menu.full-size(
 					ng-options="o.v as o.n for o in [{ n: 'On', v: true }, { n: 'Off', v: false }]"
 					ng-options="o.v as o.n for o in [{ n: 'On', v: true }, { n: 'Off', v: false }]"
 				)
 				)
 
 
+			.form-controls.code-check-setting
+				label(for="syntaxValidation") #{translate("syntax_validation")}
+				select(
+					name="syntaxValidation"
+					ng-model="settings.syntaxValidation"
+					ng-options="o.v as o.n for o in [{ n: 'On', v: true }, { n: 'Off', v: false }]"
+				)
+
 			.form-controls
 			.form-controls
 				label(for="theme") #{translate("theme")}
 				label(for="theme") #{translate("theme")}
 				select(
 				select(
@@ -197,6 +205,10 @@ script(type='text/ng-template', id='wordCountModalTemplate')
 			)
 			)
 		div(ng-if="!status.loading")
 		div(ng-if="!status.loading")
 			.container-fluid
 			.container-fluid
+				.row(ng-show='data.messages.length > 0')
+					.col-xs-12
+						.alert.alert-danger
+							p(style="white-space: pre-wrap") {{data.messages}}
 				.row
 				.row
 					.col-xs-4
 					.col-xs-4
 						.pull-right #{translate("total_words")} :
 						.pull-right #{translate("total_words")} :

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

@@ -229,7 +229,7 @@ div.full-size.pdf(ng-controller="PdfController")
 
 
 				p.entry-content(ng-show="entry.content") {{ entry.content.trim() }}
 				p.entry-content(ng-show="entry.content") {{ entry.content.trim() }}
 
 
-		p
+		div
 			.files-dropdown-container
 			.files-dropdown-container
 				a.btn.btn-default.btn-sm(
 				a.btn.btn-default.btn-sm(
 					href,
 					href,
@@ -344,7 +344,7 @@ div.full-size.pdf(ng-controller="PdfController")
 					strong #{translate("ask_proj_owner_to_upgrade_for_faster_compiles")}
 					strong #{translate("ask_proj_owner_to_upgrade_for_faster_compiles")}
 				p #{translate("free_accounts_have_timeout_upgrade_to_increase")}
 				p #{translate("free_accounts_have_timeout_upgrade_to_increase")}
 				p Plus:
 				p Plus:
-				p
+				div
 					ul.list-unstyled
 					ul.list-unstyled
 						li
 						li
 							i.fa.fa-check &nbsp;
 							i.fa.fa-check &nbsp;

+ 332 - 0
services/web/app/views/project/editor/review-panel.jade

@@ -0,0 +1,332 @@
+#review-panel
+	.review-panel-toolbar
+		resolved-comments-dropdown(
+			entries="reviewPanel.resolvedComments"
+			threads="reviewPanel.commentThreads"
+			resolved-ids="reviewPanel.resolvedThreadIds"
+			docs="docs"
+			on-open="refreshResolvedCommentsDropdown();"
+			on-unresolve="unresolveComment(threadId);"
+			on-delete="deleteComment(entryId, threadId);"
+			is-loading="reviewPanel.dropdown.loading"
+			permissions="permissions"
+		)
+		span.review-panel-toolbar-label(ng-if="permissions.write")
+			span(ng-click="toggleTrackChanges(true)", ng-if="editor.wantTrackChanges === false") Track Changes is 
+				strong off
+			span(ng-click="toggleTrackChanges(false)", ng-if="editor.wantTrackChanges === true") Track Changes is 
+				strong on
+			review-panel-toggle(ng-if="editor.wantTrackChanges == editor.trackChanges", ng-model="editor.wantTrackChanges", on-toggle="toggleTrackChanges")
+		span.review-panel-toolbar-label.review-panel-toolbar-label-disabled(ng-if="!permissions.write")
+			span(ng-if="editor.wantTrackChanges === false") Track Changes is 
+				strong off
+			span(ng-if="editor.wantTrackChanges === true") Track Changes is 
+				strong on
+		span.review-panel-toolbar-spinner(ng-if="editor.wantTrackChanges != editor.trackChanges")
+			i.fa.fa-spin.fa-spinner
+
+	.rp-entry-list(
+		review-panel-sorted
+		ng-if="reviewPanel.subView === SubViews.CUR_FILE"
+	)
+		.rp-entry-list-inner
+			.rp-entry-wrapper(
+				ng-repeat="(entry_id, entry) in reviewPanel.entries[editor.open_doc_id]"
+				ng-if="!(entry.type === 'comment' && reviewPanel.commentThreads[entry.thread_id].resolved === true)"
+			)
+				div(ng-if="entry.type === 'insert' || entry.type === 'delete'")
+					change-entry(
+						entry="entry"
+						user="users[entry.metadata.user_id]"
+						on-reject="rejectChange(entry_id);"
+						on-accept="acceptChange(entry_id);"
+						on-indicator-click="toggleReviewPanel();"
+						permissions="permissions"
+					)
+
+				div(ng-if="entry.type === 'comment'")
+					comment-entry(
+						entry="entry"
+						threads="reviewPanel.commentThreads"
+						on-resolve="resolveComment(entry, entry_id)"
+						on-reply="submitReply(entry, entry_id);"
+						on-indicator-click="toggleReviewPanel();"
+						permissions="permissions"
+						ng-if="!reviewPanel.loadingThreads"
+					)
+				
+				div(ng-if="entry.type === 'add-comment' && permissions.comment")
+					add-comment-entry(
+						on-start-new="startNewComment();"
+						on-submit="submitNewComment(content);"
+						on-cancel="cancelNewComment();"
+						on-indicator-click="toggleReviewPanel();"
+					)
+
+	.rp-entry-list(
+		ng-if="reviewPanel.subView === SubViews.OVERVIEW"
+	)
+		.rp-loading(ng-if="reviewPanel.overview.loading")
+			i.fa.fa-spinner.fa-spin
+		.rp-overview-file(
+			ng-repeat="doc in docs"
+			ng-if="!reviewPanel.overview.loading"
+		)
+			.rp-overview-file-header(
+				ng-if="reviewPanel.entries[doc.doc.id] | notEmpty"
+			)
+				| {{ doc.path }}
+			.rp-entry-wrapper(
+				ng-repeat="(entry_id, entry) in reviewPanel.entries[doc.doc.id] | orderOverviewEntries"
+				ng-if="!(entry.type === 'comment' && reviewPanel.commentThreads[entry.thread_id].resolved === true)"
+			)
+				div(ng-if="entry.type === 'insert' || entry.type === 'delete'")
+					change-entry(
+						entry="entry"
+						user="users[entry.metadata.user_id]"
+						on-indicator-click="toggleReviewPanel();"
+						ng-click="gotoEntry(doc.doc.id, entry)"
+						permissions="permissions"
+					)
+
+				div(ng-if="entry.type === 'comment'")
+					comment-entry(
+						entry="entry"
+						threads="reviewPanel.commentThreads"
+						on-reply="submitReply(entry, entry_id);"
+						on-indicator-click="toggleReviewPanel();"
+						ng-click="gotoEntry(doc.doc.id, entry)"
+						permissions="permissions"
+					)
+
+	.rp-nav
+		a.rp-nav-item(
+			href
+			ng-click="setSubView(SubViews.CUR_FILE);"
+			ng-class="{ 'rp-nav-item-active' : reviewPanel.subView === SubViews.CUR_FILE }"
+		)
+			i.fa.fa-file-text-o
+			span.rp-nav-label Current file
+		a.rp-nav-item(
+			href
+			ng-click="setSubView(SubViews.OVERVIEW);"
+			ng-class="{ 'rp-nav-item-active' : reviewPanel.subView === SubViews.OVERVIEW }"
+		)
+			i.fa.fa-list
+			span.rp-nav-label Overview
+
+
+script(type='text/ng-template', id='changeEntryTemplate')
+	div
+		.rp-entry-callout(
+			ng-class="'rp-entry-callout-' + entry.type"
+		)
+		.rp-entry-indicator(
+			ng-switch="entry.type"
+			ng-class="{ 'rp-entry-indicator-focused': entry.focused }"
+			ng-click="onIndicatorClick();"
+		)
+			i.fa.fa-pencil(ng-switch-when="insert")
+			i.rp-icon-delete(ng-switch-when="delete")
+		.rp-entry(
+			ng-class="[ 'rp-entry-' + entry.type, (entry.focused ? 'rp-entry-focused' : '')]"
+		)
+			.rp-entry-body
+				.rp-entry-action-icon(ng-switch="entry.type")
+					i.fa.fa-pencil(ng-switch-when="insert")
+					i.rp-icon-delete(ng-switch-when="delete")
+				.rp-entry-details
+					.rp-entry-description(ng-switch="entry.type")
+						span(ng-switch-when="insert") Added&nbsp;
+							ins.rp-content-highlight {{ entry.content | limitTo:(isCollapsed ? contentLimit : entry.content.length) }}
+							a.rp-collapse-toggle(
+								href
+								ng-if="needsCollapsing"
+								ng-click="toggleCollapse();"
+							) {{ isCollapsed ? '... (show all)' : ' (show less)' }}
+						span(ng-switch-when="delete") Deleted&nbsp;
+							del.rp-content-highlight {{ entry.content | limitTo:(isCollapsed ? contentLimit : entry.content.length) }}
+							a.rp-collapse-toggle(
+								href
+								ng-if="needsCollapsing"
+								ng-click="toggleCollapse();"
+							) {{ isCollapsed ? '... (show all)' : ' (show less)' }}
+					.rp-entry-metadata
+						| {{ entry.metadata.ts | date : 'MMM d, y h:mm a' }}&nbsp;&bull;&nbsp;
+						span.rp-entry-user(style="color: hsl({{ user.hue }}, 70%, 40%);") {{ user.name }} 
+			.rp-entry-actions(ng-if="permissions.write")
+				a.rp-entry-button(href, ng-click="onReject();") 
+					i.fa.fa-times
+					| &nbsp;Reject
+				a.rp-entry-button(href, ng-click="onAccept();") 
+					i.fa.fa-check
+					| &nbsp;Accept
+
+script(type='text/ng-template', id='commentEntryTemplate')
+	.rp-comment-wrapper(
+		ng-class="{ 'rp-comment-wrapper-resolving': state.animating }"
+	)
+		.rp-entry-callout.rp-entry-callout-comment
+		.rp-entry-indicator(
+			ng-class="{ 'rp-entry-indicator-focused': entry.focused }"
+			ng-click="onIndicatorClick();"
+		)
+			i.fa.fa-comment
+		.rp-entry.rp-entry-comment(
+			ng-class="{ 'rp-entry-focused': entry.focused, 'rp-entry-comment-resolving': state.animating }"
+		)
+			div
+				.rp-comment(
+					ng-repeat="comment in threads[entry.thread_id].messages track by comment.id"
+				)
+					p.rp-comment-content 
+						span.rp-entry-user(
+							style="color: hsl({{ comment.user.hue }}, 70%, 40%);"
+						) {{ comment.user.name }}:&nbsp;
+						| {{ comment.content }}
+					.rp-entry-metadata
+						| {{ comment.timestamp | date : 'MMM d, y h:mm a' }}
+			.rp-loading(ng-if="!threads[entry.thread_id] || threads[entry.thread_id].submitting")
+				i.fa.fa-spinner.fa-spin
+			.rp-comment-reply(ng-if="permissions.comment")
+				textarea.rp-comment-input(
+					ng-model="entry.replyContent"
+					ng-keypress="handleCommentReplyKeyPress($event);"
+					stop-propagation="click"
+					placeholder="{{ 'Hit \"Enter\" to reply' + (entry.resolved ? ' and re-open' : '') }}"
+				)
+			.rp-entry-actions
+				button.rp-entry-button(
+					ng-click="animateAndCallOnResolve();"
+					ng-if="permissions.comment && permissions.write"
+				) 
+					i.fa.fa-inbox
+					| &nbsp;Resolve
+				button.rp-entry-button(
+					ng-click="onReply();"
+					ng-if="permissions.comment"
+					ng-disabled="!entry.replyContent.length"
+				)
+					i.fa.fa-reply
+					| &nbsp;Reply
+	
+script(type='text/ng-template', id='resolvedCommentEntryTemplate')
+	.rp-resolved-comment
+		div
+			.rp-resolved-comment-context
+				| Quoted text on&nbsp;
+				span.rp-resolved-comment-context-file {{ thread.docName }}
+				p.rp-resolved-comment-context-quote
+					span {{ thread.content | limitTo:(isCollapsed ? contentLimit : thread.content.length)}}
+					a.rp-collapse-toggle(
+						href
+						ng-if="needsCollapsing"
+						ng-click="toggleCollapse();"
+					) &nbsp;{{ isCollapsed ? '... (show all)' : ' (show less)' }}
+			.rp-comment(
+				ng-repeat="comment in thread.messages track by comment.id"
+			)
+				p.rp-comment-content 
+					span.rp-entry-user(
+						style="color: hsl({{ comment.user.hue }}, 70%, 40%);"
+						ng-if="$first || comment.user.id !== thread.messages[$index - 1].user.id"
+					) {{ comment.user.name }}:&nbsp;
+					| {{ comment.content }}
+				.rp-entry-metadata
+					| {{ comment.timestamp | date : 'MMM d, y h:mm a' }}
+			.rp-comment.rp-comment-resolver
+				p.rp-comment-resolver-content 
+					span.rp-entry-user(
+						style="color: hsl({{ thread.resolved_by_user.hue }}, 70%, 40%);"
+					) {{ thread.resolved_by_user.name }}:&nbsp;
+					| Marked as resolved.
+				.rp-entry-metadata
+					| {{ thread.resolved_at | date : 'MMM d, y h:mm a' }}
+				
+		.rp-entry-actions(ng-if="permissions.comment && permissions.write")
+			a.rp-entry-button(
+				href
+				ng-click="onUnresolve({ 'threadId': thread.threadId });"
+			) 
+				| &nbsp;Re-open
+			//- a.rp-entry-button(
+			//- 	href
+			//- 	ng-click="onDelete({ 'entryId': thread.entryId, 'threadId': thread.threadId });"
+			//- )
+			//- 	| &nbsp;Delete
+
+
+script(type='text/ng-template', id='addCommentEntryTemplate')
+	div
+		.rp-entry-callout.rp-entry-callout-add-comment
+		.rp-entry-indicator(
+			ng-if="!commentState.adding"
+			ng-click="startNewComment(); onIndicatorClick();"
+			tooltip="Add a comment"
+			tooltip-placement="right"
+			tooltip-append-to-body="true"
+		)
+			i.fa.fa-commenting
+		.rp-entry.rp-entry-add-comment(
+			ng-class="[ (state.isAdding ? 'rp-entry-adding-comment' : ''), (entry.focused ? 'rp-entry-focused' : '')]"
+		)
+			a.rp-add-comment-btn(
+				href
+				ng-if="!state.isAdding"
+				ng-click="startNewComment();"
+			) 
+				i.fa.fa-comment
+				| &nbsp;Add comment
+			div(ng-if="state.isAdding")
+				.rp-new-comment
+					textarea.rp-comment-input(
+						ng-model="state.content"
+						ng-keypress="handleCommentKeyPress($event);"
+						placeholder="Add your comment here"
+						focus-on="comment:new:open"
+					)
+				.rp-entry-actions
+					button.rp-entry-button(
+						ng-click="cancelNewComment();"
+					) 
+						i.fa.fa-times
+						| &nbsp;Cancel
+					button.rp-entry-button(
+						ng-click="submitNewComment()"
+						ng-disabled="!state.content.length"
+					) 
+						i.fa.fa-comment
+						| &nbsp;Comment
+
+script(type='text/ng-template', id='resolvedCommentsDropdownTemplate')
+	.resolved-comments
+		.resolved-comments-backdrop(
+			ng-class="{ 'resolved-comments-backdrop-visible' : state.isOpen }"
+			ng-click="state.isOpen = false"
+		)
+		a.resolved-comments-toggle(
+			href
+			ng-click="toggleOpenState();"
+			tooltip="Resolved Comments"
+			tooltip-placement="bottom"
+			tooltip-append-to-body="true"
+		)
+			i.fa.fa-inbox
+		.resolved-comments-dropdown(
+			ng-class="{ 'resolved-comments-dropdown-open' : state.isOpen }"
+		)
+			.rp-loading(ng-if="isLoading")
+				i.fa.fa-spinner.fa-spin
+			.resolved-comments-scroller(
+				ng-if="!isLoading"
+			)
+				resolved-comment-entry(
+					ng-repeat="thread in resolvedComments | orderBy:'resolved_at':true"
+					thread="thread"
+					on-unresolve="handleUnresolve(threadId);"
+					on-delete="handleDelete(entryId, threadId);"
+					permissions="permissions"
+				)
+				.rp-loading(ng-if="!resolvedComments.length")
+					| No resolved threads.
+			

+ 9 - 2
services/web/app/views/project/editor/share.jade

@@ -137,10 +137,17 @@ script(type='text/ng-template', id='shareProjectModalTemplate')
 					p.small(ng-show="startedFreeTrial")
 					p.small(ng-show="startedFreeTrial")
 						| #{translate("refresh_page_after_starting_free_trial")}.
 						| #{translate("refresh_page_after_starting_free_trial")}.
 
 
-	.modal-footer
+	.modal-footer.modal-footer-share
 		.modal-footer-left
 		.modal-footer-left
 			i.fa.fa-refresh.fa-spin(ng-show="state.inflight")
 			i.fa.fa-refresh.fa-spin(ng-show="state.inflight")
-			span.text-danger.error(ng-show="state.error") #{translate("generic_something_went_wrong")}
+			span.text-danger.error(ng-show="state.error")
+				span(ng-switch="state.errorReason")
+					span(ng-switch-when="cannot_invite_non_user")
+						| #{translate("cannot_invite_non_user")}
+					span(ng-switch-when="cannot_invite_self")
+						| #{translate("cannot_invite_self")}
+					span(ng-switch-default)
+						| #{translate("generic_something_went_wrong")}
 		button.btn.btn-default(
 		button.btn.btn-default(
 			ng-click="done()"
 			ng-click="done()"
 		) #{translate("close")}
 		) #{translate("close")}

+ 1 - 1
services/web/app/views/project/invite/show.jade

@@ -20,7 +20,7 @@ block content
 								form.form(
 								form.form(
 									name="acceptForm",
 									name="acceptForm",
 									method="POST",
 									method="POST",
-									action="/project/#{invite.projectId}/invite/#{invite._id}/accept"
+									action="/project/#{invite.projectId}/invite/token/#{invite.token}/accept"
 								)
 								)
 									input(name='_csrf', type='hidden', value=csrfToken)
 									input(name='_csrf', type='hidden', value=csrfToken)
 									input(name='token', type='hidden', value="#{invite.token}")
 									input(name='token', type='hidden', value="#{invite.token}")

+ 43 - 5
services/web/app/views/project/list.jade

@@ -19,11 +19,49 @@ block content
 			}
 			}
 		};
 		};
 
 
-	.content.content-alt(ng-controller="ProjectPageController")
+	.content.content-alt.project-list-page(ng-controller="ProjectPageController")
 		.container
 		.container
-				
+			.announcements(
+				ng-controller="AnnouncementsController"
+				ng-class="{ 'announcements-open': ui.isOpen }"
+				ng-cloak
+			)
+				.announcements-backdrop(
+					ng-if="ui.isOpen"
+					ng-click="toggleAnnouncementsUI();"
+				)
+				a.announcements-btn(
+					href
+					ng-if="announcements.length"
+					ng-click="toggleAnnouncementsUI();"
+					ng-class="{ 'announcements-btn-open': ui.isOpen, 'announcements-btn-has-new': ui.newItems }"
+				)
+					span.announcements-badge(ng-if="ui.newItems") {{ ui.newItems }}
+				.announcements-body(
+					ng-if="ui.isOpen"
+				)
+					.announcements-scroller
+						.announcement(
+							ng-repeat="announcement in announcements | filter:(ui.newItems ? { read: false } : '') track by announcement.id"
+						)
+							h2.announcement-header {{ announcement.title }}
+							p.announcement-description(ng-bind-html="announcement.excerpt")
+							.announcement-meta
+								p.announcement-date {{ announcement.date | date:"longDate" }}
+								a.announcement-link(
+									ng-href="{{ announcement.url }}"
+									target="_blank"
+								) Read more
+						div.text-center(
+							ng-if="ui.newItems > 0 && ui.newItems < announcements.length"
+						)
+							a.btn.btn-default.btn-sm(
+								href
+								ng-click="showAll();"
+							) Show all
+
 			.row(ng-cloak)
 			.row(ng-cloak)
-				span(ng-show="first_sign_up == 'default' || projects.length > 0")
+				span(ng-if="projects.length > 0")
 					aside.col-md-2.col-xs-3
 					aside.col-md-2.col-xs-3
 						include ./list/side-bar
 						include ./list/side-bar
 
 
@@ -31,8 +69,8 @@ block content
 						include ./list/notifications
 						include ./list/notifications
 						include ./list/project-list
 						include ./list/project-list
 				
 				
-				span(ng-if="first_sign_up == 'minimial' && projects.length == 0")
+				span(ng-if="projects.length === 0")
 					.col-md-offset-2.col-md-8.col-md-offset-2.col-xs-8
 					.col-md-offset-2.col-md-8.col-md-offset-2.col-xs-8
-						include ./list/project-list-minimal
+						include ./list/empty-project-list
 
 
 		include ./list/modals
 		include ./list/modals

+ 46 - 0
services/web/app/views/project/list/empty-project-list.jade

@@ -0,0 +1,46 @@
+.row.row-spaced
+	.col-xs-12
+		.card.card-thin.project-list-card
+			div.welcome.text-centered(ng-cloak)
+				h2 #{translate("welcome_to_sl")}
+				p #{translate("new_to_latex_look_at")} 
+					a(href="/templates") #{translate("templates").toLowerCase()}
+					|   #{translate("or")}
+					a(href="/learn")  #{translate("latex_help_guide")}
+
+
+					.row
+						.col-md-offset-4.col-md-4
+							.dropdown.minimal-create-proj-dropdown(dropdown)
+								a.btn.btn-success.dropdown-toggle(
+									href="#",
+									data-toggle="dropdown",
+									dropdown-toggle
+								)
+									| Create First Project
+
+								ul.dropdown-menu.minimal-create-proj-dropdown-menu(role="menu")
+									li
+										a(
+											href,
+											ng-click="openCreateProjectModal()"
+										) #{translate("blank_project")}
+									li
+										a(
+											href,
+											ng-click="openCreateProjectModal('example')"
+										) #{translate("example_project")}
+									li
+										a(
+											href,
+											ng-click="openUploadProjectModal()"
+										) #{translate("upload_project")}
+									!= moduleIncludes("newProjectMenu", locals)
+									if (templates)
+										li.divider
+										li.dropdown-header #{translate("templates")}
+										each item in templates
+											li
+												a.menu-indent(href=item.url) #{translate(item.name)}
+
+							

+ 22 - 5
services/web/app/views/project/list/notifications.jade

@@ -4,14 +4,31 @@ span(ng-controller="NotificationsController").userNotifications
 		ng-cloak
 		ng-cloak
 	)
 	)
 		li.notification_entry(
 		li.notification_entry(
-			ng-repeat="unreadNotification in notifications",
+			ng-repeat="notification in notifications",
 		)
 		)
-			.row(ng-hide="unreadNotification.hide")
+			.row(ng-hide="notification.hide")
 				.col-xs-12
 				.col-xs-12
-					.alert.alert-info
+					.alert.alert-info(ng-if="notification.templateKey == 'notification_project_invite'", ng-controller="ProjectInviteNotificationController")
 						div.notification_inner
 						div.notification_inner
-							span(ng-bind-html="unreadNotification.html").notification_body
+							.notification_body(ng-show="!notification.accepted")
+								| !{translate("notification_project_invite_message")}
+								a.pull-right.btn.btn-sm.btn-info(href, ng-click="accept()", ng-disabled="notification.inflight")
+									span(ng-show="!notification.inflight") #{translate("join_project")}
+									span(ng-show="notification.inflight")
+										i.fa.fa-fw.fa-spinner.fa-spin
+										| &nbsp;
+										| #{translate("joining")}...
+							.notification_body(ng-show="notification.accepted")
+								| !{translate("notification_project_invite_accepted_message")}
+								a.pull-right.btn.btn-sm.btn-info(href="/project/{{ notification.messageOpts.projectId }}") #{translate("open_project")}
 							span().notification_close
 							span().notification_close
-								button(ng-click="dismiss(unreadNotification)").close.pull-right
+								button(ng-click="dismiss(notification)").close.pull-right
+									span(aria-hidden="true") &times;
+									span.sr-only #{translate("close")}
+					.alert.alert-info(ng-if="notification.templateKey != 'notification_project_invite'")
+						div.notification_inner
+							span(ng-bind-html="notification.html").notification_body
+							span().notification_close
+								button(ng-click="dismiss(notification)").close.pull-right
 									span(aria-hidden="true") &times;
 									span(aria-hidden="true") &times;
 									span.sr-only #{translate("close")}
 									span.sr-only #{translate("close")}

+ 0 - 198
services/web/app/views/project/list/project-list-minimal.jade

@@ -1,198 +0,0 @@
-.row
-	.col-xs-12(ng-cloak)
-
-		.project-tools(ng-cloak)
-			.btn-toolbar(ng-show="filter != 'archived'")
-				.btn-group(ng-hide="selectedProjects.length < 1")
-					a.btn.btn-default(
-						href='#',
-						tooltip="#{translate('download')}",
-						tooltip-placement="bottom",
-						tooltip-append-to-body="true",
-						ng-click="downloadSelectedProjects()"
-					)
-						i.fa.fa-cloud-download
-					a.btn.btn-default(
-						href='#',
-						tooltip="#{translate('delete')}",
-						tooltip-placement="bottom",
-						tooltip-append-to-body="true",
-						ng-click="openArchiveProjectsModal()"
-					)
-						i.fa.fa-trash-o
-
-				.btn-group.dropdown(ng-hide="selectedProjects.length < 1", dropdown)
-					a.btn.btn-default.dropdown-toggle(
-						href="#",
-						data-toggle="dropdown",
-						dropdown-toggle,
-						tooltip="#{translate('add_to_folders')}",
-						tooltip-append-to-body="true",
-						tooltip-placement="bottom"
-					)
-						i.fa.fa-folder-open-o
-						|  
-						span.caret
-					ul.dropdown-menu.dropdown-menu-right.js-tags-dropdown-menu(
-						role="menu"
-						ng-controller="TagListController"
-					)
-						li.dropdown-header #{translate("add_to_folder")}
-						li(
-							ng-repeat="tag in tags | filter:nonEmpty | orderBy:'name'",
-							ng-controller="TagDropdownItemController"
-						)
-							a(href="#", ng-click="addOrRemoveProjectsFromTag()", stop-propagation="click")
-								i.fa(
-									ng-class="{\
-										'fa-check-square-o': areSelectedProjectsInTag == true,\
-										'fa-square-o': areSelectedProjectsInTag == false,\
-										'fa-minus-square-o': areSelectedProjectsInTag == 'partial'\
-									}"
-								)
-								|  {{tag.name}}
-						li.divider
-						li
-							a(href="#", ng-click="openNewTagModal()", stop-propagation="click") #{translate("create_new_folder")}
-
-				.btn-group(ng-hide="selectedProjects.length != 1", dropdown).dropdown
-					a.btn.btn-default.dropdown-toggle(
-						href='#',
-						data-toggle="dropdown",
-						dropdown-toggle
-					) #{translate("more")}
-						span.caret
-					ul.dropdown-menu.dropdown-menu-right(role="menu")
-						li(ng-show="getFirstSelectedProject().accessLevel == 'owner'")
-							a(
-								href='#',
-								ng-click="openRenameProjectModal()"
-							) #{translate("rename")}
-						li
-							a(
-								href='#',
-								ng-click="openCloneProjectModal()"
-							) #{translate("make_copy")}
-
-			.btn-toolbar(ng-show="filter == 'archived'")
-				.btn-group(ng-hide="selectedProjects.length < 1")
-					a.btn.btn-default(
-						href='#',
-						data-original-title="Restore",
-						data-toggle="tooltip",
-						data-placement="bottom",
-						ng-click="restoreSelectedProjects()"
-					) #{translate("restore")}
-
-				.btn-group(ng-hide="selectedProjects.length < 1")
-					a.btn.btn-danger(
-						href='#',
-						data-original-title="Delete Forever",
-						data-toggle="tooltip",
-						data-placement="bottom",
-						ng-click="openDeleteProjectsModal()"
-					) #{translate("delete_forever")}
-
-.row.row-spaced
-	.col-xs-12
-		.card.card-thin.project-list-card
-			ul.list-unstyled.project-list.structured-list(
-				select-all-list,
-				ng-if="projects.length > 0",
-				max-height="projectListHeight - 25",
-				ng-cloak
-			)
-				li.container-fluid
-					.row
-						.col-xs-6
-							input.select-all(
-								select-all,
-								type="checkbox"
-							)
-							span.header.clickable(ng-click="changePredicate('name')") #{translate("title")}
-								i.tablesort.fa(ng-class="getSortIconClass('name')")
-						.col-xs-2
-							span.header.clickable(ng-click="changePredicate('accessLevel')") #{translate("owner")}
-								i.tablesort.fa(ng-class="getSortIconClass('accessLevel')")
-						.col-xs-4
-							span.header.clickable(ng-click="changePredicate('lastUpdated')") #{translate("last_modified")}
-								i.tablesort.fa(ng-class="getSortIconClass('lastUpdated')")
-				li.project_entry.container-fluid(
-					ng-repeat="project in visibleProjects | orderBy:predicate:reverse",
-					ng-controller="ProjectListItemController"
-				)
-					.row
-						.col-xs-6
-							input.select-item(
-								select-individual,
-								type="checkbox",
-								ng-model="project.selected"
-							)
-							span
-								a.projectName(href="/project/{{project.id}}") {{project.name}}
-								span(
-									ng-controller="TagListController"
-								)
-									a.label.label-default.tag-label(
-										href,
-										ng-repeat='tag in project.tags',
-										ng-click="selectTag(tag)"
-									) {{tag.name}}
-						.col-xs-2
-							span.owner {{ownerName()}}
-						.col-xs-4
-							span.last-modified {{project.lastUpdated | formatDate}}
-				li(
-					ng-if="visibleProjects.length == 0",
-					ng-cloak
-				)
-					.row
-						.col-xs-12.text-centered
-							small #{translate("no_projects")}
-
-			div.welcome.text-centered(ng-if="projects.length == 0", ng-cloak)
-				h2 #{translate("welcome_to_sl")}
-				p #{translate("new_to_latex_look_at")} 
-					a(href="/templates") #{translate("templates").toLowerCase()}
-					|   #{translate("or")}
-					a(href="/learn")  #{translate("latex_help_guide")}
-
-
-					.row
-						.col-md-offset-4.col-md-4
-							.dropdown.minimal-create-proj-dropdown(dropdown)
-								a.btn.btn-success.dropdown-toggle(
-									href="#",
-									data-toggle="dropdown",
-									dropdown-toggle
-								)
-									| Create First Project
-
-								ul.dropdown-menu.minimal-create-proj-dropdown-menu(role="menu", style="text-align:center;")
-									li
-										a(
-											href,
-											ng-click="openCreateProjectModal()"
-											sixpack-convert="first_sign_up",
-										) #{translate("blank_project")}
-									li
-										a(
-											href,
-											sixpack-convert="first_sign_up",
-											ng-click="openCreateProjectModal('example')"
-										) #{translate("example_project")}
-									li
-										a(
-											href,
-											sixpack-convert="first_sign_up",
-											ng-click="openUploadProjectModal()"
-										) #{translate("upload_project")}
-									!= moduleIncludes("newProjectMenu", locals)
-									if (templates)
-										li.divider
-										li.dropdown-header #{translate("templates")}
-										each item in templates
-											li
-												a.menu-indent(href=item.url, sixpack-convert="first_sign_up") #{translate(item.name)}
-
-							

+ 2 - 2
services/web/app/views/project/list/project-list.jade

@@ -8,7 +8,7 @@
 				input.form-control.col-md-7.col-xs-12(
 				input.form-control.col-md-7.col-xs-12(
 					placeholder="#{translate('search_projects')}…",
 					placeholder="#{translate('search_projects')}…",
 					autofocus='autofocus',
 					autofocus='autofocus',
-					ng-model="searchText",
+					ng-model="searchText.value",
 					focus-on='search:clear',
 					focus-on='search:clear',
 					ng-keyup="searchProjects()"
 					ng-keyup="searchProjects()"
 				)
 				)
@@ -16,7 +16,7 @@
 				i.fa.fa-times.form-control-feedback(
 				i.fa.fa-times.form-control-feedback(
 					ng-click="clearSearchText()",
 					ng-click="clearSearchText()",
 					style="cursor: pointer;",
 					style="cursor: pointer;",
-					ng-show="searchText.length > 0"
+					ng-show="searchText.value.length > 0"
 				)
 				)
 			//- i.fa.fa-remove
 			//- i.fa.fa-remove
 
 

+ 10 - 45
services/web/app/views/project/list/side-bar.jade

@@ -109,52 +109,17 @@
 					) #{translate("complete")}
 					) #{translate("complete")}
 
 
 
 
-		.row-spaced(ng-if="hasProjects && userHasNoSubscription", ng-cloak, sixpack-switch="left-menu-upgraed-rotation").text-centered
-			span(sixpack-default).text-centered
-				hr
-				p.small #{translate("on_free_sl")}
-				p
-					a(href="/user/subscription/plans", sixpack-convert="left-menu-upgraed-rotation").btn.btn-primary #{translate("upgrade")}
-				p.small.text-centered
-					| #{translate("or_unlock_features_bonus")} 
-					a(href="/user/bonus") #{translate("sharing_sl")} .
+		.row-spaced(ng-if="hasProjects && userHasNoSubscription", ng-cloak).text-centered
+			hr
+			p.small #{translate("on_free_sl")}
+			p
+				a(href="/user/subscription/plans", sixpack-convert="left-menu-upgraed-rotation").btn.btn-primary #{translate("upgrade")}
+			p.small.text-centered
+				| #{translate("or_unlock_features_bonus")} 
+				a(href="/user/bonus") #{translate("sharing_sl")} .
 
 
-			span(sixpack-when="random").text-centered
-				span(ng-if="randomView == 'default'")
-					hr
-					p.small #{translate("on_free_sl")}
-					p
-						a(href="/user/subscription/plans", sixpack-convert="left-menu-upgraed-rotation").btn.btn-primary #{translate("upgrade")}
-					p.small.text-centered
-						| #{translate("or_unlock_features_bonus")} 
-						a(href="/user/bonus") #{translate("sharing_sl")} .
-
-				span(ng-if="randomView == 'dropbox'")
-					hr
-					.card.card-thin
-						p
-							span Get Dropbox Sync
-						p
-							img(src=buildImgPath("dropbox/simple_logo.png"))
-						p
-							a(href="/user/subscription/plans", sixpack-convert="left-menu-upgraed-rotation").btn.btn-primary #{translate("upgrade")}
-						p.small.text-centered
-							| #{translate("or_unlock_features_bonus")} 
-							a(href="/user/bonus") #{translate("sharing_sl")} .
-
-				span(ng-if="randomView == 'github'")
-					hr
-					.card.card-thin
-						p
-							span Get Github Sync
-						p
-							img(src=buildImgPath("github/octocat.jpg"))
-						p
-							a(href="/user/subscription/plans", sixpack-convert="left-menu-upgraed-rotation").btn.btn-primary #{translate("upgrade")}
-						p.small.text-centered
-							| #{translate("or_unlock_features_bonus")} 
-							a(href="/user/bonus") #{translate("sharing_sl")} .
+			
 script.
 script.
-	window.userHasNoSubscription = #{settings.enableSubscriptions && !hasSubscription}
+	window.userHasNoSubscription = #{!!(settings.enableSubscriptions && !hasSubscription)}
 
 
 
 

Некоторые файлы не были показаны из-за большого количества измененных файлов