ソースを参照

Merge branch 'master' into node-6.9

Shane Kilkelly 9 年 前
親
コミット
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"
 PackageVersions = require "./app/coffee/infrastructure/PackageVersions"
+require('es6-promise').polyfill()
 
 module.exports = (grunt) ->
 	grunt.loadNpmTasks 'grunt-contrib-coffee'
@@ -18,6 +19,7 @@ module.exports = (grunt) ->
 	grunt.loadNpmTasks 'grunt-contrib-watch'
 	grunt.loadNpmTasks 'grunt-parallel'
 	grunt.loadNpmTasks 'grunt-exec'
+	grunt.loadNpmTasks 'grunt-postcss'
 	# grunt.loadNpmTasks 'grunt-contrib-imagemin'
 	# grunt.loadNpmTasks 'grunt-sprity'
 
@@ -136,8 +138,14 @@ module.exports = (grunt) ->
 				files:
 					"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:
 			run:
@@ -222,11 +230,11 @@ module.exports = (grunt) ->
 
 		sed:
 			version:
-				path: "app/views/sentry.jade"
+				path: "app/views/sentry.pug"
 				pattern: '@@COMMIT@@',
 				replacement: '<%= commit %>',
 			release:
-				path: "app/views/sentry.jade"
+				path: "app/views/sentry.pug"
 				pattern: "@@RELEASE@@"
 				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: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: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']
@@ -389,5 +397,5 @@ module.exports = (grunt) ->
 
 	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?
 			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?
-				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)
 			else
 				res.json announcements

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

@@ -1,24 +1,46 @@
 AnalyticsManager = require("../Analytics/AnalyticsManager")
 BlogHandler = require("../Blog/BlogHandler")
-async = require("async")
-_ = require("lodash")
 logger = require("logger-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 {
 			lastEvent: (cb)->
-				AnalyticsManager.getLastOccurance user_id, "announcement-alert-dismissed", cb
+				AnalyticsManager.getLastOccurance user._id, "announcement-alert-dismissed", cb
 			announcements: (cb)->
 				BlogHandler.getLatestAnnouncements cb
 		}, (err, results)->
 			if err?
-				logger.err err:err, user_id:user_id, "error getting unread announcements"
+				logger.err err:err, user_id:user._id, "error getting unread announcements"
 				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
 
@@ -35,6 +57,6 @@ module.exports =
 				announcement.read = read
 				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

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

@@ -148,6 +148,7 @@ module.exports = AuthenticationController =
 			return next()
 		else
 			logger.log url:req.url, "user trying to access endpoint not in global whitelist"
+			AuthenticationController._setRedirectInSession(req)
 			return res.redirect "/login"
 
 	httpAuth: basicAuth (user, pass)->
@@ -193,8 +194,8 @@ module.exports = AuthenticationController =
 
 	_setRedirectInSession: (req, value) ->
 		if !value?
-			value = if Object.keys(req.query).length > 0 then "#{req.path}?#{querystring.stringify(req.query)}" else req.path
-		if req.session?
+			value = if Object.keys(req.query).length > 0 then "#{req.path}?#{querystring.stringify(req.query)}" else "#{req.path}"
+		if req.session? && !value.match(new RegExp('^\/(socket.io|js|stylesheets|img)\/.*$'))
 			req.session.postLoginRedirect = value
 
 	_getRedirectFromSession: (req) ->

+ 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")
 logger = require("logger-sharelatex")
 AuthenticationController = require('../Authentication/AuthenticationController')
+UserInfoManager = require('../User/UserInfoManager')
+UserInfoController = require('../User/UserInfoController')
+CommentsController = require('../Comments/CommentsController')
 
 module.exports =
-
-
 	sendMessage: (req, res, next)->
-		project_id = req.params.Project_id
-		messageContent = req.body.content
+		project_id = req.params.project_id
+		content = req.body.content
 		user_id = AuthenticationController.getLoggedInUserId(req)
 		if !user_id?
 			err = new Error('no logged-in user')
 			return next(err)
-		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
 		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)}"
 		].join("&")
 
-	notifyUserOfProjectInvite: (project_id, email, invite, callback)->
+	notifyUserOfProjectInvite: (project_id, email, invite, sendingUser, callback)->
 		Project
 			.findOne(_id: project_id )
 			.select("name owner_ref")
@@ -24,4 +24,5 @@ module.exports = CollaboratorsEmailHandler =
 						name: project.name
 					inviteUrl: CollaboratorsEmailHandler._buildInviteUrl(project, invite)
 					owner: project.owner_ref
+					sendingUser_id: sendingUser._id
 				EmailHandler.sendEmail "projectInvite", emailOptions, callback

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

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

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

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

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

@@ -22,9 +22,15 @@ module.exports =
 		webRouter.post(
 			'/project/:Project_id/invite',
 			RateLimiterMiddlewear.rateLimit({
-				endpointName: "invite-to-project"
+				endpointName: "invite-to-project-by-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
 			}),
 			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}")
 				logger.error err: error, project_id: project_id, "error getting all docs from docstore"
 				callback(error)
+	
+	getAllRanges: (project_id, callback = (error) ->) ->
+		logger.log { project_id }, "getting all doc ranges for project in docstore api"
+		url = "#{settings.apis.docstore.url}/project/#{project_id}/ranges"
+		request.get {
+			url: url
+			json: true
+		}, (error, res, docs) ->
+			return callback(error) if error?
+			if 200 <= res.statusCode < 300
+				callback(null, docs)
+			else
+				error = new Error("docstore api responded with non-success code: #{res.statusCode}")
+				logger.error err: error, project_id: project_id, "error getting all doc ranges from docstore"
+				callback(error)
 
 	getDoc: (project_id, doc_id, options = {}, callback = (error, lines, rev, version) ->) ->
 		if typeof(options) == "function"
@@ -45,13 +60,13 @@ module.exports = DocstoreManager =
 			return callback(error) if error?
 			if 200 <= res.statusCode < 300
 				logger.log doc_id: doc_id, project_id: project_id, version: doc.version, rev: doc.rev, "got doc from docstore api"
-				callback(null, doc.lines, doc.rev, doc.version)
+				callback(null, doc.lines, doc.rev, doc.version, doc.ranges)
 			else
 				error = new Error("docstore api responded with non-success code: #{res.statusCode}")
 				logger.error err: error, project_id: project_id, doc_id: doc_id, "error getting doc from docstore"
 				callback(error)
 
-	updateDoc: (project_id, doc_id, lines, 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"
 		url = "#{settings.apis.docstore.url}/project/#{project_id}/doc/#{doc_id}"
 		request.post {
@@ -59,6 +74,7 @@ module.exports = DocstoreManager =
 			json:
 				lines: lines
 				version: version
+				ranges: ranges
 		}, (error, res, result) ->
 			return callback(error) if error?
 			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}"
 				return callback(error)
 
-	getDocument: (project_id, doc_id, fromVersion, callback = (error, exists, doclines, version) ->) ->
+	getDocument: (project_id, doc_id, fromVersion, callback = (error, doclines, version, ranges, ops) ->) ->
 		timer = new metrics.Timer("get-document")
 		url = "#{settings.apis.documentupdater.url}/project/#{project_id}/doc/#{doc_id}?fromVersion=#{fromVersion}"
 		logger.log project_id:project_id, doc_id: doc_id, "getting doc from document updater"
@@ -110,7 +110,7 @@ module.exports = DocumentUpdaterHandler =
 					body = JSON.parse(body)
 				catch error
 					return callback(error)
-				callback null, body.lines, body.version, body.ops
+				callback null, body.lines, body.version, body.ranges, body.ops
 			else
 				logger.error project_id:project_id, doc_id:doc_id, url: url, "doc updater returned a non-success status code: #{res.statusCode}"
 				callback new Error("doc updater returned a non-success status code: #{res.statusCode}")
@@ -137,15 +137,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}"
 				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"
 DOCLINESKEY = "doclines"

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

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

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

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

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

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

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

@@ -1,6 +1,12 @@
 _ = require('underscore')
+
 PersonalEmailLayout = require("./Layouts/PersonalEmailLayout")
 NotificationEmailLayout = require("./Layouts/NotificationEmailLayout")
+BaseWithHeaderEmailLayout = require("./Layouts/BaseWithHeaderEmailLayout") 
+
+SingleCTAEmailBody = require("./Bodies/SingleCTAEmailBody")
+
+
 settings = require("settings-sharelatex")
 
 
@@ -61,7 +67,7 @@ ShareLaTeX Co-founder
 
 templates.passwordResetRequested =
 	subject:  _.template "Password Reset - #{settings.appName}"
-	layout: NotificationEmailLayout
+	layout: BaseWithHeaderEmailLayout
 	type:"notification"
 	plainTextTemplate: _.template """
 Password Reset
@@ -78,36 +84,21 @@ Thank you
 
 #{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 =
 	subject: _.template "<%= project.name %> - shared by <%= owner.email %>"
-	layout: NotificationEmailLayout
+	layout: BaseWithHeaderEmailLayout
 	type:"notification"
 	plainTextTemplate: _.template """
 Hi, <%= owner.email %> wants to share '<%= project.name %>' with you.
@@ -118,23 +109,23 @@ Thank you
 
 #{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 =
 	subject: _.template "Verify Email to join <%= group_name %> group"
-	layout: NotificationEmailLayout
+	layout: BaseWithHeaderEmailLayout
 	type:"notification"
 	plainTextTemplate: _.template """
 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 %>
 """
-	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 =

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

@@ -4,7 +4,7 @@ Settings = require('settings-sharelatex')
 nodemailer = require("nodemailer")
 sesTransport = require('nodemailer-ses-transport')
 sgTransport = require('nodemailer-sendgrid-transport')
-
+rateLimiter = require('../../infrastructure/RateLimiter')
 _ = require("underscore")
 
 if Settings.email? and Settings.email.fromAddress?
@@ -39,24 +39,39 @@ if nm_client?
 else
 	logger.warn "Failed to create email transport. Please check your settings. No email will be sent."
 
+checkCanSendEmail = (options, callback)->
+	if !options.sendingUser_id? #email not sent from user, not rate limited
+		return callback(null, true)
+	opts = 
+		endpointName: "send_email"
+		timeInterval: 60 * 60 * 3
+		subjectName: options.sendingUser_id
+		throttle: 100
+	rateLimiter.addCount opts, callback
 
 module.exports =
 	sendEmail : (options, callback = (error) ->)->
 		logger.log receiver:options.to, subject:options.subject, "sending email"
-		metrics.inc "email"
-		options =
-			to: options.to
-			from: defaultFromAddress
-			subject: options.subject
-			html: options.html
-			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?
-				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")
 ProjectUpdateHandler = require("../Project/ProjectUpdateHandler")
 Project = require("../../models/Project").Project
-TrackChangesManager = require("../TrackChanges/TrackChangesManager")
-
 
 MILISECONDS_IN_DAY = 86400000
 module.exports = InactiveProjectManager =
@@ -52,7 +50,6 @@ module.exports = InactiveProjectManager =
 		logger.log project_id:project_id, "deactivating inactive project"
 		jobs = [
 			(cb)-> DocstoreManager.archiveProject project_id, cb
-			# (cb)-> TrackChangesManager.archiveProject project_id, cb
 			(cb)-> ProjectUpdateHandler.markAsInactive project_id, cb
 		]
 		async.series jobs, (err)->

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

@@ -53,7 +53,11 @@ module.exports =
 					if req.body.login_after
 						UserGetter.getUser user_id, {email: 1}, (err, user) ->
 							return next(err) if err?
-							AuthenticationController.doLogin {email:user.email, password: password}, req, res, next
+							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
 						res.sendStatus 200
 			else

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

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

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

@@ -19,6 +19,11 @@ module.exports = ProjectEditorHandler =
 
 		if !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)
 		result.owner = owner
@@ -32,6 +37,8 @@ module.exports = ProjectEditorHandler =
 			compileGroup:"standard"
 			templates: false
 			references: false
+			trackChanges: false
+			trackChangesVisible: trackChangesVisible
 		})
 
 		return result

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

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

+ 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
 ATTEMPT_LIMIT = 10
 
+
 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 = ->)->
-		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
 			params = (opts.params or []).map (p) -> req.params[p]
 			params.push user_id
+			subjectName = params.join(":")
+			if opts.ipOnly
+				subjectName = req.ip
 			if !opts.endpointName?
 				throw new Error("no endpointName provided")
 			options = {
 				endpointName: opts.endpointName
 				timeInterval: opts.timeInterval or 60
-				subjectName:  params.join(":")
+				subjectName:  subjectName
 				throttle:     opts.maxRequests or 6
 			}
 			RateLimiter.addCount options, (error, canContinue)->

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

@@ -6,8 +6,6 @@ Project = require('../../models/Project').Project
 DocumentUpdaterHandler = require('../DocumentUpdater/DocumentUpdaterHandler')
 Settings = require('settings-sharelatex')
 util = require('util')
-redis = require("redis-sharelatex")
-rclient = redis.createClient(Settings.redis.web)
 RecurlyWrapper = require('../Subscription/RecurlyWrapper')
 SubscriptionHandler = require('../Subscription/SubscriptionHandler')
 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"
 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 =
 	index : (req,res)->
@@ -28,10 +28,10 @@ module.exports = HomeController =
 
 	externalPage: (page, title) ->
 		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!
 				if exists
-					res.render "external/#{page}.jade",
+					res.render "external/#{page}.pug",
 						title: title
 				else
 					ErrorController.notFound(req, res, next)

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

@@ -1,20 +1,25 @@
 logger = require("logger-sharelatex")
 Project = require("../../models/Project").Project
-User = require("../../models/User").User
+UserGetter = require("../User/UserGetter")
 SubscriptionLocator = require("./SubscriptionLocator")
 Settings = require("settings-sharelatex")
 CollaboratorsHandler = require("../Collaborators/CollaboratorsHandler")
 CollaboratorsInvitesHandler = require("../Collaborators/CollaboratorsInviteHandler")
 
 module.exports =
-
 	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?
-			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
 				callback null, Settings.defaultPlanCode.collaborators
+		
 
 	canAddXCollaborators: (project_id, x_collaborators, callback = (error, allowed)->) ->
 		@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"
 			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')
 Settings = require('settings-sharelatex')
 FileTypeManager = require('../Uploads/FileTypeManager')
-uuid = require('node-uuid')
+uuid = require('uuid')
 fs = require('fs')
 
 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"
-request = require "request"
-settings = require "settings-sharelatex"
-AuthenticationController = require "../Authentication/AuthenticationController"
+UserInfoController = require "../User/UserInfoController"
+DocumentUpdaterHandler = require "../DocumentUpdater/DocumentUpdaterHandler"
+EditorRealTimeController = require("../Editor/EditorRealTimeController")
+TrackChangesManager = require "./TrackChangesManager"
 
 module.exports = TrackChangesController =
-	proxyToTrackChangesApi: (req, res, next = (error) ->) ->
-		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 =
-	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)
 
 	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')
-redis = require('redis-sharelatex')
 logger = require("logger-sharelatex")
 Async = require('async')
 _ = require('underscore')

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

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

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

@@ -1,6 +1,6 @@
 fs   = require "fs"
 Path = require "path"
-jade = require "jade"
+pug = require "pug"
 async = require "async"
 
 MODULE_BASE_PATH = Path.resolve(__dirname + "/../../../modules")
@@ -29,7 +29,7 @@ module.exports = Modules =
 		for module in @modules
 			for view, partial of module.viewIncludes or {}
 				@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) ->
 		compiledPartials = Modules.viewIncludes[view] or []

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

@@ -1,15 +1,27 @@
 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) ->
-		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')
 Router = require('../router')
 metrics.inc("startup")
-redis = require("redis-sharelatex")
 UserSessionsRedis = require('../Features/User/UserSessionsRedis')
 
 sessionsRedisClient = UserSessionsRedis.client()
@@ -62,7 +61,7 @@ if Settings.behindProxy
 
 webRouter.use express.static(__dirname + '/../../../public', {maxAge: staticCacheAge })
 app.set 'views', __dirname + '/../../views'
-app.set 'view engine', 'jade'
+app.set 'view engine', 'pug'
 Modules.loadViewIncludes app
 
 

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

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

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

@@ -2,7 +2,7 @@ Project = require('./Project').Project
 Settings = require 'settings-sharelatex'
 _ = require('underscore')
 mongoose = require('mongoose')
-uuid = require('node-uuid')
+uuid = require('uuid')
 Schema = mongoose.Schema
 ObjectId = Schema.ObjectId
 
@@ -37,9 +37,10 @@ UserSchema = new Schema
 							compileGroup:  { type:String,  default: Settings.defaultFeatures.compileGroup }
 							templates:     { type:Boolean, default: Settings.defaultFeatures.templates }
 							references:    { type:Boolean, default: Settings.defaultFeatures.references }
+							trackChanges:  { type:Boolean, default: Settings.defaultFeatures.trackChanges }
 						}
 	featureSwitches	  : {
-		pdfng: { type: Boolean }
+		track_changes: { type: Boolean }
 	}
 	referal_id : {type:String, default:() -> uuid.v4().split("-")[0]}
 	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")
 ProjectDownloadsController = require "./Features/Downloads/ProjectDownloadsController"
 FileStoreController = require("./Features/FileStore/FileStoreController")
-TrackChangesController = require("./Features/TrackChanges/TrackChangesController")
+HistoryController = require("./Features/History/HistoryController")
 PasswordResetRouter = require("./Features/PasswordReset/PasswordResetRouter")
 StaticPagesRouter = require("./Features/StaticPages/StaticPagesRouter")
 ChatController = require("./Features/Chat/ChatController")
@@ -40,6 +40,8 @@ AuthorizationMiddlewear = require('./Features/Authorization/AuthorizationMiddlew
 BetaProgramController = require('./Features/BetaProgram/BetaProgramController')
 AnalyticsRouter = require('./Features/Analytics/AnalyticsRouter')
 AnnouncementsController = require("./Features/Announcements/AnnouncementsController")
+TrackChangesController = require("./Features/TrackChanges/TrackChangesController")
+CommentsController = require "./Features/Comments/CommentsController"
 
 logger = require("logger-sharelatex")
 _ = require("underscore")
@@ -171,9 +173,14 @@ module.exports = class Router
 
 		webRouter.post '/project/:Project_id/rename', AuthorizationMiddlewear.ensureUserCanAdminProject, ProjectController.renameProject
 
-		webRouter.get  "/project/:Project_id/updates", AuthorizationMiddlewear.ensureUserCanReadProject, TrackChangesController.proxyToTrackChangesApi
-		webRouter.get  "/project/:Project_id/doc/:doc_id/diff", AuthorizationMiddlewear.ensureUserCanReadProject, TrackChangesController.proxyToTrackChangesApi
-		webRouter.post "/project/:Project_id/doc/:doc_id/version/:version_id/restore", AuthorizationMiddlewear.ensureUserCanReadProject, TrackChangesController.proxyToTrackChangesApi
+		webRouter.get  "/project/:Project_id/updates", AuthorizationMiddlewear.ensureUserCanReadProject, HistoryController.proxyToHistoryApi
+		webRouter.get  "/project/:Project_id/doc/:doc_id/diff", AuthorizationMiddlewear.ensureUserCanReadProject, HistoryController.proxyToHistoryApi
+		webRouter.post "/project/:Project_id/doc/:doc_id/version/:version_id/restore", AuthorizationMiddlewear.ensureUserCanReadProject, HistoryController.proxyToHistoryApi
+
+		webRouter.get "/project/:project_id/ranges", AuthorizationMiddlewear.ensureUserCanReadProject, TrackChangesController.getAllRanges
+		webRouter.get "/project/:project_id/changes/users", AuthorizationMiddlewear.ensureUserCanReadProject, TrackChangesController.getAllChangesUsers
+		webRouter.post "/project/:project_id/doc/:doc_id/changes/:change_id/accept", AuthorizationMiddlewear.ensureUserCanWriteProjectContent, TrackChangesController.acceptChange
+		webRouter.post "/project/:project_id/track_changes", AuthorizationMiddlewear.ensureUserCanWriteProjectContent, TrackChangesController.toggleTrackChanges
 
 		webRouter.get  '/Project/:Project_id/download/zip', AuthorizationMiddlewear.ensureUserCanReadProject, ProjectDownloadsController.downloadProject
 		webRouter.get  '/project/download/zip', AuthorizationMiddlewear.ensureUserCanReadMultipleProjects, ProjectDownloadsController.downloadMultipleProjects
@@ -223,8 +230,17 @@ module.exports = class Router
 		webRouter.post "/spelling/check", AuthenticationController.requireLogin(), SpellingController.proxyRequestToSpellingApi
 		webRouter.post "/spelling/learn", AuthenticationController.requireLogin(), SpellingController.proxyRequestToSpellingApi
 
-		webRouter.get  "/project/:Project_id/messages", AuthorizationMiddlewear.ensureUserCanReadProject, ChatController.getMessages
-		webRouter.post "/project/:Project_id/messages", AuthorizationMiddlewear.ensureUserCanReadProject, ChatController.sendMessage
+		webRouter.get  "/project/:project_id/messages", AuthorizationMiddlewear.ensureUserCanReadProject, ChatController.getMessages
+		webRouter.post "/project/:project_id/messages", AuthorizationMiddlewear.ensureUserCanReadProject, ChatController.sendMessage
+		
+		# Note: Read only users can still comment
+		webRouter.post "/project/:project_id/thread/:thread_id/messages", AuthorizationMiddlewear.ensureUserCanReadProject, CommentsController.sendComment
+		webRouter.get  "/project/:project_id/threads", AuthorizationMiddlewear.ensureUserCanReadProject, CommentsController.getThreads
+		webRouter.post "/project/:project_id/thread/:thread_id/resolve", AuthorizationMiddlewear.ensureUserCanWriteProjectContent, CommentsController.resolveThread
+		webRouter.post "/project/:project_id/thread/:thread_id/reopen", AuthorizationMiddlewear.ensureUserCanWriteProjectContent, CommentsController.reopenThread
+		webRouter.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/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")
 								.row-spaced
 									ul
-										-each agents, url in openSockets
+										each agents, url in openSockets
 											li #{url} - total : #{agents.length}
 												ul
-													-each agent in agents
+													each agent in agents
 														li #{agent}
 
 							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")
 							span(ng-bind-html="suggestion.name")
 							i.fa.fa-angle-right
-			label.desc(ng-show="'#{getUserEmail()}'.length < 1")
+			label.desc(ng-show="'"+getUserEmail()+"'.length < 1")
 				| #{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
 				| #{translate("project_url")} (#{translate("optional")})
 			.form-group
@@ -37,6 +37,6 @@ script(type='text/ng-template', id='supportModalTemplate')
 			.form-group
 				textarea.field.text.medium.span8.form-control(ng-model="form.message",type='text', value='', tabindex='4', onkeyup='')
 			.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")
-			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.
 						| We should be back within minutes, but if not, or you have
 						| 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='stylesheet', href=buildCssPath('/style.css'))
 
+		block _headLinks
+
 		if settings.i18n.subdomainLang
 			each subdomainDetails in settings.i18n.subdomainLang
 				if !subdomainDetails.hide
@@ -30,7 +32,7 @@ html(itemscope, itemtype='http://schema.org/Product')
 		meta(itemprop="name", content="ShareLaTeX, the Online LaTeX Editor")
 		
 		-if (typeof(meta) == "undefined")
-			meta(itemprop="description", name="description", content='#{translate("site_description")}')
+			meta(itemprop="description", name="description", content=translate("site_description"))
 		-else
 			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",
 							aria-haspopup="true",
 							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")
 							li.dropdown-header #{translate("language")}
@@ -23,7 +23,7 @@ footer.site-footer
 								if !subdomainDetails.hide
 									li.lngOption
 										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)}
 										//- img(src="/img/flags/24/.png")
 				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}")
 				i.fa.fa-bars
 			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)
 				a(href='/').navbar-title #{nav.title}
 			else
@@ -24,7 +24,10 @@ nav.navbar.navbar-default
 							li
 								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.dropdown
 							li.dropdown(class=item.class, dropdown)
@@ -35,9 +38,6 @@ nav.navbar.navbar-default
 									each child in item.dropdown
 										if child.divider
 											li.divider
-										else if child.user_email
-											li
-												div.subdued #{getUserEmail()}
 										else
 											li
 												if child.url
@@ -50,7 +50,35 @@ nav.navbar.navbar-default
 									a(href=item.url, class=item.class) !{translate(item.text)}
 								else
 									| !{translate(item.text)}
-				
-				
-				
 
+				// logged out
+				if !getSessionUser()
+					// register link
+					if !externalAuthenticationSystemUsed()
+						li
+							a(href="/register") #{translate('register')}
+
+					// login link
+					li
+						a(href="/login") #{translate('log_in')}
+
+				// projects link and account menu
+				if getSessionUser()
+					li
+						a(href="/project") #{translate('Projects')}
+					li.dropdown(dropdown)
+						a.dropbodw-toggle(href, dropdown-toggle)
+							| #{translate('Account')}
+							b.caret
+						ul.dropdown-menu
+							li
+								div.subdued #{getUserEmail()}
+							li.divider
+							li
+								a(href="/user/settings") #{translate('Account Settings')}
+							if nav.showSubscriptionLink
+								li
+									a(href="/user/subscription") #{translate('subscription')}
+							li.divider
+							li
+								a(href="/logout") #{translate('log_out')}

+ 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.anonymous = #{anonymous};
 		window.maxDocLength = #{maxDocLength};
+		window.trackChangesEnabled = #{trackChangesEnabled};
 		window.wikiEnabled = #{!!(settings.apis.wiki && settings.apis.wiki.url)};
 		window.requirejs = {
 			"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
 		textarea(
-			placeholder="#{translate('your_message')}...",
+			placeholder=translate('your_message')+"...",
 			on-enter="sendMessage()",
 			ng-model="newMessageContent",
 			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-overview': (reviewPanel.subView === SubViews.OVERVIEW),\
 			'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")
@@ -51,12 +53,12 @@ div.full-size(
 			syntax-validation="settings.syntaxValidation",
 			review-panel="reviewPanel",
 			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"
+			renderer-data="reviewPanel.rendererData"
 		)
-		
+
 		include ./review-panel
 
 	.ui-layout-east
@@ -68,7 +70,7 @@ div.full-size(
 		ng-controller="PdfSynctexController"
 	)
 		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-append-to-body="true"
 			ng-click="syncToPdf()"
@@ -76,7 +78,7 @@ div.full-size(
 			i.fa.fa-long-arrow-right
 		br
 		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-append-to-body="true"
 			ng-click="syncToCode()"
@@ -88,4 +90,4 @@ div.full-size(
 	ng-show="ui.view == '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(
 			href,
 			ng-click="openNewDocModal()",
-			tooltip-html="'#{translate('new_file').replace(' ', '<br>')}'",
+			tooltip-html="'"+translate('new_file').replace(' ', '<br>')+"'",
 			tooltip-placement="bottom"
 		)
 			i.fa.fa-file
 		a(
 			href,
 			ng-click="openNewFolderModal()",
-			tooltip-html="'#{translate('new_folder').replace(' ', '<br>')}'",
+			tooltip-html="'"+translate('new_folder').replace(' ', '<br>')+"'",
 			tooltip-placement="bottom"
 		)
 			i.fa.fa-folder
 		a(
 			href,
 			ng-click="openUploadFileModal()",
-			tooltip="#{translate('upload')}",
+			tooltip=translate('upload'),
 			tooltip-placement="bottom"
 		)
 			i.fa.fa-upload
@@ -26,7 +26,7 @@ aside#file-tree(ng-controller="FileTreeController", ng-class="{ 'multi-selected'
 			a(
 				href,
 				ng-click="startRenamingSelected()",
-				tooltip="#{translate('rename')}",
+				tooltip=translate('rename'),
 				tooltip-placement="bottom",
 				ng-show="multiSelectedCount == 0"
 			)
@@ -34,7 +34,7 @@ aside#file-tree(ng-controller="FileTreeController", ng-class="{ 'multi-selected'
 			a(
 				href,
 				ng-click="openDeleteModalForSelected()",
-				tooltip="#{translate('delete')}",
+				tooltip=translate('delete'),
 				tooltip-placement="bottom",
 				tooltip-append-to-body="true"
 			)
@@ -431,4 +431,4 @@ script(type='text/ng-template', id='invalidFileNameModalTemplate')
 	.modal-footer
 		button.btn.btn-default(
 			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",
 			href='#',
 			tooltip-placement="bottom",
-			tooltip="#{translate('rename')}",
+			tooltip=translate('rename'),
 			tooltip-append-to-body="true",
 			ng-click="startRenaming()",
 			ng-show="!state.renaming"
@@ -71,7 +71,7 @@ header.toolbar.toolbar-header.toolbar-with-labels(
 			span.dropdown(dropdown, ng-if="onlineUsersArray.length >= 4")
 				span.online-user.online-user-multi(
 					dropdown-toggle,
-					tooltip="#{translate('connected_users')}",
+					tooltip=translate('connected_users'),
 					tooltip-placement="left"
 				)
 					strong {{ onlineUsersArray.length }}
@@ -87,7 +87,7 @@ header.toolbar.toolbar-header.toolbar-with-labels(
 
 		a.btn.btn-full-height(
 			href,
-			ng-if="trackChangesFeatureFlag",
+			ng-if="project.features.trackChangesVisible",
 			ng-class="{ active: ui.reviewPanelOpen }"
 			ng-click="toggleReviewPanel()"
 		)
@@ -121,4 +121,4 @@ header.toolbar.toolbar-header.toolbar-with-labels(
 			span.label.label-info(
 				ng-show="unreadMessages > 0"
 			) {{ 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
 			div.link-disabled(
 				ng-if="!pdf.url"
-				tooltip="#{translate('please_compile_pdf_before_download')}"
+				tooltip=translate('please_compile_pdf_before_download')
 				tooltip-placement="bottom"
 			)
 				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()")
 					i.fa.fa-fw.fa-eye
 					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
 					span.link-disabled &nbsp;&nbsp; #{translate("word_count")}	
 
@@ -200,7 +200,7 @@ script(type='text/ng-template', id='wordCountModalTemplate')
 				span &nbsp; #{translate("loading")}...  
 			div.pdf-disabled(
 				ng-if="!pdf.url"
-				tooltip="#{translate('please_compile_pdf_before_word_count')}"
+				tooltip=translate('please_compile_pdf_before_word_count')
 				tooltip-placement="bottom"
 			)
 		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
 		.btn-group(
 			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-popup-delay="500"
 			tooltip-append-to-body="true"
@@ -53,7 +53,7 @@ div.full-size.pdf(ng-controller="PdfController")
 			href
 			ng-click="stop()"
 			ng-show="pdf.compiling",
-			tooltip="#{translate('stop_compile')}"
+			tooltip=translate('stop_compile')
 			tooltip-placement="bottom"
 		)
 			i.fa.fa-stop()
@@ -61,7 +61,7 @@ div.full-size.pdf(ng-controller="PdfController")
 			href
 			ng-click="toggleLogs()"
 			ng-class="{ 'active': shouldShowLogs == true }"
-			tooltip="#{translate('logs_and_output_files')}"
+			tooltip=translate('logs_and_output_files')
 			tooltip-placement="bottom"
 		)
 			i.fa.fa-file-text-o
@@ -77,7 +77,7 @@ div.full-size.pdf(ng-controller="PdfController")
 			ng-href="{{pdf.downloadUrl || pdf.url}}"
 			target="_blank"
 			ng-if="pdf.url"
-			tooltip="#{translate('download_pdf')}"
+			tooltip=translate('download_pdf')
 			tooltip-placement="bottom"
 		)
 			i.fa.fa-download
@@ -87,7 +87,7 @@ div.full-size.pdf(ng-controller="PdfController")
 				href,
 				ng-click="switchToFlatLayout()"
 				ng-show="ui.pdfLayout == 'sideBySide'"
-				tooltip="#{translate('full_screen')}"
+				tooltip=translate('full_screen')
 				tooltip-placement="bottom"
 				tooltip-append-to-body="true"
 			)
@@ -96,7 +96,7 @@ div.full-size.pdf(ng-controller="PdfController")
 				href,
 				ng-click="switchToSideBySideLayout()"
 				ng-show="ui.pdfLayout == 'flat'"
-				tooltip="#{translate('split_screen')}"
+				tooltip=translate('split_screen')
 				tooltip-placement="bottom"
 				tooltip-append-to-body="true"
 			)
@@ -233,7 +233,7 @@ div.full-size.pdf(ng-controller="PdfController")
 			.files-dropdown-container
 				a.btn.btn-default.btn-sm(
 					href,
-					tooltip="#{translate('clear_cached_files')}",
+					tooltip=translate('clear_cached_files'),
 					tooltip-placement="top",
 					tooltip-append-to-body="true",
 					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
 					a(
 						href
-						tooltip="#{translate('remove_collaborator')}"
+						tooltip=translate('remove_collaborator')
 						tooltip-placement="bottom"
 						ng-click="removeMember(member)"
 					)
@@ -55,7 +55,7 @@ script(type='text/ng-template', id='shareProjectModalTemplate')
 				.col-xs-1
 					a(
 						href
-						tooltip="#{translate('revoke_invite')}"
+						tooltip=translate('revoke_invite')
 						tooltip-placement="bottom"
 						ng-click="revokeInvite(invite)"
 					)
@@ -66,7 +66,7 @@ script(type='text/ng-template', id='shareProjectModalTemplate')
 					.form-group
 						tags-input(
 							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"
 							focus-on="open"
 							display-property="display"
@@ -144,6 +144,8 @@ script(type='text/ng-template', id='shareProjectModalTemplate')
 				span(ng-switch="state.errorReason")
 					span(ng-switch-when="cannot_invite_non_user")
 						| #{translate("cannot_invite_non_user")}
+					span(ng-switch-when="cannot_invite_self")
+						| #{translate("cannot_invite_self")}
 					span(ng-switch-default)
 						| #{translate("generic_something_went_wrong")}
 		button.btn.btn-default(

+ 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(
 									name="acceptForm",
 									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='token', type='hidden', value="#{invite.token}")
+									input(name='token', type='hidden', value=invite.token)
 									.form-group.text-center
 										button.btn.btn-lg.btn-primary(type="submit")
 											| #{translate("join_project")}
 									.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
 						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-group.has-feedback.has-feedback-left.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',
 					ng-model="searchText.value",
 					focus-on='search:clear',
@@ -25,7 +25,7 @@
 				.btn-group(ng-hide="selectedProjects.length < 1")
 					a.btn.btn-default(
 						href,
-						tooltip="#{translate('download')}",
+						tooltip=translate('download'),
 						tooltip-placement="bottom",
 						tooltip-append-to-body="true",
 						ng-click="downloadSelectedProjects()"
@@ -33,7 +33,7 @@
 						i.fa.fa-cloud-download
 					a.btn.btn-default(
 						href,
-						tooltip="#{translate('delete')}",
+						tooltip=translate('delete'),
 						tooltip-placement="bottom",
 						tooltip-append-to-body="true",
 						ng-click="openArchiveProjectsModal()"
@@ -45,7 +45,7 @@
 						href,
 						data-toggle="dropdown",
 						dropdown-toggle,
-						tooltip="#{translate('add_to_folders')}",
+						tooltip=translate('add_to_folders'),
 						tooltip-append-to-body="true",
 						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
 								.col-md-8.col-md-offset-2.bonus-banner
 									.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
 								.col-md-8.col-md-offset-2.bonus-banner
@@ -34,12 +34,12 @@ block content
 							.row
 								.col-md-8.col-md-offset-2.bonus-banner
 									.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
 								.col-md-8.col-md-offset-2.bonus-banner
 									.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
 								.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;")
 									- for (var i = 0; i <= 10; i++) {
 										- if (refered_user_count == i)
-											.number(style="left: #{i}0%").active #{i}
+											.number(style="left: "+i+"0%").active #{i}
 										- else
-											.number(style="left: #{i}0%") #{i}
+											.number(style="left: "+i+"0%") #{i}
 									- }
 
 							.row.ab-bonus
@@ -68,7 +68,7 @@ block content
 									.progress
 										- if (refered_user_count == 0)
 											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
 								.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
 						table.table
-							-each project in projects
+							each project in projects
 								tr
 									- project_id = project._id.toString()
 									td(width="50%") #{project.name}
 									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

+ 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(style="text-align: center") You can find the page you were looking for here:
 		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 (sentrySrc.match(/^([a-z]+:)?\/\//i))
-		script(src="#{sentrySrc}")
+		script(src=sentrySrc)
 	- else
-		script(src=buildJsPath("libs/#{sentrySrc}", {fingerprint:false}))
+		script(src=buildJsPath("libs/"+sentrySrc, {fingerprint:false}))
 - if (typeof(sentrySrc) != "undefined")
 	script(type="text/javascript").
 		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)
 	-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
 				strong #{plan.name}
 			td {{refreshPrice(plan.planCode)}}
@@ -22,18 +22,18 @@ mixin printPlan(plan)
 					| {{prices[plan.planCode]}} / #{translate("month")}
 			td
 				-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])
 					button.btn.disabled #{translate("your_plan")}
 				-else
 					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
 
 
 mixin printPlans(plans)
-	-each plan in plans
-		mixin printPlan(plan)
+	each plan in plans
+		+printPlan(plan)
 
 block content
 	.content.content-alt(ng-cloak)
@@ -46,7 +46,7 @@ block content
 							| &nbsp;
 							| #{translate("your_billing_details_were_saved")}
 					.card(ng-if="view == 'overview'")
-						.page-header(x-current-plan="#{subscription.planCode}")
+						.page-header(x-current-plan=subscription.planCode)
 							h1 #{translate("your_subscription")} 
 
 						- if (subscription && user._id+'' == subscription.admin_id+'')
@@ -97,9 +97,9 @@ block content
 										th !{translate("name")}
 										th !{translate("price")}
 										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
@@ -107,7 +107,7 @@ block content
 								div 
 									p !{translate("member_of_group_subscription", {admin_email: "<strong>" + groupSubscription.admin_id.email + "</strong>"})}
 									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+'')
 							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()"
 													required
 												)
-													mixin countries_options()
+													+countries_options()
 												span.input-feedback-message {{ simpleCCForm.country.$error.required ? 'This field is required' : '' }}
 
 											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")
 			.row(ng-cloak)
 				.col-md-6.col-md-offset-3
-					.card(ng-init="planName = #{JSON.stringify(planName)}")
+					.card(ng-init="planName = "+JSON.stringify(planName))
 						.page-header
 							h1.text-centered #{translate("move_to_annual_billing")}
 						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)
 		.translations-message(ng-hide="hidei18nNotification")
 			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
 				span(aria-hidden="true") &times;
 				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"
 									required,
 									ng-model="email",
-									ng-init="email = #{JSON.stringify(email)}",
+									ng-init="email = "+JSON.stringify(email),
 									ng-model-options="{ updateOn: 'blur' }",
 									disabled
 								)

この差分においてかなりの量のファイルが変更されているため、一部のファイルを表示していません