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

Merge branch 'master' into node-6.9

Shane Kilkelly 9 лет назад
Родитель
Сommit
621a07aff2
100 измененных файлов с 1698 добавлено и 605 удалено
  1. 14 6
      services/web/Gruntfile.coffee
  2. 4 4
      services/web/app/coffee/Features/Announcements/AnnouncementsController.coffee
  3. 30 8
      services/web/app/coffee/Features/Announcements/AnnouncementsHandler.coffee
  4. 3 2
      services/web/app/coffee/Features/Authentication/AuthenticationController.coffee
  5. 82 0
      services/web/app/coffee/Features/Chat/ChatApiHandler.coffee
  6. 21 20
      services/web/app/coffee/Features/Chat/ChatController.coffee
  7. 0 32
      services/web/app/coffee/Features/Chat/ChatHandler.coffee
  8. 2 1
      services/web/app/coffee/Features/Collaborators/CollaboratorsEmailHandler.coffee
  9. 33 12
      services/web/app/coffee/Features/Collaborators/CollaboratorsInviteController.coffee
  10. 2 2
      services/web/app/coffee/Features/Collaborators/CollaboratorsInviteHandler.coffee
  11. 8 2
      services/web/app/coffee/Features/Collaborators/CollaboratorsRouter.coffee
  12. 111 0
      services/web/app/coffee/Features/Comments/CommentsController.coffee
  13. 18 2
      services/web/app/coffee/Features/Docstore/DocstoreManager.coffee
  14. 32 10
      services/web/app/coffee/Features/DocumentUpdater/DocumentUpdaterHandler.coffee
  15. 4 3
      services/web/app/coffee/Features/Documents/DocumentController.coffee
  16. 0 1
      services/web/app/coffee/Features/Editor/EditorController.coffee
  17. 49 0
      services/web/app/coffee/Features/Email/Bodies/SingleCTAEmailBody.coffee
  18. 64 56
      services/web/app/coffee/Features/Email/EmailBuilder.coffee
  19. 32 17
      services/web/app/coffee/Features/Email/EmailSender.coffee
  20. 380 0
      services/web/app/coffee/Features/Email/Layouts/BaseWithHeaderEmailLayout.coffee
  21. 20 0
      services/web/app/coffee/Features/History/HistoryController.coffee
  22. 28 0
      services/web/app/coffee/Features/History/HistoryManager.coffee
  23. 0 3
      services/web/app/coffee/Features/InactiveData/InactiveProjectManager.coffee
  24. 5 1
      services/web/app/coffee/Features/PasswordReset/PasswordResetController.coffee
  25. 3 2
      services/web/app/coffee/Features/Project/ProjectController.coffee
  26. 7 0
      services/web/app/coffee/Features/Project/ProjectEditorHandler.coffee
  27. 3 3
      services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee
  28. 13 15
      services/web/app/coffee/Features/Security/LoginRateLimiter.coffee
  29. 4 1
      services/web/app/coffee/Features/Security/RateLimiterMiddlewear.coffee
  30. 0 2
      services/web/app/coffee/Features/ServerAdmin/AdminController.coffee
  31. 3 3
      services/web/app/coffee/Features/StaticPages/HomeController.coffee
  32. 11 10
      services/web/app/coffee/Features/Subscription/LimitationsManager.coffee
  33. 1 1
      services/web/app/coffee/Features/ThirdPartyDataStore/UpdateMerger.coffee
  34. 23 0
      services/web/app/coffee/Features/TrackChanges/RangesManager.coffee
  35. 39 17
      services/web/app/coffee/Features/TrackChanges/TrackChangesController.coffee
  36. 3 26
      services/web/app/coffee/Features/TrackChanges/TrackChangesManager.coffee
  37. 10 13
      services/web/app/coffee/Features/User/UserInfoController.coffee
  38. 5 0
      services/web/app/coffee/Features/User/UserInfoManager.coffee
  39. 0 1
      services/web/app/coffee/Features/User/UserSessionsManager.coffee
  40. 3 0
      services/web/app/coffee/infrastructure/ExpressLocals.coffee
  41. 2 2
      services/web/app/coffee/infrastructure/Modules.coffee
  42. 25 13
      services/web/app/coffee/infrastructure/RateLimiter.coffee
  43. 28 0
      services/web/app/coffee/infrastructure/RedisWrapper.coffee
  44. 1 2
      services/web/app/coffee/infrastructure/Server.coffee
  45. 1 0
      services/web/app/coffee/models/Project.coffee
  46. 3 2
      services/web/app/coffee/models/User.coffee
  47. 22 6
      services/web/app/coffee/router.coffee
  48. 2 2
      services/web/app/views/admin/index.pug
  49. 0 0
      services/web/app/views/admin/register.pug
  50. 0 0
      services/web/app/views/beta_program/opt_in.pug
  51. 0 0
      services/web/app/views/blog/blog_holder.pug
  52. 5 5
      services/web/app/views/contact-us-modal.pug
  53. 0 0
      services/web/app/views/general/404.pug
  54. 0 0
      services/web/app/views/general/500.pug
  55. 1 1
      services/web/app/views/general/closed.pug
  56. 3 1
      services/web/app/views/layout.pug
  57. 3 3
      services/web/app/views/layout/footer.pug
  58. 36 8
      services/web/app/views/layout/navbar.pug
  59. 1 0
      services/web/app/views/project/editor.pug
  60. 0 0
      services/web/app/views/project/editor/binary-file.pug
  61. 1 1
      services/web/app/views/project/editor/chat.pug
  62. 10 8
      services/web/app/views/project/editor/editor.pug
  63. 0 0
      services/web/app/views/project/editor/feature-onboarding.pug
  64. 6 6
      services/web/app/views/project/editor/file-tree.pug
  65. 4 4
      services/web/app/views/project/editor/header.pug
  66. 0 0
      services/web/app/views/project/editor/history.pug
  67. 0 0
      services/web/app/views/project/editor/hotkeys.pug
  68. 3 3
      services/web/app/views/project/editor/left-menu.pug
  69. 7 7
      services/web/app/views/project/editor/pdf.pug
  70. 0 0
      services/web/app/views/project/editor/publish-template.pug
  71. 0 219
      services/web/app/views/project/editor/review-panel.jade
  72. 426 0
      services/web/app/views/project/editor/review-panel.pug
  73. 5 3
      services/web/app/views/project/editor/share.pug
  74. 0 0
      services/web/app/views/project/invite/not-valid.pug
  75. 3 3
      services/web/app/views/project/invite/show.pug
  76. 1 1
      services/web/app/views/project/list.pug
  77. 0 0
      services/web/app/views/project/list/empty-project-list.pug
  78. 0 0
      services/web/app/views/project/list/modals.pug
  79. 0 0
      services/web/app/views/project/list/notifications.pug
  80. 4 4
      services/web/app/views/project/list/project-list.pug
  81. 0 0
      services/web/app/views/project/list/side-bar.pug
  82. 6 6
      services/web/app/views/referal/bonus.pug
  83. 2 2
      services/web/app/views/restore.pug
  84. 1 1
      services/web/app/views/scribtex-modal.pug
  85. 2 2
      services/web/app/views/sentry.pug
  86. 0 0
      services/web/app/views/subscriptions/custom_account.pug
  87. 10 10
      services/web/app/views/subscriptions/dashboard.pug
  88. 0 0
      services/web/app/views/subscriptions/edit-billing-details.pug
  89. 0 0
      services/web/app/views/subscriptions/group/invite.pug
  90. 0 0
      services/web/app/views/subscriptions/group/successful_join.pug
  91. 0 0
      services/web/app/views/subscriptions/group_admin.pug
  92. 1 1
      services/web/app/views/subscriptions/new.pug
  93. 0 0
      services/web/app/views/subscriptions/plans.pug
  94. 0 0
      services/web/app/views/subscriptions/successful_subscription.pug
  95. 1 1
      services/web/app/views/subscriptions/upgradeToAnnual.pug
  96. 0 0
      services/web/app/views/tests.pug
  97. 1 1
      services/web/app/views/translations/translation_message.pug
  98. 0 0
      services/web/app/views/university/case_study.pug
  99. 0 0
      services/web/app/views/university/university_holder.pug
  100. 1 1
      services/web/app/views/user/activate.pug

+ 14 - 6
services/web/Gruntfile.coffee

@@ -1,5 +1,6 @@
 fs = require "fs"
 fs = require "fs"
 PackageVersions = require "./app/coffee/infrastructure/PackageVersions"
 PackageVersions = require "./app/coffee/infrastructure/PackageVersions"
+require('es6-promise').polyfill()
 
 
 module.exports = (grunt) ->
 module.exports = (grunt) ->
 	grunt.loadNpmTasks 'grunt-contrib-coffee'
 	grunt.loadNpmTasks 'grunt-contrib-coffee'
@@ -18,6 +19,7 @@ module.exports = (grunt) ->
 	grunt.loadNpmTasks 'grunt-contrib-watch'
 	grunt.loadNpmTasks 'grunt-contrib-watch'
 	grunt.loadNpmTasks 'grunt-parallel'
 	grunt.loadNpmTasks 'grunt-parallel'
 	grunt.loadNpmTasks 'grunt-exec'
 	grunt.loadNpmTasks 'grunt-exec'
+	grunt.loadNpmTasks 'grunt-postcss'
 	# grunt.loadNpmTasks 'grunt-contrib-imagemin'
 	# grunt.loadNpmTasks 'grunt-contrib-imagemin'
 	# grunt.loadNpmTasks 'grunt-sprity'
 	# grunt.loadNpmTasks 'grunt-sprity'
 
 
@@ -136,8 +138,14 @@ module.exports = (grunt) ->
 				files:
 				files:
 					"public/stylesheets/style.css": "public/stylesheets/style.less"
 					"public/stylesheets/style.css": "public/stylesheets/style.less"
 
 
-
-
+		postcss:
+			options:
+				map: true,
+				processors: [
+					require('autoprefixer')({browsers: [ 'last 2 versions', 'ie >= 10' ]})
+				]
+			dist:
+				src: 'public/stylesheets/style.css'
 
 
 		env:
 		env:
 			run:
 			run:
@@ -222,11 +230,11 @@ module.exports = (grunt) ->
 
 
 		sed:
 		sed:
 			version:
 			version:
-				path: "app/views/sentry.jade"
+				path: "app/views/sentry.pug"
 				pattern: '@@COMMIT@@',
 				pattern: '@@COMMIT@@',
 				replacement: '<%= commit %>',
 				replacement: '<%= commit %>',
 			release:
 			release:
-				path: "app/views/sentry.jade"
+				path: "app/views/sentry.pug"
 				pattern: "@@RELEASE@@"
 				pattern: "@@RELEASE@@"
 				replacement: process.env.BUILD_NUMBER || "(unknown build)"
 				replacement: process.env.BUILD_NUMBER || "(unknown build)"
 
 
@@ -366,7 +374,7 @@ module.exports = (grunt) ->
 	
 	
 	grunt.registerTask 'compile:server', 'Compile the server side coffee script', ['clean:app', 'coffee:app', 'coffee:app_dir', 'compile:modules:server']
 	grunt.registerTask 'compile:server', 'Compile the server side coffee script', ['clean:app', 'coffee:app', 'coffee:app_dir', 'compile:modules:server']
 	grunt.registerTask 'compile:client', 'Compile the client side coffee script', ['coffee:client', 'coffee:sharejs', 'wrap_sharejs', "compile:modules:client", 'compile:modules:inject_clientside_includes']
 	grunt.registerTask 'compile:client', 'Compile the client side coffee script', ['coffee:client', 'coffee:sharejs', 'wrap_sharejs', "compile:modules:client", 'compile:modules:inject_clientside_includes']
-	grunt.registerTask 'compile:css', 'Compile the less files to css', ['less']
+	grunt.registerTask 'compile:css', 'Compile the less files to css', ['less', 'postcss:dist']
 	grunt.registerTask 'compile:minify', 'Concat and minify the client side js', ['requirejs', "file_append", "exec:cssmin",]
 	grunt.registerTask 'compile:minify', 'Concat and minify the client side js', ['requirejs', "file_append", "exec:cssmin",]
 	grunt.registerTask 'compile:unit_tests', 'Compile the unit tests', ['clean:unit_tests', 'coffee:unit_tests']
 	grunt.registerTask 'compile:unit_tests', 'Compile the unit tests', ['clean:unit_tests', 'coffee:unit_tests']
 	grunt.registerTask 'compile:acceptance_tests', 'Compile the acceptance tests', ['clean:acceptance_tests', 'coffee:acceptance_tests']
 	grunt.registerTask 'compile:acceptance_tests', 'Compile the acceptance tests', ['clean:acceptance_tests', 'coffee:acceptance_tests']
@@ -389,5 +397,5 @@ module.exports = (grunt) ->
 
 
 	grunt.registerTask 'default', 'run'
 	grunt.registerTask 'default', 'run'
 
 
-	grunt.registerTask 'version', "Write the version number into sentry.jade", ['git-rev-parse', 'sed']
+	grunt.registerTask 'version', "Write the version number into sentry.pug", ['git-rev-parse', 'sed']
 
 

+ 4 - 4
services/web/app/coffee/Features/Announcements/AnnouncementsController.coffee

@@ -9,11 +9,11 @@ module.exports =
 		if !settings?.apis?.analytics?.url? or !settings.apis.blog.url?
 		if !settings?.apis?.analytics?.url? or !settings.apis.blog.url?
 			return res.json []
 			return res.json []
 
 
-		user_id = AuthenticationController.getLoggedInUserId(req)
-		logger.log {user_id}, "getting unread announcements"
-		AnnouncementsHandler.getUnreadAnnouncements user_id, (err, announcements)->
+		user = AuthenticationController.getSessionUser(req)
+		logger.log {user_id:user?._id}, "getting unread announcements"
+		AnnouncementsHandler.getUnreadAnnouncements user, (err, announcements)->
 			if err?
 			if err?
-				logger.err {err, user_id}, "unable to get unread announcements"
+				logger.err {err:err, user_id:user._id}, "unable to get unread announcements"
 				next(err)
 				next(err)
 			else
 			else
 				res.json announcements
 				res.json announcements

+ 30 - 8
services/web/app/coffee/Features/Announcements/AnnouncementsHandler.coffee

@@ -1,24 +1,46 @@
 AnalyticsManager = require("../Analytics/AnalyticsManager")
 AnalyticsManager = require("../Analytics/AnalyticsManager")
 BlogHandler = require("../Blog/BlogHandler")
 BlogHandler = require("../Blog/BlogHandler")
-async = require("async")
-_ = require("lodash")
 logger = require("logger-sharelatex")
 logger = require("logger-sharelatex")
 settings = require("settings-sharelatex")
 settings = require("settings-sharelatex")
+async = require("async")
+_ = require("lodash")
+
+module.exports = AnnouncementsHandler = 
 
 
-module.exports =
+	_domainSpecificAnnouncements : (email)->
+		domainSpecific = _.filter settings?.domainAnnouncements, (domainAnnouncment)->
+			matches = _.filter domainAnnouncment.domains, (domain)->
+				return email.indexOf(domain) != -1
+			return matches.length > 0 and domainAnnouncment.id?
+		return domainSpecific or []
+
+
+	getUnreadAnnouncements : (user, callback = (err, announcements)->)->
+		if !user? and !user._id?
+			return callback("user not supplied")
 
 
-	getUnreadAnnouncements : (user_id, callback = (err, announcements)->)->
 		async.parallel {
 		async.parallel {
 			lastEvent: (cb)->
 			lastEvent: (cb)->
-				AnalyticsManager.getLastOccurance user_id, "announcement-alert-dismissed", cb
+				AnalyticsManager.getLastOccurance user._id, "announcement-alert-dismissed", cb
 			announcements: (cb)->
 			announcements: (cb)->
 				BlogHandler.getLatestAnnouncements cb
 				BlogHandler.getLatestAnnouncements cb
 		}, (err, results)->
 		}, (err, results)->
 			if err?
 			if err?
-				logger.err err:err, user_id:user_id, "error getting unread announcements"
+				logger.err err:err, user_id:user._id, "error getting unread announcements"
 				return callback(err)
 				return callback(err)
 			
 			
-			announcements = _.sortBy(results.announcements, "date").reverse()
+			domainSpecific = AnnouncementsHandler._domainSpecificAnnouncements(user?.email)
+
+			domainSpecific = _.map domainSpecific, (domainAnnouncment)->
+				try
+					domainAnnouncment.date = new Date(domainAnnouncment.date)
+					return domainAnnouncment
+				catch e
+					return callback(e)
+
+			announcements = results.announcements
+			announcements = _.union announcements, domainSpecific
+			announcements = _.sortBy(announcements, "date").reverse()
 
 
 			lastSeenBlogId = results?.lastEvent?.segmentation?.blogPostId
 			lastSeenBlogId = results?.lastEvent?.segmentation?.blogPostId
 
 
@@ -35,6 +57,6 @@ module.exports =
 				announcement.read = read
 				announcement.read = read
 				return announcement
 				return announcement
 
 
-			logger.log announcementsLength:announcements?.length, user_id:user_id, "returning announcements"
+			logger.log announcementsLength:announcements?.length, user_id:user?._id, "returning announcements"
 
 
 			callback null, announcements
 			callback null, announcements

+ 3 - 2
services/web/app/coffee/Features/Authentication/AuthenticationController.coffee

@@ -148,6 +148,7 @@ module.exports = AuthenticationController =
 			return next()
 			return next()
 		else
 		else
 			logger.log url:req.url, "user trying to access endpoint not in global whitelist"
 			logger.log url:req.url, "user trying to access endpoint not in global whitelist"
+			AuthenticationController._setRedirectInSession(req)
 			return res.redirect "/login"
 			return res.redirect "/login"
 
 
 	httpAuth: basicAuth (user, pass)->
 	httpAuth: basicAuth (user, pass)->
@@ -193,8 +194,8 @@ module.exports = AuthenticationController =
 
 
 	_setRedirectInSession: (req, value) ->
 	_setRedirectInSession: (req, value) ->
 		if !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 = 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
 			req.session.postLoginRedirect = value
 
 
 	_getRedirectFromSession: (req) ->
 	_getRedirectFromSession: (req) ->

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

@@ -0,0 +1,82 @@
+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
+	
+	deleteThread: (project_id, thread_id, callback = (error) ->) ->
+		ChatApiHandler._apiRequest {
+			url:    "#{settings.apis.chat.internal_url}/project/#{project_id}/thread/#{thread_id}"
+			method: "DELETE"
+		}, callback
+	
+	editMessage: (project_id, thread_id, message_id, content, callback = (error) ->) ->
+		ChatApiHandler._apiRequest {
+			url:    "#{settings.apis.chat.internal_url}/project/#{project_id}/thread/#{thread_id}/messages/#{message_id}/edit"
+			method: "POST"
+			json:
+				content: content
+		}, callback
+	
+	deleteMessage: (project_id, thread_id, message_id, callback = (error) ->) ->
+		ChatApiHandler._apiRequest {
+			url:    "#{settings.apis.chat.internal_url}/project/#{project_id}/thread/#{thread_id}/messages/#{message_id}"
+			method: "DELETE"
+		}, callback
+		

+ 21 - 20
services/web/app/coffee/Features/Chat/ChatController.coffee

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

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

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

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

@@ -11,7 +11,7 @@ module.exports = CollaboratorsEmailHandler =
 			"user_first_name=#{encodeURIComponent(project.owner_ref.first_name)}"
 			"user_first_name=#{encodeURIComponent(project.owner_ref.first_name)}"
 		].join("&")
 		].join("&")
 
 
-	notifyUserOfProjectInvite: (project_id, email, invite, callback)->
+	notifyUserOfProjectInvite: (project_id, email, invite, sendingUser, callback)->
 		Project
 		Project
 			.findOne(_id: project_id )
 			.findOne(_id: project_id )
 			.select("name owner_ref")
 			.select("name owner_ref")
@@ -24,4 +24,5 @@ module.exports = CollaboratorsEmailHandler =
 						name: project.name
 						name: project.name
 					inviteUrl: CollaboratorsEmailHandler._buildInviteUrl(project, invite)
 					inviteUrl: CollaboratorsEmailHandler._buildInviteUrl(project, invite)
 					owner: project.owner_ref
 					owner: project.owner_ref
+					sendingUser_id: sendingUser._id
 				EmailHandler.sendEmail "projectInvite", emailOptions, callback
 				EmailHandler.sendEmail "projectInvite", emailOptions, callback

+ 33 - 12
services/web/app/coffee/Features/Collaborators/CollaboratorsInviteController.coffee

@@ -10,6 +10,7 @@ EditorRealTimeController = require("../Editor/EditorRealTimeController")
 NotificationsBuilder = require("../Notifications/NotificationsBuilder")
 NotificationsBuilder = require("../Notifications/NotificationsBuilder")
 AnalyticsManger = require("../Analytics/AnalyticsManager")
 AnalyticsManger = require("../Analytics/AnalyticsManager")
 AuthenticationController = require("../Authentication/AuthenticationController")
 AuthenticationController = require("../Authentication/AuthenticationController")
+rateLimiter = require("../../infrastructure/RateLimiter")
 
 
 module.exports = CollaboratorsInviteController =
 module.exports = CollaboratorsInviteController =
 
 
@@ -31,12 +32,28 @@ module.exports = CollaboratorsInviteController =
 				callback(null, userExists)
 				callback(null, userExists)
 		else
 		else
 			callback(null, true)
 			callback(null, true)
+	
+	_checkRateLimit: (user_id, callback = (error) ->) ->
+		LimitationsManager.allowedNumberOfCollaboratorsForUser user_id, (err, collabLimit = 1)->
+			return callback(err) if err?
+			if collabLimit == -1 
+				collabLimit = 20
+			collabLimit = collabLimit * 10
+			opts = 
+				endpointName: "invite-to-project-by-user-id"
+				timeInterval: 60 * 30
+				subjectName: user_id
+				throttle: collabLimit
+			rateLimiter.addCount opts, callback
 
 
 	inviteToProject: (req, res, next) ->
 	inviteToProject: (req, res, next) ->
 		projectId = req.params.Project_id
 		projectId = req.params.Project_id
 		email = req.body.email
 		email = req.body.email
 		sendingUser = AuthenticationController.getSessionUser(req)
 		sendingUser = AuthenticationController.getSessionUser(req)
 		sendingUserId = sendingUser._id
 		sendingUserId = sendingUser._id
+		if email == sendingUser.email
+			logger.log {projectId, email, sendingUserId}, "cannot invite yourself to project"
+			return res.json {invite: null, error: 'cannot_invite_self'}
 		logger.log {projectId, email, sendingUserId}, "inviting to project"
 		logger.log {projectId, email, sendingUserId}, "inviting to project"
 		LimitationsManager.canAddXCollaborators projectId, 1, (error, allowed) =>
 		LimitationsManager.canAddXCollaborators projectId, 1, (error, allowed) =>
 			return next(error) if error?
 			return next(error) if error?
@@ -48,20 +65,24 @@ module.exports = CollaboratorsInviteController =
 			if !email? or email == ""
 			if !email? or email == ""
 				logger.log {projectId, email, sendingUserId}, "invalid email address"
 				logger.log {projectId, email, sendingUserId}, "invalid email address"
 				return res.sendStatus(400)
 				return res.sendStatus(400)
-			CollaboratorsInviteController._checkShouldInviteEmail email, (err, shouldAllowInvite)->
-				if err?
-					logger.err {err, email, projectId, sendingUserId}, "error checking if we can invite this email address"
-					return next(err)
-				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) ->
+			CollaboratorsInviteController._checkRateLimit sendingUserId, (error, underRateLimit) ->
+				return next(error) if error?
+				if !underRateLimit
+					return res.sendStatus(429)
+				CollaboratorsInviteController._checkShouldInviteEmail email, (err, shouldAllowInvite)->
 					if err?
 					if err?
-						logger.err {projectId, email, sendingUserId}, "error creating project invite"
+						logger.err {err, email, projectId, sendingUserId}, "error checking if we can invite this email address"
 						return next(err)
 						return next(err)
-					logger.log {projectId, email, sendingUserId}, "invite created"
-					EditorRealTimeController.emitToRoom(projectId, 'project:membership:changed', {invites: true})
-					return res.json {invite: invite}
+					if !shouldAllowInvite
+						logger.log {email, projectId, sendingUserId}, "not allowed to send an invite to this email address"
+						return res.json {invite: null, error: 'cannot_invite_non_user'}
+					CollaboratorsInviteHandler.inviteToProject projectId, sendingUser, email, privileges, (err, invite) ->
+						if err?
+							logger.err {projectId, email, sendingUserId}, "error creating project invite"
+							return next(err)
+						logger.log {projectId, email, sendingUserId}, "invite created"
+						EditorRealTimeController.emitToRoom(projectId, 'project:membership:changed', {invites: true})
+						return res.json {invite: invite}
 
 
 	revokeInvite: (req, res, next) ->
 	revokeInvite: (req, res, next) ->
 		projectId = req.params.Project_id
 		projectId = req.params.Project_id

+ 2 - 2
services/web/app/coffee/Features/Collaborators/CollaboratorsInviteHandler.coffee

@@ -53,7 +53,7 @@ module.exports = CollaboratorsInviteHandler =
 
 
 	_sendMessages: (projectId, sendingUser, invite, callback=(err)->) ->
 	_sendMessages: (projectId, sendingUser, invite, callback=(err)->) ->
 		logger.log {projectId, inviteId: invite._id}, "sending notification and email for invite"
 		logger.log {projectId, inviteId: invite._id}, "sending notification and email for invite"
-		CollaboratorsEmailHandler.notifyUserOfProjectInvite projectId, invite.email, invite, (err)->
+		CollaboratorsEmailHandler.notifyUserOfProjectInvite projectId, invite.email, invite, sendingUser, (err)->
 			return callback(err) if err?
 			return callback(err) if err?
 			CollaboratorsInviteHandler._trySendInviteNotification projectId, sendingUser, invite, (err)->
 			CollaboratorsInviteHandler._trySendInviteNotification projectId, sendingUser, invite, (err)->
 				return callback(err) if err?
 				return callback(err) if err?
@@ -80,7 +80,7 @@ module.exports = CollaboratorsInviteHandler =
 				# Send email and notification in background
 				# Send email and notification in background
 				CollaboratorsInviteHandler._sendMessages projectId, sendingUser, invite, (err) ->
 				CollaboratorsInviteHandler._sendMessages projectId, sendingUser, invite, (err) ->
 					if err?
 					if err?
-						logger.err {projectId, email}, "error sending messages for invite"
+						logger.err {err, projectId, email}, "error sending messages for invite"
 				callback(null, invite)
 				callback(null, invite)
 
 
 
 

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

@@ -22,9 +22,15 @@ module.exports =
 		webRouter.post(
 		webRouter.post(
 			'/project/:Project_id/invite',
 			'/project/:Project_id/invite',
 			RateLimiterMiddlewear.rateLimit({
 			RateLimiterMiddlewear.rateLimit({
-				endpointName: "invite-to-project"
+				endpointName: "invite-to-project-by-project-id"
 				params: ["Project_id"]
 				params: ["Project_id"]
-				maxRequests: 200
+				maxRequests: 100
+				timeInterval: 60 * 10
+			}),
+			RateLimiterMiddlewear.rateLimit({
+				endpointName: "invite-to-project-by-ip"
+				ipOnly:true
+				maxRequests: 100
 				timeInterval: 60 * 10
 				timeInterval: 60 * 10
 			}),
 			}),
 			AuthenticationController.requireLogin(),
 			AuthenticationController.requireLogin(),

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

@@ -0,0 +1,111 @@
+ChatApiHandler = require("../Chat/ChatApiHandler")
+EditorRealTimeController = require("../Editor/EditorRealTimeController")
+logger = require("logger-sharelatex")
+AuthenticationController = require('../Authentication/AuthenticationController')
+UserInfoManager = require('../User/UserInfoManager')
+UserInfoController = require('../User/UserInfoController')
+DocumentUpdaterHandler = require "../DocumentUpdater/DocumentUpdaterHandler"
+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
+	
+	deleteThread: (req, res, next) ->
+		{project_id, doc_id, thread_id} = req.params
+		logger.log {project_id, doc_id, thread_id}, "deleting comment thread"
+		DocumentUpdaterHandler.deleteThread project_id, doc_id, thread_id, (err) ->
+			return next(err) if err?
+			ChatApiHandler.deleteThread project_id, thread_id, (err, threads) ->
+				return next(err) if err?
+				EditorRealTimeController.emitToRoom project_id, "delete-thread", thread_id, (err)->
+				res.send 204
+	
+	editMessage: (req, res, next) ->
+		{project_id, thread_id, message_id} = req.params
+		{content} = req.body
+		logger.log {project_id, thread_id, message_id}, "editing message thread"
+		ChatApiHandler.editMessage project_id, thread_id, message_id, content, (err) ->
+			return next(err) if err?
+			EditorRealTimeController.emitToRoom project_id, "edit-message", thread_id, message_id, content, (err)->
+			res.send 204
+	
+	deleteMessage: (req, res, next) ->
+		{project_id, thread_id, message_id} = req.params
+		logger.log {project_id, thread_id, message_id}, "deleting message"
+		ChatApiHandler.deleteMessage project_id, thread_id, message_id, (err, threads) ->
+			return next(err) if err?
+			EditorRealTimeController.emitToRoom project_id, "delete-message", thread_id, message_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

+ 18 - 2
services/web/app/coffee/Features/Docstore/DocstoreManager.coffee

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

+ 32 - 10
services/web/app/coffee/Features/DocumentUpdater/DocumentUpdaterHandler.coffee

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

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

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

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

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

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

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

+ 64 - 56
services/web/app/coffee/Features/Email/EmailBuilder.coffee

@@ -1,6 +1,12 @@
 _ = require('underscore')
 _ = require('underscore')
+
 PersonalEmailLayout = require("./Layouts/PersonalEmailLayout")
 PersonalEmailLayout = require("./Layouts/PersonalEmailLayout")
 NotificationEmailLayout = require("./Layouts/NotificationEmailLayout")
 NotificationEmailLayout = require("./Layouts/NotificationEmailLayout")
+BaseWithHeaderEmailLayout = require("./Layouts/BaseWithHeaderEmailLayout") 
+
+SingleCTAEmailBody = require("./Bodies/SingleCTAEmailBody")
+
+
 settings = require("settings-sharelatex")
 settings = require("settings-sharelatex")
 
 
 
 
@@ -61,7 +67,7 @@ ShareLaTeX Co-founder
 
 
 templates.passwordResetRequested =
 templates.passwordResetRequested =
 	subject:  _.template "Password Reset - #{settings.appName}"
 	subject:  _.template "Password Reset - #{settings.appName}"
-	layout: NotificationEmailLayout
+	layout: BaseWithHeaderEmailLayout
 	type:"notification"
 	type:"notification"
 	plainTextTemplate: _.template """
 	plainTextTemplate: _.template """
 Password Reset
 Password Reset
@@ -78,36 +84,21 @@ Thank you
 
 
 #{settings.appName} - <%= siteUrl %>
 #{settings.appName} - <%= siteUrl %>
 """
 """
-	compiledTemplate: _.template """
-<h2>Password Reset</h2>
-<p>
-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>
-
-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>
-"""
+	compiledTemplate: (opts) -> 
+		SingleCTAEmailBody({
+			title: "Password Reset"
+			greeting: "Hi,"
+			message: "We got a request to reset your #{settings.appName} password."
+			secondaryMessage: "If you ignore this message, your password won't be changed.<br>If you didn't request a password reset, let us know."
+			ctaText: "Reset password"
+			ctaURL: opts.setNewPasswordUrl
+			gmailGoToAction: null
+		})
 
 
 
 
 templates.projectInvite =
 templates.projectInvite =
 	subject: _.template "<%= project.name %> - shared by <%= owner.email %>"
 	subject: _.template "<%= project.name %> - shared by <%= owner.email %>"
-	layout: NotificationEmailLayout
+	layout: BaseWithHeaderEmailLayout
 	type:"notification"
 	type:"notification"
 	plainTextTemplate: _.template """
 	plainTextTemplate: _.template """
 Hi, <%= owner.email %> wants to share '<%= project.name %>' with you.
 Hi, <%= owner.email %> wants to share '<%= project.name %>' with you.
@@ -118,23 +109,23 @@ Thank you
 
 
 #{settings.appName} - <%= siteUrl %>
 #{settings.appName} - <%= siteUrl %>
 """
 """
-	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>
-"""
-
+	compiledTemplate: (opts) -> 
+		SingleCTAEmailBody({
+			title: "#{ opts.project.name } &ndash; shared by #{ opts.owner.email }"
+			greeting: "Hi,"
+			message: "#{ opts.owner.email } wants to share &ldquo;#{ opts.project.name }&rdquo; with you."
+			secondaryMessage: null
+			ctaText: "View project"
+			ctaURL: opts.inviteUrl
+			gmailGoToAction: 
+				target: opts.inviteUrl
+				name: "View project"
+				description: "Join #{ opts.project.name } at ShareLaTeX"
+		})
 
 
 templates.completeJoinGroupAccount =
 templates.completeJoinGroupAccount =
 	subject: _.template "Verify Email to join <%= group_name %> group"
 	subject: _.template "Verify Email to join <%= group_name %> group"
-	layout: NotificationEmailLayout
+	layout: BaseWithHeaderEmailLayout
 	type:"notification"
 	type:"notification"
 	plainTextTemplate: _.template """
 	plainTextTemplate: _.template """
 Hi, please verify your email to join the <%= group_name %> and get your free premium account
 Hi, please verify your email to join the <%= group_name %> and get your free premium account
@@ -145,22 +136,39 @@ Thank You
 
 
 #{settings.appName} - <%= siteUrl %>
 #{settings.appName} - <%= siteUrl %>
 """
 """
-	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>
+	compiledTemplate: (opts) -> 
+		SingleCTAEmailBody({
+			title: "Verify Email to join #{ opts.group_name } group"
+			greeting: "Hi,"
+			message: "please verify your email to join the #{ opts.group_name } group and get your free premium account."
+			secondaryMessage: null
+			ctaText: "Verify now"
+			ctaURL: opts.completeJoinUrl
+			gmailGoToAction: null
+		})
+
+
+templates.testEmail =
+	subject: _.template "A Test Email from ShareLaTeX"
+	layout: BaseWithHeaderEmailLayout
+	type:"notification"
+	plainTextTemplate: _.template """
+Hi,
+
+This is a test email sent from ShareLaTeX.
+
+#{settings.appName} - <%= siteUrl %>
 """
 """
+	compiledTemplate: (opts) -> 
+		SingleCTAEmailBody({
+			title: "A Test Email from ShareLaTeX"
+			greeting: "Hi,"
+			message: "This is a test email sent from ShareLaTeX"
+			secondaryMessage: null
+			ctaText: "Open ShareLaTeX"
+			ctaURL: "/"
+			gmailGoToAction: null
+		})
 
 
 
 
 module.exports =
 module.exports =

+ 32 - 17
services/web/app/coffee/Features/Email/EmailSender.coffee

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

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

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

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

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

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

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

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

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

+ 5 - 1
services/web/app/coffee/Features/PasswordReset/PasswordResetController.coffee

@@ -53,7 +53,11 @@ module.exports =
 					if req.body.login_after
 					if req.body.login_after
 						UserGetter.getUser user_id, {email: 1}, (err, user) ->
 						UserGetter.getUser user_id, {email: 1}, (err, user) ->
 							return next(err) if err?
 							return next(err) if err?
-							AuthenticationController.doLogin {email:user.email, password: password}, req, res, next
+							AuthenticationController.afterLoginSessionSetup req, user, (err) ->
+								if err?
+									logger.err {err, email: user.email}, "Error setting up session after setting password"
+									return next(err)
+								res.json {redir: AuthenticationController._getRedirectFromSession(req) || "/project"}
 					else
 					else
 						res.sendStatus 200
 						res.sendStatus 200
 			else
 			else

+ 3 - 2
services/web/app/coffee/Features/Project/ProjectController.coffee

@@ -197,11 +197,11 @@ module.exports = ProjectController =
 			user_id = null
 			user_id = null
 
 
 		project_id = req.params.Project_id
 		project_id = req.params.Project_id
-		logger.log project_id:project_id, "loading editor"
+		logger.log project_id:project_id, anonymous:anonymous, user_id:user_id, "loading editor"
 
 
 		async.parallel {
 		async.parallel {
 			project: (cb)->
 			project: (cb)->
-				ProjectGetter.getProject project_id, { name: 1, lastUpdated: 1}, cb
+				ProjectGetter.getProject project_id, { name: 1, lastUpdated: 1, track_changes: 1 }, cb
 			user: (cb)->
 			user: (cb)->
 				if !user_id?
 				if !user_id?
 					cb null, defaultSettingsForAnonymousUser(user_id)
 					cb null, defaultSettingsForAnonymousUser(user_id)
@@ -267,6 +267,7 @@ module.exports = ProjectController =
 						pdfViewer : user.ace.pdfViewer
 						pdfViewer : user.ace.pdfViewer
 						syntaxValidation: user.ace.syntaxValidation
 						syntaxValidation: user.ace.syntaxValidation
 					}
 					}
+					trackChangesEnabled: !!project.track_changes
 					privilegeLevel: privilegeLevel
 					privilegeLevel: privilegeLevel
 					chatUrl: Settings.apis.chat.url
 					chatUrl: Settings.apis.chat.url
 					anonymous: anonymous
 					anonymous: anonymous

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

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

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

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

+ 13 - 15
services/web/app/coffee/Features/Security/LoginRateLimiter.coffee

@@ -1,23 +1,21 @@
-Settings = require('settings-sharelatex')
-redis = require("redis-sharelatex")
-rclient = redis.createClient(Settings.redis.web)
+RateLimiter = require('../../infrastructure/RateLimiter')
 
 
-buildKey = (k)->
-	return "LoginRateLimit:#{k}"
 
 
 ONE_MIN = 60
 ONE_MIN = 60
 ATTEMPT_LIMIT = 10
 ATTEMPT_LIMIT = 10
 
 
+
 module.exports =
 module.exports =
-	processLoginRequest: (email, callback)->
-		multi = rclient.multi()
-		multi.incr(buildKey(email))
-		multi.get(buildKey(email))
-		multi.expire(buildKey(email), ONE_MIN * 2)
-		multi.exec (err, results)->
-			loginCount = results[1]
-			allow = loginCount <= ATTEMPT_LIMIT
-			callback err, allow
+
+	processLoginRequest: (email, callback) ->
+		opts =
+			endpointName: 'login'
+			throttle: ATTEMPT_LIMIT
+			timeInterval: ONE_MIN * 2
+			subjectName: email
+		RateLimiter.addCount opts, (err, shouldAllow) ->
+			callback(err, shouldAllow)
 
 
 	recordSuccessfulLogin: (email, callback = ->)->
 	recordSuccessfulLogin: (email, callback = ->)->
-		rclient.del buildKey(email), callback
+		RateLimiter.clearRateLimit 'login', email, callback
+

+ 4 - 1
services/web/app/coffee/Features/Security/RateLimiterMiddlewear.coffee

@@ -19,12 +19,15 @@ module.exports = RateLimiterMiddlewear =
 			user_id = AuthenticationController.getLoggedInUserId(req) || req.ip
 			user_id = AuthenticationController.getLoggedInUserId(req) || req.ip
 			params = (opts.params or []).map (p) -> req.params[p]
 			params = (opts.params or []).map (p) -> req.params[p]
 			params.push user_id
 			params.push user_id
+			subjectName = params.join(":")
+			if opts.ipOnly
+				subjectName = req.ip
 			if !opts.endpointName?
 			if !opts.endpointName?
 				throw new Error("no endpointName provided")
 				throw new Error("no endpointName provided")
 			options = {
 			options = {
 				endpointName: opts.endpointName
 				endpointName: opts.endpointName
 				timeInterval: opts.timeInterval or 60
 				timeInterval: opts.timeInterval or 60
-				subjectName:  params.join(":")
+				subjectName:  subjectName
 				throttle:     opts.maxRequests or 6
 				throttle:     opts.maxRequests or 6
 			}
 			}
 			RateLimiter.addCount options, (error, canContinue)->
 			RateLimiter.addCount options, (error, canContinue)->

+ 0 - 2
services/web/app/coffee/Features/ServerAdmin/AdminController.coffee

@@ -6,8 +6,6 @@ Project = require('../../models/Project').Project
 DocumentUpdaterHandler = require('../DocumentUpdater/DocumentUpdaterHandler')
 DocumentUpdaterHandler = require('../DocumentUpdater/DocumentUpdaterHandler')
 Settings = require('settings-sharelatex')
 Settings = require('settings-sharelatex')
 util = require('util')
 util = require('util')
-redis = require("redis-sharelatex")
-rclient = redis.createClient(Settings.redis.web)
 RecurlyWrapper = require('../Subscription/RecurlyWrapper')
 RecurlyWrapper = require('../Subscription/RecurlyWrapper')
 SubscriptionHandler = require('../Subscription/SubscriptionHandler')
 SubscriptionHandler = require('../Subscription/SubscriptionHandler')
 projectEntityHandler = require('../Project/ProjectEntityHandler')
 projectEntityHandler = require('../Project/ProjectEntityHandler')

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

@@ -7,7 +7,7 @@ fs = require "fs"
 ErrorController = require "../Errors/ErrorController"
 ErrorController = require "../Errors/ErrorController"
 AuthenticationController = require('../Authentication/AuthenticationController')
 AuthenticationController = require('../Authentication/AuthenticationController')
 
 
-homepageExists = fs.existsSync Path.resolve(__dirname + "/../../../views/external/home.jade")
+homepageExists = fs.existsSync Path.resolve(__dirname + "/../../../views/external/home.pug")
 
 
 module.exports = HomeController =
 module.exports = HomeController =
 	index : (req,res)->
 	index : (req,res)->
@@ -28,10 +28,10 @@ module.exports = HomeController =
 
 
 	externalPage: (page, title) ->
 	externalPage: (page, title) ->
 		return (req, res, next = (error) ->) ->
 		return (req, res, next = (error) ->) ->
-			path = Path.resolve(__dirname + "/../../../views/external/#{page}.jade")
+			path = Path.resolve(__dirname + "/../../../views/external/#{page}.pug")
 			fs.exists path, (exists) -> # No error in this callback - old method in Node.js!
 			fs.exists path, (exists) -> # No error in this callback - old method in Node.js!
 				if exists
 				if exists
-					res.render "external/#{page}.jade",
+					res.render "external/#{page}.pug",
 						title: title
 						title: title
 				else
 				else
 					ErrorController.notFound(req, res, next)
 					ErrorController.notFound(req, res, next)

+ 11 - 10
services/web/app/coffee/Features/Subscription/LimitationsManager.coffee

@@ -1,20 +1,25 @@
 logger = require("logger-sharelatex")
 logger = require("logger-sharelatex")
 Project = require("../../models/Project").Project
 Project = require("../../models/Project").Project
-User = require("../../models/User").User
+UserGetter = require("../User/UserGetter")
 SubscriptionLocator = require("./SubscriptionLocator")
 SubscriptionLocator = require("./SubscriptionLocator")
 Settings = require("settings-sharelatex")
 Settings = require("settings-sharelatex")
 CollaboratorsHandler = require("../Collaborators/CollaboratorsHandler")
 CollaboratorsHandler = require("../Collaborators/CollaboratorsHandler")
 CollaboratorsInvitesHandler = require("../Collaborators/CollaboratorsInviteHandler")
 CollaboratorsInvitesHandler = require("../Collaborators/CollaboratorsInviteHandler")
 
 
 module.exports =
 module.exports =
-
 	allowedNumberOfCollaboratorsInProject: (project_id, callback) ->
 	allowedNumberOfCollaboratorsInProject: (project_id, callback) ->
-		getOwnerOfProject project_id, (error, owner)->
+		Project.findById project_id, 'owner_ref', (error, project) =>
+			return callback(error) if error?
+			@allowedNumberOfCollaboratorsForUser project.owner_ref, callback
+	
+	allowedNumberOfCollaboratorsForUser: (user_id, callback) ->
+		UserGetter.getUser user_id, {features: 1}, (error, user) ->
 			return callback(error) if error?
 			return callback(error) if error?
-			if owner.features? and owner.features.collaborators?
-				callback null, owner.features.collaborators
+			if user.features? and user.features.collaborators?
+				callback null, user.features.collaborators
 			else
 			else
 				callback null, Settings.defaultPlanCode.collaborators
 				callback null, Settings.defaultPlanCode.collaborators
+		
 
 
 	canAddXCollaborators: (project_id, x_collaborators, callback = (error, allowed)->) ->
 	canAddXCollaborators: (project_id, x_collaborators, callback = (error, allowed)->) ->
 		@allowedNumberOfCollaboratorsInProject project_id, (error, allowed_number) =>
 		@allowedNumberOfCollaboratorsInProject project_id, (error, allowed_number) =>
@@ -63,8 +68,4 @@ module.exports =
 			logger.log user_id:user_id, limitReached:limitReached, currentTotal: subscription.member_ids.length, membersLimit: subscription.membersLimit, "checking if subscription members limit has been reached"
 			logger.log user_id:user_id, limitReached:limitReached, currentTotal: subscription.member_ids.length, membersLimit: subscription.membersLimit, "checking if subscription members limit has been reached"
 			callback(err, limitReached, subscription)
 			callback(err, limitReached, subscription)
 
 
-getOwnerOfProject = (project_id, callback)->
-	Project.findById project_id, 'owner_ref', (error, project) ->
-		return callback(error) if error?
-		User.findById project.owner_ref, (error, owner) ->
-			callback(error, owner)
+getOwnerIdOfProject = (project_id, callback)->

+ 1 - 1
services/web/app/coffee/Features/ThirdPartyDataStore/UpdateMerger.coffee

@@ -4,7 +4,7 @@ editorController = require('../Editor/EditorController')
 logger = require('logger-sharelatex')
 logger = require('logger-sharelatex')
 Settings = require('settings-sharelatex')
 Settings = require('settings-sharelatex')
 FileTypeManager = require('../Uploads/FileTypeManager')
 FileTypeManager = require('../Uploads/FileTypeManager')
-uuid = require('node-uuid')
+uuid = require('uuid')
 fs = require('fs')
 fs = require('fs')
 
 
 module.exports =
 module.exports =

+ 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

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

@@ -1,20 +1,42 @@
+RangesManager = require "./RangesManager"
 logger = require "logger-sharelatex"
 logger = require "logger-sharelatex"
-request = require "request"
-settings = require "settings-sharelatex"
-AuthenticationController = require "../Authentication/AuthenticationController"
+UserInfoController = require "../User/UserInfoController"
+DocumentUpdaterHandler = require "../DocumentUpdater/DocumentUpdaterHandler"
+EditorRealTimeController = require("../Editor/EditorRealTimeController")
+TrackChangesManager = require "./TrackChangesManager"
 
 
 module.exports = TrackChangesController =
 module.exports = TrackChangesController =
-	proxyToTrackChangesApi: (req, res, next = (error) ->) ->
-		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)
+	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?
+			docs = ({id: d._id, ranges: d.ranges} for d in docs)
+			res.json docs
+	
+	getAllChangesUsers: (req, res, next) ->
+		project_id = req.params.project_id
+		logger.log {project_id}, "request for project range users"
+		RangesManager.getAllChangesUsers project_id, (error, users) ->
+			return next(error) if error?
+			users = (UserInfoController.formatPersonalInfo(user) for user in users)
+			# Get rid of any anonymous/deleted user objects
+			users = users.filter (u) -> u?.id?
+			res.json users
+	
+	acceptChange: (req, res, next) ->
+		{project_id, doc_id, change_id} = req.params
+		logger.log {project_id, doc_id, change_id}, "request to accept change"
+		DocumentUpdaterHandler.acceptChange project_id, doc_id, change_id, (error) ->
+			return next(error) if error?
+			EditorRealTimeController.emitToRoom project_id, "accept-change", doc_id, change_id, (err)->
+			res.send 204
+
+	toggleTrackChanges: (req, res, next) ->
+		{project_id} = req.params
+		track_changes_on = !!req.body.on
+		logger.log {project_id, track_changes_on}, "request to toggle track changes"
+		TrackChangesManager.toggleTrackChanges project_id, track_changes_on, (error) ->
+			return next(error) if error?
+			EditorRealTimeController.emitToRoom project_id, "toggle-track-changes", track_changes_on, (err)->
+			res.send 204

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

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

+ 10 - 13
services/web/app/coffee/Features/User/UserInfoController.coffee

@@ -26,17 +26,14 @@ module.exports = UserController =
 			UserController.sendFormattedPersonalInfo(user, res, next)
 			UserController.sendFormattedPersonalInfo(user, res, next)
 
 
 	sendFormattedPersonalInfo: (user, res, next = (error) ->) ->
 	sendFormattedPersonalInfo: (user, res, next = (error) ->) ->
-		UserController._formatPersonalInfo user, (error, info) ->
-			return next(error) if error?
-			res.send JSON.stringify(info)
+		info = UserController.formatPersonalInfo(user)
+		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
-		}
+	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

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

@@ -1,5 +1,4 @@
 Settings = require('settings-sharelatex')
 Settings = require('settings-sharelatex')
-redis = require('redis-sharelatex')
 logger = require("logger-sharelatex")
 logger = require("logger-sharelatex")
 Async = require('async')
 Async = require('async')
 _ = require('underscore')
 _ = require('underscore')

+ 3 - 0
services/web/app/coffee/infrastructure/ExpressLocals.coffee

@@ -189,6 +189,7 @@ module.exports = (app, webRouter, apiRouter)->
 			return AuthenticationController.isUserLoggedIn(req)
 			return AuthenticationController.isUserLoggedIn(req)
 		res.locals.getSessionUser = ->
 		res.locals.getSessionUser = ->
 			return AuthenticationController.getSessionUser(req)
 			return AuthenticationController.getSessionUser(req)
+
 		next()
 		next()
 
 
 	webRouter.use (req, res, next) ->
 	webRouter.use (req, res, next) ->
@@ -244,6 +245,8 @@ module.exports = (app, webRouter, apiRouter)->
 		for key, value of Settings.nav
 		for key, value of Settings.nav
 			res.locals.nav[key] = _.clone(Settings.nav[key])
 			res.locals.nav[key] = _.clone(Settings.nav[key])
 		res.locals.templates = Settings.templateLinks
 		res.locals.templates = Settings.templateLinks
+		if res.locals.nav.header
+			console.error {}, "The `nav.header` setting is no longer supported, use `nav.header_extras` instead"
 		next()
 		next()
 
 
 	webRouter.use (req, res, next) ->
 	webRouter.use (req, res, next) ->

+ 2 - 2
services/web/app/coffee/infrastructure/Modules.coffee

@@ -1,6 +1,6 @@
 fs   = require "fs"
 fs   = require "fs"
 Path = require "path"
 Path = require "path"
-jade = require "jade"
+pug = require "pug"
 async = require "async"
 async = require "async"
 
 
 MODULE_BASE_PATH = Path.resolve(__dirname + "/../../../modules")
 MODULE_BASE_PATH = Path.resolve(__dirname + "/../../../modules")
@@ -29,7 +29,7 @@ module.exports = Modules =
 		for module in @modules
 		for module in @modules
 			for view, partial of module.viewIncludes or {}
 			for view, partial of module.viewIncludes or {}
 				@viewIncludes[view] ||= []
 				@viewIncludes[view] ||= []
-				@viewIncludes[view].push jade.compile(fs.readFileSync(Path.join(MODULE_BASE_PATH, module.name, "app/views", partial + ".jade")), doctype: "html")
+				@viewIncludes[view].push pug.compile(fs.readFileSync(Path.join(MODULE_BASE_PATH, module.name, "app/views", partial + ".pug")), doctype: "html")
 			
 			
 	moduleIncludes: (view, locals) ->
 	moduleIncludes: (view, locals) ->
 		compiledPartials = Modules.viewIncludes[view] or []
 		compiledPartials = Modules.viewIncludes[view] or []

+ 25 - 13
services/web/app/coffee/infrastructure/RateLimiter.coffee

@@ -1,15 +1,27 @@
 settings = require("settings-sharelatex")
 settings = require("settings-sharelatex")
-redis = require("redis-sharelatex")
-rclient = redis.createClient(settings.redis.web)
-redback = require("redback").use(rclient)
-
-module.exports =
-
-	addCount: (opts, callback = (opts, shouldProcess)->)->
-		ratelimit = redback.createRateLimit(opts.endpointName)
-		ratelimit.addCount opts.subjectName, opts.timeInterval, (err, callCount)->
-			shouldProcess = callCount < opts.throttle
-			callback(err, shouldProcess)
-			
+RedisWrapper = require('./RedisWrapper')
+rclient = RedisWrapper.client('ratelimiter')
+RollingRateLimiter = require('rolling-rate-limiter')
+
+
+module.exports = RateLimiter =
+
+	addCount: (opts, callback = (err, shouldProcess)->)->
+		namespace = "RateLimit:#{opts.endpointName}:"
+		k = "{#{opts.subjectName}}"
+		limiter = RollingRateLimiter({
+			redis: rclient,
+			namespace: namespace,
+			interval: opts.timeInterval * 1000,
+			maxInInterval: opts.throttle
+		})
+		limiter k, (err, timeLeft, actionsLeft) ->
+			if err?
+				return callback(err)
+			allowed = timeLeft == 0
+			callback(null, allowed)
+
 	clearRateLimit: (endpointName, subject, callback) ->
 	clearRateLimit: (endpointName, subject, callback) ->
-		rclient.del "#{endpointName}:#{subject}", callback
+		# same as the key which will be built by RollingRateLimiter (namespace+k)
+		keyName = "RateLimit:#{endpointName}:{#{subject}}"
+		rclient.del keyName, callback

+ 28 - 0
services/web/app/coffee/infrastructure/RedisWrapper.coffee

@@ -0,0 +1,28 @@
+Settings = require 'settings-sharelatex'
+redis = require 'redis-sharelatex'
+ioredis = require 'ioredis'
+logger = require 'logger-sharelatex'
+
+
+# A per-feature interface to Redis,
+# looks up the feature in `settings.redis`
+# and returns an appropriate client.
+# Necessary because we don't want to migrate web over
+# to redis-cluster all at once.
+
+# TODO: consider merging into `redis-sharelatex`
+
+
+module.exports = Redis =
+
+	# feature = 'websessions' | 'ratelimiter' | ...
+	client: (feature) ->
+		redisFeatureSettings = Settings.redis[feature] or Settings.redis.web
+		if redisFeatureSettings?.cluster?
+			logger.log {feature}, "creating redis-cluster client"
+			rclient = new ioredis.Cluster(redisFeatureSettings.cluster)
+			rclient.__is_redis_cluster = true
+		else
+			logger.log {feature}, "creating redis client"
+			rclient = redis.createClient(redisFeatureSettings)
+		return rclient

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

@@ -7,7 +7,6 @@ crawlerLogger = require('./CrawlerLogger')
 expressLocals = require('./ExpressLocals')
 expressLocals = require('./ExpressLocals')
 Router = require('../router')
 Router = require('../router')
 metrics.inc("startup")
 metrics.inc("startup")
-redis = require("redis-sharelatex")
 UserSessionsRedis = require('../Features/User/UserSessionsRedis')
 UserSessionsRedis = require('../Features/User/UserSessionsRedis')
 
 
 sessionsRedisClient = UserSessionsRedis.client()
 sessionsRedisClient = UserSessionsRedis.client()
@@ -62,7 +61,7 @@ if Settings.behindProxy
 
 
 webRouter.use express.static(__dirname + '/../../../public', {maxAge: staticCacheAge })
 webRouter.use express.static(__dirname + '/../../../public', {maxAge: staticCacheAge })
 app.set 'views', __dirname + '/../../views'
 app.set 'views', __dirname + '/../../views'
-app.set 'view engine', 'jade'
+app.set 'view engine', 'pug'
 Modules.loadViewIncludes app
 Modules.loadViewIncludes app
 
 
 
 

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

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

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

@@ -2,7 +2,7 @@ Project = require('./Project').Project
 Settings = require 'settings-sharelatex'
 Settings = require 'settings-sharelatex'
 _ = require('underscore')
 _ = require('underscore')
 mongoose = require('mongoose')
 mongoose = require('mongoose')
-uuid = require('node-uuid')
+uuid = require('uuid')
 Schema = mongoose.Schema
 Schema = mongoose.Schema
 ObjectId = Schema.ObjectId
 ObjectId = Schema.ObjectId
 
 
@@ -37,9 +37,10 @@ UserSchema = new Schema
 							compileGroup:  { type:String,  default: Settings.defaultFeatures.compileGroup }
 							compileGroup:  { type:String,  default: Settings.defaultFeatures.compileGroup }
 							templates:     { type:Boolean, default: Settings.defaultFeatures.templates }
 							templates:     { type:Boolean, default: Settings.defaultFeatures.templates }
 							references:    { type:Boolean, default: Settings.defaultFeatures.references }
 							references:    { type:Boolean, default: Settings.defaultFeatures.references }
+							trackChanges:  { type:Boolean, default: Settings.defaultFeatures.trackChanges }
 						}
 						}
 	featureSwitches	  : {
 	featureSwitches	  : {
-		pdfng: { type: Boolean }
+		track_changes: { type: Boolean }
 	}
 	}
 	referal_id : {type:String, default:() -> uuid.v4().split("-")[0]}
 	referal_id : {type:String, default:() -> uuid.v4().split("-")[0]}
 	refered_users: [ type:ObjectId, ref:'User' ]
 	refered_users: [ type:ObjectId, ref:'User' ]

+ 22 - 6
services/web/app/coffee/router.coffee

@@ -25,7 +25,7 @@ ClsiCookieManager = require("./Features/Compile/ClsiCookieManager")
 HealthCheckController = require("./Features/HealthCheck/HealthCheckController")
 HealthCheckController = require("./Features/HealthCheck/HealthCheckController")
 ProjectDownloadsController = require "./Features/Downloads/ProjectDownloadsController"
 ProjectDownloadsController = require "./Features/Downloads/ProjectDownloadsController"
 FileStoreController = require("./Features/FileStore/FileStoreController")
 FileStoreController = require("./Features/FileStore/FileStoreController")
-TrackChangesController = require("./Features/TrackChanges/TrackChangesController")
+HistoryController = require("./Features/History/HistoryController")
 PasswordResetRouter = require("./Features/PasswordReset/PasswordResetRouter")
 PasswordResetRouter = require("./Features/PasswordReset/PasswordResetRouter")
 StaticPagesRouter = require("./Features/StaticPages/StaticPagesRouter")
 StaticPagesRouter = require("./Features/StaticPages/StaticPagesRouter")
 ChatController = require("./Features/Chat/ChatController")
 ChatController = require("./Features/Chat/ChatController")
@@ -40,6 +40,8 @@ AuthorizationMiddlewear = require('./Features/Authorization/AuthorizationMiddlew
 BetaProgramController = require('./Features/BetaProgram/BetaProgramController')
 BetaProgramController = require('./Features/BetaProgram/BetaProgramController')
 AnalyticsRouter = require('./Features/Analytics/AnalyticsRouter')
 AnalyticsRouter = require('./Features/Analytics/AnalyticsRouter')
 AnnouncementsController = require("./Features/Announcements/AnnouncementsController")
 AnnouncementsController = require("./Features/Announcements/AnnouncementsController")
+TrackChangesController = require("./Features/TrackChanges/TrackChangesController")
+CommentsController = require "./Features/Comments/CommentsController"
 
 
 logger = require("logger-sharelatex")
 logger = require("logger-sharelatex")
 _ = require("underscore")
 _ = require("underscore")
@@ -171,9 +173,14 @@ module.exports = class Router
 
 
 		webRouter.post '/project/:Project_id/rename', AuthorizationMiddlewear.ensureUserCanAdminProject, ProjectController.renameProject
 		webRouter.post '/project/:Project_id/rename', AuthorizationMiddlewear.ensureUserCanAdminProject, ProjectController.renameProject
 
 
-		webRouter.get  "/project/:Project_id/updates", AuthorizationMiddlewear.ensureUserCanReadProject, TrackChangesController.proxyToTrackChangesApi
-		webRouter.get  "/project/:Project_id/doc/:doc_id/diff", AuthorizationMiddlewear.ensureUserCanReadProject, TrackChangesController.proxyToTrackChangesApi
-		webRouter.post "/project/:Project_id/doc/:doc_id/version/:version_id/restore", AuthorizationMiddlewear.ensureUserCanReadProject, TrackChangesController.proxyToTrackChangesApi
+		webRouter.get  "/project/:Project_id/updates", AuthorizationMiddlewear.ensureUserCanReadProject, HistoryController.proxyToHistoryApi
+		webRouter.get  "/project/:Project_id/doc/:doc_id/diff", AuthorizationMiddlewear.ensureUserCanReadProject, HistoryController.proxyToHistoryApi
+		webRouter.post "/project/:Project_id/doc/:doc_id/version/:version_id/restore", AuthorizationMiddlewear.ensureUserCanReadProject, HistoryController.proxyToHistoryApi
+
+		webRouter.get "/project/:project_id/ranges", AuthorizationMiddlewear.ensureUserCanReadProject, TrackChangesController.getAllRanges
+		webRouter.get "/project/:project_id/changes/users", AuthorizationMiddlewear.ensureUserCanReadProject, TrackChangesController.getAllChangesUsers
+		webRouter.post "/project/:project_id/doc/:doc_id/changes/:change_id/accept", AuthorizationMiddlewear.ensureUserCanWriteProjectContent, TrackChangesController.acceptChange
+		webRouter.post "/project/:project_id/track_changes", AuthorizationMiddlewear.ensureUserCanWriteProjectContent, TrackChangesController.toggleTrackChanges
 
 
 		webRouter.get  '/Project/:Project_id/download/zip', AuthorizationMiddlewear.ensureUserCanReadProject, ProjectDownloadsController.downloadProject
 		webRouter.get  '/Project/:Project_id/download/zip', AuthorizationMiddlewear.ensureUserCanReadProject, ProjectDownloadsController.downloadProject
 		webRouter.get  '/project/download/zip', AuthorizationMiddlewear.ensureUserCanReadMultipleProjects, ProjectDownloadsController.downloadMultipleProjects
 		webRouter.get  '/project/download/zip', AuthorizationMiddlewear.ensureUserCanReadMultipleProjects, ProjectDownloadsController.downloadMultipleProjects
@@ -223,8 +230,17 @@ module.exports = class Router
 		webRouter.post "/spelling/check", AuthenticationController.requireLogin(), SpellingController.proxyRequestToSpellingApi
 		webRouter.post "/spelling/check", AuthenticationController.requireLogin(), SpellingController.proxyRequestToSpellingApi
 		webRouter.post "/spelling/learn", AuthenticationController.requireLogin(), SpellingController.proxyRequestToSpellingApi
 		webRouter.post "/spelling/learn", AuthenticationController.requireLogin(), SpellingController.proxyRequestToSpellingApi
 
 
-		webRouter.get  "/project/:Project_id/messages", AuthorizationMiddlewear.ensureUserCanReadProject, ChatController.getMessages
-		webRouter.post "/project/:Project_id/messages", AuthorizationMiddlewear.ensureUserCanReadProject, ChatController.sendMessage
+		webRouter.get  "/project/:project_id/messages", AuthorizationMiddlewear.ensureUserCanReadProject, ChatController.getMessages
+		webRouter.post "/project/:project_id/messages", AuthorizationMiddlewear.ensureUserCanReadProject, ChatController.sendMessage
+		
+		# Note: Read only users can still comment
+		webRouter.post "/project/:project_id/thread/:thread_id/messages", AuthorizationMiddlewear.ensureUserCanReadProject, CommentsController.sendComment
+		webRouter.get  "/project/:project_id/threads", AuthorizationMiddlewear.ensureUserCanReadProject, CommentsController.getThreads
+		webRouter.post "/project/:project_id/thread/:thread_id/resolve", AuthorizationMiddlewear.ensureUserCanWriteProjectContent, CommentsController.resolveThread
+		webRouter.post "/project/:project_id/thread/:thread_id/reopen", AuthorizationMiddlewear.ensureUserCanWriteProjectContent, CommentsController.reopenThread
+		webRouter.delete "/project/:project_id/doc/:doc_id/thread/:thread_id", AuthorizationMiddlewear.ensureUserCanWriteProjectContent, CommentsController.deleteThread
+		webRouter.post "/project/:project_id/thread/:thread_id/messages/:message_id/edit", AuthorizationMiddlewear.ensureUserCanWriteProjectContent, CommentsController.editMessage
+		webRouter.delete  "/project/:project_id/thread/:thread_id/messages/:message_id", AuthorizationMiddlewear.ensureUserCanWriteProjectContent, CommentsController.deleteMessage
 
 
 		webRouter.post "/project/:Project_id/references/index", AuthorizationMiddlewear.ensureUserCanReadProject, ReferencesController.index
 		webRouter.post "/project/:Project_id/references/index", AuthorizationMiddlewear.ensureUserCanReadProject, ReferencesController.index
 		webRouter.post "/project/:Project_id/references/indexAll", AuthorizationMiddlewear.ensureUserCanReadProject, ReferencesController.indexAll
 		webRouter.post "/project/:Project_id/references/indexAll", AuthorizationMiddlewear.ensureUserCanReadProject, ReferencesController.indexAll

+ 2 - 2
services/web/app/views/admin/index.jade → services/web/app/views/admin/index.pug

@@ -28,10 +28,10 @@ block content
 							tab(heading="Open Sockets")
 							tab(heading="Open Sockets")
 								.row-spaced
 								.row-spaced
 									ul
 									ul
-										-each agents, url in openSockets
+										each agents, url in openSockets
 											li #{url} - total : #{agents.length}
 											li #{url} - total : #{agents.length}
 												ul
 												ul
-													-each agent in agents
+													each agent in agents
 														li #{agent}
 														li #{agent}
 
 
 							tab(heading="Close Editor")
 							tab(heading="Close Editor")

+ 0 - 0
services/web/app/views/admin/register.jade → services/web/app/views/admin/register.pug


+ 0 - 0
services/web/app/views/beta_program/opt_in.jade → services/web/app/views/beta_program/opt_in.pug


+ 0 - 0
services/web/app/views/blog/blog_holder.jade → services/web/app/views/blog/blog_holder.pug


+ 5 - 5
services/web/app/views/contact-us-modal.jade → services/web/app/views/contact-us-modal.pug

@@ -24,10 +24,10 @@ script(type='text/ng-template', id='supportModalTemplate')
 						a.contact-suggestion-list-item(ng-href="{{ suggestion.url }}", ng-click="clickSuggestionLink(suggestion.url);" target="_blank")
 						a.contact-suggestion-list-item(ng-href="{{ suggestion.url }}", ng-click="clickSuggestionLink(suggestion.url);" target="_blank")
 							span(ng-bind-html="suggestion.name")
 							span(ng-bind-html="suggestion.name")
 							i.fa.fa-angle-right
 							i.fa.fa-angle-right
-			label.desc(ng-show="'#{getUserEmail()}'.length < 1")
+			label.desc(ng-show="'"+getUserEmail()+"'.length < 1")
 				| #{translate("email")}
 				| #{translate("email")}
-			.form-group(ng-show="'#{getUserEmail()}'.length < 1")
-				input.field.text.medium.span8.form-control(ng-model="form.email",  ng-init="form.email = '#{getUserEmail()}'", type='email', spellcheck='false', value='', maxlength='255', tabindex='2')
+			.form-group(ng-show="'"+getUserEmail()+"'.length < 1")
+				input.field.text.medium.span8.form-control(ng-model="form.email",  ng-init="form.email = '"+getUserEmail()+"'", type='email', spellcheck='false', value='', maxlength='255', tabindex='2')
 			label#title12.desc
 			label#title12.desc
 				| #{translate("project_url")} (#{translate("optional")})
 				| #{translate("project_url")} (#{translate("optional")})
 			.form-group
 			.form-group
@@ -37,6 +37,6 @@ script(type='text/ng-template', id='supportModalTemplate')
 			.form-group
 			.form-group
 				textarea.field.text.medium.span8.form-control(ng-model="form.message",type='text', value='', tabindex='4', onkeyup='')
 				textarea.field.text.medium.span8.form-control(ng-model="form.message",type='text', value='', tabindex='4', onkeyup='')
 			.form-group.text-center
 			.form-group.text-center
-				input.btn-success.btn.btn-lg(type='submit', ng-disabled="sending", ng-click="contactUs()" value='#{translate("contact_us")}')
+				input.btn-success.btn.btn-lg(type='submit', ng-disabled="sending", ng-click="contactUs()" value=translate("contact_us"))
 		span(ng-show="sent")
 		span(ng-show="sent")
-			p #{translate("request_sent_thank_you")}
+			p #{translate("request_sent_thank_you")}

+ 0 - 0
services/web/app/views/general/404.jade → services/web/app/views/general/404.pug


+ 0 - 0
services/web/app/views/general/500.jade → services/web/app/views/general/500.pug


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

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

+ 3 - 1
services/web/app/views/layout.jade → services/web/app/views/layout.pug

@@ -21,6 +21,8 @@ html(itemscope, itemtype='http://schema.org/Product')
 		link(rel="icon", href="/favicon.ico")
 		link(rel="icon", href="/favicon.ico")
 		link(rel='stylesheet', href=buildCssPath('/style.css'))
 		link(rel='stylesheet', href=buildCssPath('/style.css'))
 
 
+		block _headLinks
+
 		if settings.i18n.subdomainLang
 		if settings.i18n.subdomainLang
 			each subdomainDetails in settings.i18n.subdomainLang
 			each subdomainDetails in settings.i18n.subdomainLang
 				if !subdomainDetails.hide
 				if !subdomainDetails.hide
@@ -30,7 +32,7 @@ html(itemscope, itemtype='http://schema.org/Product')
 		meta(itemprop="name", content="ShareLaTeX, the Online LaTeX Editor")
 		meta(itemprop="name", content="ShareLaTeX, the Online LaTeX Editor")
 		
 		
 		-if (typeof(meta) == "undefined")
 		-if (typeof(meta) == "undefined")
-			meta(itemprop="description", name="description", content='#{translate("site_description")}')
+			meta(itemprop="description", name="description", content=translate("site_description"))
 		-else
 		-else
 			meta(itemprop="description", name="description" , content=meta)
 			meta(itemprop="description", name="description" , content=meta)
 		
 		

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

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

+ 36 - 8
services/web/app/views/layout/navbar.jade → services/web/app/views/layout/navbar.pug

@@ -4,7 +4,7 @@ nav.navbar.navbar-default
 			button.navbar-toggle(ng-init="navCollapsed = true", ng-click="navCollapsed = !navCollapsed", ng-class="{active: !navCollapsed}")
 			button.navbar-toggle(ng-init="navCollapsed = true", ng-click="navCollapsed = !navCollapsed", ng-class="{active: !navCollapsed}")
 				i.fa.fa-bars
 				i.fa.fa-bars
 			if settings.nav.custom_logo
 			if settings.nav.custom_logo
-				a(href='/', style='background-image:url("#{settings.nav.custom_logo}")').navbar-brand
+				a(href='/', style='background-image:url("'+settings.nav.custom_logo+'")').navbar-brand
 			else if (nav.title)
 			else if (nav.title)
 				a(href='/').navbar-title #{nav.title}
 				a(href='/').navbar-title #{nav.title}
 			else
 			else
@@ -24,7 +24,10 @@ nav.navbar.navbar-default
 							li
 							li
 								a(href="/admin/user") Manage Users
 								a(href="/admin/user") Manage Users
 
 
-				each item in nav.header
+
+				// 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.only_when_logged_in && getSessionUser()) || (item.only_when_logged_out && (!getSessionUser())) || (!item.only_when_logged_out && !item.only_when_logged_in))
 						if item.dropdown
 						if item.dropdown
 							li.dropdown(class=item.class, dropdown)
 							li.dropdown(class=item.class, dropdown)
@@ -35,9 +38,6 @@ nav.navbar.navbar-default
 									each child in item.dropdown
 									each child in item.dropdown
 										if child.divider
 										if child.divider
 											li.divider
 											li.divider
-										else if child.user_email
-											li
-												div.subdued #{getUserEmail()}
 										else
 										else
 											li
 											li
 												if child.url
 												if child.url
@@ -50,7 +50,35 @@ nav.navbar.navbar-default
 									a(href=item.url, class=item.class) !{translate(item.text)}
 									a(href=item.url, class=item.class) !{translate(item.text)}
 								else
 								else
 									| !{translate(item.text)}
 									| !{translate(item.text)}
-				
-				
-				
 
 
+				// logged out
+				if !getSessionUser()
+					// register link
+					if !externalAuthenticationSystemUsed()
+						li
+							a(href="/register") #{translate('register')}
+
+					// login link
+					li
+						a(href="/login") #{translate('log_in')}
+
+				// projects link and account menu
+				if getSessionUser()
+					li
+						a(href="/project") #{translate('Projects')}
+					li.dropdown(dropdown)
+						a.dropbodw-toggle(href, dropdown-toggle)
+							| #{translate('Account')}
+							b.caret
+						ul.dropdown-menu
+							li
+								div.subdued #{getUserEmail()}
+							li.divider
+							li
+								a(href="/user/settings") #{translate('Account Settings')}
+							if nav.showSubscriptionLink
+								li
+									a(href="/user/subscription") #{translate('subscription')}
+							li.divider
+							li
+								a(href="/logout") #{translate('log_out')}

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

@@ -107,6 +107,7 @@ block requirejs
 		window.csrfToken = "!{csrfToken}";
 		window.csrfToken = "!{csrfToken}";
 		window.anonymous = #{anonymous};
 		window.anonymous = #{anonymous};
 		window.maxDocLength = #{maxDocLength};
 		window.maxDocLength = #{maxDocLength};
+		window.trackChangesEnabled = #{trackChangesEnabled};
 		window.wikiEnabled = #{!!(settings.apis.wiki && settings.apis.wiki.url)};
 		window.wikiEnabled = #{!!(settings.apis.wiki && settings.apis.wiki.url)};
 		window.requirejs = {
 		window.requirejs = {
 			"paths" : {
 			"paths" : {

+ 0 - 0
services/web/app/views/project/editor/binary-file.jade → services/web/app/views/project/editor/binary-file.pug


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

@@ -50,7 +50,7 @@ aside.chat(
 
 
 	.new-message
 	.new-message
 		textarea(
 		textarea(
-			placeholder="#{translate('your_message')}...",
+			placeholder=translate('your_message')+"...",
 			on-enter="sendMessage()",
 			on-enter="sendMessage()",
 			ng-model="newMessageContent",
 			ng-model="newMessageContent",
 			ng-click="resetUnreadMessages()"
 			ng-click="resetUnreadMessages()"

+ 10 - 8
services/web/app/views/project/editor/editor.jade → services/web/app/views/project/editor/editor.pug

@@ -17,7 +17,9 @@ div.full-size(
 			'rp-state-current-file-mini': (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-state-overview': (reviewPanel.subView === SubViews.OVERVIEW),\
 			'rp-size-mini': (!ui.reviewPanelOpen && reviewPanel.hasEntries),\
 			'rp-size-mini': (!ui.reviewPanelOpen && reviewPanel.hasEntries),\
-			'rp-size-expanded': ui.reviewPanelOpen\
+			'rp-size-expanded': ui.reviewPanelOpen,\
+			'rp-layout-left': reviewPanel.layoutToLeft,\
+			'rp-loading-threads': reviewPanel.loadingThreads\
 		}"
 		}"
 	)
 	)
 		.loading-panel(ng-show="!editor.sharejs_doc || editor.opening")
 		.loading-panel(ng-show="!editor.sharejs_doc || editor.opening")
@@ -51,12 +53,12 @@ div.full-size(
 			syntax-validation="settings.syntaxValidation",
 			syntax-validation="settings.syntaxValidation",
 			review-panel="reviewPanel",
 			review-panel="reviewPanel",
 			events-bridge="reviewPanelEventsBridge"
 			events-bridge="reviewPanelEventsBridge"
-			track-changes-enabled="trackChangesFeatureFlag",
-			track-new-changes= "reviewPanel.trackNewChanges",
-			changes-tracker="reviewPanel.changesTracker",
+			track-changes-enabled="project.features.trackChangesVisible",
+			track-changes= "editor.trackChanges",
 			doc-id="editor.open_doc_id"
 			doc-id="editor.open_doc_id"
+			renderer-data="reviewPanel.rendererData"
 		)
 		)
-		
+
 		include ./review-panel
 		include ./review-panel
 
 
 	.ui-layout-east
 	.ui-layout-east
@@ -68,7 +70,7 @@ div.full-size(
 		ng-controller="PdfSynctexController"
 		ng-controller="PdfSynctexController"
 	)
 	)
 		a.btn.btn-default.btn-xs(
 		a.btn.btn-default.btn-xs(
-			tooltip="#{translate('go_to_code_location_in_pdf')}"
+			tooltip=translate('go_to_code_location_in_pdf')
 			tooltip-placement="right"
 			tooltip-placement="right"
 			tooltip-append-to-body="true"
 			tooltip-append-to-body="true"
 			ng-click="syncToPdf()"
 			ng-click="syncToPdf()"
@@ -76,7 +78,7 @@ div.full-size(
 			i.fa.fa-long-arrow-right
 			i.fa.fa-long-arrow-right
 		br
 		br
 		a.btn.btn-default.btn-xs(
 		a.btn.btn-default.btn-xs(
-			tooltip-html="'#{translate('go_to_pdf_location_in_code')}'"
+			tooltip-html="'"+translate('go_to_pdf_location_in_code')+"'"
 			tooltip-placement="right"
 			tooltip-placement="right"
 			tooltip-append-to-body="true"
 			tooltip-append-to-body="true"
 			ng-click="syncToCode()"
 			ng-click="syncToCode()"
@@ -88,4 +90,4 @@ div.full-size(
 	ng-show="ui.view == 'pdf'"
 	ng-show="ui.view == 'pdf'"
 )
 )
 	include ./pdf
 	include ./pdf
-	
+	

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


+ 6 - 6
services/web/app/views/project/editor/file-tree.jade → services/web/app/views/project/editor/file-tree.pug

@@ -3,21 +3,21 @@ aside#file-tree(ng-controller="FileTreeController", ng-class="{ 'multi-selected'
 		a(
 		a(
 			href,
 			href,
 			ng-click="openNewDocModal()",
 			ng-click="openNewDocModal()",
-			tooltip-html="'#{translate('new_file').replace(' ', '<br>')}'",
+			tooltip-html="'"+translate('new_file').replace(' ', '<br>')+"'",
 			tooltip-placement="bottom"
 			tooltip-placement="bottom"
 		)
 		)
 			i.fa.fa-file
 			i.fa.fa-file
 		a(
 		a(
 			href,
 			href,
 			ng-click="openNewFolderModal()",
 			ng-click="openNewFolderModal()",
-			tooltip-html="'#{translate('new_folder').replace(' ', '<br>')}'",
+			tooltip-html="'"+translate('new_folder').replace(' ', '<br>')+"'",
 			tooltip-placement="bottom"
 			tooltip-placement="bottom"
 		)
 		)
 			i.fa.fa-folder
 			i.fa.fa-folder
 		a(
 		a(
 			href,
 			href,
 			ng-click="openUploadFileModal()",
 			ng-click="openUploadFileModal()",
-			tooltip="#{translate('upload')}",
+			tooltip=translate('upload'),
 			tooltip-placement="bottom"
 			tooltip-placement="bottom"
 		)
 		)
 			i.fa.fa-upload
 			i.fa.fa-upload
@@ -26,7 +26,7 @@ aside#file-tree(ng-controller="FileTreeController", ng-class="{ 'multi-selected'
 			a(
 			a(
 				href,
 				href,
 				ng-click="startRenamingSelected()",
 				ng-click="startRenamingSelected()",
-				tooltip="#{translate('rename')}",
+				tooltip=translate('rename'),
 				tooltip-placement="bottom",
 				tooltip-placement="bottom",
 				ng-show="multiSelectedCount == 0"
 				ng-show="multiSelectedCount == 0"
 			)
 			)
@@ -34,7 +34,7 @@ aside#file-tree(ng-controller="FileTreeController", ng-class="{ 'multi-selected'
 			a(
 			a(
 				href,
 				href,
 				ng-click="openDeleteModalForSelected()",
 				ng-click="openDeleteModalForSelected()",
-				tooltip="#{translate('delete')}",
+				tooltip=translate('delete'),
 				tooltip-placement="bottom",
 				tooltip-placement="bottom",
 				tooltip-append-to-body="true"
 				tooltip-append-to-body="true"
 			)
 			)
@@ -431,4 +431,4 @@ script(type='text/ng-template', id='invalidFileNameModalTemplate')
 	.modal-footer
 	.modal-footer
 		button.btn.btn-default(
 		button.btn.btn-default(
 			ng-click="$close()"
 			ng-click="$close()"
-		) #{translate('ok')}
+		) #{translate('ok')}

+ 4 - 4
services/web/app/views/project/editor/header.jade → services/web/app/views/project/editor/header.pug

@@ -45,7 +45,7 @@ header.toolbar.toolbar-header.toolbar-with-labels(
 			ng-if="permissions.admin",
 			ng-if="permissions.admin",
 			href='#',
 			href='#',
 			tooltip-placement="bottom",
 			tooltip-placement="bottom",
-			tooltip="#{translate('rename')}",
+			tooltip=translate('rename'),
 			tooltip-append-to-body="true",
 			tooltip-append-to-body="true",
 			ng-click="startRenaming()",
 			ng-click="startRenaming()",
 			ng-show="!state.renaming"
 			ng-show="!state.renaming"
@@ -71,7 +71,7 @@ header.toolbar.toolbar-header.toolbar-with-labels(
 			span.dropdown(dropdown, ng-if="onlineUsersArray.length >= 4")
 			span.dropdown(dropdown, ng-if="onlineUsersArray.length >= 4")
 				span.online-user.online-user-multi(
 				span.online-user.online-user-multi(
 					dropdown-toggle,
 					dropdown-toggle,
-					tooltip="#{translate('connected_users')}",
+					tooltip=translate('connected_users'),
 					tooltip-placement="left"
 					tooltip-placement="left"
 				)
 				)
 					strong {{ onlineUsersArray.length }}
 					strong {{ onlineUsersArray.length }}
@@ -87,7 +87,7 @@ header.toolbar.toolbar-header.toolbar-with-labels(
 
 
 		a.btn.btn-full-height(
 		a.btn.btn-full-height(
 			href,
 			href,
-			ng-if="trackChangesFeatureFlag",
+			ng-if="project.features.trackChangesVisible",
 			ng-class="{ active: ui.reviewPanelOpen }"
 			ng-class="{ active: ui.reviewPanelOpen }"
 			ng-click="toggleReviewPanel()"
 			ng-click="toggleReviewPanel()"
 		)
 		)
@@ -121,4 +121,4 @@ header.toolbar.toolbar-header.toolbar-with-labels(
 			span.label.label-info(
 			span.label.label-info(
 				ng-show="unreadMessages > 0"
 				ng-show="unreadMessages > 0"
 			) {{ unreadMessages }}
 			) {{ unreadMessages }}
-			p.toolbar-label #{translate("chat")}
+			p.toolbar-label #{translate("chat")}

+ 0 - 0
services/web/app/views/project/editor/history.jade → services/web/app/views/project/editor/history.pug


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


+ 3 - 3
services/web/app/views/project/editor/left-menu.jade → services/web/app/views/project/editor/left-menu.pug

@@ -24,7 +24,7 @@ aside#left-menu.full-size(
 				| PDF
 				| PDF
 			div.link-disabled(
 			div.link-disabled(
 				ng-if="!pdf.url"
 				ng-if="!pdf.url"
-				tooltip="#{translate('please_compile_pdf_before_download')}"
+				tooltip=translate('please_compile_pdf_before_download')
 				tooltip-placement="bottom"
 				tooltip-placement="bottom"
 			)
 			)
 				i.fa.fa-file-pdf-o.fa-2x
 				i.fa.fa-file-pdf-o.fa-2x
@@ -47,7 +47,7 @@ aside#left-menu.full-size(
 				a(href, ng-if="pdf.url" ,ng-click="openWordCountModal()")
 				a(href, ng-if="pdf.url" ,ng-click="openWordCountModal()")
 					i.fa.fa-fw.fa-eye
 					i.fa.fa-fw.fa-eye
 					span &nbsp;&nbsp; #{translate("word_count")}
 					span &nbsp;&nbsp; #{translate("word_count")}
-				a.link-disabled(href, ng-if="!pdf.url" , tooltip="#{translate('please_compile_pdf_before_word_count')}")
+				a.link-disabled(href, ng-if="!pdf.url" , tooltip=translate('please_compile_pdf_before_word_count'))
 					i.fa.fa-fw.fa-eye
 					i.fa.fa-fw.fa-eye
 					span.link-disabled &nbsp;&nbsp; #{translate("word_count")}	
 					span.link-disabled &nbsp;&nbsp; #{translate("word_count")}	
 
 
@@ -200,7 +200,7 @@ script(type='text/ng-template', id='wordCountModalTemplate')
 				span &nbsp; #{translate("loading")}...  
 				span &nbsp; #{translate("loading")}...  
 			div.pdf-disabled(
 			div.pdf-disabled(
 				ng-if="!pdf.url"
 				ng-if="!pdf.url"
-				tooltip="#{translate('please_compile_pdf_before_word_count')}"
+				tooltip=translate('please_compile_pdf_before_word_count')
 				tooltip-placement="bottom"
 				tooltip-placement="bottom"
 			)
 			)
 		div(ng-if="!status.loading")
 		div(ng-if="!status.loading")

+ 7 - 7
services/web/app/views/project/editor/pdf.jade → services/web/app/views/project/editor/pdf.pug

@@ -2,7 +2,7 @@ div.full-size.pdf(ng-controller="PdfController")
 	.toolbar.toolbar-tall
 	.toolbar.toolbar-tall
 		.btn-group(
 		.btn-group(
 			dropdown,
 			dropdown,
-			tooltip-html="'#{translate('recompile_pdf')} <span class=\"keyboard-shortcut\">({{modifierKey}} + Enter)</span>'"
+			tooltip-html="'"+translate('recompile_pdf')+" <span class=\"keyboard-shortcut\">({{modifierKey}} + Enter)</span>'"
 			tooltip-class="keyboard-tooltip"
 			tooltip-class="keyboard-tooltip"
 			tooltip-popup-delay="500"
 			tooltip-popup-delay="500"
 			tooltip-append-to-body="true"
 			tooltip-append-to-body="true"
@@ -53,7 +53,7 @@ div.full-size.pdf(ng-controller="PdfController")
 			href
 			href
 			ng-click="stop()"
 			ng-click="stop()"
 			ng-show="pdf.compiling",
 			ng-show="pdf.compiling",
-			tooltip="#{translate('stop_compile')}"
+			tooltip=translate('stop_compile')
 			tooltip-placement="bottom"
 			tooltip-placement="bottom"
 		)
 		)
 			i.fa.fa-stop()
 			i.fa.fa-stop()
@@ -61,7 +61,7 @@ div.full-size.pdf(ng-controller="PdfController")
 			href
 			href
 			ng-click="toggleLogs()"
 			ng-click="toggleLogs()"
 			ng-class="{ 'active': shouldShowLogs == true }"
 			ng-class="{ 'active': shouldShowLogs == true }"
-			tooltip="#{translate('logs_and_output_files')}"
+			tooltip=translate('logs_and_output_files')
 			tooltip-placement="bottom"
 			tooltip-placement="bottom"
 		)
 		)
 			i.fa.fa-file-text-o
 			i.fa.fa-file-text-o
@@ -77,7 +77,7 @@ div.full-size.pdf(ng-controller="PdfController")
 			ng-href="{{pdf.downloadUrl || pdf.url}}"
 			ng-href="{{pdf.downloadUrl || pdf.url}}"
 			target="_blank"
 			target="_blank"
 			ng-if="pdf.url"
 			ng-if="pdf.url"
-			tooltip="#{translate('download_pdf')}"
+			tooltip=translate('download_pdf')
 			tooltip-placement="bottom"
 			tooltip-placement="bottom"
 		)
 		)
 			i.fa.fa-download
 			i.fa.fa-download
@@ -87,7 +87,7 @@ div.full-size.pdf(ng-controller="PdfController")
 				href,
 				href,
 				ng-click="switchToFlatLayout()"
 				ng-click="switchToFlatLayout()"
 				ng-show="ui.pdfLayout == 'sideBySide'"
 				ng-show="ui.pdfLayout == 'sideBySide'"
-				tooltip="#{translate('full_screen')}"
+				tooltip=translate('full_screen')
 				tooltip-placement="bottom"
 				tooltip-placement="bottom"
 				tooltip-append-to-body="true"
 				tooltip-append-to-body="true"
 			)
 			)
@@ -96,7 +96,7 @@ div.full-size.pdf(ng-controller="PdfController")
 				href,
 				href,
 				ng-click="switchToSideBySideLayout()"
 				ng-click="switchToSideBySideLayout()"
 				ng-show="ui.pdfLayout == 'flat'"
 				ng-show="ui.pdfLayout == 'flat'"
-				tooltip="#{translate('split_screen')}"
+				tooltip=translate('split_screen')
 				tooltip-placement="bottom"
 				tooltip-placement="bottom"
 				tooltip-append-to-body="true"
 				tooltip-append-to-body="true"
 			)
 			)
@@ -233,7 +233,7 @@ div.full-size.pdf(ng-controller="PdfController")
 			.files-dropdown-container
 			.files-dropdown-container
 				a.btn.btn-default.btn-sm(
 				a.btn.btn-default.btn-sm(
 					href,
 					href,
-					tooltip="#{translate('clear_cached_files')}",
+					tooltip=translate('clear_cached_files'),
 					tooltip-placement="top",
 					tooltip-placement="top",
 					tooltip-append-to-body="true",
 					tooltip-append-to-body="true",
 					ng-click="openClearCacheModal()"
 					ng-click="openClearCacheModal()"

+ 0 - 0
services/web/app/views/project/editor/publish-template.jade → services/web/app/views/project/editor/publish-template.pug


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

@@ -1,219 +0,0 @@
-#review-panel
-	.review-panel-toolbar
-		span.review-panel-toolbar-label(ng-click="reviewPanel.trackNewChanges = true;", ng-if="reviewPanel.trackNewChanges === false") Track Changes is 
-			strong off
-		span.review-panel-toolbar-label(ng-click="reviewPanel.trackNewChanges = false;", ng-if="reviewPanel.trackNewChanges === true") Track Changes is 
-			strong on
-		review-panel-toggle(ng-model="reviewPanel.trackNewChanges")
-
-	.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]"
-			)
-				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();"
-					)
-
-				div(ng-if="entry.type === 'comment'")
-					comment-entry(
-						entry="entry"
-						users="users"
-						on-resolve="resolveComment(entry, entry_id)"
-						on-unresolve="unresolveComment(entry_id)"
-						on-show-thread="showThread(entry)"
-						on-hide-thread="hideThread(entry)"
-						on-delete="deleteComment(entry_id)"
-						on-reply="submitReply(entry, entry_id);"
-						on-indicator-click="toggleReviewPanel();"
-					)
-				
-				div(ng-if="entry.type === 'add-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-overview-file(
-			ng-repeat="(doc_id, entries) in reviewPanel.entries"
-		)
-			.rp-overview-file-header
-				| {{ getFileName(doc_id) }}
-			.rp-entry-wrapper(
-				ng-repeat="(entry_id, entry) in entries | orderOverviewEntries"
-			)
-				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();"
-						ng-click="gotoEntry(doc_id, entry)"
-					)
-
-				div(ng-if="entry.type === 'comment'")
-					comment-entry(
-						entry="entry"
-						users="users"
-						on-resolve="resolveComment(entry, entry.id)"
-						on-unresolve="unresolveComment(entry.id)"
-						on-delete="deleteComment(entry.id)"
-						on-reply="submitReply(entry, entry_id);"
-						on-indicator-click="toggleReviewPanel();"
-						ng-click="gotoEntry(doc_id, entry)"
-					)
-
-	.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-header
-				.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-metadata
-					p.rp-entry-metadata-line(style="color: hsl({{ user.hue }}, 70%, 40%);") {{ user.name }} 
-					p.rp-entry-metadata-line {{ entry.metadata.ts | date : 'MMM d, y h:mm a' }}
-				.rp-avatar(style="background-color: hsl({{ user.hue }}, 70%, 50%);") {{ user.avatar_text | limitTo : 1 }}
-			.rp-entry-body(ng-switch="entry.type")
-					span(ng-switch-when="insert") Added&nbsp;
-						ins.rp-content-highlight {{ entry.content }} 
-					span(ng-switch-when="delete") Deleted&nbsp;
-						del.rp-content-highlight {{ entry.content }} 
-			.rp-entry-actions
-				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')
-	div
-		.rp-entry-callout.rp-entry-callout-comment(ng-if="!entry.resolved")
-		.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-resolved': entry.resolved}"
-		)
-			.rp-comment(
-				ng-if="!entry.resolved || entry.showWhenResolved"
-				ng-repeat="comment in entry.thread"
-				ng-class="users[comment.user_id].isSelf ? 'rp-comment-self' : '';"
-			)
-				.rp-avatar(
-					ng-if="!users[comment.user_id].isSelf;"
-					style="background-color: hsl({{ users[comment.user_id].hue }}, 70%, 50%);"
-				) {{ users[comment.user_id].avatar_text | limitTo : 1 }}
-				.rp-comment-body(style="color: hsl({{ users[comment.user_id].hue }}, 70%, 90%);")
-					p.rp-comment-content {{ comment.content }}
-					p.rp-comment-metadata
-						| {{ comment.ts | date : 'MMM d, y h:mm a' }}
-						| &nbsp;&bull;&nbsp;
-						span(style="color: hsl({{ users[comment.user_id].hue }}, 70%, 40%);") {{ users[comment.user_id].name }}
-			.rp-comment-reply(ng-if="!entry.resolved || entry.showWhenResolved")
-				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-comment-resolved-description(ng-if="entry.resolved && !entry.showWhenResolved")
-				div
-					| Comment resolved by 
-					span(style="color: hsl({{ users[entry.resolved_data.user_id].hue }}, 70%, 40%);") {{ users[entry.resolved_data.user_id].name }}
-				div {{ entry.resolved_data.ts | date : 'MMM d, y h:mm a' }}
-			.rp-entry-actions
-				a.rp-entry-button(href, ng-click="onResolve();", ng-if="!entry.resolved") 
-					i.fa.fa-check
-					| &nbsp;Mark as resolved
-				a.rp-entry-button(href, ng-click="onShowThread();", ng-if="entry.resolved && !entry.showWhenResolved")
-					| &nbsp;Show
-				a.rp-entry-button(href, ng-click="onHideThread();", ng-if="entry.resolved && entry.showWhenResolved")
-					| &nbsp;Hide
-				a.rp-entry-button(href, ng-click="onUnresolve();", ng-if="entry.resolved") 
-					| &nbsp;Re-open
-				a.rp-entry-button(href, ng-click="onDelete();", ng-if="entry.resolved") 
-					| &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"
-					)
-				.rp-entry-actions
-					a.rp-entry-button(href, ng-click="cancelNewComment();") 
-						i.fa.fa-times
-						| &nbsp;Cancel
-					a.rp-entry-button(href, ng-click="submitNewComment()") 
-						i.fa.fa-comment
-						| &nbsp;Comment

+ 426 - 0
services/web/app/views/project/editor/review-panel.pug

@@ -0,0 +1,426 @@
+#review-panel
+	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
+		
+	.review-panel-toolbar
+		resolved-comments-dropdown(
+			class="rp-flex-block"
+			entries="reviewPanel.resolvedComments"
+			threads="reviewPanel.commentThreads"
+			resolved-ids="reviewPanel.resolvedThreadIds"
+			docs="docs"
+			on-open="refreshResolvedCommentsDropdown();"
+			on-unresolve="unresolveComment(threadId);"
+			on-delete="deleteThread(entryId, docId, 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"
+				disabled="!project.features.trackChanges"
+				on-disabled-click="openTrackChangesUpgradeModal"
+			)
+		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]"
+			)
+				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();"
+						on-body-click="gotoEntry(editor.open_doc_id, entry)"
+						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();"
+						on-save-edit="saveEdit(entry.thread_id, comment)"
+						on-delete="deleteComment(entry.thread_id, comment)"
+						on-body-click="gotoEntry(editor.open_doc_id, entry)"
+						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-save-edit="saveEdit(entry.thread_id, comment)"
+						on-delete="deleteComment(entry.thread_id, comment)"
+						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 }"
+		)
+
+			.rp-loading(ng-if="!threads[entry.thread_id].submitting && (!threads[entry.thread_id] || threads[entry.thread_id].messages.length == 0)")
+				| No comments
+			.rp-comment-loaded
+				.rp-comment(
+					ng-repeat="comment in threads[entry.thread_id].messages track by comment.id"
+				)
+					p.rp-comment-content
+						span(ng-if="!comment.editing")
+							span.rp-entry-user(
+								style="color: hsl({{ comment.user.hue }}, 70%, 40%);",
+							) {{ comment.user.name }}:&nbsp;
+							span(ng-bind-html="comment.content | linky:'_blank'")
+						textarea.rp-comment-input(
+							expandable-text-area
+							ng-if="comment.editing"
+							ng-model="comment.content"
+							ng-keypress="saveEditOnEnter($event, comment);"
+							ng-blur="saveEdit(comment)"
+							autofocus
+							stop-propagation="click"
+						)
+					.rp-entry-metadata(ng-if="!comment.editing")
+						span(ng-if="!comment.deleting") {{ comment.timestamp | date : 'MMM d, y h:mm a' }}
+						span.rp-comment-actions(ng-if="comment.user.isSelf && !comment.deleting")
+							| &nbsp;&bull;&nbsp;
+							a(href, ng-click="startEditing(comment)") Edit
+							span(ng-if="threads[entry.thread_id].messages.length > 1")
+								| &nbsp;&bull;&nbsp;
+								a(href, ng-click="confirmDelete(comment)") Delete
+						span.rp-confim-delete(ng-if="comment.user.isSelf && comment.deleting")
+							| Are you sure?
+							| &bull;&nbsp;
+							a(href, ng-click="doDelete(comment)") Delete
+							| &nbsp;&bull;&nbsp;
+							a(href, ng-click="cancelDelete(comment)") Cancel
+
+			.rp-loading(ng-if="threads[entry.thread_id].submitting")
+				i.fa.fa-spinner.fa-spin
+			.rp-comment-reply(ng-if="permissions.comment")
+				textarea.rp-comment-input(
+					expandable-text-area
+					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;
+					span(ng-bind-html="comment.content | linky:'_blank'")
+				.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, 'docId': thread.docId, '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(
+						expandable-text-area
+						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, docId, threadId);"
+					permissions="permissions"
+				)
+				.rp-loading(ng-if="!resolvedComments.length")
+					| No resolved threads.
+			
+script(type="text/ng-template", id="trackChangesUpgradeModalTemplate")
+	.modal-header
+		button.close(
+			type="button"
+			data-dismiss="modal"
+			ng-click="cancel()"
+		) &times;
+		h3 Upgrade to Track Changes
+	.modal-body
+		.teaser-video-container
+			video.teaser-video(autoplay, loop)
+				source(src="/img/teasers/track-changes/teaser-track-changes.mp4", type="video/mp4")
+				img(src="/img/teasers/track-changes/teaser-track-changes.gif")
+		
+		h4.teaser-title See changes in your documents, live
+	
+		p.small(ng-show="startedFreeTrial")
+			| #{translate("refresh_page_after_starting_free_trial")}
+
+		.row
+			.col-md-10.col-md-offset-1
+				ul.list-unstyled
+					li
+						i.fa.fa-check &nbsp;
+						| Track any change, in real-time
+					
+					li
+						i.fa.fa-check &nbsp;
+						| Review your peers' work
+
+					li
+						i.fa.fa-check &nbsp;
+						| Accept or reject each change individually
+					
+
+		.row.text-center(ng-controller="FreeTrialModalController")
+			a.btn.btn-success(
+				href
+				ng-click="startFreeTrial('track-changes')"
+			) Try it for free
+
+	.modal-footer()
+		button.btn.btn-default(
+			ng-click="cancel()"
+		)
+			span #{translate("close")}

+ 5 - 3
services/web/app/views/project/editor/share.jade → services/web/app/views/project/editor/share.pug

@@ -38,7 +38,7 @@ script(type='text/ng-template', id='shareProjectModalTemplate')
 				.col-xs-1
 				.col-xs-1
 					a(
 					a(
 						href
 						href
-						tooltip="#{translate('remove_collaborator')}"
+						tooltip=translate('remove_collaborator')
 						tooltip-placement="bottom"
 						tooltip-placement="bottom"
 						ng-click="removeMember(member)"
 						ng-click="removeMember(member)"
 					)
 					)
@@ -55,7 +55,7 @@ script(type='text/ng-template', id='shareProjectModalTemplate')
 				.col-xs-1
 				.col-xs-1
 					a(
 					a(
 						href
 						href
-						tooltip="#{translate('revoke_invite')}"
+						tooltip=translate('revoke_invite')
 						tooltip-placement="bottom"
 						tooltip-placement="bottom"
 						ng-click="revokeInvite(invite)"
 						ng-click="revokeInvite(invite)"
 					)
 					)
@@ -66,7 +66,7 @@ script(type='text/ng-template', id='shareProjectModalTemplate')
 					.form-group
 					.form-group
 						tags-input(
 						tags-input(
 							template="shareTagTemplate"
 							template="shareTagTemplate"
-							placeholder="#{settings.customisation.shareProjectPlaceholder || 'joe@example.com, sue@example.com, ...'}"
+							placeholder=settings.customisation.shareProjectPlaceholder || 'joe@example.com, sue@example.com, ...'
 							ng-model="inputs.contacts"
 							ng-model="inputs.contacts"
 							focus-on="open"
 							focus-on="open"
 							display-property="display"
 							display-property="display"
@@ -144,6 +144,8 @@ script(type='text/ng-template', id='shareProjectModalTemplate')
 				span(ng-switch="state.errorReason")
 				span(ng-switch="state.errorReason")
 					span(ng-switch-when="cannot_invite_non_user")
 					span(ng-switch-when="cannot_invite_non_user")
 						| #{translate("cannot_invite_non_user")}
 						| #{translate("cannot_invite_non_user")}
+					span(ng-switch-when="cannot_invite_self")
+						| #{translate("cannot_invite_self")}
 					span(ng-switch-default)
 					span(ng-switch-default)
 						| #{translate("generic_something_went_wrong")}
 						| #{translate("generic_something_went_wrong")}
 		button.btn.btn-default(
 		button.btn.btn-default(

+ 0 - 0
services/web/app/views/project/invite/not-valid.jade → services/web/app/views/project/invite/not-valid.pug


+ 3 - 3
services/web/app/views/project/invite/show.jade → services/web/app/views/project/invite/show.pug

@@ -20,12 +20,12 @@ block content
 								form.form(
 								form.form(
 									name="acceptForm",
 									name="acceptForm",
 									method="POST",
 									method="POST",
-									action="/project/#{invite.projectId}/invite/token/#{invite.token}/accept"
+									action="/project/"+invite.projectId+"/invite/token/"+invite.token+"/accept"
 								)
 								)
 									input(name='_csrf', type='hidden', value=csrfToken)
 									input(name='_csrf', type='hidden', value=csrfToken)
-									input(name='token', type='hidden', value="#{invite.token}")
+									input(name='token', type='hidden', value=invite.token)
 									.form-group.text-center
 									.form-group.text-center
 										button.btn.btn-lg.btn-primary(type="submit")
 										button.btn.btn-lg.btn-primary(type="submit")
 											| #{translate("join_project")}
 											| #{translate("join_project")}
 									.form-group.text-center
 									.form-group.text-center
-							
+							

+ 1 - 1
services/web/app/views/project/list.jade → services/web/app/views/project/list.pug

@@ -73,4 +73,4 @@ block content
 					.col-md-offset-2.col-md-8.col-md-offset-2.col-xs-8
 					.col-md-offset-2.col-md-8.col-md-offset-2.col-xs-8
 						include ./list/empty-project-list
 						include ./list/empty-project-list
 
 
-		include ./list/modals
+		include ./list/modals

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


+ 0 - 0
services/web/app/views/project/list/modals.jade → services/web/app/views/project/list/modals.pug


+ 0 - 0
services/web/app/views/project/list/notifications.jade → services/web/app/views/project/list/notifications.pug


+ 4 - 4
services/web/app/views/project/list/project-list.jade → services/web/app/views/project/list/project-list.pug

@@ -6,7 +6,7 @@
 		form.project-search.form-horizontal(role="form")
 		form.project-search.form-horizontal(role="form")
 			.form-group.has-feedback.has-feedback-left.col-md-7.col-xs-12
 			.form-group.has-feedback.has-feedback-left.col-md-7.col-xs-12
 				input.form-control.col-md-7.col-xs-12(
 				input.form-control.col-md-7.col-xs-12(
-					placeholder="#{translate('search_projects')}…",
+					placeholder=translate('search_projects')+"…",
 					autofocus='autofocus',
 					autofocus='autofocus',
 					ng-model="searchText.value",
 					ng-model="searchText.value",
 					focus-on='search:clear',
 					focus-on='search:clear',
@@ -25,7 +25,7 @@
 				.btn-group(ng-hide="selectedProjects.length < 1")
 				.btn-group(ng-hide="selectedProjects.length < 1")
 					a.btn.btn-default(
 					a.btn.btn-default(
 						href,
 						href,
-						tooltip="#{translate('download')}",
+						tooltip=translate('download'),
 						tooltip-placement="bottom",
 						tooltip-placement="bottom",
 						tooltip-append-to-body="true",
 						tooltip-append-to-body="true",
 						ng-click="downloadSelectedProjects()"
 						ng-click="downloadSelectedProjects()"
@@ -33,7 +33,7 @@
 						i.fa.fa-cloud-download
 						i.fa.fa-cloud-download
 					a.btn.btn-default(
 					a.btn.btn-default(
 						href,
 						href,
-						tooltip="#{translate('delete')}",
+						tooltip=translate('delete'),
 						tooltip-placement="bottom",
 						tooltip-placement="bottom",
 						tooltip-append-to-body="true",
 						tooltip-append-to-body="true",
 						ng-click="openArchiveProjectsModal()"
 						ng-click="openArchiveProjectsModal()"
@@ -45,7 +45,7 @@
 						href,
 						href,
 						data-toggle="dropdown",
 						data-toggle="dropdown",
 						dropdown-toggle,
 						dropdown-toggle,
-						tooltip="#{translate('add_to_folders')}",
+						tooltip=translate('add_to_folders'),
 						tooltip-append-to-body="true",
 						tooltip-append-to-body="true",
 						tooltip-placement="bottom"
 						tooltip-placement="bottom"
 					)
 					)

+ 0 - 0
services/web/app/views/project/list/side-bar.jade → services/web/app/views/project/list/side-bar.pug


+ 6 - 6
services/web/app/views/referal/bonus.jade → services/web/app/views/referal/bonus.pug

@@ -24,7 +24,7 @@ block content
 							.row
 							.row
 								.col-md-8.col-md-offset-2.bonus-banner
 								.col-md-8.col-md-offset-2.bonus-banner
 									.title
 									.title
-										a(href='https://twitter.com/share?text=is%20trying%20out%20the%20online%20LaTeX%20Editor%20ShareLaTeX&url=#{encodeURIComponent(buildReferalUrl("t"))}&counturl=https://www.sharelatex.com', target="_blank").twitter Tweet
+										a(href='https://twitter.com/share?text=is%20trying%20out%20the%20online%20LaTeX%20Editor%20ShareLaTeX&url='+encodeURIComponent(buildReferalUrl("t"))+'&counturl=https://www.sharelatex.com', target="_blank").twitter Tweet
 									
 									
 							.row
 							.row
 								.col-md-8.col-md-offset-2.bonus-banner
 								.col-md-8.col-md-offset-2.bonus-banner
@@ -34,12 +34,12 @@ block content
 							.row
 							.row
 								.col-md-8.col-md-offset-2.bonus-banner
 								.col-md-8.col-md-offset-2.bonus-banner
 									.title
 									.title
-										a(href="https://plus.google.com/share?url=#{encodeURIComponent(buildReferalUrl('gp'))}", onclick="javascript:window.open(this.href, '', 'menubar=no,toolbar=no,resizable=yes,scrollbars=yes,height=600,width=600');return false;").google-plus #{translate("share_us_on_googleplus")}
+										a(href="https://plus.google.com/share?url="+encodeURIComponent(buildReferalUrl('gp')), onclick="javascript:window.open(this.href, '', 'menubar=no,toolbar=no,resizable=yes,scrollbars=yes,height=600,width=600');return false;").google-plus #{translate("share_us_on_googleplus")}
 									
 									
 							.row
 							.row
 								.col-md-8.col-md-offset-2.bonus-banner
 								.col-md-8.col-md-offset-2.bonus-banner
 									.title
 									.title
-										a(href='mailto:?subject=Online LaTeX editor you may like &body=Hey, I have been using the online LaTeX editor ShareLaTeX recently and thought you might like to check it out. #{encodeURIComponent(buildReferalUrl("e"))}', title='Share by Email').email #{translate("email_us_to_your_friends")}
+										a(href='mailto:?subject=Online LaTeX editor you may like &body=Hey, I have been using the online LaTeX editor ShareLaTeX recently and thought you might like to check it out. '+encodeURIComponent(buildReferalUrl("e")), title='Share by Email').email #{translate("email_us_to_your_friends")}
 
 
 							.row
 							.row
 								.col-md-8.col-md-offset-2.bonus-banner
 								.col-md-8.col-md-offset-2.bonus-banner
@@ -58,9 +58,9 @@ block content
 								.col-md-10.col-md-offset-1.bonus-banner(style="position: relative; height: 30px; margin-top: 20px;")
 								.col-md-10.col-md-offset-1.bonus-banner(style="position: relative; height: 30px; margin-top: 20px;")
 									- for (var i = 0; i <= 10; i++) {
 									- for (var i = 0; i <= 10; i++) {
 										- if (refered_user_count == i)
 										- if (refered_user_count == i)
-											.number(style="left: #{i}0%").active #{i}
+											.number(style="left: "+i+"0%").active #{i}
 										- else
 										- else
-											.number(style="left: #{i}0%") #{i}
+											.number(style="left: "+i+"0%") #{i}
 									- }
 									- }
 
 
 							.row.ab-bonus
 							.row.ab-bonus
@@ -68,7 +68,7 @@ block content
 									.progress
 									.progress
 										- if (refered_user_count == 0)
 										- if (refered_user_count == 0)
 											div(style="text-align: center; padding: 4px;") #{translate("spread_the_word_and_fill_bar")}
 											div(style="text-align: center; padding: 4px;") #{translate("spread_the_word_and_fill_bar")}
-										.progress-bar.progress-bar-info(style="width: #{refered_user_count}0%")
+										.progress-bar.progress-bar-info(style="width: "+refered_user_count+"0%")
 
 
 							.row.ab-bonus
 							.row.ab-bonus
 								.col-md-10.col-md-offset-1.bonus-banner(style="position: relative; height: 70px;")
 								.col-md-10.col-md-offset-1.bonus-banner(style="position: relative; height: 70px;")

+ 2 - 2
services/web/app/views/restore.jade → services/web/app/views/restore.pug

@@ -19,11 +19,11 @@ block content
 
 
 					.row-fluid
 					.row-fluid
 						table.table
 						table.table
-							-each project in projects
+							each project in projects
 								tr
 								tr
 									- project_id = project._id.toString()
 									- project_id = project._id.toString()
 									td(width="50%") #{project.name}
 									td(width="50%") #{project.name}
 									td(width="25%")
 									td(width="25%")
-										a.btn(href="/project/#{project_id}/zip") Download latest version as Zip
+										a.btn(href="/project/"+project_id+"/zip") Download latest version as Zip
 
 
 		include general/small-footer
 		include general/small-footer

+ 1 - 1
services/web/app/views/scribtex-modal.jade → services/web/app/views/scribtex-modal.pug

@@ -5,4 +5,4 @@ script(type='text/ng-template', id='scribtexModalTemplate')
 		p ScribTeX has moved to <strong>https://scribtex.sharelatex.com</strong>. Please update your bookmarks.
 		p ScribTeX has moved to <strong>https://scribtex.sharelatex.com</strong>. Please update your bookmarks.
 		p(style="text-align: center") You can find the page you were looking for here:
 		p(style="text-align: center") You can find the page you were looking for here:
 		p(style="text-align: center")
 		p(style="text-align: center")
-			a(href="https://scribtex.sharelatex.com#{scribtexPath}", style="font-size: 16px") https://scribtex.sharelatex.com#{scribtexPath}
+			a(href="https://scribtex.sharelatex.com"+scribtexPath, style="font-size: 16px") https://scribtex.sharelatex.com#{scribtexPath}

+ 2 - 2
services/web/app/views/sentry.jade → services/web/app/views/sentry.pug

@@ -1,8 +1,8 @@
 - if (typeof(sentrySrc) != "undefined")
 - if (typeof(sentrySrc) != "undefined")
 	- if (sentrySrc.match(/^([a-z]+:)?\/\//i))
 	- if (sentrySrc.match(/^([a-z]+:)?\/\//i))
-		script(src="#{sentrySrc}")
+		script(src=sentrySrc)
 	- else
 	- else
-		script(src=buildJsPath("libs/#{sentrySrc}", {fingerprint:false}))
+		script(src=buildJsPath("libs/"+sentrySrc, {fingerprint:false}))
 - if (typeof(sentrySrc) != "undefined")
 - if (typeof(sentrySrc) != "undefined")
 	script(type="text/javascript").
 	script(type="text/javascript").
 		if (typeof(Raven) != "undefined" && Raven.config) {
 		if (typeof(Raven) != "undefined" && Raven.config) {

+ 0 - 0
services/web/app/views/subscriptions/custom_account.jade → services/web/app/views/subscriptions/custom_account.pug


+ 10 - 10
services/web/app/views/subscriptions/dashboard.jade → services/web/app/views/subscriptions/dashboard.pug

@@ -12,7 +12,7 @@ block scripts
 
 
 mixin printPlan(plan)
 mixin printPlan(plan)
 	-if (!plan.hideFromUsers)
 	-if (!plan.hideFromUsers)
-		tr(ng-controller="ChangePlanFormController", ng-init="plan=#{JSON.stringify(plan)}", ng-show="shouldShowPlan(plan.planCode)")
+		tr(ng-controller="ChangePlanFormController", ng-init="plan="+JSON.stringify(plan), ng-show="shouldShowPlan(plan.planCode)")
 			td
 			td
 				strong #{plan.name}
 				strong #{plan.name}
 			td {{refreshPrice(plan.planCode)}}
 			td {{refreshPrice(plan.planCode)}}
@@ -22,18 +22,18 @@ mixin printPlan(plan)
 					| {{prices[plan.planCode]}} / #{translate("month")}
 					| {{prices[plan.planCode]}} / #{translate("month")}
 			td
 			td
 				-if (subscription.state == "free-trial")
 				-if (subscription.state == "free-trial")
-					a(href="/user/subscription/new?planCode=#{plan.planCode}").btn.btn-success #{translate("subscribe_to_this_plan")}
+					a(href="/user/subscription/new?planCode="+plan.planCode).btn.btn-success #{translate("subscribe_to_this_plan")}
 				-else if (typeof(subscription.planCode) != "undefined" && plan.planCode == subscription.planCode.split("_")[0])
 				-else if (typeof(subscription.planCode) != "undefined" && plan.planCode == subscription.planCode.split("_")[0])
 					button.btn.disabled #{translate("your_plan")}
 					button.btn.disabled #{translate("your_plan")}
 				-else
 				-else
 					form
 					form
-						input(type="hidden", ng-model="plan_code", name="plan_code", value="#{plan.planCode}")
+						input(type="hidden", ng-model="plan_code", name="plan_code", value=plan.planCode)
 						input(type="submit", ng-click="changePlan()", value=translate("change_to_this_plan")).btn.btn-success
 						input(type="submit", ng-click="changePlan()", value=translate("change_to_this_plan")).btn.btn-success
 
 
 
 
 mixin printPlans(plans)
 mixin printPlans(plans)
-	-each plan in plans
-		mixin printPlan(plan)
+	each plan in plans
+		+printPlan(plan)
 
 
 block content
 block content
 	.content.content-alt(ng-cloak)
 	.content.content-alt(ng-cloak)
@@ -46,7 +46,7 @@ block content
 							| &nbsp;
 							| &nbsp;
 							| #{translate("your_billing_details_were_saved")}
 							| #{translate("your_billing_details_were_saved")}
 					.card(ng-if="view == 'overview'")
 					.card(ng-if="view == 'overview'")
-						.page-header(x-current-plan="#{subscription.planCode}")
+						.page-header(x-current-plan=subscription.planCode)
 							h1 #{translate("your_subscription")} 
 							h1 #{translate("your_subscription")} 
 
 
 						- if (subscription && user._id+'' == subscription.admin_id+'')
 						- if (subscription && user._id+'' == subscription.admin_id+'')
@@ -97,9 +97,9 @@ block content
 										th !{translate("name")}
 										th !{translate("name")}
 										th !{translate("price")}
 										th !{translate("price")}
 										th
 										th
-									mixin printPlans(plans.studentAccounts)
-									mixin printPlans(plans.individualMonthlyPlans)
-									mixin printPlans(plans.individualAnnualPlans)
+									+printPlans(plans.studentAccounts)
+									+printPlans(plans.individualMonthlyPlans)
+									+printPlans(plans.individualAnnualPlans)
 							
 							
 
 
 						each groupSubscription in groupSubscriptions
 						each groupSubscription in groupSubscriptions
@@ -107,7 +107,7 @@ block content
 								div 
 								div 
 									p !{translate("member_of_group_subscription", {admin_email: "<strong>" + groupSubscription.admin_id.email + "</strong>"})}
 									p !{translate("member_of_group_subscription", {admin_email: "<strong>" + groupSubscription.admin_id.email + "</strong>"})}
 									span 
 									span 
-										button.btn.btn-danger(ng-click="removeSelfFromGroup('#{groupSubscription.admin_id._id}')") #{translate("leave_group")}
+										button.btn.btn-danger(ng-click="removeSelfFromGroup('"+groupSubscription.admin_id._id+"')") #{translate("leave_group")}
 
 
 						-if(subscription.groupPlan && user._id+'' == subscription.admin_id+'')
 						-if(subscription.groupPlan && user._id+'' == subscription.admin_id+'')
 							div
 							div

+ 0 - 0
services/web/app/views/subscriptions/edit-billing-details.jade → services/web/app/views/subscriptions/edit-billing-details.pug


+ 0 - 0
services/web/app/views/subscriptions/group/invite.jade → services/web/app/views/subscriptions/group/invite.pug


+ 0 - 0
services/web/app/views/subscriptions/group/successful_join.jade → services/web/app/views/subscriptions/group/successful_join.pug


+ 0 - 0
services/web/app/views/subscriptions/group_admin.jade → services/web/app/views/subscriptions/group_admin.pug


+ 1 - 1
services/web/app/views/subscriptions/new.jade → services/web/app/views/subscriptions/new.pug

@@ -164,7 +164,7 @@ block content
 													ng-change="updateCountry()"
 													ng-change="updateCountry()"
 													required
 													required
 												)
 												)
-													mixin countries_options()
+													+countries_options()
 												span.input-feedback-message {{ simpleCCForm.country.$error.required ? 'This field is required' : '' }}
 												span.input-feedback-message {{ simpleCCForm.country.$error.required ? 'This field is required' : '' }}
 
 
 											if (showVatField)
 											if (showVatField)

+ 0 - 0
services/web/app/views/subscriptions/plans.jade → services/web/app/views/subscriptions/plans.pug


+ 0 - 0
services/web/app/views/subscriptions/successful_subscription.jade → services/web/app/views/subscriptions/successful_subscription.pug


+ 1 - 1
services/web/app/views/subscriptions/upgradeToAnnual.jade → services/web/app/views/subscriptions/upgradeToAnnual.pug

@@ -6,7 +6,7 @@ block content
 		.container(ng-controller="AnnualUpgradeController")
 		.container(ng-controller="AnnualUpgradeController")
 			.row(ng-cloak)
 			.row(ng-cloak)
 				.col-md-6.col-md-offset-3
 				.col-md-6.col-md-offset-3
-					.card(ng-init="planName = #{JSON.stringify(planName)}")
+					.card(ng-init="planName = "+JSON.stringify(planName))
 						.page-header
 						.page-header
 							h1.text-centered #{translate("move_to_annual_billing")}
 							h1.text-centered #{translate("move_to_annual_billing")}
 						div(ng-hide="upgradeComplete")
 						div(ng-hide="upgradeComplete")

+ 0 - 0
services/web/app/views/tests.jade → services/web/app/views/tests.pug


+ 1 - 1
services/web/app/views/translations/translation_message.jade → services/web/app/views/translations/translation_message.pug

@@ -2,7 +2,7 @@
 	span(ng-controller="TranslationsPopupController", ng-cloak)
 	span(ng-controller="TranslationsPopupController", ng-cloak)
 		.translations-message(ng-hide="hidei18nNotification")
 		.translations-message(ng-hide="hidei18nNotification")
 			a(href=recomendSubdomain.url+currentUrl) !{translate("click_here_to_view_sl_in_lng", {lngName:"<strong>" + translate(recomendSubdomain.lngCode) + "</strong>"})} 
 			a(href=recomendSubdomain.url+currentUrl) !{translate("click_here_to_view_sl_in_lng", {lngName:"<strong>" + translate(recomendSubdomain.lngCode) + "</strong>"})} 
-				img(src=buildImgPath("flags/24/#{recomendSubdomain.lngCode}.png"))
+				img(src=buildImgPath("flags/24/" + recomendSubdomain.lngCode + ".png"))
 			button(ng-click="dismiss()").close.pull-right
 			button(ng-click="dismiss()").close.pull-right
 				span(aria-hidden="true") &times;
 				span(aria-hidden="true") &times;
 				span.sr-only #{translate("close")}
 				span.sr-only #{translate("close")}

+ 0 - 0
services/web/app/views/university/case_study.jade → services/web/app/views/university/case_study.pug


+ 0 - 0
services/web/app/views/university/university_holder.jade → services/web/app/views/university/university_holder.pug


+ 1 - 1
services/web/app/views/user/activate.jade → services/web/app/views/user/activate.pug

@@ -36,7 +36,7 @@ block content
 									placeholder="email@example.com"
 									placeholder="email@example.com"
 									required,
 									required,
 									ng-model="email",
 									ng-model="email",
-									ng-init="email = #{JSON.stringify(email)}",
+									ng-init="email = "+JSON.stringify(email),
 									ng-model-options="{ updateOn: 'blur' }",
 									ng-model-options="{ updateOn: 'blur' }",
 									disabled
 									disabled
 								)
 								)

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