Przeglądaj źródła

Merge branch 'master' into sk-pug

Shane Kilkelly 9 lat temu
rodzic
commit
4e9426e6bf
51 zmienionych plików z 1109 dodań i 472 usunięć
  1. 11 3
      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. 22 1
      services/web/app/coffee/Features/Chat/ChatApiHandler.coffee
  5. 16 3
      services/web/app/coffee/Features/Collaborators/CollaboratorsInviteController.coffee
  6. 7 1
      services/web/app/coffee/Features/Collaborators/CollaboratorsRouter.coffee
  7. 28 0
      services/web/app/coffee/Features/Comments/CommentsController.coffee
  8. 16 0
      services/web/app/coffee/Features/DocumentUpdater/DocumentUpdaterHandler.coffee
  9. 29 7
      services/web/app/coffee/Features/Email/EmailBuilder.coffee
  10. 13 15
      services/web/app/coffee/Features/Security/LoginRateLimiter.coffee
  11. 4 1
      services/web/app/coffee/Features/Security/RateLimiterMiddlewear.coffee
  12. 0 2
      services/web/app/coffee/Features/ServerAdmin/AdminController.coffee
  13. 1 1
      services/web/app/coffee/Features/ThirdPartyDataStore/UpdateMerger.coffee
  14. 0 1
      services/web/app/coffee/Features/User/UserSessionsManager.coffee
  15. 1 0
      services/web/app/coffee/infrastructure/ExpressLocals.coffee
  16. 25 13
      services/web/app/coffee/infrastructure/RateLimiter.coffee
  17. 28 0
      services/web/app/coffee/infrastructure/RedisWrapper.coffee
  18. 0 1
      services/web/app/coffee/infrastructure/Server.coffee
  19. 1 1
      services/web/app/coffee/models/User.coffee
  20. 3 0
      services/web/app/coffee/router.coffee
  21. 0 9
      services/web/app/views/project/editor/editor.pug
  22. 56 16
      services/web/app/views/project/editor/review-panel.pug
  23. 1 1
      services/web/app/views/project/list.pug
  24. 10 0
      services/web/config/settings.defaults.coffee
  25. 140 163
      services/web/npm-shrinkwrap.json
  26. 9 6
      services/web/package.json
  27. 11 0
      services/web/public/coffee/directives/asyncForm.coffee
  28. 16 0
      services/web/public/coffee/directives/expandableTextArea.coffee
  29. 1 0
      services/web/public/coffee/ide.coffee
  30. 3 0
      services/web/public/coffee/ide/editor/Document.coffee
  31. 2 1
      services/web/public/coffee/ide/editor/EditorManager.coffee
  32. 1 1
      services/web/public/coffee/ide/editor/directives/aceEditor.coffee
  33. 35 20
      services/web/public/coffee/ide/editor/directives/aceEditor/track-changes/TrackChangesManager.coffee
  34. 1 2
      services/web/public/coffee/ide/review-panel/RangesTracker.coffee
  35. 66 11
      services/web/public/coffee/ide/review-panel/controllers/ReviewPanelController.coffee
  36. 32 1
      services/web/public/coffee/ide/review-panel/directives/commentEntry.coffee
  37. 3 2
      services/web/public/coffee/ide/review-panel/directives/resolvedCommentsDropdown.coffee
  38. 1 1
      services/web/public/coffee/main/project-list/left-hand-menu-promo-controller.coffee
  39. 3 4
      services/web/public/coffee/main/project-list/project-list.coffee
  40. BIN
      services/web/public/img/about/chris.jpg
  41. BIN
      services/web/public/img/about/geri.jpg
  42. 4 0
      services/web/public/stylesheets/app/editor/file-tree.less
  43. 24 14
      services/web/public/stylesheets/app/editor/review-panel.less
  44. 76 6
      services/web/test/UnitTests/coffee/Announcement/AnnouncementsHandlerTests.coffee
  45. 71 66
      services/web/test/UnitTests/coffee/Collaborators/CollaboratorsInviteControllerTests.coffee
  46. 75 0
      services/web/test/UnitTests/coffee/Comments/CommentsControllerTests.coffee
  47. 35 0
      services/web/test/UnitTests/coffee/DocumentUpdater/DocumentUpdaterHandlerTests.coffee
  48. 45 49
      services/web/test/UnitTests/coffee/Security/LoginRateLimiterTests.coffee
  49. 46 37
      services/web/test/UnitTests/coffee/infrastructure/RateLimterTests.coffee
  50. 66 0
      services/web/test/UnitTests/coffee/infrastructure/RedisWrapperTests.coffee
  51. 37 0
      services/web/test/acceptance/coffee/RegistrationTests.coffee

+ 11 - 3
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:
@@ -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']

+ 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

+ 22 - 1
services/web/app/coffee/Features/Chat/ChatApiHandler.coffee

@@ -58,4 +58,25 @@ module.exports = ChatApiHandler =
 		ChatApiHandler._apiRequest {
 			url:    "#{settings.apis.chat.internal_url}/project/#{project_id}/thread/#{thread_id}/reopen"
 			method: "POST"
-		}, callback
+		}, 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
+		

+ 16 - 3
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 =
 
@@ -22,7 +23,7 @@ module.exports = CollaboratorsInviteController =
 				return next(err)
 			res.json({invites: invites})
 
-	_checkShouldInviteEmail: (email, callback=(err, shouldAllowInvite)->) ->
+	_checkShouldInviteEmail: (sendingUser, email, callback=(err, shouldAllowInvite)->) ->
 		if Settings.restrictInvitesToExistingAccounts == true
 			logger.log {email}, "checking if user exists with this email"
 			UserGetter.getUser {email: email}, {_id: 1}, (err, user) ->
@@ -30,7 +31,19 @@ module.exports = CollaboratorsInviteController =
 				userExists = user? and user?._id?
 				callback(null, userExists)
 		else
-			callback(null, true)
+			UserGetter.getUser sendingUser._id, {features:1, _id:1}, (err, user)->
+				if err?
+					return callback(err)
+				collabLimit = user?.features?.collaborators || 1
+				if collabLimit == -1 
+					collabLimit = 20
+				collabLimit = collabLimit * 10
+				opts = 
+					endpointName: "invite_to_project"
+					timeInterval: 60 * 30
+					subjectName: sendingUser._id
+					throttle: collabLimit
+				rateLimiter.addCount opts, callback
 
 	inviteToProject: (req, res, next) ->
 		projectId = req.params.Project_id
@@ -51,7 +64,7 @@ module.exports = CollaboratorsInviteController =
 			if !email? or email == ""
 				logger.log {projectId, email, sendingUserId}, "invalid email address"
 				return res.sendStatus(400)
-			CollaboratorsInviteController._checkShouldInviteEmail email, (err, shouldAllowInvite)->
+			CollaboratorsInviteController._checkShouldInviteEmail sendingUser, email, (err, shouldAllowInvite)->
 				if err?
 					logger.err {err, email, projectId, sendingUserId}, "error checking if we can invite this email address"
 					return next(err)

+ 7 - 1
services/web/app/coffee/Features/Collaborators/CollaboratorsRouter.coffee

@@ -24,7 +24,13 @@ module.exports =
 			RateLimiterMiddlewear.rateLimit({
 				endpointName: "invite-to-project"
 				params: ["Project_id"]
-				maxRequests: 200
+				maxRequests: 100
+				timeInterval: 60 * 10
+			}),
+			RateLimiterMiddlewear.rateLimit({
+				endpointName: "invite-to-project-ip"
+				ipOnly:true
+				maxRequests: 100
 				timeInterval: 60 * 10
 			}),
 			AuthenticationController.requireLogin(),

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

@@ -4,6 +4,7 @@ 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 =
@@ -50,6 +51,33 @@ module.exports = CommentsController =
 			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 = {}

+ 16 - 0
services/web/app/coffee/Features/DocumentUpdater/DocumentUpdaterHandler.coffee

@@ -153,6 +153,22 @@ module.exports = DocumentUpdaterHandler =
 				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"
 DOCIDSWITHPENDINGUPDATES = "DocsWithPendingUpdates"

+ 29 - 7
services/web/app/coffee/Features/Email/EmailBuilder.coffee

@@ -97,7 +97,7 @@ Thank you
 
 
 templates.projectInvite =
-	subject: _.template "<%= project.name %> - shared by <%= owner.email %>"
+	subject: _.template "<%= project.name.slice(0, 40) %> - shared by <%= owner.email %>"
 	layout: BaseWithHeaderEmailLayout
 	type:"notification"
 	plainTextTemplate: _.template """
@@ -111,20 +111,18 @@ Thank you
 """
 	compiledTemplate: (opts) -> 
 		SingleCTAEmailBody({
-			title: "#{ opts.project.name } &ndash; shared by #{ opts.owner.email }"
+			title: "#{ opts.project.name.slice(0, 40) } &ndash; shared by #{ opts.owner.email }"
 			greeting: "Hi,"
-			message: "#{ opts.owner.email } wants to share &ldquo;#{ opts.project.name }&rdquo; with you."
+			message: "#{ opts.owner.email } wants to share &ldquo;#{ opts.project.name.slice(0, 40) }&rdquo; with you."
 			secondaryMessage: null
 			ctaText: "View project"
 			ctaURL: opts.inviteUrl
 			gmailGoToAction: 
 				target: opts.inviteUrl
 				name: "View project"
-				description: "Join #{ opts.project.name } at ShareLaTeX"
+				description: "Join #{ opts.project.name.slice(0, 40) } at ShareLaTeX"
 		})
 
-
-
 templates.completeJoinGroupAccount =
 	subject: _.template "Verify Email to join <%= group_name %> group"
 	layout: BaseWithHeaderEmailLayout
@@ -149,6 +147,30 @@ Thank You
 			gmailGoToAction: null
 		})
 
+
+templates.testEmail =
+	subject: _.template "A Test Email from ShareLaTeX"
+	layout: BaseWithHeaderEmailLayout
+	type:"notification"
+	plainTextTemplate: _.template """
+Hi,
+
+This is a test email sent from ShareLaTeX.
+
+#{settings.appName} - <%= siteUrl %>
+"""
+	compiledTemplate: (opts) -> 
+		SingleCTAEmailBody({
+			title: "A Test Email from ShareLaTeX"
+			greeting: "Hi,"
+			message: "This is a test email sent from ShareLaTeX"
+			secondaryMessage: null
+			ctaText: "Open ShareLaTeX"
+			ctaURL: "/"
+			gmailGoToAction: null
+		})
+
+
 module.exports =
 	templates: templates
 
@@ -163,4 +185,4 @@ module.exports =
 			html: template.layout(opts)
 			text: template?.plainTextTemplate?(opts)
 			type:template.type
-		}
+		}

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

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

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

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

+ 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

+ 0 - 1
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()

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

+ 3 - 0
services/web/app/coffee/router.coffee

@@ -238,6 +238,9 @@ module.exports = class Router
 		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

+ 0 - 9
services/web/app/views/project/editor/editor.pug

@@ -59,15 +59,6 @@ div.full-size(
 			renderer-data="reviewPanel.rendererData"
 		)
 
-		a.rp-track-changes-indicator(
-			href
-			ng-if="editor.wantTrackChanges"
-			ng-click="toggleReviewPanel();"
-			ng-class="{ 'rp-track-changes-indicator-on-dark' : darkTheme }"
-		) Track changes is 
-			strong on
-
-		
 		include ./review-panel
 
 	.ui-layout-east

+ 56 - 16
services/web/app/views/project/editor/review-panel.pug

@@ -1,13 +1,22 @@
 #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="deleteComment(entryId, threadId);"
+			on-delete="deleteThread(entryId, docId, threadId);"
 			is-loading="reviewPanel.dropdown.loading"
 			permissions="permissions"
 		)
@@ -32,7 +41,6 @@
 		.rp-entry-list-inner
 			.rp-entry-wrapper(
 				ng-repeat="(entry_id, entry) in reviewPanel.entries[editor.open_doc_id]"
-				ng-if="!(entry.type === 'comment' && reviewPanel.commentThreads[entry.thread_id].resolved === true)"
 			)
 				div(ng-if="entry.type === 'insert' || entry.type === 'delete'")
 					change-entry(
@@ -51,6 +59,8 @@
 						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)"
 						permissions="permissions"
 						ng-if="!reviewPanel.loadingThreads"
 					)
@@ -94,6 +104,8 @@
 						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"
@@ -175,21 +187,48 @@ script(type='text/ng-template', id='commentEntryTemplate')
 		.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
 			div
 				.rp-comment(
 					ng-repeat="comment in threads[entry.thread_id].messages track by comment.id"
 				)
-					p.rp-comment-content 
-						span.rp-entry-user(
-							style="color: hsl({{ comment.user.hue }}, 70%, 40%);"
-						) {{ comment.user.name }}:&nbsp;
-						| {{ comment.content }}
-					.rp-entry-metadata
-						| {{ comment.timestamp | date : 'MMM d, y h:mm a' }}
-			.rp-loading(ng-if="!threads[entry.thread_id] || threads[entry.thread_id].submitting")
+					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;
+							| {{ comment.content }}
+						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"
@@ -249,11 +288,11 @@ script(type='text/ng-template', id='resolvedCommentEntryTemplate')
 				ng-click="onUnresolve({ 'threadId': thread.threadId });"
 			) 
 				| &nbsp;Re-open
-			//- a.rp-entry-button(
-			//- 	href
-			//- 	ng-click="onDelete({ 'entryId': thread.entryId, 'threadId': thread.threadId });"
-			//- )
-			//- 	| &nbsp;Delete
+			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')
@@ -280,6 +319,7 @@ script(type='text/ng-template', id='addCommentEntryTemplate')
 			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"
@@ -324,7 +364,7 @@ script(type='text/ng-template', id='resolvedCommentsDropdownTemplate')
 					ng-repeat="thread in resolvedComments | orderBy:'resolved_at':true"
 					thread="thread"
 					on-unresolve="handleUnresolve(threadId);"
-					on-delete="handleDelete(entryId, threadId);"
+					on-delete="handleDelete(entryId, docId, threadId);"
 					permissions="permissions"
 				)
 				.rp-loading(ng-if="!resolvedComments.length")

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

+ 10 - 0
services/web/config/settings.defaults.coffee

@@ -48,6 +48,16 @@ module.exports = settings =
 		# 		{host: 'localhost', port: 7005}
 		# 	]
 
+		# ratelimiter:
+		# 	cluster: [
+		# 		{host: 'localhost', port: 7000}
+		# 		{host: 'localhost', port: 7001}
+		# 		{host: 'localhost', port: 7002}
+		# 		{host: 'localhost', port: 7003}
+		# 		{host: 'localhost', port: 7004}
+		# 		{host: 'localhost', port: 7005}
+		# 	]
+
 		api:
 			host: "localhost"
 			port: "6379"

Plik diff jest za duży
+ 140 - 163
services/web/npm-shrinkwrap.json


+ 9 - 6
services/web/package.json

@@ -42,7 +42,6 @@
     "mongojs": "0.18.2",
     "mongoose": "4.1.0",
     "multer": "^0.1.8",
-    "node-uuid": "1.4.1",
     "nodemailer": "2.1.0",
     "nodemailer-sendgrid-transport": "^0.2.0",
     "nodemailer-ses-transport": "^1.3.0",
@@ -50,7 +49,6 @@
     "passport": "^0.3.2",
     "passport-ldapauth": "^0.6.0",
     "passport-local": "^1.0.0",
-    "redback": "0.4.0",
     "redis": "0.10.1",
     "redis-sharelatex": "0.0.9",
     "request": "^2.69.0",
@@ -65,13 +63,17 @@
     "v8-profiler": "^5.2.3",
     "xml2js": "0.2.0",
     "passport-saml": "^0.15.0",
-    "pug": "^2.0.0-beta6"
+    "pug": "^2.0.0-beta6",
+    "uuid": "^3.0.1",
+    "rolling-rate-limiter": "git+https://github.com/ShaneKilkelly/rolling-rate-limiter.git#master"
   },
   "devDependencies": {
+    "autoprefixer": "^6.6.1",
     "bunyan": "0.22.1",
-    "translations-sharelatex": "git+https://github.com/sharelatex/translations-sharelatex.git#master",
     "chai": "",
     "chai-spies": "",
+    "clean-css": "^3.4.18",
+    "es6-promise": "^4.0.5",
     "grunt-available-tasks": "0.4.1",
     "grunt-bunyan": "0.5.0",
     "grunt-contrib-clean": "0.5.0",
@@ -80,7 +82,6 @@
     "grunt-contrib-requirejs": "0.4.1",
     "grunt-contrib-watch": "^1.0.0",
     "grunt-env": "0.4.4",
-    "clean-css": "^3.4.18",
     "grunt-exec": "^0.4.7",
     "grunt-execute": "^0.2.2",
     "grunt-file-append": "0.0.6",
@@ -88,9 +89,11 @@
     "grunt-mocha-test": "0.9.0",
     "grunt-newer": "^1.2.0",
     "grunt-parallel": "^0.5.1",
+    "grunt-postcss": "^0.8.0",
     "grunt-sed": "^0.1.1",
     "sandboxed-module": "0.2.0",
     "sinon": "",
-    "timekeeper": ""
+    "timekeeper": "",
+    "translations-sharelatex": "git+https://github.com/sharelatex/translations-sharelatex.git#master"
   }
 }

+ 11 - 0
services/web/public/coffee/directives/asyncForm.coffee

@@ -33,6 +33,11 @@ define [
 							response.success = true
 							response.error = false
 
+							onSuccessHandler = scope[attrs.onSuccess]
+							if onSuccessHandler
+								onSuccessHandler(data, status, headers, config)
+								return
+
 							if data.redir?
 								ga('send', 'event', formName, 'success')
 								window.location = data.redir
@@ -50,6 +55,12 @@ define [
 							scope[attrs.name].inflight = false
 							response.success = false
 							response.error = true
+
+							onErrorHandler = scope[attrs.onError]
+							if onErrorHandler
+								onErrorHandler(data, status, headers, config)
+								return
+
 							if status == 403 # Forbidden
 								response.message =
 									text: "Session error. Please check you have cookies enabled. If the problem persists, try clearing your cache and cookies."

+ 16 - 0
services/web/public/coffee/directives/expandableTextArea.coffee

@@ -0,0 +1,16 @@
+define [
+	"base"
+], (App) ->
+	App.directive "expandableTextArea", () ->
+		restrict: "A"
+		link: (scope, el) ->
+			resetHeight = () ->
+				el.css("height", "auto")
+				el.css("height", el.prop("scrollHeight"))
+
+			scope.$watch (() -> el.val()), resetHeight
+
+			resetHeight()
+
+
+	

+ 1 - 0
services/web/public/coffee/ide.coffee

@@ -28,6 +28,7 @@ define [
 	"directives/onEnter"
 	"directives/stopPropagation"
 	"directives/rightClick"
+	"directives/expandableTextArea"
 	"services/queued-http"
 	"filters/formatDate"
 	"main/event"

+ 3 - 0
services/web/public/coffee/ide/editor/Document.coffee

@@ -84,6 +84,9 @@ define [
 		setTrackingChanges: (track_changes) ->
 			@doc.track_changes = track_changes
 		
+		getTrackingChanges: () ->
+			!!@doc.track_changes
+		
 		setTrackChangesIdSeeds: (id_seeds) ->
 			@doc.track_changes_id_seeds = id_seeds
 

+ 2 - 1
services/web/public/coffee/ide/editor/EditorManager.coffee

@@ -162,8 +162,9 @@ define [
 				@_syncTimeout = null
 
 			want = @$scope.editor.wantTrackChanges
-			have = @$scope.editor.trackChanges
+			have = doc.getTrackingChanges()
 			if want == have
+				@$scope.editor.trackChanges = want
 				return
 
 			do tryToggle = () =>

+ 1 - 1
services/web/public/coffee/ide/editor/directives/aceEditor.coffee

@@ -279,7 +279,7 @@ define [
 
 					session.setUseWrapMode(true)
 					# use syntax validation only when explicitly set
-					if scope.syntaxValidation? and syntaxValidationEnabled
+					if scope.syntaxValidation? and syntaxValidationEnabled and !scope.fileName.match(/\.bib$/)
 						session.setOption("useWorker", scope.syntaxValidation);
 
 					# now attach session to editor

+ 35 - 20
services/web/public/coffee/ide/editor/directives/aceEditor/track-changes/TrackChangesManager.coffee

@@ -35,8 +35,8 @@ define [
 			@$scope.$on "comment:remove", (e, comment_id) =>
 				@removeCommentId(comment_id)
 			
-			@$scope.$on "comment:resolve_thread", (e, thread_id) =>
-				@resolveCommentByThreadId(thread_id)
+			@$scope.$on "comment:resolve_threads", (e, thread_ids) =>
+				@resolveCommentByThreadIds(thread_ids)
 			
 			@$scope.$on "comment:unresolve_thread", (e, thread_id) =>
 				@unresolveCommentByThreadId(thread_id)
@@ -105,29 +105,45 @@ define [
 			# ace has updated
 			@rangesTracker.on "insert:added", (change) =>
 				sl_console.log "[insert:added]", change
-				setTimeout () => @_onInsertAdded(change)
+				setTimeout () =>
+					@_onInsertAdded(change)
+					@broadcastChange()
 			@rangesTracker.on "insert:removed", (change) =>
 				sl_console.log "[insert:removed]", change
-				setTimeout () => @_onInsertRemoved(change)
+				setTimeout () =>
+					@_onInsertRemoved(change)
+					@broadcastChange()
 			@rangesTracker.on "delete:added", (change) =>
 				sl_console.log "[delete:added]", change
-				setTimeout () => @_onDeleteAdded(change)
+				setTimeout () =>
+					@_onDeleteAdded(change)
+					@broadcastChange()
 			@rangesTracker.on "delete:removed", (change) =>
 				sl_console.log "[delete:removed]", change
-				setTimeout () => @_onDeleteRemoved(change)
+				setTimeout () =>
+					@_onDeleteRemoved(change)
+					@broadcastChange()
 			@rangesTracker.on "changes:moved", (changes) =>
 				sl_console.log "[changes:moved]", changes
-				setTimeout () => @_onChangesMoved(changes)
+				setTimeout () =>
+					@_onChangesMoved(changes)
+					@broadcastChange()
 
 			@rangesTracker.on "comment:added", (comment) =>
 				sl_console.log "[comment:added]", comment
-				setTimeout () => @_onCommentAdded(comment)
+				setTimeout () =>
+					@_onCommentAdded(comment)
+					@broadcastChange()
 			@rangesTracker.on "comment:moved", (comment) =>
 				sl_console.log "[comment:moved]", comment
-				setTimeout () => @_onCommentMoved(comment)
+				setTimeout () =>
+					@_onCommentMoved(comment)
+					@broadcastChange()
 			@rangesTracker.on "comment:removed", (comment) =>
 				sl_console.log "[comment:removed]", comment
-				setTimeout () => @_onCommentRemoved(comment)
+				setTimeout () =>
+					@_onCommentRemoved(comment)
+					@broadcastChange()
 			
 			@rangesTracker.on "clear", () =>
 				@clearAnnotations()
@@ -150,6 +166,8 @@ define [
 
 			for comment in @rangesTracker.comments
 				@_onCommentAdded(comment)
+			
+			@broadcastChange()
 
 		addComment: (offset, content, thread_id) ->
 			op = { c: content, p: offset, t: thread_id }
@@ -190,15 +208,20 @@ define [
 		removeCommentId: (comment_id) ->
 			@rangesTracker.removeCommentId(comment_id)
 
-		resolveCommentByThreadId: (thread_id) ->
+		resolveCommentByThreadIds: (thread_ids) ->
+			resolve_ids = {}
+			for id in thread_ids
+				resolve_ids[id] = true
 			for comment in @rangesTracker?.comments or []
-				if comment.op.t == thread_id
+				if resolve_ids[comment.op.t]
 					@_onCommentRemoved(comment)
+			@broadcastChange()
 			
 		unresolveCommentByThreadId: (thread_id) ->
 			for comment in @rangesTracker?.comments or []
 				if comment.op.t == thread_id
 					@_onCommentAdded(comment)
+			@broadcastChange()
 
 		checkMapping: () ->
 			# TODO: reintroduce this check
@@ -303,7 +326,6 @@ define [
 			background_marker_id = session.addMarker background_range, "track-changes-marker track-changes-added-marker", "text"
 			callout_marker_id = @_createCalloutMarker(start, "track-changes-added-marker-callout")
 			@changeIdToMarkerIdMap[change.id] = { background_marker_id, callout_marker_id }
-			@broadcastChange()
 
 		_onDeleteAdded: (change) ->
 			position = @_shareJsOffsetToAcePosition(change.op.p)
@@ -318,7 +340,6 @@ define [
 
 			callout_marker_id = @_createCalloutMarker(position, "track-changes-deleted-marker-callout")
 			@changeIdToMarkerIdMap[change.id] = { background_marker_id, callout_marker_id }
-			@broadcastChange()
 		
 		_onInsertRemoved: (change) ->
 			{background_marker_id, callout_marker_id} = @changeIdToMarkerIdMap[change.id]
@@ -326,7 +347,6 @@ define [
 			session = @editor.getSession()
 			session.removeMarker background_marker_id
 			session.removeMarker callout_marker_id
-			@broadcastChange()
 		
 		_onDeleteRemoved: (change) ->
 			{background_marker_id, callout_marker_id} = @changeIdToMarkerIdMap[change.id]
@@ -334,7 +354,6 @@ define [
 			session = @editor.getSession()
 			session.removeMarker background_marker_id
 			session.removeMarker callout_marker_id
-			@broadcastChange()
 		
 		_onCommentAdded: (comment) ->
 			if @rangesTracker.resolvedThreadIds[comment.op.t]
@@ -350,7 +369,6 @@ define [
 				background_marker_id = session.addMarker background_range, "track-changes-marker track-changes-comment-marker", "text"
 				callout_marker_id = @_createCalloutMarker(start, "track-changes-comment-marker-callout")
 				@changeIdToMarkerIdMap[comment.id] = { background_marker_id, callout_marker_id }
-			@broadcastChange()
 		
 		_onCommentRemoved: (comment) ->
 			if @changeIdToMarkerIdMap[comment.id]?
@@ -360,7 +378,6 @@ define [
 				session = @editor.getSession()
 				session.removeMarker background_marker_id
 				session.removeMarker callout_marker_id
-			@broadcastChange()
 
 		_aceRangeToShareJs: (range) ->
 			lines = @editor.getSession().getDocument().getLines 0, range.row
@@ -385,14 +402,12 @@ define [
 					end = start
 				@_updateMarker(change.id, start, end)
 			@editor.renderer.updateBackMarkers()
-			@broadcastChange()
 		
 		_onCommentMoved: (comment) ->
 			start = @_shareJsOffsetToAcePosition(comment.op.p)
 			end = @_shareJsOffsetToAcePosition(comment.op.p + comment.op.c.length)
 			@_updateMarker(comment.id, start, end)
 			@editor.renderer.updateBackMarkers()
-			@broadcastChange()
 	
 		_updateMarker: (change_id, start, end) ->
 			return if !@changeIdToMarkerIdMap[change_id]?

+ 1 - 2
services/web/public/coffee/ide/review-panel/RangesTracker.coffee

@@ -105,9 +105,8 @@ load = (EventEmitter) ->
 				throw new Error("unknown op type")
 			
 		addComment: (op, metadata) ->
-			# TODO: Don't allow overlapping comments?
 			@comments.push comment = {
-				id: @newId()
+				id: op.t or @newId()
 				op: # Copy because we'll modify in place
 					c: op.c
 					p: op.p

+ 66 - 11
services/web/public/coffee/ide/review-panel/controllers/ReviewPanelController.coffee

@@ -65,6 +65,18 @@ define [
 			
 		ide.socket.on "reopen-thread", (thread_id) ->
 			_onCommentReopened(thread_id)
+		
+		ide.socket.on "delete-thread", (thread_id) ->
+			_onThreadDeleted(thread_id)
+			$scope.$apply () ->
+		
+		ide.socket.on "edit-message", (thread_id, message_id, content) ->
+			_onCommentEdited(thread_id, message_id, content)
+			$scope.$apply () ->
+		
+		ide.socket.on "delete-message", (thread_id, message_id) ->
+			_onCommentDeleted(thread_id, message_id)
+			$scope.$apply () ->
 
 		rangesTrackers = {}
 
@@ -214,8 +226,10 @@ define [
 				delete delete_changes[comment.id]
 				if $scope.reviewPanel.resolvedThreadIds[comment.op.t]
 					new_comment = resolvedComments[comment.id] ?= {}
+					delete entries[comment.id]
 				else
 					new_comment = entries[comment.id] ?= {}
+					delete resolvedComments[comment.id]
 				new_entry = {
 					type: "comment"
 					thread_id: comment.op.t
@@ -342,31 +356,72 @@ define [
 			event_tracking.sendMB "rp-comment-reopen"
 		
 		_onCommentResolved = (thread_id, user) ->
-			thread = $scope.reviewPanel.commentThreads[thread_id]
+			thread = getThread(thread_id)
+			return if !thread?
 			thread.resolved = true
 			thread.resolved_by_user = formatUser(user)
 			thread.resolved_at = new Date()
 			$scope.reviewPanel.resolvedThreadIds[thread_id] = true
-			$scope.$broadcast "comment:resolve_thread", thread_id
+			$scope.$broadcast "comment:resolve_threads", [thread_id]
 		
 		_onCommentReopened = (thread_id) ->
-			thread = $scope.reviewPanel.commentThreads[thread_id]
+			thread = getThread(thread_id)
+			return if !thread?
 			delete thread.resolved
 			delete thread.resolved_by_user
 			delete thread.resolved_at
 			delete $scope.reviewPanel.resolvedThreadIds[thread_id]
 			$scope.$broadcast "comment:unresolve_thread", thread_id
 
-		_onCommentDeleted = (thread_id) ->
-			if $scope.reviewPanel.resolvedThreadIds[thread_id]?
-				delete $scope.reviewPanel.resolvedThreadIds[thread_id]
-				
+		_onThreadDeleted = (thread_id) ->
+			delete $scope.reviewPanel.resolvedThreadIds[thread_id]
 			delete $scope.reviewPanel.commentThreads[thread_id]
+			$scope.$broadcast "comment:remove", thread_id
+		
+		_onCommentEdited = (thread_id, comment_id, content) ->
+			thread = getThread(thread_id)
+			return if !thread?
+			for message in thread.messages
+				if message.id == comment_id
+					message.content = content
+			updateEntries()
 		
-		$scope.deleteComment = (entry_id, thread_id) ->
-			_onCommentDeleted(thread_id)
-			$scope.$broadcast "comment:remove", entry_id
+		_onCommentDeleted = (thread_id, comment_id) ->
+			thread = getThread(thread_id)
+			return if !thread?
+			thread.messages = thread.messages.filter (m) -> m.id != comment_id
+			updateEntries()
+		
+		$scope.deleteThread = (entry_id, doc_id, thread_id) ->
+			_onThreadDeleted(thread_id)
+			$http({
+				method: "DELETE"
+				url: "/project/#{$scope.project_id}/doc/#{doc_id}/thread/#{thread_id}",
+				headers: {
+					'X-CSRF-Token': window.csrfToken
+				}
+			})
 			event_tracking.sendMB "rp-comment-delete"
+		
+		$scope.saveEdit = (thread_id, comment) ->
+			$http.post("/project/#{$scope.project_id}/thread/#{thread_id}/messages/#{comment.id}/edit", {
+				content: comment.content
+				_csrf: window.csrfToken
+			})
+			$timeout () ->
+				$scope.$broadcast "review-panel:layout"
+
+		$scope.deleteComment = (thread_id, comment) ->
+			_onCommentDeleted(thread_id, comment.id)
+			$http({
+				method: "DELETE"
+				url: "/project/#{$scope.project_id}/thread/#{thread_id}/messages/#{comment.id}",
+				headers: {
+					'X-CSRF-Token': window.csrfToken
+				}
+			})
+			$timeout () ->
+				$scope.$broadcast "review-panel:layout"
 
 		$scope.setSubView = (subView) -> 
 			$scope.reviewPanel.subView = subView
@@ -428,9 +483,9 @@ define [
 						for comment in thread.messages
 							formatComment(comment)
 						if thread.resolved_by_user?
-							$scope.$broadcast "comment:resolve_thread", thread_id
 							thread.resolved_by_user = formatUser(thread.resolved_by_user)
 							$scope.reviewPanel.resolvedThreadIds[thread_id] = true
+							$scope.$broadcast "comment:resolve_threads", [thread_id]
 					$scope.reviewPanel.commentThreads = threads
 					$timeout () ->
 						$scope.$broadcast "review-panel:layout"

+ 32 - 1
services/web/public/coffee/ide/review-panel/directives/commentEntry.coffee

@@ -11,6 +11,8 @@ define [
 			onResolve: "&"
 			onReply: "&"
 			onIndicatorClick: "&"
+			onSaveEdit: "&"
+			onDelete: "&"
 		link: (scope, element, attrs) ->
 			scope.state =
 				animating: false
@@ -26,4 +28,33 @@ define [
 				scope.state.animating = true
 				element.find(".rp-entry").css("top", 0)
 				$timeout((() -> scope.onResolve()), 350)
-				return true
+				return true
+			
+			scope.startEditing = (comment) ->
+				comment.editing = true
+				setTimeout () ->
+					scope.$emit "review-panel:layout"
+			
+			scope.saveEdit = (comment) ->
+				comment.editing = false
+				scope.onSaveEdit({comment:comment})
+			
+			scope.confirmDelete = (comment) ->
+				comment.deleting = true
+				setTimeout () ->
+					scope.$emit "review-panel:layout"
+				
+			scope.cancelDelete = (comment) ->
+				comment.deleting = false
+				setTimeout () ->
+					scope.$emit "review-panel:layout"
+			
+			scope.doDelete = (comment) ->
+				comment.deleting = false
+				scope.onDelete({comment: comment})
+	
+			scope.saveEditOnEnter = (ev, comment) ->
+				if ev.keyCode == 13 and !ev.shiftKey and !ev.ctrlKey and !ev.metaKey
+					ev.preventDefault()
+					scope.saveEdit(comment)
+				

+ 3 - 2
services/web/public/coffee/ide/review-panel/directives/resolvedCommentsDropdown.coffee

@@ -31,8 +31,9 @@ define [
 				scope.onUnresolve({ threadId })
 				scope.resolvedComments = scope.resolvedComments.filter (c) -> c.threadId != threadId
 
-			scope.handleDelete = (entryId, threadId) ->
-				scope.onDelete({ entryId, threadId })
+			scope.handleDelete = (entryId, docId, threadId) ->
+				scope.onDelete({ entryId, docId, threadId })
+				scope.resolvedComments = scope.resolvedComments.filter (c) -> c.threadId != threadId
 
 			getDocNameById = (docId) ->
 				doc = _.find(scope.docs, (doc) -> doc.doc.id == docId)

+ 1 - 1
services/web/public/coffee/main/project-list/left-hand-menu-promo-controller.coffee

@@ -6,4 +6,4 @@ define [
 
 		$scope.hasProjects = window.data.projects.length > 0
 		$scope.userHasNoSubscription = window.userHasNoSubscription
-		$scope.randomView = _.shuffle(["default", "dropbox", "github"])[0]
+

+ 3 - 4
services/web/public/coffee/main/project-list/project-list.coffee

@@ -14,10 +14,9 @@ define [
 		$scope.searchText = 
 			value : ""
 
-		if $scope.projects.length == 0
-			$timeout () ->
-				recalculateProjectListHeight()
-			, 10
+		$timeout () ->
+			recalculateProjectListHeight()
+		, 10
 
 		recalculateProjectListHeight = () ->
 			topOffset = $(".project-list-card")?.offset()?.top

BIN
services/web/public/img/about/chris.jpg


BIN
services/web/public/img/about/geri.jpg


+ 4 - 0
services/web/public/stylesheets/app/editor/file-tree.less

@@ -35,6 +35,10 @@ aside#file-tree {
 			line-height: 2.6;
 			position: relative;
 
+			.entity {
+				user-select: none;
+			}
+
 			.entity-name {
 				color: @gray-darker;
 				cursor: pointer;

+ 24 - 14
services/web/public/stylesheets/app/editor/review-panel.less

@@ -32,6 +32,7 @@
 
 
 .rp-button() {
+	display: block; // IE doesn't do flex with inline items.
 	background-color: @rp-highlight-blue;
 	color: #FFF;
 	text-align: center;
@@ -119,14 +120,11 @@
 	.rp-size-expanded & {
 		display: flex;
 		align-items: center;
+		justify-content: space-between;
 		padding: 0 5px;
 	}
-	// .rp-state-current-file & {
-	// 	position: absolute;
-	// 	top: 0;
-	// 	left: 0;
-	// 	right: 0;
-	// }
+
+	position: relative;
 	height: @rp-toolbar-height;
 	border-bottom: 1px solid @rp-border-grey;
 	background-color: @rp-bg-dim-blue;
@@ -338,6 +336,9 @@
 		.rp-entry-details {
 			line-height: 1.4;
 			margin-left: 5px;
+			// We need to set any low-enough flex base size (0px), making it growable (1) and non-shrinkable (0).
+			// This is needed to ensure that IE makes the element fill the available space. 
+			flex: 1 0 1px; 
 
 			.rp-state-overview & {
 				margin-left: 0;
@@ -351,6 +352,9 @@
 					font-weight: @rp-semibold-weight;
 					font-style: normal;
 				}
+				.rp-comment-actions {
+					a { color: @rp-type-blue; }
+				}
 
 			.rp-content-highlight {
 				color: @rp-type-darkgrey;
@@ -414,12 +418,6 @@
 			margin: 0;
 			color: @rp-type-darkgrey;
 		}
-
-		.rp-comment-metadata {
-			color: @rp-type-blue;
-			font-size: @rp-small-font-size;
-			margin: 0;
-		}
 	
 	.rp-comment-resolver {
 		color: @rp-type-blue;
@@ -452,6 +450,7 @@
 		border: solid 1px @rp-border-grey;
 		resize: vertical;
 		color: @rp-type-darkgrey;
+		margin-top: 3px;
 	}
 
 .rp-icon-delete {
@@ -592,6 +591,7 @@
 	z-index: 2;
 }
 	.rp-nav-item {
+		display: block;
 		color: lighten(@rp-type-blue, 25%);
 		flex: 0 0 50%;
 		border-top: solid 3px transparent;
@@ -790,7 +790,7 @@
 	position: absolute;
 	width: 300px;
 	left: -150px;
-	max-height: 90%;
+	max-height: ~"calc(100vh - 100px)";
 	margin-top: @rp-entry-arrow-width * 1.5;
 	margin-left: 1em;
 	background-color: @rp-bg-blue;
@@ -813,7 +813,9 @@
 	}
 }
 	.resolved-comments-scroller {
-		flex: 0 0 100%;
+		flex: 0 0 auto; // Can't use 100% in the flex-basis key here, IE won't account for padding.
+		width: 100%; // We need to set the width explicitly, as flex-basis won't work.
+		max-height: ~"calc(100vh - 100px)"; // We also need to explicitly set the max-height, IE won't compute the flex-determined height.
 		padding: 5px;
 		overflow-y: auto;
 	}
@@ -841,6 +843,7 @@
 	border-bottom-left-radius: 3px;
 	font-size: 10px;
 	z-index: 2;
+	white-space: nowrap;
 
 	&.rp-track-changes-indicator-on-dark {
 		background-color: rgba(88, 88, 88, .8);
@@ -865,3 +868,10 @@
 		display: block;
 	}
 }
+
+// Helper class for elements which aren't treated as flex-items by IE10, e.g:
+// * inline items;
+// * unknown elements (elements which aren't standard DOM elements, such as custom element directives)
+.rp-flex-block {
+	display: block;
+}

+ 76 - 6
services/web/test/UnitTests/coffee/Announcement/AnnouncementsHandlerTests.coffee

@@ -10,18 +10,21 @@ expect = require("chai").expect
 describe 'AnnouncementsHandler', ->
 
 	beforeEach ->
-		@user_id = "some_id"
+		@user = 
+			_id:"some_id"
+			email: "someone@gmail.com"
 		@AnalyticsManager =
 			getLastOccurance: sinon.stub()
 		@BlogHandler =
 			getLatestAnnouncements:sinon.stub()
+		@settings = {}
 		@handler = SandboxedModule.require modulePath, requires:
 			"../Analytics/AnalyticsManager":@AnalyticsManager
 			"../Blog/BlogHandler":@BlogHandler
+			"settings-sharelatex":@settings
 			"logger-sharelatex":
 				log:->
 
-
 	describe "getUnreadAnnouncements", ->
 		beforeEach ->
 			@stubbedAnnouncements = [
@@ -44,7 +47,7 @@ describe 'AnnouncementsHandler', ->
 
 		it "should mark all announcements as read is false", (done)->
 			@AnalyticsManager.getLastOccurance.callsArgWith(2, null, [])
-			@handler.getUnreadAnnouncements @user_id, (err, announcements)=>
+			@handler.getUnreadAnnouncements @user, (err, announcements)=>
 				announcements[0].read.should.equal false
 				announcements[1].read.should.equal false
 				announcements[2].read.should.equal false
@@ -53,7 +56,7 @@ describe 'AnnouncementsHandler', ->
 
 		it "should should be sorted again to ensure correct order", (done)->
 			@AnalyticsManager.getLastOccurance.callsArgWith(2, null, [])
-			@handler.getUnreadAnnouncements @user_id, (err, announcements)=>
+			@handler.getUnreadAnnouncements @user, (err, announcements)=>
 				announcements[3].should.equal @stubbedAnnouncements[2]
 				announcements[2].should.equal @stubbedAnnouncements[3]
 				announcements[1].should.equal @stubbedAnnouncements[1]
@@ -62,7 +65,7 @@ describe 'AnnouncementsHandler', ->
 
 		it "should return older ones marked as read as well", (done)->
 			@AnalyticsManager.getLastOccurance.callsArgWith(2, null, {segmentation:{blogPostId:"/2014/04/12/title-date-irrelivant"}})
-			@handler.getUnreadAnnouncements @user_id, (err, announcements)=>
+			@handler.getUnreadAnnouncements @user, (err, announcements)=>
 				announcements[0].id.should.equal @stubbedAnnouncements[0].id
 				announcements[0].read.should.equal false
 
@@ -79,7 +82,7 @@ describe 'AnnouncementsHandler', ->
 
 		it "should return all of them marked as read", (done)->
 			@AnalyticsManager.getLastOccurance.callsArgWith(2, null, {segmentation:{blogPostId:"/2016/11/01/introducting-latex-code-checker"}})
-			@handler.getUnreadAnnouncements @user_id, (err, announcements)=>
+			@handler.getUnreadAnnouncements @user, (err, announcements)=>
 				announcements[0].read.should.equal true
 				announcements[1].read.should.equal true
 				announcements[2].read.should.equal true
@@ -87,3 +90,70 @@ describe 'AnnouncementsHandler', ->
 				done()
 
 
+		describe "with custom domain announcements", ->
+			beforeEach ->
+				@stubbedDomainSpecificAnn = [
+					{
+						domains: ["gmail.com", 'yahoo.edu']
+						title: "some message"
+						excerpt: "read this"
+						url:"http://www.sharelatex.com/i/somewhere"
+						id:"iaaa"
+						date: new Date(1308369600000).toString()
+					}
+				]
+
+				@handler._domainSpecificAnnouncements = sinon.stub().returns(@stubbedDomainSpecificAnn)
+
+			it "should insert the domain specific in the correct place", (done)->
+				@AnalyticsManager.getLastOccurance.callsArgWith(2, null, [])
+				@handler.getUnreadAnnouncements @user, (err, announcements)=>
+					announcements[4].should.equal @stubbedAnnouncements[2]
+					announcements[3].should.equal @stubbedAnnouncements[3]
+					announcements[2].should.equal @stubbedAnnouncements[1]
+					announcements[1].should.equal @stubbedDomainSpecificAnn[0]
+					announcements[0].should.equal @stubbedAnnouncements[0]
+					done()
+
+		describe "_domainSpecificAnnouncements", ->
+			beforeEach ->
+				@settings.domainAnnouncements = [
+					{
+						domains: ["gmail.com", 'yahoo.edu']
+						title: "some message"
+						excerpt: "read this"
+						url:"http://www.sharelatex.com/i/somewhere"
+						id:"id1"
+						date: new Date(1308369600000).toString()
+					},	{
+						domains: ["gmail.com", 'yahoo.edu']
+						title: "some message"
+						excerpt: "read this"
+						url:"http://www.sharelatex.com/i/somewhere"
+						date: new Date(1308369600000).toString()
+					},	{
+						domains: ["gmail.com", 'yahoo.edu']
+						title: "some message"
+						excerpt: "read this"
+						url:"http://www.sharelatex.com/i/somewhere"
+						id:"id3"
+						date: new Date(1308369600000).toString()
+					}
+				]
+
+			it "should filter announcments which don't have an id", (done) ->
+				result = @handler._domainSpecificAnnouncements "someone@gmail.com"
+				result.length.should.equal 2
+				result[0].id.should.equal "id1"
+				result[1].id.should.equal "id3"
+				done()
+
+
+			it "should match on domain", (done) ->
+				@settings.domainAnnouncements[2].domains = ["yahoo.com"]
+				result = @handler._domainSpecificAnnouncements "someone@gmail.com"
+				result.length.should.equal 1
+				result[0].id.should.equal "id1"
+				done()
+
+

+ 71 - 66
services/web/test/UnitTests/coffee/Collaborators/CollaboratorsInviteControllerTests.coffee

@@ -14,11 +14,20 @@ describe "CollaboratorsInviteController", ->
 		@user =
 			_id: 'id'
 		@AnalyticsManger = recordEvent: sinon.stub()
+		@sendingUser = null
 		@AuthenticationController =
-			getSessionUser: (req) => req.session.user
+			getSessionUser: (req) => 
+				@sendingUser = req.session.user
+				return @sendingUser
+		
+		@RateLimiter =
+			addCount: sinon.stub
+
+		@LimitationsManager = {}
+
 		@CollaboratorsInviteController = SandboxedModule.require modulePath, requires:
 			"../Project/ProjectGetter": @ProjectGetter = {}
-			'../Subscription/LimitationsManager' : @LimitationsManager = {}
+			'../Subscription/LimitationsManager' : @LimitationsManager
 			'../User/UserGetter': @UserGetter = {getUser: sinon.stub()}
 			"./CollaboratorsHandler": @CollaboratorsHandler = {}
 			"./CollaboratorsInviteHandler": @CollaboratorsInviteHandler = {}
@@ -28,6 +37,7 @@ describe "CollaboratorsInviteController", ->
 			"../Analytics/AnalyticsManager": @AnalyticsManger
 			'../Authentication/AuthenticationController': @AuthenticationController
 			'settings-sharelatex': @settings = {}
+			"../../infrastructure/RateLimiter":@RateLimiter
 		@res = new MockResponse()
 		@req = new MockRequest()
 
@@ -104,15 +114,10 @@ describe "CollaboratorsInviteController", ->
 		describe 'when all goes well', ->
 
 			beforeEach ->
-				@_checkShouldInviteEmail = sinon.stub(
-					@CollaboratorsInviteController, '_checkShouldInviteEmail'
-				).callsArgWith(1, null, true)
+				@CollaboratorsInviteController._checkShouldInviteEmail = sinon.stub().callsArgWith(2, null, true)
 				@LimitationsManager.canAddXCollaborators = sinon.stub().callsArgWith(2, null, true)
 				@CollaboratorsInviteController.inviteToProject @req, @res, @next
 
-			afterEach ->
-				@_checkShouldInviteEmail.restore()
-
 			it 'should produce json response', ->
 				@res.json.callCount.should.equal 1
 				({invite: @invite}).should.deep.equal(@res.json.firstCall.args[0])
@@ -122,8 +127,8 @@ describe "CollaboratorsInviteController", ->
 				@LimitationsManager.canAddXCollaborators.calledWith(@project_id).should.equal true
 
 			it 'should have called _checkShouldInviteEmail', ->
-				@_checkShouldInviteEmail.callCount.should.equal 1
-				@_checkShouldInviteEmail.calledWith(@targetEmail).should.equal true
+				@CollaboratorsInviteController._checkShouldInviteEmail.callCount.should.equal 1
+				@CollaboratorsInviteController._checkShouldInviteEmail.calledWith(@sendingUser, @targetEmail).should.equal true
 
 			it 'should have called inviteToProject', ->
 				@CollaboratorsInviteHandler.inviteToProject.callCount.should.equal 1
@@ -136,22 +141,17 @@ describe "CollaboratorsInviteController", ->
 		describe 'when the user is not allowed to add more collaborators', ->
 
 			beforeEach ->
-				@_checkShouldInviteEmail = sinon.stub(
-					@CollaboratorsInviteController, '_checkShouldInviteEmail'
-				).callsArgWith(1, null, true)
+				@CollaboratorsInviteController._checkShouldInviteEmail = sinon.stub().callsArgWith(2, null, true)
 				@LimitationsManager.canAddXCollaborators = sinon.stub().callsArgWith(2, null, false)
 				@CollaboratorsInviteController.inviteToProject @req, @res, @next
 
-			afterEach ->
-				@_checkShouldInviteEmail.restore()
-
 			it 'should produce json response without an invite', ->
 				@res.json.callCount.should.equal 1
 				({invite: null}).should.deep.equal(@res.json.firstCall.args[0])
 
 			it 'should not have called _checkShouldInviteEmail', ->
-				@_checkShouldInviteEmail.callCount.should.equal 0
-				@_checkShouldInviteEmail.calledWith(@targetEmail).should.equal false
+				@CollaboratorsInviteController._checkShouldInviteEmail.callCount.should.equal 0
+				@CollaboratorsInviteController._checkShouldInviteEmail.calledWith(@sendingUser, @targetEmail).should.equal false
 
 			it 'should not have called inviteToProject', ->
 				@CollaboratorsInviteHandler.inviteToProject.callCount.should.equal 0
@@ -159,23 +159,18 @@ describe "CollaboratorsInviteController", ->
 		describe 'when canAddXCollaborators produces an error', ->
 
 			beforeEach ->
-				@_checkShouldInviteEmail = sinon.stub(
-					@CollaboratorsInviteController, '_checkShouldInviteEmail'
-				).callsArgWith(1, null, true)
+				@CollaboratorsInviteController._checkShouldInviteEmail = sinon.stub().callsArgWith(2, null, true)
 				@err = new Error('woops')
 				@LimitationsManager.canAddXCollaborators = sinon.stub().callsArgWith(2, @err)
 				@CollaboratorsInviteController.inviteToProject @req, @res, @next
 
-			afterEach ->
-				@_checkShouldInviteEmail.restore()
-
 			it 'should call next with an error', ->
 				@next.callCount.should.equal 1
 				@next.calledWith(@err).should.equal true
 
 			it 'should not have called _checkShouldInviteEmail', ->
-				@_checkShouldInviteEmail.callCount.should.equal 0
-				@_checkShouldInviteEmail.calledWith(@targetEmail).should.equal false
+				@CollaboratorsInviteController._checkShouldInviteEmail.callCount.should.equal 0
+				@CollaboratorsInviteController._checkShouldInviteEmail.calledWith(@sendingUser, @targetEmail).should.equal false
 
 			it 'should not have called inviteToProject', ->
 				@CollaboratorsInviteHandler.inviteToProject.callCount.should.equal 0
@@ -183,16 +178,11 @@ describe "CollaboratorsInviteController", ->
 		describe 'when inviteToProject produces an error', ->
 
 			beforeEach ->
-				@_checkShouldInviteEmail = sinon.stub(
-					@CollaboratorsInviteController, '_checkShouldInviteEmail'
-				).callsArgWith(1, null, true)
+				@CollaboratorsInviteController._checkShouldInviteEmail = sinon.stub().callsArgWith(2, null, true)
 				@err = new Error('woops')
 				@CollaboratorsInviteHandler.inviteToProject = sinon.stub().callsArgWith(4, @err)
 				@CollaboratorsInviteController.inviteToProject @req, @res, @next
 
-			afterEach ->
-				@_checkShouldInviteEmail.restore()
-
 			it 'should call next with an error', ->
 				@next.callCount.should.equal 1
 				@next.calledWith(@err).should.equal true
@@ -202,8 +192,8 @@ describe "CollaboratorsInviteController", ->
 				@LimitationsManager.canAddXCollaborators.calledWith(@project_id).should.equal true
 
 			it 'should have called _checkShouldInviteEmail', ->
-				@_checkShouldInviteEmail.callCount.should.equal 1
-				@_checkShouldInviteEmail.calledWith(@targetEmail).should.equal true
+				@CollaboratorsInviteController._checkShouldInviteEmail.callCount.should.equal 1
+				@CollaboratorsInviteController._checkShouldInviteEmail.calledWith(@sendingUser, @targetEmail).should.equal true
 
 			it 'should have called inviteToProject', ->
 				@CollaboratorsInviteHandler.inviteToProject.callCount.should.equal 1
@@ -212,22 +202,17 @@ describe "CollaboratorsInviteController", ->
 		describe 'when _checkShouldInviteEmail disallows the invite', ->
 
 			beforeEach ->
-				@_checkShouldInviteEmail = sinon.stub(
-					@CollaboratorsInviteController, '_checkShouldInviteEmail'
-				).callsArgWith(1, null, false)
+				@CollaboratorsInviteController._checkShouldInviteEmail = sinon.stub().callsArgWith(2, null, false)
 				@LimitationsManager.canAddXCollaborators = sinon.stub().callsArgWith(2, null, true)
 				@CollaboratorsInviteController.inviteToProject @req, @res, @next
 
-			afterEach ->
-				@_checkShouldInviteEmail.restore()
-
 			it 'should produce json response with no invite, and an error property', ->
 				@res.json.callCount.should.equal 1
 				({invite: null, error: 'cannot_invite_non_user'}).should.deep.equal(@res.json.firstCall.args[0])
 
 			it 'should have called _checkShouldInviteEmail', ->
-				@_checkShouldInviteEmail.callCount.should.equal 1
-				@_checkShouldInviteEmail.calledWith(@targetEmail).should.equal true
+				@CollaboratorsInviteController._checkShouldInviteEmail.callCount.should.equal 1
+				@CollaboratorsInviteController._checkShouldInviteEmail.calledWith(@sendingUser, @targetEmail).should.equal true
 
 			it 'should not have called inviteToProject', ->
 				@CollaboratorsInviteHandler.inviteToProject.callCount.should.equal 0
@@ -235,22 +220,17 @@ describe "CollaboratorsInviteController", ->
 		describe 'when _checkShouldInviteEmail produces an error', ->
 
 			beforeEach ->
-				@_checkShouldInviteEmail = sinon.stub(
-					@CollaboratorsInviteController, '_checkShouldInviteEmail'
-				).callsArgWith(1, new Error('woops'))
+				@CollaboratorsInviteController._checkShouldInviteEmail = sinon.stub().callsArgWith(2, new Error('woops'))
 				@LimitationsManager.canAddXCollaborators = sinon.stub().callsArgWith(2, null, true)
 				@CollaboratorsInviteController.inviteToProject @req, @res, @next
 
-			afterEach ->
-				@_checkShouldInviteEmail.restore()
-
 			it 'should call next with an error', ->
 				@next.callCount.should.equal 1
 				@next.calledWith(@err).should.equal true
 
 			it 'should have called _checkShouldInviteEmail', ->
-				@_checkShouldInviteEmail.callCount.should.equal 1
-				@_checkShouldInviteEmail.calledWith(@targetEmail).should.equal true
+				@CollaboratorsInviteController._checkShouldInviteEmail.callCount.should.equal 1
+				@CollaboratorsInviteController._checkShouldInviteEmail.calledWith(@sendingUser, @targetEmail).should.equal true
 
 			it 'should not have called inviteToProject', ->
 				@CollaboratorsInviteHandler.inviteToProject.callCount.should.equal 0
@@ -260,14 +240,10 @@ describe "CollaboratorsInviteController", ->
 			beforeEach ->
 				@req.session.user = {_id: 'abc', email: 'me@example.com'}
 				@req.body.email = 'me@example.com'
-				@_checkShouldInviteEmail = sinon.stub(
-					@CollaboratorsInviteController, '_checkShouldInviteEmail'
-				).callsArgWith(1, null, true)
+				@CollaboratorsInviteController._checkShouldInviteEmail = sinon.stub().callsArgWith(2, null, true)
 				@LimitationsManager.canAddXCollaborators = sinon.stub().callsArgWith(2, null, true)
 				@CollaboratorsInviteController.inviteToProject @req, @res, @next
 
-			afterEach ->
-				@_checkShouldInviteEmail.restore()
 
 			it 'should reject action, return json response with error code', ->
 				@res.json.callCount.should.equal 1
@@ -277,7 +253,7 @@ describe "CollaboratorsInviteController", ->
 				@LimitationsManager.canAddXCollaborators.callCount.should.equal 0
 
 			it 'should not have called _checkShouldInviteEmail', ->
-				@_checkShouldInviteEmail.callCount.should.equal 0
+				@CollaboratorsInviteController._checkShouldInviteEmail.callCount.should.equal 0
 
 			it 'should not have called inviteToProject', ->
 				@CollaboratorsInviteHandler.inviteToProject.callCount.should.equal 0
@@ -702,13 +678,14 @@ describe "CollaboratorsInviteController", ->
 
 		beforeEach ->
 			@email = 'user@example.com'
-			@call = (callback) =>
-				@CollaboratorsInviteController._checkShouldInviteEmail @email, callback
+
 
 		describe 'when we should be restricting to existing accounts', ->
 
 			beforeEach ->
 				@settings.restrictInvitesToExistingAccounts = true
+				@call = (callback) =>
+					@CollaboratorsInviteController._checkShouldInviteEmail {}, @email, callback
 
 			describe 'when user account is present', ->
 
@@ -753,18 +730,46 @@ describe "CollaboratorsInviteController", ->
 						expect(shouldAllow).to.equal undefined
 						done()
 
-		describe 'when we should not be restricting', ->
+		describe 'when we should not be restricting on only registered users but do rate limit', ->
 
 			beforeEach ->
 				@settings.restrictInvitesToExistingAccounts = false
+				@sendingUser = 
+					_id:"32312313"
+					features:
+						collaborators:17.8
+				@UserGetter.getUser = sinon.stub().callsArgWith(2, null, @sendingUser)
+
+			it 'should callback with `true` when rate limit under', (done) ->
+				@RateLimiter.addCount = sinon.stub().callsArgWith(1, null, true)
+				@CollaboratorsInviteController._checkShouldInviteEmail @sendingUser, @email, (err, result)=>
+					@RateLimiter.addCount.called.should.equal true
+					result.should.equal true
+					done()
 
-			it 'should callback with `true`', (done) ->
-				@call (err, shouldAllow) =>
-					expect(err).to.equal null
-					expect(shouldAllow).to.equal true
+			it 'should callback with `false` when rate limit hit', (done) ->
+				@RateLimiter.addCount = sinon.stub().callsArgWith(1, null, false)
+				@CollaboratorsInviteController._checkShouldInviteEmail @sendingUser, @email, (err, result)=>
+					@RateLimiter.addCount.called.should.equal true
+					result.should.equal false
+					done()
+		
+			it 'should call rate limiter with 10x the collaborators', (done) ->
+				@RateLimiter.addCount = sinon.stub().callsArgWith(1, null, true)
+				@CollaboratorsInviteController._checkShouldInviteEmail @sendingUser, @email, (err, result)=>
+					@RateLimiter.addCount.args[0][0].throttle.should.equal(178)
 					done()
 
-			it 'should not have called getUser', (done) ->
-				@call (err, shouldAllow) =>
-					@UserGetter.getUser.callCount.should.equal 0
+			it 'should call rate limiter with 200 when collaborators is -1', (done) ->
+				@sendingUser.features.collaborators = -1
+				@RateLimiter.addCount = sinon.stub().callsArgWith(1, null, true)
+				@CollaboratorsInviteController._checkShouldInviteEmail @sendingUser, @email, (err, result)=>
+					@RateLimiter.addCount.args[0][0].throttle.should.equal(200)
 					done()
+
+			it 'should call rate limiter with 10 when user has no collaborators set', (done) ->
+				delete @sendingUser.features
+				@RateLimiter.addCount = sinon.stub().callsArgWith(1, null, true)
+				@CollaboratorsInviteController._checkShouldInviteEmail @sendingUser, @email, (err, result)=>
+					@RateLimiter.addCount.args[0][0].throttle.should.equal(10)
+					done()

+ 75 - 0
services/web/test/UnitTests/coffee/Comments/CommentsControllerTests.coffee

@@ -23,6 +23,7 @@ describe "CommentsController", ->
 			'../Authentication/AuthenticationController': @AuthenticationController
 			'../User/UserInfoManager': @UserInfoManager = {}
 			'../User/UserInfoController': @UserInfoController = {}
+			"../DocumentUpdater/DocumentUpdaterHandler": @DocumentUpdaterHandler = {}
 		@req = {}
 		@res =
 			json: sinon.stub()
@@ -134,6 +135,80 @@ describe "CommentsController", ->
 		it "should return a success code", ->
 			@res.send.calledWith(204).should.equal
 
+	describe "deleteThread", ->
+		beforeEach ->
+			@req.params =
+				project_id: @project_id = "mock-project-id"
+				doc_id: @doc_id = "mock-doc-id"
+				thread_id: @thread_id = "mock-thread-id"
+			@DocumentUpdaterHandler.deleteThread = sinon.stub().yields()
+			@ChatApiHandler.deleteThread = sinon.stub().yields()
+			@CommentsController.deleteThread @req, @res
+		
+		it "should ask the doc udpater to delete the thread", ->
+			@DocumentUpdaterHandler.deleteThread
+				.calledWith(@project_id, @doc_id, @thread_id)
+				.should.equal true
+
+		it "should ask the chat handler to delete the thread", ->
+			@ChatApiHandler.deleteThread
+				.calledWith(@project_id, @thread_id)
+				.should.equal true
+
+		it "should tell the client the thread was deleted", ->
+			@EditorRealTimeController.emitToRoom
+				.calledWith(@project_id, "delete-thread", @thread_id)
+				.should.equal true
+
+		it "should return a success code", ->
+			@res.send.calledWith(204).should.equal
+
+	describe "editMessage", ->
+		beforeEach ->
+			@req.params =
+				project_id: @project_id = "mock-project-id"
+				thread_id: @thread_id = "mock-thread-id"
+				message_id: @message_id = "mock-thread-id"
+			@req.body =
+				content: @content = "mock-content"
+			@ChatApiHandler.editMessage = sinon.stub().yields()
+			@CommentsController.editMessage @req, @res
+
+		it "should ask the chat handler to edit the comment", ->
+			@ChatApiHandler.editMessage
+				.calledWith(@project_id, @thread_id, @message_id, @content)
+				.should.equal true
+
+		it "should tell the client the comment was edited", ->
+			@EditorRealTimeController.emitToRoom
+				.calledWith(@project_id, "edit-message", @thread_id, @message_id, @content)
+				.should.equal true
+
+		it "should return a success code", ->
+			@res.send.calledWith(204).should.equal
+
+	describe "deleteMessage", ->
+		beforeEach ->
+			@req.params =
+				project_id: @project_id = "mock-project-id"
+				thread_id: @thread_id = "mock-thread-id"
+				message_id: @message_id = "mock-thread-id"
+			@ChatApiHandler.deleteMessage = sinon.stub().yields()
+			@CommentsController.deleteMessage @req, @res
+
+		it "should ask the chat handler to deleted the message", ->
+			@ChatApiHandler.deleteMessage
+				.calledWith(@project_id, @thread_id, @message_id)
+				.should.equal true
+
+		it "should tell the client the message was deleted", ->
+			@EditorRealTimeController.emitToRoom
+				.calledWith(@project_id, "delete-message", @thread_id, @message_id)
+				.should.equal true
+
+		it "should return a success code", ->
+			@res.send.calledWith(204).should.equal
+
 	describe "_injectUserInfoIntoThreads", ->
 		beforeEach ->
 			@users = {

+ 35 - 0
services/web/test/UnitTests/coffee/DocumentUpdater/DocumentUpdaterHandlerTests.coffee

@@ -330,3 +330,38 @@ describe 'DocumentUpdaterHandler', ->
 				@callback
 					.calledWith(new Error("doc updater returned failure status code: 500"))
 					.should.equal true
+
+	describe "deleteThread", ->
+		beforeEach ->
+			@thread_id = "mock-thread-id-1"
+			@callback = sinon.stub()
+
+		describe "successfully", ->
+			beforeEach ->
+				@request.del = sinon.stub().callsArgWith(1, null, {statusCode: 200}, @body)
+				@handler.deleteThread @project_id, @doc_id, @thread_id, @callback
+
+			it 'should delete the thread in the document updater', ->
+				url = "#{@settings.apis.documentupdater.url}/project/#{@project_id}/doc/#{@doc_id}/comment/#{@thread_id}"
+				@request.del.calledWith(url).should.equal true
+
+			it "should call the callback", ->
+				@callback.calledWith(null).should.equal true
+
+		describe "when the document updater API returns an error", ->
+			beforeEach ->
+				@request.del = sinon.stub().callsArgWith(1, @error = new Error("something went wrong"), null, null)
+				@handler.deleteThread @project_id, @doc_id, @thread_id, @callback
+
+			it "should return an error to the callback", ->
+				@callback.calledWith(@error).should.equal true
+
+		describe "when the document updater returns a failure error code", ->
+			beforeEach ->
+				@request.del = sinon.stub().callsArgWith(1, null, { statusCode: 500 }, "")
+				@handler.deleteThread @project_id, @doc_id, @thread_id, @callback
+
+			it "should return the callback with an error", ->
+				@callback
+					.calledWith(new Error("doc updater returned failure status code: 500"))
+					.should.equal true

+ 45 - 49
services/web/test/UnitTests/coffee/Security/LoginRateLimiterTests.coffee

@@ -1,78 +1,74 @@
 SandboxedModule = require('sandboxed-module')
 sinon = require('sinon')
 require('chai').should()
+expect = require('chai').expect
 modulePath = require('path').join __dirname, '../../../../app/js/Features/Security/LoginRateLimiter'
 
-buildKey = (k)->
-	return "LoginRateLimit:#{k}"
 
 describe "LoginRateLimiter", ->
+
 	beforeEach ->
 		@email = "bob@bob.com"
-		@incrStub = sinon.stub()
-		@getStub = sinon.stub()
-		@execStub = sinon.stub()
-		@expireStub = sinon.stub()
-		@delStub = sinon.stub().callsArgWith(1)
-
-		@rclient = 
-			auth:->
-			del: @delStub
-			multi: =>
-				incr: @incrStub
-				expire: @expireStub
-				get: @getStub
-				exec: @execStub
+		@RateLimiter =
+			clearRateLimit: sinon.stub()
+			addCount: sinon.stub()
 
 		@LoginRateLimiter = SandboxedModule.require modulePath, requires:
-			'redis-sharelatex' : createClient: () => @rclient
-			"settings-sharelatex":{redis:{}}
-	
+			'../../infrastructure/RateLimiter': @RateLimiter
+
 	describe "processLoginRequest", ->
 
-		it "should inc the counter for login requests in redis", (done)->
-			@execStub.callsArgWith(0, "null", ["",""])
-			@LoginRateLimiter.processLoginRequest @email, =>
-				@incrStub.calledWith(buildKey(@email)).should.equal true
-				done()
+		beforeEach ->
+			@RateLimiter.addCount = sinon.stub().callsArgWith(1, null, true)
 
-		it "should set a expire", (done)->
-			@execStub.callsArgWith(0, "null", ["",""])
-			@LoginRateLimiter.processLoginRequest @email, =>
-				@expireStub.calledWith(buildKey(@email), 60 * 2).should.equal true
+		it 'should call RateLimiter.addCount', (done) ->
+			@LoginRateLimiter.processLoginRequest @email, (err, allow) =>
+				@RateLimiter.addCount.callCount.should.equal 1
+				expect(@RateLimiter.addCount.lastCall.args[0].endpointName).to.equal 'login'
+				expect(@RateLimiter.addCount.lastCall.args[0].subjectName).to.equal @email
 				done()
 
-		it "should return true if the count is below 10", (done)->
-			@execStub.callsArgWith(0, "null", ["", 9])
-			@LoginRateLimiter.processLoginRequest @email, (err, isAllowed)=>
-				isAllowed.should.equal true
-				done()
+		describe 'when login is allowed', ->
 
-		it "should return true if the count is 10", (done)->
-			@execStub.callsArgWith(0, "null", ["", 10])
-			@LoginRateLimiter.processLoginRequest @email, (err, isAllowed)=>
-				isAllowed.should.equal true
-				done()
+			beforeEach ->
+				@RateLimiter.addCount = sinon.stub().callsArgWith(1, null, true)
 
-		it "should return false if the count is above 10", (done)->
-			@execStub.callsArgWith(0, "null", ["", 11])
-			@LoginRateLimiter.processLoginRequest @email, (err, isAllowed)=>
-				isAllowed.should.equal false
-				done()
+			it 'should call pass allow=true', (done) ->
+				@LoginRateLimiter.processLoginRequest @email, (err, allow) =>
+					expect(err).to.equal null
+					expect(allow).to.equal true
+					done()
 
+		describe 'when login is blocked', ->
 
-	describe "smoke test user", ->
+			beforeEach ->
+				@RateLimiter.addCount = sinon.stub().callsArgWith(1, null, false)
 
-		it "should have a higher limit", (done)->
-			done()
+			it 'should call pass allow=false', (done) ->
+				@LoginRateLimiter.processLoginRequest @email, (err, allow) =>
+					expect(err).to.equal null
+					expect(allow).to.equal false
+					done()
 
+		describe 'when addCount produces an error', ->
 
+			beforeEach ->
+				@RateLimiter.addCount = sinon.stub().callsArgWith(1, new Error('woops'))
 
+			it 'should produce an error', (done) ->
+				@LoginRateLimiter.processLoginRequest @email, (err, allow) =>
+					expect(err).to.not.equal null
+					expect(err).to.be.instanceof Error
+					done()
 
 
 	describe "recordSuccessfulLogin", ->
 
-		it "should delete the user key", (done)->
+		beforeEach ->
+			@RateLimiter.clearRateLimit = sinon.stub().callsArgWith 2, null
+
+		it "should call clearRateLimit", (done)->
 			@LoginRateLimiter.recordSuccessfulLogin @email, =>
-				@delStub.calledWith(buildKey(@email)).should.equal true
-				done()
+				@RateLimiter.clearRateLimit.callCount.should.equal 1
+				@RateLimiter.clearRateLimit.calledWith('login', @email).should.equal true
+				done()

+ 46 - 37
services/web/test/UnitTests/coffee/infrastructure/RateLimterTests.coffee

@@ -6,7 +6,7 @@ expect = chai.expect
 modulePath = "../../../../app/js/infrastructure/RateLimiter.js"
 SandboxedModule = require('sandboxed-module')
 
-describe "FileStoreHandler", ->
+describe "RateLimiter", ->
 
 	beforeEach ->
 		@settings = 
@@ -15,23 +15,27 @@ describe "FileStoreHandler", ->
 					port:"1234"
 					host:"somewhere"
 					password: "password"
-		@redbackInstance = 
-			addCount: sinon.stub()
-
-		@redback = 
-			createRateLimit: sinon.stub().returns(@redbackInstance)
-		@redis = 
-			createClient: ->
-				return auth:->
+		@rclient =
+			incr: sinon.stub()
+			get: sinon.stub()
+			expire: sinon.stub()
+			exec: sinon.stub()
+		@rclient.multi = sinon.stub().returns(@rclient)
+		@RedisWrapper =
+			client: sinon.stub().returns(@rclient)
+
+		@limiterFn = sinon.stub()
+		@RollingRateLimiter = (opts) =>
+			return @limiterFn
 
 		@limiter = SandboxedModule.require modulePath, requires:
+			"rolling-rate-limiter": @RollingRateLimiter
 			"settings-sharelatex":@settings
 			"logger-sharelatex" : @logger = {log:sinon.stub(), err:sinon.stub()}
-			"redis-sharelatex": @redis
-			"redback": use: => @redback
+			"./RedisWrapper": @RedisWrapper
 
 		@endpointName = "compiles"
-		@subject = "some project id"
+		@subject = "some-project-id"
 		@timeInterval = 20
 		@throttleLimit = 5
 
@@ -40,43 +44,48 @@ describe "FileStoreHandler", ->
 			subjectName: @subject
 			throttle: @throttleLimit
 			timeInterval: @timeInterval
+		@key = "RateLimiter:#{@endpointName}:{#{@subject}}"
+
+
 
 
-	describe "addCount", ->
+	describe 'when action is permitted', ->
 
 		beforeEach ->
-			@redbackInstance.addCount.callsArgWith(2, null, 10)
+			@limiterFn = sinon.stub().callsArgWith(1, null, 0, 22)
 
-		it "should use correct namespace", (done)->
-			@limiter.addCount @details, =>
-				@redback.createRateLimit.calledWith(@endpointName).should.equal true
+		it 'should not produce and error', (done) ->
+			@limiter.addCount {}, (err, should) ->
+				expect(err).to.equal null
 				done()
 
-		it "should only call it once", (done)->
-			@limiter.addCount @details, =>
-				@redbackInstance.addCount.callCount.should.equal 1
+		it 'should callback with true', (done) ->
+			@limiter.addCount {}, (err, should) ->
+				expect(should).to.equal true
 				done()
 
-		it  "should use the subjectName", (done)->
-			@limiter.addCount @details, =>
-				@redbackInstance.addCount.calledWith(@details.subjectName, @details.timeInterval).should.equal true
-				done()
+	describe 'when action is not permitted', ->
 
-		it "should return true if the count is less than throttle", (done)-> 
-			@details.throttle = 100
-			@limiter.addCount @details, (err, canProcess)=>
-				canProcess.should.equal true
-				done()
+		beforeEach ->
+			@limiterFn = sinon.stub().callsArgWith(1, null, 4000, 0)
 
-		it "should return true if the count is less than throttle", (done)-> 
-			@details.throttle = 1
-			@limiter.addCount @details, (err, canProcess)=>
-				canProcess.should.equal false
+		it 'should not produce and error', (done) ->
+			@limiter.addCount {}, (err, should) ->
+				expect(err).to.equal null
 				done()
 
-		it "should return false if the limit is matched", (done)-> 
-			@details.throttle = 10
-			@limiter.addCount @details, (err, canProcess)=>
-				canProcess.should.equal false
+		it 'should callback with false', (done) ->
+			@limiter.addCount {}, (err, should) ->
+				expect(should).to.equal false
 				done()
 
+	describe 'when limiter produces an error', ->
+
+		beforeEach ->
+			@limiterFn = sinon.stub().callsArgWith(1, new Error('woops'))
+
+		it 'should produce and error', (done) ->
+			@limiter.addCount {}, (err, should) ->
+				expect(err).to.not.equal null
+				expect(err).to.be.instanceof Error
+				done()

+ 66 - 0
services/web/test/UnitTests/coffee/infrastructure/RedisWrapperTests.coffee

@@ -0,0 +1,66 @@
+assert = require("chai").assert
+sinon = require('sinon')
+chai = require('chai')
+should = chai.should()
+expect = chai.expect
+modulePath = "../../../../app/js/infrastructure/RedisWrapper.js"
+SandboxedModule = require('sandboxed-module')
+
+describe 'RedisWrapper', ->
+
+	beforeEach ->
+		@featureName = 'somefeature'
+		@settings =
+			redis:
+				web:
+					port:"1234"
+					host:"somewhere"
+					password: "password"
+				somefeature: {}
+		@normalRedisInstance =
+			thisIsANormalRedisInstance: true
+			n: 1
+		@clusterRedisInstance =
+			thisIsAClusterRedisInstance: true
+			n: 2
+		@redis =
+			createClient: sinon.stub().returns(@normalRedisInstance)
+		@ioredis =
+			Cluster: sinon.stub().returns(@clusterRedisInstance)
+		@logger = {log: sinon.stub()}
+
+		@RedisWrapper = SandboxedModule.require modulePath, requires:
+			'logger-sharelatex': @logger
+			'settings-sharelatex': @settings
+			'redis-sharelatex': @redis
+			'ioredis': @ioredis
+
+	describe 'client', ->
+
+		beforeEach ->
+			@call = () =>
+				@RedisWrapper.client(@featureName)
+
+		describe 'when feature uses cluster', ->
+
+			beforeEach ->
+				@settings.redis.somefeature =
+					cluster: [1, 2, 3]
+
+			it 'should return a cluster client', ->
+				client = @call()
+				expect(client).to.equal @clusterRedisInstance
+				expect(client.__is_redis_cluster).to.equal true
+
+		describe 'when feature uses normal redis', ->
+
+			beforeEach ->
+				@settings.redis.somefeature =
+					port:"1234"
+					host:"somewhere"
+					password: "password"
+
+			it 'should return a regular redis client', ->
+				client = @call()
+				expect(client).to.equal @normalRedisInstance
+				expect(client.__is_redis_cluster).to.equal undefined

+ 37 - 0
services/web/test/acceptance/coffee/RegistrationTests.coffee

@@ -1,9 +1,11 @@
 expect = require("chai").expect
+assert = require("chai").assert
 async = require("async")
 User = require "./helpers/User"
 request = require "./helpers/request"
 settings = require "settings-sharelatex"
 redis = require "./helpers/redis"
+_ = require 'lodash'
 
 
 
@@ -32,6 +34,41 @@ tryLoginThroughRegistrationForm = (user, email, password, callback=(err, respons
 		}, callback
 
 
+describe "LoginRateLimit", ->
+
+	before ->
+		@user = new User()
+		@badEmail = 'bademail@example.com'
+		@badPassword = 'badpassword'
+
+	it 'should rate limit login attempts after 10 within two minutes', (done) ->
+		@user.request.get '/login', (err, res, body) =>
+			async.timesSeries(
+				15
+				, (n, cb) =>
+					@user.getCsrfToken (error) =>
+						return cb(error) if error?
+						@user.request.post {
+							url: "/login"
+							json:
+								email: @badEmail
+								password: @badPassword
+						}, (err, response, body) =>
+							cb(null, body?.message?.text)
+				, (err, results) =>
+					# ten incorrect-credentials messages, then five rate-limit messages
+					expect(results.length).to.equal 15
+					assert.deepEqual(
+						results,
+						_.concat(
+							_.fill([1..10], 'Your email or password is incorrect. Please try again'),
+							_.fill([1..5], 'This account has had too many login requests. Please wait 2 minutes before trying to log in again')
+						)
+					)
+					done()
+			)
+
+
 describe "LoginViaRegistration", ->
 
 	before (done) ->

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików