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

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
 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/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/brand/plans.css
 public/minjs/
 
-public/js/main.js
 Gemfile.lock
 
 *.swp

+ 11 - 7
services/web/Gruntfile.coffee

@@ -1,4 +1,5 @@
 fs = require "fs"
+PackageVersions = require "./app/coffee/infrastructure/PackageVersions"
 
 module.exports = (grunt) ->
 	grunt.loadNpmTasks 'grunt-contrib-coffee'
@@ -157,12 +158,13 @@ module.exports = (grunt) ->
 					inlineText: false
 					preserveLicenseComments: false
 					paths:
-						"moment": "libs/moment-2.9.0"
+						"moment": "libs/#{PackageVersions.lib('moment')}"
 						"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:
-						"libs/pdf":
-							deps: ["libs/pdfjs-1.3.91/compatibility"]
+						"pdfjs-dist/build/pdf":
+							deps: ["libs/#{PackageVersions.lib('pdfjs')}/compatibility"]
 
 					skipDirOptimize: true
 					modules: [
@@ -171,11 +173,13 @@ module.exports = (grunt) ->
 							exclude: ["libs"]
 						}, {
 							name: "ide",
-							exclude: ["libs", "libs/pdf"]
+							exclude: ["libs", "pdfjs-dist/build/pdf"]
 						}, {
 							name: "libs"
 						},{
 							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 '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'
 

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

@@ -4,4 +4,4 @@ module.exports = AnalyticsController =
 	recordEvent: (req, res, next) ->
 		AnalyticsManager.recordEvent req.session?.user?._id, req.params.event, req.body, (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"
 _ = 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")
 UserSessionsManager = require("../User/UserSessionsManager")
 Analytics = require "../Analytics/AnalyticsManager"
+passport = require 'passport'
 
 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)->
+			return done(err) if err?
 			if !isAllowed
 				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) ->
-				return next(error) if error?
+				return done(error) if error?
 				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
 					AuthenticationController._recordFailedLogin()
 					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
-			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
-			return callback null, null
-
-		UserGetter.getUser query, callback
+			return null
 
 	requireLogin: () ->
 		doRequest = (req, res, next = (error) ->) ->
-			if !req.session.user?
+			if !AuthenticationController.isUserLoggedIn(req)
 				AuthenticationController._redirectToLoginOrRegisterPage(req, res)
 			else
-				req.user = req.session.user
-				return next()
+				req.user = AuthenticationController.getSessionUser(req)
+				next()
 
 		return doRequest
 
@@ -81,10 +144,11 @@ module.exports = AuthenticationController =
 
 		if req.headers['authorization']?
 			return AuthenticationController.httpAuth(req, res, next)
-		else if req.session.user?
+		else if AuthenticationController.isUserLoggedIn(req)
 			return next()
 		else
 			logger.log url:req.url, "user trying to access endpoint not in global whitelist"
+			AuthenticationController._setRedirectInSession(req)
 			return res.redirect "/login"
 
 	httpAuth: basicAuth (user, pass)->
@@ -94,22 +158,23 @@ module.exports = AuthenticationController =
 		return isValid
 
 	_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)
 		else
 			AuthenticationController._redirectToLoginPage(req, res)
 
-
 	_redirectToLoginPage: (req, res) ->
 		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)}"
 		res.redirect url
 		Metrics.inc "security.login-redirect"
 
 	_redirectToRegisterPage: (req, res) ->
 		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)}"
 		res.redirect url
 		Metrics.inc "security.login-redirect"
@@ -127,25 +192,15 @@ module.exports = AuthenticationController =
 		Metrics.inc "user.login.failed"
 		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
 
 	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) ->
 			return callback(error) if error?
 			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"
 ObjectId = require("mongojs").ObjectId
 Errors = require "../Errors/Errors"
+AuthenticationController = require "../Authentication/AuthenticationController"
 
 module.exports = AuthorizationMiddlewear =
 	ensureUserCanReadMultipleProjects: (req, res, next) ->
@@ -20,7 +21,7 @@ module.exports = AuthorizationMiddlewear =
 					AuthorizationMiddlewear.redirectToRestricted req, res, next
 				else
 					next()
-		
+
 	ensureUserCanReadProject: (req, res, next) ->
 		AuthorizationMiddlewear._getUserAndProjectId req, (error, user_id, project_id) ->
 			return next(error) if error?
@@ -32,7 +33,7 @@ module.exports = AuthorizationMiddlewear =
 				else
 					logger.log {user_id, project_id}, "denying user read access to project"
 					AuthorizationMiddlewear.redirectToRestricted req, res, next
-		
+
 	ensureUserCanWriteProjectSettings: (req, res, next) ->
 		AuthorizationMiddlewear._getUserAndProjectId req, (error, user_id, project_id) ->
 			return next(error) if error?
@@ -44,7 +45,7 @@ module.exports = AuthorizationMiddlewear =
 				else
 					logger.log {user_id, project_id}, "denying user write access to project settings"
 					AuthorizationMiddlewear.redirectToRestricted req, res, next
-		
+
 	ensureUserCanWriteProjectContent: (req, res, next) ->
 		AuthorizationMiddlewear._getUserAndProjectId req, (error, user_id, project_id) ->
 			return next(error) if error?
@@ -56,7 +57,7 @@ module.exports = AuthorizationMiddlewear =
 				else
 					logger.log {user_id, project_id}, "denying user write access to project settings"
 					AuthorizationMiddlewear.redirectToRestricted req, res, next
-		
+
 	ensureUserCanAdminProject: (req, res, next) ->
 		AuthorizationMiddlewear._getUserAndProjectId req, (error, user_id, project_id) ->
 			return next(error) if error?
@@ -68,7 +69,7 @@ module.exports = AuthorizationMiddlewear =
 				else
 					logger.log {user_id, project_id}, "denying user admin access to project"
 					AuthorizationMiddlewear.redirectToRestricted req, res, next
-		
+
 	ensureUserIsSiteAdmin: (req, res, next) ->
 		AuthorizationMiddlewear._getUserId req, (error, user_id) ->
 			return next(error) if error?
@@ -90,22 +91,22 @@ module.exports = AuthorizationMiddlewear =
 		AuthorizationMiddlewear._getUserId req, (error, user_id) ->
 			return callback(error) if error?
 			callback(null, user_id, project_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) ->
-		res.redirect "/restricted"
-	
+		res.redirect "/restricted?from=#{encodeURIComponent(req.url)}"
+
 	restricted : (req, res, next)->
-		if req.session.user?
+		if AuthenticationController.isUserLoggedIn(req)
 			res.render 'user/restricted',
 				title:'restricted'
 		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"
 Settings = require "settings-sharelatex"
 logger = require 'logger-sharelatex'
+AuthenticationController = require '../Authentication/AuthenticationController'
 
 
 module.exports = BetaProgramController =
 
 	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"
-		if !user_id
+		if !user_id?
 			return next(new Error("no user id in session"))
 		BetaProgramHandler.optIn user_id, (err) ->
 			if err
@@ -17,9 +18,9 @@ module.exports = BetaProgramController =
 			return res.redirect "/beta/participate"
 
 	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"
-		if !user_id
+		if !user_id?
 			return next(new Error("no user id in session"))
 		BetaProgramHandler.optOut user_id, (err) ->
 			if err
@@ -27,7 +28,7 @@ module.exports = BetaProgramController =
 			return res.redirect "/beta/participate"
 
 	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"
 		UserLocator.findById user_id, (err, user)->
 			if err

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

@@ -28,7 +28,7 @@ module.exports = BlogController =
 			try
 				data = JSON.parse(data)
 				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
 				logger.err err:err, data:data, "error parsing data from 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")
 logger = require("logger-sharelatex")
+AuthenticationController = require('../Authentication/AuthenticationController')
+UserInfoManager = require('../User/UserInfoManager')
+UserInfoController = require('../User/UserInfoController')
+CommentsController = require('../Comments/CommentsController')
 
 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
 		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)}"
 		].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
 			.findOne(_id: project_id )
 			.select("name owner_ref")
@@ -45,4 +24,5 @@ module.exports = CollaboratorsEmailHandler =
 						name: project.name
 					inviteUrl: CollaboratorsEmailHandler._buildInviteUrl(project, invite)
 					owner: project.owner_ref
+					sendingUser_id: sendingUser._id
 				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')
 CollaboratorsInviteHandler = require('./CollaboratorsInviteHandler')
 logger = require('logger-sharelatex')
+Settings = require('settings-sharelatex')
 EmailHelper = require "../Helpers/EmailHelper"
 EditorRealTimeController = require("../Editor/EditorRealTimeController")
 NotificationsBuilder = require("../Notifications/NotificationsBuilder")
 AnalyticsManger = require("../Analytics/AnalyticsManager")
+AuthenticationController = require("../Authentication/AuthenticationController")
+rateLimiter = require("../../infrastructure/RateLimiter")
 
 module.exports = CollaboratorsInviteController =
 
@@ -20,11 +23,36 @@ module.exports = CollaboratorsInviteController =
 				return next(err)
 			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) ->
 		projectId = req.params.Project_id
 		email = req.body.email
-		sendingUser = req.session.user
+		sendingUser = AuthenticationController.getSessionUser(req)
 		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"
 		LimitationsManager.canAddXCollaborators projectId, 1, (error, allowed) =>
 			return next(error) if error?
@@ -36,13 +64,20 @@ module.exports = CollaboratorsInviteController =
 			if !email? or email == ""
 				logger.log {projectId, email, sendingUserId}, "invalid email address"
 				return res.sendStatus(400)
-			CollaboratorsInviteHandler.inviteToProject projectId, sendingUser, email, privileges, (err, invite) ->
+			CollaboratorsInviteController._checkShouldInviteEmail sendingUser, email, (err, shouldAllowInvite)->
 				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)
-				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) ->
 		projectId = req.params.Project_id
@@ -58,8 +93,8 @@ module.exports = CollaboratorsInviteController =
 	resendInvite: (req, res, next) ->
 		projectId = req.params.Project_id
 		inviteId = req.params.invite_id
-		sendingUser = req.session.user
 		logger.log {projectId, inviteId}, "resending invite"
+		sendingUser = AuthenticationController.getSessionUser(req)
 		CollaboratorsInviteHandler.resendInvite projectId, sendingUser, inviteId, (err) ->
 			if err?
 				logger.err {projectId, inviteId}, "error resending invite"
@@ -69,11 +104,11 @@ module.exports = CollaboratorsInviteController =
 	viewInvite: (req, res, next) ->
 		projectId = req.params.Project_id
 		token = req.params.token
-		currentUser = req.session.user
 		_renderInvalidPage = () ->
 			logger.log {projectId, token}, "invite not valid, rendering not-valid page"
 			res.render "project/invite/not-valid", {title: "Invalid Invite"}
 		# check if the user is already a member of the project
+		currentUser = AuthenticationController.getSessionUser(req)
 		CollaboratorsHandler.isUserMemberOfProject currentUser._id, projectId, (err, isMember, _privilegeLevel) ->
 			if err?
 				logger.err {err, projectId}, "error checking if user is member of project"
@@ -111,14 +146,16 @@ module.exports = CollaboratorsInviteController =
 
 	acceptInvite: (req, res, next) ->
 		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?
-				logger.err {projectId, inviteId}, "error accepting invite by token"
+				logger.err {projectId, token}, "error accepting invite by token"
 				return next(err)
 			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)->) ->
 		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?
 			CollaboratorsInviteHandler._trySendInviteNotification projectId, sendingUser, invite, (err)->
 				return callback(err) if err?
@@ -77,10 +77,12 @@ module.exports = CollaboratorsInviteHandler =
 				if err?
 					logger.err {err, projectId, sendingUserId: sendingUser._id, email}, "error saving token"
 					return callback(err)
+				# Send email and notification in background
 				CollaboratorsInviteHandler._sendMessages projectId, sendingUser, invite, (err) ->
 					if err?
 						logger.err {projectId, email}, "error sending messages for invite"
-					callback(err, invite)
+				callback(null, invite)
+
 
 	revokeInvite: (projectId, inviteId, callback=(err)->) ->
 		logger.log {projectId, inviteId}, "removing invite"
@@ -102,7 +104,7 @@ module.exports = CollaboratorsInviteHandler =
 				return callback(null)
 			CollaboratorsInviteHandler._sendMessages projectId, sendingUser, invite, (err) ->
 				if err?
-					logger.err {projectid, inviteId}, "error resending invite messages"
+					logger.err {projectId, inviteId}, "error resending invite messages"
 					return callback(err)
 				callback(null)
 
@@ -117,15 +119,15 @@ module.exports = CollaboratorsInviteHandler =
 				return callback(null, null)
 			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) ->
 			if err?
-				logger.err {err, projectId, inviteId}, "error finding invite"
+				logger.err {err, projectId, tokenString}, "error finding invite"
 				return callback(err)
 			if !invite
 				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)
 			inviteId = invite._id
 			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({
 				endpointName: "invite-to-project"
 				params: ["Project_id"]
-				maxRequests: 200
+				maxRequests: 100
+				timeInterval: 60 * 10
+			}),
+			RateLimiterMiddlewear.rateLimit({
+				endpointName: "invite-to-project-ip"
+				ipOnly:true
+				maxRequests: 100
 				timeInterval: 60 * 10
 			}),
 			AuthenticationController.requireLogin(),
@@ -66,7 +72,7 @@ module.exports =
 		)
 
 		webRouter.post(
-			'/project/:Project_id/invite/:invite_id/accept',
+			'/project/:Project_id/invite/token/:token/accept',
 			AuthenticationController.requireLogin(),
 			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)->
 			callback(err, serverId)
 
+	clearServerId: (project_id, callback = (err)->)->
+		if !clsiCookiesEnabled
+			return callback()
+		rclient.del buildKey(project_id), callback
 
 	getCookieJar: (project_id, callback = (err, jar)->)->
 		if !clsiCookiesEnabled

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

@@ -16,53 +16,53 @@ module.exports = CompileController =
 		res.setTimeout(5 * 60 * 1000)
 		project_id = req.params.Project_id
 		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?
-			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) ->) ->
 		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?
-			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) ->
 		# callback with user_id if per-user, undefined otherwise
 		if not Settings.disablePerUserCompiles
-			AuthenticationController.getLoggedInUserId req, callback # -> (error, user_id)
+			user_id = AuthenticationController.getLoggedInUserId req
+			return callback(null, user_id)
 		else
 			callback() # do a per-project compile, not per-user
 
 	_downloadAsUser: (req, callback) ->
 		# callback with user_id if per-user, undefined otherwise
 		if not Settings.disablePerUserCompiles
-			AuthenticationController.getLoggedInUserId req, callback # -> (error, user_id)
+			user_id = AuthenticationController.getLoggedInUserId req
+			return callback(null, user_id)
 		else
 			callback() # do a per-project compile, not per-user
 
@@ -151,9 +151,9 @@ module.exports = CompileController =
 		{page, h, v} = req.query
 		if not page?.match(/^\d+$/)
 			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"))
-		if not v?.match(/^\d+\.\d+$/)
+		if not v?.match(/^-?\d+\.\d+$/)
 			return next(new Error("invalid v parameter"))
 		# whether this request is going to a per-user container
 		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 =
 	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?
-			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?
-				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?
-					
-					# 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) ->
 		return  {
 			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}")
 				logger.error err: error, project_id: project_id, "error getting all docs from docstore"
 				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"
 			callback = options
 			options = {}
@@ -45,19 +60,21 @@ module.exports = DocstoreManager =
 			return callback(error) if error?
 			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"
-				callback(null, doc.lines, doc.rev)
+				callback(null, doc.lines, doc.rev, doc.version, doc.ranges)
 			else
 				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"
 				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"
 		url = "#{settings.apis.docstore.url}/project/#{project_id}/doc/#{doc_id}"
 		request.post {
 			url: url
 			json:
 				lines: lines
+				version: version
+				ranges: ranges
 		}, (error, res, result) ->
 			return callback(error) if error?
 			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}"
 				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")
 		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"
@@ -110,7 +110,7 @@ module.exports = DocumentUpdaterHandler =
 					body = JSON.parse(body)
 				catch error
 					return callback(error)
-				callback null, body.lines, body.version, body.ops
+				callback null, body.lines, body.version, body.ranges, body.ops
 			else
 				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}")
@@ -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}"
 				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"
 DOCLINESKEY = "doclines"

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

@@ -7,7 +7,7 @@ module.exports =
 		doc_id = req.params.doc_id
 		plain = req?.query?.plain == 'true'
 		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?
 				logger.err err:error, doc_id:doc_id, project_id:project_id, "error finding element for getDocument"
 				return next(error)
@@ -18,14 +18,16 @@ module.exports =
 				res.type "json"
 				res.send JSON.stringify {
 					lines: lines
+					version: version
+					ranges: ranges
 				}
 
 	setDocument: (req, res, next = (error) ->) ->
 		project_id = req.params.Project_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)"
-		ProjectEntityHandler.updateDocLines project_id, doc_id, lines, (error) ->
+		ProjectEntityHandler.updateDocLines project_id, doc_id, lines, version, ranges, (error) ->
 			if error?
 				logger.err err:error, doc_id:doc_id, project_id:project_id, "error finding element for getDocument"
 				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")
 DocumentUpdaterHandler = require('../DocumentUpdater/DocumentUpdaterHandler')
 EditorRealTimeController = require("./EditorRealTimeController")
-TrackChangesManager = require("../TrackChanges/TrackChangesManager")
 async = require('async')
 LockManager = require("../../infrastructure/LockManager")
 _ = 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')
+
 PersonalEmailLayout = require("./Layouts/PersonalEmailLayout")
 NotificationEmailLayout = require("./Layouts/NotificationEmailLayout")
+BaseWithHeaderEmailLayout = require("./Layouts/BaseWithHeaderEmailLayout") 
+
+SingleCTAEmailBody = require("./Bodies/SingleCTAEmailBody")
+
+
 settings = require("settings-sharelatex")
 
 
 
 templates = {}
 
+
 templates.registered =
 	subject:  _.template "Activate your #{settings.appName} Account"
 	layout: PersonalEmailLayout
 	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 """
 <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>
 """
 
+
 templates.canceledSubscription =
 	subject:  _.template "ShareLaTeX thoughts"
 	layout: PersonalEmailLayout
 	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 '''
 <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>
 
@@ -36,94 +64,112 @@ ShareLaTeX Co-founder
 </p>
 '''
 
+
 templates.passwordResetRequested =
 	subject:  _.template "Password Reset - #{settings.appName}"
-	layout: NotificationEmailLayout
+	layout: BaseWithHeaderEmailLayout
 	type:"notification"
-	compiledTemplate: _.template """
-<h2>Password Reset</h2>
-<p>
+	plainTextTemplate: _.template """
+Password Reset
+
 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.
-<p>
+
 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 =
-	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"
-	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 =
 	subject: _.template "Verify Email to join <%= group_name %> group"
-	layout: NotificationEmailLayout
+	layout: BaseWithHeaderEmailLayout
 	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 =
 	templates: templates
@@ -137,5 +183,6 @@ module.exports =
 		return {
 			subject : template.subject(opts)
 			html: template.layout(opts)
+			text: template?.plainTextTemplate?(opts)
 			type:template.type
 		}

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

@@ -3,7 +3,7 @@ EmailBuilder = require "./EmailBuilder"
 EmailSender = require "./EmailSender"
 
 if !settings.email?
-	settings.email = 
+	settings.email =
 		lifecycleEnabled:false
 
 
@@ -14,6 +14,7 @@ module.exports =
 		if email.type == "lifecycle" and !settings.email.lifecycle
 			return callback()
 		opts.html = email.html
+		opts.text = email.text
 		opts.subject = email.subject
 		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")
 sesTransport = require('nodemailer-ses-transport')
 sgTransport = require('nodemailer-sendgrid-transport')
-
+rateLimiter = require('../../infrastructure/RateLimiter')
 _ = require("underscore")
 
 if Settings.email? and Settings.email.fromAddress?
@@ -25,7 +25,7 @@ else if Settings?.email?.parameters?.sendgridApiKey?
 	logger.log "using sendgrid for email"
 	nm_client = nodemailer.createTransport(sgTransport({auth:{api_key:Settings?.email?.parameters?.sendgridApiKey}}))
 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"
@@ -39,21 +39,39 @@ if nm_client?
 else
 	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 =
 	sendEmail : (options, callback = (error) ->)->
 		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?
-				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"
 logger = require "logger-sharelatex"
+AuthenticationController = require '../Authentication/AuthenticationController'
 
 module.exports = ErrorController =
 	notFound: (req, res)->
@@ -11,15 +12,16 @@ module.exports = ErrorController =
 		res.status(500)
 		res.render 'general/500',
 			title: "Server Error"
-	
+
 	handleError: (error, req, res, next) ->
+		user = AuthenticationController.getSessionUser(req)
 		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)
 			return
 		if error instanceof Errors.NotFoundError
 			logger.warn {err: error, url: req.url}, "not found error"
 			ErrorController.notFound req, res
 		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")
 			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
-				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")
 ProjectUpdateHandler = require("../Project/ProjectUpdateHandler")
 Project = require("../../models/Project").Project
-TrackChangesManager = require("../TrackChanges/TrackChangesManager")
-
 
 MILISECONDS_IN_DAY = 86400000
 module.exports = InactiveProjectManager =
@@ -52,7 +50,6 @@ module.exports = InactiveProjectManager =
 		logger.log project_id:project_id, "deactivating inactive project"
 		jobs = [
 			(cb)-> DocstoreManager.archiveProject project_id, cb
-			# (cb)-> TrackChangesManager.archiveProject project_id, cb
 			(cb)-> ProjectUpdateHandler.markAsInactive project_id, cb
 		]
 		async.series jobs, (err)->

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

@@ -11,8 +11,8 @@ module.exports =
 			messageOpts =
 				groupName: licence.name
 				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 = ->)->
 			NotificationsHandler.markAsReadWithKey user._id, @key, callback
@@ -26,6 +26,6 @@ module.exports =
 				projectId: project._id.toString()
 				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"
-			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=()->) ->
 			NotificationsHandler.markAsReadByKeyOnly @key, callback

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

@@ -1,18 +1,20 @@
 NotificationsHandler = require("./NotificationsHandler")
+AuthenticationController = require("../Authentication/AuthenticationController")
 logger = require("logger-sharelatex")
 _ = require("underscore")
 
 module.exports =
 
 	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)
 				return notification
 			res.send(unreadNotifications)
 
 	markNotificationAsRead: (req, res)->
-		user_id = req.session.user._id
+		user_id = AuthenticationController.getLoggedInUserId(req)
 		notification_id = req.params.notification_id
 		NotificationsHandler.markAsRead user_id, notification_id, ->
 			res.send()

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

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

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

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

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

@@ -126,7 +126,7 @@ module.exports = ProjectEntityHandler =
 			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
 			# 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? 
 
 				ProjectEntityHandler._putElement project, folder_id, doc, "doc", (err, result)=>
@@ -292,7 +292,7 @@ module.exports = ProjectEntityHandler =
 					return callback(err)
 				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)->
 			return callback(err) if err?
 			return callback(new Errors.NotFoundError("project not found")) if !project?
@@ -307,7 +307,7 @@ module.exports = ProjectEntityHandler =
 					return callback(error)
 
 				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?
 						logger.error err: err, doc_id: doc_id, project_id:project_id, lines: lines, "error sending doc to docstore"
 						return callback(err)

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

@@ -81,7 +81,7 @@ module.exports = ProjectLocator =
 				else
 					getRootDoc project
 
-	findElementByPath: (project_or_id, needlePath, callback = (err, foundEntity)->)->
+	findElementByPath: (project_or_id, needlePath, callback = (err, foundEntity, type)->)->
 
 		getParentFolder = (haystackFolder, foldersList, level, cb)->
 			if foldersList.length == 0
@@ -100,12 +100,22 @@ module.exports = ProjectLocator =
 
 		getEntity = (folder, entityName, cb)->
 			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?
-				cb null, result
+				cb null, result, type
 			else
 				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?
 				return callback("project could not be found for finding a element #{project_or_id}")
 			if needlePath == '' || needlePath == '/'
-				return callback(null, project.rootFolder[0])
+				return callback(null, project.rootFolder[0], "folder")
 
 			if needlePath.indexOf('/') == 0
 				needlePath = needlePath.substring(1)

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

@@ -1,9 +1,11 @@
 logger = require('logger-sharelatex')
 ReferalHandler = require('./ReferalHandler')
+AuthenticationController = require('../Authentication/AuthenticationController')
 
-module.exports = 
+module.exports =
 	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",
 				title: "bonus_please_recommend_us"
 				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"
 logger = require "logger-sharelatex"
+AuthenticationController = require('../Authentication/AuthenticationController')
 
 module.exports = RateLimiterMiddlewear =
 	###
 	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
 	parameters in the request URL, e.g.:
-	
+
 	    app.get "/project/:project_id", RateLimiterMiddlewear.rateLimit(endpointName: "open-editor", params: ["project_id"])
-	
+
 	will rate limit each project_id separately.
-	
+
 	Unique clients are identified by user_id if logged in, and IP address if not.
 	###
 	rateLimit: (opts) ->
 		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.push user_id
+			subjectName = params.join(":")
+			if opts.ipOnly
+				subjectName = req.ip
 			if !opts.endpointName?
 				throw new Error("no endpointName provided")
 			options = {
 				endpointName: opts.endpointName
 				timeInterval: opts.timeInterval or 60
-				subjectName:  params.join(":")
+				subjectName:  subjectName
 				throttle:     opts.maxRequests or 6
 			}
 			RateLimiter.addCount options, (error, canContinue)->
@@ -37,4 +38,4 @@ module.exports = RateLimiterMiddlewear =
 					logger.warn options, "rate limit exceeded"
 					res.status(429) # Too many requests
 					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'
 Settings = require 'settings-sharelatex'
 logger = require 'logger-sharelatex'
+AuthenticationController = require('../Authentication/AuthenticationController')
 
 TEN_SECONDS = 1000 * 10
 
 module.exports = SpellingController =
 	proxyRequestToSpellingApi: (req, res, next) ->
+		user_id = AuthenticationController.getLoggedInUserId(req)
 		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
 		request(url: Settings.apis.spelling.url + url, method: req.method, headers: req.headers, json: req.body, timeout:TEN_SECONDS)
 		.on "error", (error) ->

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

@@ -5,12 +5,13 @@ Path = require "path"
 fs = require "fs"
 
 ErrorController = require "../Errors/ErrorController"
+AuthenticationController = require('../Authentication/AuthenticationController')
 
 homepageExists = fs.existsSync Path.resolve(__dirname + "/../../../views/external/home.jade")
 
 module.exports = HomeController =
 	index : (req,res)->
-		if req.session.user
+		if AuthenticationController.isUserLoggedIn(req)
 			if req.query.scribtex_path?
 				res.redirect "/project?scribtex_path=#{req.query.scribtex_path}"
 			else
@@ -33,4 +34,4 @@ module.exports = HomeController =
 					res.render "external/#{page}.jade",
 						title: title
 				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")
 logger = require("logger-sharelatex")
-_ = require("underscore")
-ErrorController = require "../Errors/ErrorController"
-StaticPageHelpers = require("./StaticPageHelpers")
-sanitize = require('sanitizer')
 Settings = require("settings-sharelatex")
-contentful = require('contentful')
-marked = require("marked")
 sixpack = require("../../infrastructure/Sixpack")
 
 
 
-module.exports = UniversityController = 
+module.exports = UniversityController =
 
 	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)->
-		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",
 			method: "put"
 		}, (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) ->

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

@@ -8,192 +8,190 @@ Settings   = require 'settings-sharelatex'
 logger     = require('logger-sharelatex')
 GeoIpLookup = require("../../infrastructure/GeoIpLookup")
 SubscriptionDomainHandler = require("./SubscriptionDomainHandler")
+UserGetter = require "../User/UserGetter"
 
 module.exports = SubscriptionController =
 
 	plansPage: (req, res, next) ->
 		plans = SubscriptionViewModelBuilder.buildViewModel()
-		if !req.session.user?
-			baseUrl = "/register?redir="
-		else
-			baseUrl = ""
 		viewName = "subscriptions/plans"
 		if req.query.v?
 			viewName = "#{viewName}_#{req.query.v}"
 		logger.log viewName:viewName, "showing plans page"
+		currentUser = null
 		GeoIpLookup.getCurrencyCode req.query?.ip || req.ip, (err, recomendedCurrency)->
 			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
 	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
-								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) ->
-		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)->
-		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) ->
-		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) ->
 		res.redirect "/user/subscription?saved_billing_details=true"
 
 	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)->
-		AuthenticationController.getLoggedInUser req, (error, user) =>
+		user = AuthenticationController.getSessionUser(req)
+		SubscriptionViewModelBuilder.buildUsersSubscriptionViewModel user, (error, subscription) ->
 			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) ->
-		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)->
 		_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)
-			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)->
-		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)->
 		logger.log data: req.body, "received recurly callback"
@@ -207,47 +205,44 @@ module.exports = SubscriptionController =
 			res.sendStatus 200
 
 	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)->
-		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)->
-		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) ->
 		xml = ""

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

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

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

@@ -7,6 +7,8 @@ SubscriptionUpdater = require("./SubscriptionUpdater")
 LimitationsManager = require('./LimitationsManager')
 EmailHandler = require("../Email/EmailHandler")
 Events = require "../../infrastructure/Events"
+Analytics = require("../Analytics/AnalyticsManager")
+
 
 module.exports =
 
@@ -52,6 +54,7 @@ module.exports =
 					setTimeout (-> EmailHandler.sendEmail "canceledSubscription", emailOpts
 					), ONE_HOUR_IN_MS
 					Events.emit "cancelSubscription", user._id
+					Analytics.recordEvent user._id, "subscription-canceled"
 					callback()
 			else
 				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"
 			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"
 		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
 
 	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")
 LimitationsManager = require("./LimitationsManager")
 SubscriptionLocator = require("./SubscriptionLocator")
+logger = require('logger-sharelatex')
 _ = require("underscore")
 
 module.exports =
@@ -16,6 +17,10 @@ module.exports =
 				if subscription?
 					return callback(error) if error?
 					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)->
 						tax = recurlySubscription?.tax_in_cents || 0
 						callback null, {
@@ -39,7 +44,7 @@ module.exports =
 		allPlans = {}
 		plans.forEach (plan)->
 			allPlans[plan.planCode] = plan
-			
+
 		result =
 			allPlans: allPlans
 
@@ -49,7 +54,7 @@ module.exports =
 
 		result.studentAccounts = _.filter plans, (plan)->
 			plan.planCode.indexOf("student") != -1
-			
+
 		result.groupMonthlyPlans = _.filter plans, (plan)->
 			plan.groupPlan and !plan.annual
 
@@ -63,4 +68,3 @@ module.exports =
 			!plan.groupPlan and plan.annual and plan.planCode.indexOf("student") == -1
 
 		return result
-

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

@@ -1,48 +1,49 @@
 TagsHandler = require("./TagsHandler")
 logger = require("logger-sharelatex")
+AuthenticationController = require('../Authentication/AuthenticationController')
 
 module.exports =
 	getAllTags: (req, res, next)->
-		user_id = req.session.user._id
+		user_id = AuthenticationController.getLoggedInUserId(req)
 		logger.log {user_id}, "getting tags"
 		TagsHandler.getAllTags user_id, (error, allTags)->
 			return next(error) if error?
 			res.json(allTags)
-	
+
 	createTag: (req, res, next) ->
-		user_id = req.session.user._id
+		user_id = AuthenticationController.getLoggedInUserId(req)
 		name = req.body.name
 		logger.log {user_id, name}, "creating tag"
 		TagsHandler.createTag user_id, name, (error, tag) ->
 			return next(error) if error?
 			res.json(tag)
-	
+
 	addProjectToTag: (req, res, next) ->
-		user_id = req.session.user._id
+		user_id = AuthenticationController.getLoggedInUserId(req)
 		{tag_id, project_id} = req.params
 		logger.log {user_id, tag_id, project_id}, "adding tag to project"
 		TagsHandler.addProjectToTag user_id, tag_id, project_id, (error) ->
 			return next(error) if error?
 			res.status(204).end()
-	
+
 	removeProjectFromTag: (req, res, next) ->
-		user_id = req.session.user._id
+		user_id = AuthenticationController.getLoggedInUserId(req)
 		{tag_id, project_id} = req.params
 		logger.log {user_id, tag_id, project_id}, "removing tag from project"
 		TagsHandler.removeProjectFromTag user_id, tag_id, project_id, (error) ->
 			return next(error) if error?
 			res.status(204).end()
-	
+
 	deleteTag: (req, res, next) ->
-		user_id = req.session.user._id
+		user_id = AuthenticationController.getLoggedInUserId(req)
 		tag_id = req.params.tag_id
 		logger.log {user_id, tag_id}, "deleting tag"
 		TagsHandler.deleteTag user_id, tag_id, (error) ->
 			return next(error) if error?
 			res.status(204).end()
-	
+
 	renameTag: (req, res, next) ->
-		user_id = req.session.user._id
+		user_id = AuthenticationController.getLoggedInUserId(req)
 		tag_id = req.params.tag_id
 		name = req.body?.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
 
 	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?
 				logger.log element:element, project_id:project_id, path:path, "could not find entity for deleting, assuming it was already deleted"
 				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)->
 				logger.log project_id:project_id, path:path, "finished processing update to delete entity from tpds"
 				callback()
@@ -56,12 +51,13 @@ module.exports =
 					return callback(err)
 				logger.log docLines:docLines, doc_id:doc_id, project_id:project_id, "processing doc update from tpds"
 				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
 					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)->
 			finish = (err)->
@@ -69,10 +65,13 @@ module.exports =
 				callback(err)
 			logger.log project_id:project_id, file_id:file_id, path:path, "processing file update from tpds"
 			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
 				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)->)->
 			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"
-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 =
-	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?
-			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 =
-	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"
 FileSystemImportManager = require "./FileSystemImportManager"
 ProjectUploadManager    = require "./ProjectUploadManager"
+AuthenticationController = require('../Authentication/AuthenticationController')
 
 module.exports = ProjectUploadController =
 	uploadProject: (req, res, next) ->
 		timer = new metrics.Timer("project-upload")
-		user_id = req.session.user._id
+		user_id = AuthenticationController.getLoggedInUserId(req)
 		{originalname, path} = req.files.qqfile
 		name = Path.basename(originalname, ".zip")
 		ProjectUploadManager.createProjectFromZipArchive user_id, name, path, (error, project) ->
@@ -24,7 +25,7 @@ module.exports = ProjectUploadController =
 					project: project._id, file_path: path, file_name: name,
 					"uploaded project"
 				res.send success: true, project_id: project._id
-	
+
 	uploadFile: (req, res, next) ->
 		timer = new metrics.Timer("file-upload")
 		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"
 			return res.send success: false
 		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) ->
 			fs.unlink path, ->
 			timer.done()
@@ -50,6 +51,3 @@ module.exports = ProjectUploadController =
 					project_id: project_id, file_path: path, file_name: name, folder_id: folder_id
 					"uploaded file"
 				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")
 Url = require("url")
 AuthenticationManager = require("../Authentication/AuthenticationManager")
+AuthenticationController = require('../Authentication/AuthenticationController')
 UserSessionsManager = require("./UserSessionsManager")
 UserUpdater = require("./UserUpdater")
 settings = require "settings-sharelatex"
 
 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)->
-		UserLocator.findById req.session.user._id, (err, user)->
+		user_id = AuthenticationController.getLoggedInUserId(req)
+		UserLocator.findById user_id, (err, user)->
 			newsLetterManager.unsubscribe user, ->
 				res.send()
 
 	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)->
 			if err? or !user?
 				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
 			if req.body.pdfViewer?
 				user.ace.pdfViewer = req.body.pdfViewer
+			if req.body.syntaxValidation?
+				user.ace.syntaxValidation = req.body.syntaxValidation
 			user.save (err)->
 				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
 				else if newEmail.indexOf("@") == -1
+					# email invalid
 					return res.sendStatus(400)
 				else
+					# update the user email
 					UserUpdater.changeEmailAddress user_id, newEmail, (err)->
 						if err?
 							logger.err err:err, user_id:user_id, newEmail:newEmail, "problem updaing users email address"
@@ -73,7 +102,7 @@ module.exports = UserController =
 							if err?
 								logger.err err:err, user_id:user_id, "error getting user for email update"
 								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
 								if err?
 									logger.err err:err, "error populateGroupLicenceInvite"
@@ -81,9 +110,10 @@ module.exports = UserController =
 
 	logout : (req, res)->
 		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
-		user = req?.session?.user
+		req.logout?()  # passport logout
 		req.session.destroy (err)->
 			if err
 				logger.err err: err, 'error destorying session'
@@ -102,13 +132,22 @@ module.exports = UserController =
 				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) ->)->
 		metrics.inc "user.password-change"
 		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?
 			if(user)
-				logger.log user: req.session.user, "changing password"
+				logger.log user: user._id, "changing password"
 				newPassword1 = req.body.newPassword1
 				newPassword2 = req.body.newPassword2
 				if newPassword1 != newPassword2
@@ -128,7 +167,7 @@ module.exports = UserController =
 									type:'success'
 									text:'Your password has been changed'
 			else
-				logger.log user: user, "current password wrong"
+				logger.log user_id: user_id, "current password wrong"
 				res.send
 					message:
 					  type:'error'

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

@@ -17,6 +17,7 @@ module.exports =
 		user = new User()
 		user.email = opts.email
 		user.holdingAccount = opts.holdingAccount
+		user.ace.syntaxValidation = true
 
 		username = opts.email.match(/^[^@]*/)
 		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")
 UserUpdater = require("./UserUpdater")
 sanitize = require('sanitizer')
+AuthenticationController = require('../Authentication/AuthenticationController')
 
 module.exports = UserController =
 	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,
 			role:true, institution:true,
 			email: true, signUpDate: true
@@ -24,20 +26,14 @@ module.exports = UserController =
 			UserController.sendFormattedPersonalInfo(user, res, next)
 
 	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")
 UserGetter = require("./UserGetter")
+UserSessionsManager = require("./UserSessionsManager")
 ErrorController = require("../Errors/ErrorController")
 logger = require("logger-sharelatex")
 Settings = require("settings-sharelatex")
 fs = require('fs')
+AuthenticationController = require('../Authentication/AuthenticationController')
 
 module.exports =
 
@@ -18,18 +20,17 @@ module.exports =
 
 		res.render 'user/register',
 			title: 'register'
-			redir: req.query.redir
 			sharedProjectData: sharedProjectData
 			newTemplateData: newTemplateData
 			new_email:req.query.new_email || ""
-	
+
 	activateAccountPage: (req, res) ->
 		# An 'activation' is actually just a password reset on an account that
 		# was set with a random password originally.
 		logger.log query:req.query, "activiate account page called"
 		if !req.query?.user_id? or !req.query?.token?
 			return ErrorController.notFound(req, res)
-			
+
 		UserGetter.getUser req.query.user_id, {email: 1, loginCount: 1}, (error, user) ->
 			return next(error) if error?
 			if !user
@@ -47,17 +48,35 @@ module.exports =
 					token: req.query.token
 
 	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',
 			title: 'login',
-			redir: req.query.redir,
 			email: req.query.email
 
 	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?
 			res.render 'user/settings',
 				title:'account_settings'
 				user: user,
+				shouldAllowEditingDetails: shouldAllowEditingDetails
 				languages: Settings.languages,
 				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")
 Async = require('async')
 _ = require('underscore')
+UserSessionsRedis = require('./UserSessionsRedis')
 
-rclient = redis.createClient(Settings.redis.web)
+rclient = UserSessionsRedis.client()
 
 module.exports = UserSessionsManager =
-
-	_sessionSetKey: (user) ->
-		return "UserSessions:#{user._id}"
-
 	# mimic the key used by the express sessions
 	_sessionKey: (sessionId) ->
 		return "sess:#{sessionId}"
@@ -23,7 +20,7 @@ module.exports = UserSessionsManager =
 			logger.log {user_id: user._id}, "no sessionId to track, returning"
 			return callback(null)
 		logger.log {user_id: user._id, sessionId}, "onLogin handler"
-		sessionSetKey = UserSessionsManager._sessionSetKey(user)
+		sessionSetKey = UserSessionsRedis.sessionSetKey(user)
 		value = UserSessionsManager._sessionKey sessionId
 		rclient.multi()
 			.sadd(sessionSetKey, value)
@@ -43,7 +40,7 @@ module.exports = UserSessionsManager =
 			logger.log {user_id: user._id}, "no sessionId to untrack, returning"
 			return callback(null)
 		logger.log {user_id: user._id, sessionId}, "onLogout handler"
-		sessionSetKey = UserSessionsManager._sessionSetKey(user)
+		sessionSetKey = UserSessionsRedis.sessionSetKey(user)
 		value = UserSessionsManager._sessionKey sessionId
 		rclient.multi()
 			.srem(sessionSetKey, value)
@@ -55,15 +52,45 @@ module.exports = UserSessionsManager =
 				UserSessionsManager._checkSessions(user, () ->)
 				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)->) ->
-		if !retain
+		if !retain?
 			retain = []
 		retain = retain.map((i) -> UserSessionsManager._sessionKey(i))
-		if !user
+		if !user?
 			logger.log {}, "no user to revoke sessions for, returning"
 			return callback(null)
 		logger.log {user_id: user._id}, "revoking all existing sessions for user"
-		sessionSetKey = UserSessionsManager._sessionSetKey(user)
+		sessionSetKey = UserSessionsRedis.sessionSetKey(user)
 		rclient.smembers sessionSetKey, (err, sessionKeys) ->
 			if err?
 				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"
 				return callback(null)
 			logger.log {user_id: user._id, count: keysToDelete.length}, "deleting sessions for user"
-			rclient.multi()
-				.del(keysToDelete)
-				.srem(sessionSetKey, keysToDelete)
-				.exec (err, result) ->
+
+			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?
-						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)
 					callback(null)
 
@@ -86,7 +119,7 @@ module.exports = UserSessionsManager =
 		if !user
 			logger.log {}, "no user to touch sessions for, returning"
 			return callback(null)
-		sessionSetKey = UserSessionsManager._sessionSetKey(user)
+		sessionSetKey = UserSessionsRedis.sessionSetKey(user)
 		rclient.expire sessionSetKey, "#{Settings.cookieSessionLength}", (err, response) ->
 			if err?
 				logger.err {err, user_id: user._id}, "error while updating ttl on UserSessions set"
@@ -98,7 +131,7 @@ module.exports = UserSessionsManager =
 			logger.log {}, "no user, returning"
 			return callback(null)
 		logger.log {user_id: user._id}, "checking sessions for user"
-		sessionSetKey = UserSessionsManager._sessionSetKey(user)
+		sessionSetKey = UserSessionsRedis.sessionSetKey(user)
 		rclient.smembers sessionSetKey, (err, sessionKeys) ->
 			if err?
 				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')
 querystring = require('querystring')
 SystemMessageManager = require("../Features/SystemMessages/SystemMessageManager")
+AuthenticationController = require("../Features/Authentication/AuthenticationController")
 _ = require("underscore")
+async = require("async")
 Modules = require "./Modules"
 Url = require "url"
-
+PackageVersions = require "./PackageVersions"
 fingerprints = {}
 Path = require 'path'
 
-
 jsPath =
 	if Settings.useMinifiedJs
 		"/minjs/"
 	else
 		"/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
 	if exists
 		content = fs.readFileSync filePath
-		hash = crypto.createHash("md5").update(content).digest("hex")
-		logger.log "#{filePath}: #{hash}"
-		fingerprints[path] = hash
+		return content
 	else
 		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) ->
 	if fingerprints[path]?
@@ -59,12 +71,13 @@ module.exports = (app, webRouter, apiRouter)->
 		res.locals.session = req.session
 		next()
 
-	webRouter.use (req, res, next)-> 
+	webRouter.use (req, res, next)->
 
 		cdnBlocked = req.query.nocdn == 'true' or req.session.cdnBlocked
+		user_id = AuthenticationController.getLoggedInUserId(req)
 
 		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
 
 		isDark = req.headers?.host?.slice(0,4)?.toLowerCase() == "dark"
@@ -77,16 +90,16 @@ module.exports = (app, webRouter, apiRouter)->
 			staticFilesBase = Settings.cdn?.web?.darkHost
 		else
 			staticFilesBase = ""
-		
+
 		res.locals.jsPath = jsPath
 		res.locals.fullJsPath = Url.resolve(staticFilesBase, jsPath)
-
+		res.locals.lib = PackageVersions.lib
 
 		res.locals.buildJsPath = (jsFile, opts = {})->
 			path = Path.join(jsPath, jsFile)
 
 			doFingerPrint = opts.fingerprint != false
-			
+
 			if !opts.qs?
 				opts.qs = {}
 
@@ -95,14 +108,13 @@ module.exports = (app, webRouter, apiRouter)->
 
 			if opts.cdn != false
 				path = Url.resolve(staticFilesBase, path)
-				
+
 			qs = querystring.stringify(opts.qs)
 
 			if qs? and qs.length > 0
 				path = path + "?" + qs
 			return path
 
-
 		res.locals.buildCssPath = (cssFile)->
 			path = Path.join("/stylesheets/", cssFile)
 			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
 		next()
 
@@ -123,7 +135,9 @@ module.exports = (app, webRouter, apiRouter)->
 		res.locals.translate = (key, vars = {}) ->
 			vars.appName = Settings.appName
 			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()
 
 	webRouter.use (req, res, next)->
@@ -131,9 +145,10 @@ module.exports = (app, webRouter, apiRouter)->
 			Settings.siteUrl.substring(Settings.siteUrl.indexOf("//")+2)
 		next()
 
-	webRouter.use (req, res, next)->
+	webRouter.use (req, res, next) ->
 		res.locals.getUserEmail = ->
-			email = req?.session?.user?.email or ""
+			user = AuthenticationController.getSessionUser(req)
+			email = user?.email or ""
 			return email
 		next()
 
@@ -143,15 +158,17 @@ module.exports = (app, webRouter, apiRouter)->
 			return formatedPrivileges[privilegeLevel] || "Private"
 		next()
 
-	webRouter.use (req, res, next)-> 
+	webRouter.use (req, res, next)->
 		res.locals.buildReferalUrl = (referal_medium) ->
 			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
 		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 = ->
 			tagLines = [
 				"Roar!"
@@ -167,7 +184,11 @@ module.exports = (app, webRouter, apiRouter)->
 			return ""
 
 		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()
 
 	webRouter.use (req, res, next) ->
@@ -179,25 +200,26 @@ module.exports = (app, webRouter, apiRouter)->
 			return req.query?[field]
 		next()
 
-	webRouter.use (req, res, next)-> 
+	webRouter.use (req, res, next)->
 		res.locals.fingerprint = getFingerprint
 		next()
 
-	webRouter.use (req, res, next)-> 
+	webRouter.use (req, res, next)->
 		res.locals.formatPrice = SubscriptionFormatters.formatPrice
 		next()
 
 	webRouter.use (req, res, next)->
 		res.locals.externalAuthenticationSystemUsed = ->
-			Settings.ldap?
+			Settings.ldap? or Settings.saml?
 		next()
 
 	webRouter.use (req, res, next)->
-		if req.session.user?
+		currentUser = AuthenticationController.getSessionUser(req)
+		if currentUser?
 			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
 				res.locals.justRegistered = true
 				delete req.session.justRegistered
@@ -222,8 +244,10 @@ module.exports = (app, webRouter, apiRouter)->
 		for key, value of Settings.nav
 			res.locals.nav[key] = _.clone(Settings.nav[key])
 		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()
-		
+
 	webRouter.use (req, res, next) ->
 		SystemMessageManager.getMessages (error, messages = []) ->
 			res.locals.systemMessages = messages
@@ -246,5 +270,3 @@ module.exports = (app, webRouter, apiRouter)->
 		res.locals.moduleIncludes = Modules.moduleIncludes
 		res.locals.moduleIncludesAvailable = Modules.moduleIncludesAvailable
 		next()
-
-

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

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

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

@@ -18,6 +18,10 @@ module.exports = Modules =
 	applyRouter: (webRouter, apiRouter) ->
 		for module in @modules
 			module.router?.apply(webRouter, apiRouter)
+
+	applyNonCsrfRouter: (webRouter, apiRouter) ->
+		for module in @modules
+			module.nonCsrfRouter?.apply(webRouter, apiRouter)
 			
 	viewIncludes: {}
 	loadViewIncludes: (app) ->
@@ -25,14 +29,14 @@ module.exports = Modules =
 		for module in @modules
 			for view, partial of module.viewIncludes or {}
 				@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) ->
-		partials = Modules.viewIncludes[view] or []
+		compiledPartials = Modules.viewIncludes[view] or []
 		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
 
 	moduleIncludesAvailable: (view) ->
@@ -58,4 +62,4 @@ module.exports = Modules =
 				return callback(error) if error?
 				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')
 metrics.inc("startup")
 redis = require("redis-sharelatex")
-rclient = redis.createClient(Settings.redis.web)
+UserSessionsRedis = require('../Features/User/UserSessionsRedis')
+
+sessionsRedisClient = UserSessionsRedis.client()
 
 session = require("express-session")
 RedisStore = require('connect-redis')(session)
@@ -19,7 +21,11 @@ csrf = require('csurf')
 csrfProtection = csrf()
 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")
 
@@ -32,6 +38,7 @@ Modules = require "./Modules"
 
 ErrorController = require "../Features/Errors/ErrorController"
 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/mongoose/node_modules/mongodb"), logger)
@@ -83,6 +90,29 @@ webRouter.use session
 		secure: Settings.secureCookie
 	store: sessionStore
 	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 translations.expressMiddlewear
 webRouter.use translations.setLangBasedOnDomainMiddlewear
@@ -90,8 +120,8 @@ webRouter.use translations.setLangBasedOnDomainMiddlewear
 # Measure expiry from last request, not last login
 webRouter.use (req, res, next) ->
 	req.session.touch()
-	if req?.session?.user?
-		UserSessionsManager.touch(req.session.user, (err)->)
+	if AuthenticationController.isUserLoggedIn(req)
+		UserSessionsManager.touch(AuthenticationController.getSessionUser(req), (err)->)
 	next()
 
 webRouter.use ReferalConnect.use

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

@@ -32,6 +32,7 @@ ProjectSchema = new Schema
 	archived          : { type: Boolean }
 	deletedDocs       : [DeletedDocSchema]
 	imageName         : { type: String }
+	track_changes     : { type: Boolean }
 
 ProjectSchema.statics.getProject = (project_or_id, fields, callback)->
 	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}
 							spellCheckLanguage :   {type : String, default: "en"}
 							pdfViewer   :   {type : String, default: "pdfjs"}
+							syntaxValidation   :   {type : Boolean}
 						}
 	features		  : {
 							collaborators: { type:Number,  default: Settings.defaultFeatures.collaborators }
@@ -38,7 +39,7 @@ UserSchema = new Schema
 							references:    { type:Boolean, default: Settings.defaultFeatures.references }
 						}
 	featureSwitches	  : {
-		pdfng: { type: Boolean }
+		track_changes: { type: Boolean }
 	}
 	referal_id : {type:String, default:() -> uuid.v4().split("-")[0]}
 	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'
 metrics = require('./infrastructure/Metrics')
 ReferalController = require('./Features/Referal/ReferalController')
-ReferalMiddleware = require('./Features/Referal/ReferalMiddleware')
 AuthenticationController = require('./Features/Authentication/AuthenticationController')
 TagsController = require("./Features/Tags/TagsController")
 NotificationsController = require("./Features/Notifications/NotificationsController")
@@ -22,10 +21,11 @@ UserPagesController = require('./Features/User/UserPagesController')
 DocumentController = require('./Features/Documents/DocumentController')
 CompileManager = require("./Features/Compile/CompileManager")
 CompileController = require("./Features/Compile/CompileController")
+ClsiCookieManager = require("./Features/Compile/ClsiCookieManager")
 HealthCheckController = require("./Features/HealthCheck/HealthCheckController")
 ProjectDownloadsController = require "./Features/Downloads/ProjectDownloadsController"
 FileStoreController = require("./Features/FileStore/FileStoreController")
-TrackChangesController = require("./Features/TrackChanges/TrackChangesController")
+HistoryController = require("./Features/History/HistoryController")
 PasswordResetRouter = require("./Features/PasswordReset/PasswordResetRouter")
 StaticPagesRouter = require("./Features/StaticPages/StaticPagesRouter")
 ChatController = require("./Features/Chat/ChatController")
@@ -39,6 +39,9 @@ ReferencesController = require('./Features/References/ReferencesController')
 AuthorizationMiddlewear = require('./Features/Authorization/AuthorizationMiddlewear')
 BetaProgramController = require('./Features/BetaProgram/BetaProgramController')
 AnalyticsRouter = require('./Features/Analytics/AnalyticsRouter')
+AnnouncementsController = require("./Features/Announcements/AnnouncementsController")
+TrackChangesController = require("./Features/TrackChanges/TrackChangesController")
+CommentsController = require "./Features/Comments/CommentsController"
 
 logger = require("logger-sharelatex")
 _ = require("underscore")
@@ -52,7 +55,8 @@ module.exports = class Router
 		webRouter.get  '/login', UserPagesController.loginPage
 		AuthenticationController.addEndpointToLoginWhitelist '/login'
 
-		webRouter.post '/login', AuthenticationController.login
+		webRouter.post '/login', AuthenticationController.passportLogin
+
 		webRouter.get  '/logout', UserController.logout
 		webRouter.get  '/restricted', AuthorizationMiddlewear.restricted
 
@@ -70,12 +74,12 @@ module.exports = class Router
 		RealTimeProxyRouter.apply(webRouter, apiRouter)
 		ContactRouter.apply(webRouter, apiRouter)
 		AnalyticsRouter.apply(webRouter, apiRouter)
-		
+
 		Modules.applyRouter(webRouter, apiRouter)
 
 
 		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.getPage
@@ -87,8 +91,11 @@ module.exports = class Router
 		webRouter.post '/user/settings', AuthenticationController.requireLogin(), UserController.updateUserSettings
 		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', AuthenticationController.requireLogin(), UserController.deleteUser
+		webRouter.post '/user/delete', AuthenticationController.requireLogin(), UserController.tryDeleteUser
 
 		webRouter.get  '/user/personal_info', AuthenticationController.requireLogin(), UserInfoController.getLoggedInUsersPersonalInfo
 		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.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/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.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
 		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/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/indexAll", AuthorizationMiddlewear.ensureUserCanReadProject, ReferencesController.indexAll
@@ -251,14 +272,27 @@ module.exports = class Router
 		apiRouter.get '/health_check/redis', HealthCheckController.checkRedis
 
 		apiRouter.get "/status/compiler/:Project_id", AuthorizationMiddlewear.ensureUserCanReadProject, (req, res) ->
+			project_id = req.params.Project_id
 			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"
+				handler = null
 			), 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) ->
 			res.send({

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

@@ -3,7 +3,8 @@ html(itemscope, itemtype='http://schema.org/Product')
 	head
 		title Something went wrong
 		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")
 	body
 		.content
@@ -12,7 +13,9 @@ html(itemscope, itemtype='http://schema.org/Product')
 					.col-md-8.col-md-offset-2.text-center
 						.page-header
 							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
 							| 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

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

@@ -11,4 +11,4 @@ block content
 						| Sorry, ShareLaTeX is briefly down for maintenance.
 						| We should be back within minutes, but if not, or you have
 						| 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").
 			window.csrfToken = "#{csrfToken}";
 			
-		block scripts
 		script(src=buildJsPath("libs/jquery-1.11.1.min.js", {fingerprint:false}))
 		script(type="text/javascript").
 			var noCdnKey = "nocdn=true"
 			var cdnBlocked = typeof jQuery === 'undefined'
 			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;
 			}
+
+		block scripts
+
 		script(src=buildJsPath("libs/angular-1.3.15.min.js", {fingerprint:false}))
 
 		script.
@@ -110,23 +112,27 @@ html(itemscope, itemtype='http://schema.org/Product')
 			include layout/navbar
 
 		block content
+
 		div(ng-controller="AbTestController")
 		- if(typeof(suppressFooter) == "undefined")
 			include layout/footer
 
-
-
 		- if (typeof(lookingForScribtex) != "undefined" && lookingForScribtex)
 			span(ng-controller="ScribtexPopupController")
 			include scribtex-modal
 
-
-		- if(typeof(suppressFooter) == "undefined")
+		block requirejs
 			script(type='text/javascript').
+				// minimal requirejs configuration (can be extended/overridden)
 				window.requirejs = {
-					"urlArgs" : "fingerprint=#{fingerprint(jsPath + 'main.js')}-#{fingerprint(jsPath + 'libs.js')}",
 					"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(
@@ -135,7 +141,6 @@ html(itemscope, itemtype='http://schema.org/Product')
 				src=buildJsPath('libs/require.js')
 			)
 
-
 		include contact-us-modal
 		include sentry
 		

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

@@ -13,7 +13,7 @@ nav.navbar.navbar-default
 		.navbar-collapse.collapse(collapse="navCollapsed")
 
 			ul.nav.navbar-nav.navbar-right
-				if (session && session.user && session.user.isAdmin)
+				if (getSessionUser() && getSessionUser().isAdmin)
 					li.dropdown(class="subdued", dropdown)
 						a.dropdown-toggle(href, dropdown-toggle)
 							| Admin
@@ -24,8 +24,11 @@ nav.navbar.navbar-default
 							li
 								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
 							li.dropdown(class=item.class, dropdown)
 								a.dropdown-toggle(href, dropdown-toggle)
@@ -47,7 +50,35 @@ nav.navbar.navbar-default
 									a(href=item.url, class=item.class) !{translate(item.text)}
 								else
 									| !{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
 	- var suppressNavbar = true
 	- var suppressFooter = true
-	- var suppressDefaultJs = true
 	- var suppressSystemMessages = true
 
 block content
@@ -16,7 +15,8 @@ block content
 				p.text-center.text-danger(ng-if="state.error").ng-cloak 
 					span(ng-bind-html="state.error")
 
-
+		include ./editor/feature-onboarding
+		
 		.global-alerts(ng-cloak)
 			.alert.alert-danger.small(ng-if="connection.forced_disconnect")
 				strong #{translate("disconnected")}
@@ -38,7 +38,7 @@ block content
 
 		include ./editor/left-menu
 
-		#chat-wrapper(
+		#chat-wrapper.full-size(
 			layout="chat",
 			spacing-open="12",
 			spacing-closed="0",
@@ -66,7 +66,7 @@ block content
 					.ui-layout-center
 						include ./editor/editor
 						include ./editor/binary-file
-						include ./editor/track-changes
+						include ./editor/history
 						include ./editor/publish-template
 
 			.ui-layout-east
@@ -86,7 +86,17 @@ block content
 		.modal-footer
 			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>'
 	//- and doesn't prematurely end the script tag.
@@ -97,50 +107,46 @@ block content
 		window.csrfToken = "!{csrfToken}";
 		window.anonymous = #{anonymous};
 		window.maxDocLength = #{maxDocLength};
+		window.trackChangesEnabled = #{trackChangesEnabled};
 		window.wikiEnabled = #{!!(settings.apis.wiki && settings.apis.wiki.url)};
 		window.requirejs = {
 			"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')}",
 			"waitSeconds": 0,
 			"shim": {
-				"libs/pdf": {
-					deps: ["libs/pdfjs-1.3.91/compatibility"]
+				"pdfjs-dist/build/pdf": {
+					"deps": ["libs/#{lib('pdfjs')}/compatibility"]
 				},
 				"ace/ext-searchbox": {
-					deps: ["ace/ace"]
+					"deps": ["ace/ace"]
+				},
+				"ace/ext-modelist": {
+					"deps": ["ace/ace"]
 				},
 				"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(
 		data-main=buildJsPath("ide.js", {fingerprint:false}),
 		baseurl=fullJsPath,
-		data-ace-base=buildJsPath('ace', {fingerprint:false}),
+		data-ace-base=buildJsPath(lib('ace'), {fingerprint:false}),
 		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"
 	initial-size-east="'50%'"
 	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")
 			span(ng-show="editor.open_doc_id")
 				i.fa.fa-spin.fa-refresh
@@ -24,7 +37,7 @@ div.full-size(
 			keybindings="settings.mode",
 			font-size="settings.fontSize",
 			auto-complete="settings.autoComplete",
-			spell-check="true",
+			spell-check="!anonymous",
 			spell-check-language="project.spellCheckLanguage",
 			highlights="onlineUserCursorHighlights[editor.open_doc_id]"
 			show-print-margin="false",
@@ -32,12 +45,31 @@ div.full-size(
 			last-updated="editor.last_updated",
 			cursor-position="editor.cursorPosition",
 			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]",
 			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
 		div(ng-if="ui.pdfLayout == 'sideBySide'")
 			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']"
 			)
 
-			li(ng-show="deletedDocs.length > 0 && ui.view == 'track-changes'")
+			li(ng-show="deletedDocs.length > 0 && ui.view == 'history'")
 				h3 #{translate("deleted_files")}
 			li(
 				ng-class="{ 'selected': entity.selected }",
 				ng-repeat="entity in deletedDocs | orderBy:'name'",
 				ng-controller="FileTreeEntityController",
-				ng-show="ui.view == 'track-changes'"
+				ng-show="ui.view == 'history'"
 			)
 				.entity
 					.entity-name(
@@ -315,8 +315,12 @@ script(type='text/ng-template', id='newDocModalTemplate')
 				required,
 				ng-model="inputs.name",
 				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
 		button.btn.btn-default(
 			ng-disabled="state.inflight"
@@ -341,8 +345,12 @@ script(type='text/ng-template', id='newFolderModalTemplate')
 				required,
 				ng-model="inputs.name",
 				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
 		button.btn.btn-default(
 			ng-disabled="state.inflight"
@@ -414,3 +422,13 @@ script(type='text/ng-template', id='deleteEntityModalTemplate')
 		)
 			span(ng-hide="state.inflight") #{translate("delete")}
 			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")
 		a(
 			href,
@@ -85,39 +85,40 @@ header.toolbar.toolbar-header(ng-cloak, ng-hide="state.loading")
 							) {{ user.name.slice(0,1) }}
 							| {{ 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(
 			href,
 			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
+			p.toolbar-label #{translate("share")}
 		a.btn.btn-full-height(
 			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
+			p.toolbar-label #{translate("history")}
 		a.btn.btn-full-height(
 			href,
-			tooltip="#{translate('chat')}",
-			tooltip-placement="bottom",
 			ng-class="{ active: ui.chatOpen }",
-			ng-mouseenter="trackHover('chat')"
-			ng-click="toggleChat()",
+			ng-click="toggleChat();",
 			ng-controller="ChatButtonController",
-			ng-show="!anonymous"
+			ng-show="!anonymous",
 		)
 			i.fa.fa-fw.fa-comment(
 				ng-class="{ 'bounce': unreadMessages > 0 }"
 			)
 			span.label.label-info(
 				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")
 				p #{translate("ask_proj_owner_to_upgrade_for_history")}
 				p
-					a.small(href, ng-click="toggleTrackChanges()") #{translate("cancel")}
+					a.small(href, ng-click="toggleHistory()") #{translate("cancel")}
 
 	aside.change-list(
-		ng-controller="TrackChangesListController"
+		ng-controller="HistoryListController"
 		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
 			ul.list-unstyled(
 				ng-class="{\
-					'hover-state': trackChanges.hoveringOverListSelectors\
+					'hover-state': history.hoveringOverListSelectors\
 				}"
 			)
 				li.change(
-					ng-repeat="update in trackChanges.updates"
+					ng-repeat="update in history.updates"
 					ng-class="{\
 						'first-in-day': update.meta.first_in_day,\
 						'selected': update.inSelection,\
@@ -65,7 +104,7 @@ div#trackChanges(ng-show="ui.view == 'track-changes'")
 						'hover-selected-to': update.hoverSelectedTo,\
 						'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 }}
@@ -108,55 +147,55 @@ div#trackChanges(ng-show="ui.view == 'track-changes'")
 								.color-square(style="background-color: hsl(100, 100%, 50%)")
 								span #{translate("anonymous")}
 
-			.loading(ng-show="trackChanges.loading")
+			.loading(ng-show="history.loading")
 				i.fa.fa-spin.fa-refresh
 				| &nbsp;&nbsp; #{translate("loading")}...
 
-	.diff-panel.full-size(ng-controller="TrackChangesDiffController")
+	.diff-panel.full-size(ng-controller="HistoryDiffController")
 		.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
 				span.name
-					| <strong>{{trackChanges.diff.highlights.length}} </strong>
+					| <strong>{{history.diff.highlights.length}} </strong>
 					ng-pluralize(
-						count="trackChanges.diff.highlights.length",
+						count="history.diff.highlights.length",
 						when="{\
 							'one': 'change',\
 							'other': 'changes'\
 						}"
 					)
-					|  in <strong>{{trackChanges.diff.doc.name}}</strong>
+					|  in <strong>{{history.diff.doc.name}}</strong>
 				.toolbar-right
 					a.btn.btn-danger.btn-sm(
 						href,
 						ng-click="openRestoreDiffModal()"
 					) #{translate("restore_to_before_these_changes")}
 			.diff-editor.hide-ace-cursor(
-				ace-editor="track-changes",
+				ace-editor="history",
 				theme="settings.theme",
 				font-size="settings.fontSize",
-				text="trackChanges.diff.text",
-				highlights="trackChanges.diff.highlights",
+				text="history.diff.text",
+				highlights="history.diff.highlights",
 				read-only="true",
 				resize-on="layout:main:resize",
 				navigate-highlights="true"
 			)
 		.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
 				a.btn.btn-primary.btn-lg(
 					href,
 					ng-click="restoreDeletedDoc()",
-					ng-disabled="trackChanges.diff.restoreInProgress"
+					ng-disabled="history.diff.restoreInProgress"
 				) #{translate("restore")}
 
 		.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
 				a.btn.btn-default(
@@ -164,13 +203,13 @@ div#trackChanges(ng-show="ui.view == 'track-changes'")
 					ng-click="backToEditorAfterRestore()",
 				) #{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
 			| &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")}
 
-script(type="text/ng-template", id="trackChangesRestoreDiffModalTemplate")
+script(type="text/ng-template", id="historyRestoreDiffModalTemplate")
 	.modal-header
 		button.close(
 			type="button"

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

@@ -66,6 +66,31 @@ script(type="text/ng-template", id="hotkeysModalTemplate")
 				.hotkey
 					span.combination {{ctrl}} + I
 					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
 		button.btn.btn-default(
 			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 }]"
 				)
 
+			.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
 				label(for="theme") #{translate("theme")}
 				select(
@@ -197,6 +205,10 @@ script(type='text/ng-template', id='wordCountModalTemplate')
 			)
 		div(ng-if="!status.loading")
 			.container-fluid
+				.row(ng-show='data.messages.length > 0')
+					.col-xs-12
+						.alert.alert-danger
+							p(style="white-space: pre-wrap") {{data.messages}}
 				.row
 					.col-xs-4
 						.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
+		div
 			.files-dropdown-container
 				a.btn.btn-default.btn-sm(
 					href,
@@ -344,7 +344,7 @@ div.full-size.pdf(ng-controller="PdfController")
 					strong #{translate("ask_proj_owner_to_upgrade_for_faster_compiles")}
 				p #{translate("free_accounts_have_timeout_upgrade_to_increase")}
 				p Plus:
-				p
+				div
 					ul.list-unstyled
 						li
 							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")
 						| #{translate("refresh_page_after_starting_free_trial")}.
 
-	.modal-footer
+	.modal-footer.modal-footer-share
 		.modal-footer-left
 			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(
 			ng-click="done()"
 		) #{translate("close")}

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

@@ -20,7 +20,7 @@ block content
 								form.form(
 									name="acceptForm",
 									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='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
-				
+			.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)
-				span(ng-show="first_sign_up == 'default' || projects.length > 0")
+				span(ng-if="projects.length > 0")
 					aside.col-md-2.col-xs-3
 						include ./list/side-bar
 
@@ -31,8 +69,8 @@ block content
 						include ./list/notifications
 						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
-						include ./list/project-list-minimal
+						include ./list/empty-project-list
 
 		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
 	)
 		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
-					.alert.alert-info
+					.alert.alert-info(ng-if="notification.templateKey == 'notification_project_invite'", ng-controller="ProjectInviteNotificationController")
 						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
-								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.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(
 					placeholder="#{translate('search_projects')}…",
 					autofocus='autofocus',
-					ng-model="searchText",
+					ng-model="searchText.value",
 					focus-on='search:clear',
 					ng-keyup="searchProjects()"
 				)
@@ -16,7 +16,7 @@
 				i.fa.fa-times.form-control-feedback(
 					ng-click="clearSearchText()",
 					style="cursor: pointer;",
-					ng-show="searchText.length > 0"
+					ng-show="searchText.value.length > 0"
 				)
 			//- i.fa.fa-remove
 

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

@@ -109,52 +109,17 @@
 					) #{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.
-	window.userHasNoSubscription = #{settings.enableSubscriptions && !hasSubscription}
+	window.userHasNoSubscription = #{!!(settings.enableSubscriptions && !hasSubscription)}
 
 

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