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

Merge remote-tracking branch 'origin/master' into bg-improve-upload-robustness

Brian Gough 9 лет назад
Родитель
Сommit
e23871118d
47 измененных файлов с 632 добавлено и 271 удалено
  1. 24 24
      services/web/app/coffee/Features/Chat/ChatController.coffee
  2. 2 2
      services/web/app/coffee/Features/Compile/ClsiCookieManager.coffee
  3. 2 2
      services/web/app/coffee/Features/Compile/CompileManager.coffee
  4. 23 0
      services/web/app/coffee/Features/Cooldown/CooldownManager.coffee
  5. 17 0
      services/web/app/coffee/Features/Cooldown/CooldownMiddlewear.coffee
  6. 9 0
      services/web/app/coffee/Features/Docstore/DocstoreManager.coffee
  7. 0 14
      services/web/app/coffee/Features/DocumentUpdater/DocumentUpdaterHandler.coffee
  8. 3 7
      services/web/app/coffee/Features/Editor/EditorRealTimeController.coffee
  9. 3 0
      services/web/app/coffee/Features/Errors/ErrorController.coffee
  10. 10 0
      services/web/app/coffee/Features/Errors/Errors.coffee
  11. 8 6
      services/web/app/coffee/Features/HealthCheck/HealthCheckController.coffee
  12. 3 0
      services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee
  13. 2 2
      services/web/app/coffee/Features/Security/OneTimeTokenHandler.coffee
  14. 11 4
      services/web/app/coffee/Features/ThirdPartyDataStore/TpdsUpdateHandler.coffee
  15. 0 1
      services/web/app/coffee/Features/User/UserSessionsManager.coffee
  16. 3 15
      services/web/app/coffee/Features/User/UserSessionsRedis.coffee
  17. 8 8
      services/web/app/coffee/infrastructure/LockManager.coffee
  18. 1 15
      services/web/app/coffee/infrastructure/RedisWrapper.coffee
  19. 1 0
      services/web/app/coffee/router.coffee
  20. 1 1
      services/web/app/views/project/editor/editor.pug
  21. 1 1
      services/web/app/views/project/editor/header.pug
  22. 19 7
      services/web/app/views/project/list/project-list.pug
  23. 9 0
      services/web/app/views/project/list/side-bar.pug
  24. 10 0
      services/web/config/settings.defaults.coffee
  25. 29 30
      services/web/npm-shrinkwrap.json
  26. 1 1
      services/web/package.json
  27. 14 0
      services/web/public/coffee/directives/selectAll.coffee
  28. 1 0
      services/web/public/coffee/ide.coffee
  29. 10 7
      services/web/public/coffee/ide/review-panel/controllers/ReviewPanelController.coffee
  30. 47 2
      services/web/public/coffee/main/project-list/project-list.coffee
  31. 5 1
      services/web/public/coffee/main/project-list/tag-controllers.coffee
  32. 4 0
      services/web/public/stylesheets/app/editor/toolbar.less
  33. 30 2
      services/web/public/stylesheets/app/project-list.less
  34. 2 2
      services/web/test/UnitTests/coffee/Compile/ClsiCookieManagerTests.coffee
  35. 2 2
      services/web/test/UnitTests/coffee/Compile/CompileManagerTests.coffee
  36. 120 0
      services/web/test/UnitTests/coffee/Cooldown/CooldownManagerTests.coffee
  37. 88 0
      services/web/test/UnitTests/coffee/Cooldown/CooldownMiddlewearTests.coffee
  38. 35 0
      services/web/test/UnitTests/coffee/Docstore/DocstoreManagerTests.coffee
  39. 0 44
      services/web/test/UnitTests/coffee/DocumentUpdater/DocumentUpdaterHandlerTests.coffee
  40. 5 8
      services/web/test/UnitTests/coffee/Editor/EditorRealTimeControllerTests.coffee
  41. 2 2
      services/web/test/UnitTests/coffee/Security/OneTimeTokenHandlerTests.coffee
  42. 36 0
      services/web/test/UnitTests/coffee/ThirdPartyDataStore/TpdsUpdateHandlerTests.coffee
  43. 4 4
      services/web/test/UnitTests/coffee/infrastructure/LockManager/CheckingTheLock.coffee
  44. 3 3
      services/web/test/UnitTests/coffee/infrastructure/LockManager/ReleasingTheLock.coffee
  45. 2 2
      services/web/test/UnitTests/coffee/infrastructure/LockManager/getLockTests.coffee
  46. 3 3
      services/web/test/UnitTests/coffee/infrastructure/LockManager/tryLockTests.coffee
  47. 19 49
      services/web/test/UnitTests/coffee/infrastructure/RedisWrapperTests.coffee

+ 24 - 24
services/web/app/coffee/Features/Chat/ChatController.coffee

@@ -34,32 +34,32 @@ module.exports = ChatController =
 				res.json messages
 
 	_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 = []
+		# There will be a lot of repitition of user_ids, so first build a list
+		# of unique ones to perform db look ups on, then use these to populate the
+		# user fields
+		user_ids = {}
 		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()
+			if thread.resolved
+				user_ids[thread.resolved_by_user_id] = true
+			for message in thread.messages
+				user_ids[message.user_id] = true
 		
+		jobs = []
+		users = {}
+		for user_id, _ of user_ids
+			do (user_id) ->
+				jobs.push (cb) ->
+					UserInfoManager.getPersonalInfo user_id, (err, user) ->
+						return cb(error) if error?
+						user = UserInfoController.formatPersonalInfo user
+						users[user_id] = user
+						cb()
+
 		async.series jobs, (error) ->
 			return callback(error) if error?
+			for thread_id, thread of threads
+				if thread.resolved
+					thread.resolved_by_user = users[thread.resolved_by_user_id]
+				for message in thread.messages
+					message.user = users[message.user_id]
 			return callback null, threads

+ 2 - 2
services/web/app/coffee/Features/Compile/ClsiCookieManager.coffee

@@ -1,7 +1,7 @@
 Settings = require "settings-sharelatex"
 request = require('request')
-redis = require("redis-sharelatex")
-rclient = redis.createClient(Settings.redis.web)
+RedisWrapper = require("../../infrastructure/RedisWrapper")
+rclient = RedisWrapper.client("clsi_cookie")
 Cookie = require('cookie')
 logger = require "logger-sharelatex"
 

+ 2 - 2
services/web/app/coffee/Features/Compile/CompileManager.coffee

@@ -1,6 +1,6 @@
 Settings = require('settings-sharelatex')
-redis = require("redis-sharelatex")
-rclient = redis.createClient(Settings.redis.web)
+RedisWrapper = require("../../infrastructure/RedisWrapper")
+rclient = RedisWrapper.client("clsi_recently_compiled")
 DocumentUpdaterHandler = require "../DocumentUpdater/DocumentUpdaterHandler"
 Project = require("../../models/Project").Project
 ProjectRootDocManager = require "../Project/ProjectRootDocManager"

+ 23 - 0
services/web/app/coffee/Features/Cooldown/CooldownManager.coffee

@@ -0,0 +1,23 @@
+RedisWrapper = require('../../infrastructure/RedisWrapper')
+rclient = RedisWrapper.client('cooldown')
+logger = require('logger-sharelatex')
+
+
+COOLDOWN_IN_SECONDS = 60 * 10
+
+
+module.exports = CooldownManager =
+
+	_buildKey: (projectId) ->
+		"Cooldown:{#{projectId}}"
+
+	putProjectOnCooldown: (projectId, callback=(err)->) ->
+		logger.log {projectId}, "[Cooldown] putting project on cooldown for #{COOLDOWN_IN_SECONDS} seconds"
+		rclient.set(CooldownManager._buildKey(projectId), '1', 'EX', COOLDOWN_IN_SECONDS, callback)
+
+	isProjectOnCooldown: (projectId, callback=(err, isOnCooldown)->) ->
+		rclient.get CooldownManager._buildKey(projectId), (err, result) ->
+			if err?
+				return callback(err)
+			callback(null, result == "1")
+

+ 17 - 0
services/web/app/coffee/Features/Cooldown/CooldownMiddlewear.coffee

@@ -0,0 +1,17 @@
+CooldownManager = require('./CooldownManager')
+logger = require('logger-sharelatex')
+
+
+module.exports = CooldownMiddlewear =
+
+	freezeProject: (req, res, next) ->
+			projectId = req.params.Project_id
+			if !projectId?
+				return next(new Error('[Cooldown] No projectId parameter on route'))
+			CooldownManager.isProjectOnCooldown projectId, (err, projectIsOnCooldown) ->
+				if err?
+					return next(err)
+				if projectIsOnCooldown
+					logger.log {projectId}, "[Cooldown] project is on cooldown, denying request"
+					return res.sendStatus(429)
+				next()

+ 9 - 0
services/web/app/coffee/Features/Docstore/DocstoreManager.coffee

@@ -1,6 +1,7 @@
 request = require("request").defaults(jar: false)
 logger = require "logger-sharelatex"
 settings = require "settings-sharelatex"
+Errors  = require "../Errors/Errors"
 
 module.exports = DocstoreManager =
 	deleteDoc: (project_id, doc_id, callback = (error) ->) ->
@@ -10,6 +11,10 @@ module.exports = DocstoreManager =
 			return callback(error) if error?
 			if 200 <= res.statusCode < 300
 				callback(null)
+			else if res.statusCode is 404
+				error = new Errors.NotFoundError("tried to delete doc not in docstore")
+				logger.error err: error, project_id: project_id, doc_id: doc_id, "tried to delete doc not in docstore"
+				callback(error) # maybe suppress the error when delete doc which is not present?
 			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 deleting doc in docstore"
@@ -61,6 +66,10 @@ module.exports = DocstoreManager =
 			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, doc.ranges)
+			else if res.statusCode is 404
+				error = new Errors.NotFoundError("doc not found in docstore")
+				logger.error err: error, project_id: project_id, doc_id: doc_id, "doc not found in docstore"
+				callback(error)
 			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"

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

@@ -5,24 +5,10 @@ _ = require 'underscore'
 async = require 'async'
 logger = require('logger-sharelatex')
 metrics = require('metrics-sharelatex')
-redis = require("redis-sharelatex")
-rclient = redis.createClient(settings.redis.web)
 Project = require("../../models/Project").Project
 ProjectLocator = require('../../Features/Project/ProjectLocator')
 
 module.exports = DocumentUpdaterHandler =
-	
-	queueChange : (project_id, doc_id, change, callback = ()->)->
-		jsonChange = JSON.stringify change
-		doc_key = keys.combineProjectIdAndDocId(project_id, doc_id)
-		multi = rclient.multi()
-		multi.rpush keys.pendingUpdates(doc_id:doc_id), jsonChange
-		multi.sadd  keys.docsWithPendingUpdates, doc_key
-		multi.rpush "pending-updates-list", doc_key
-		multi.exec (error) ->
-			return callback(error) if error?
-			callback()
-
 	flushProjectToMongo: (project_id, callback = (error) ->)->
 		logger.log project_id:project_id, "flushing project from document updater"
 		timer = new metrics.Timer("flushing.mongo.project")

+ 3 - 7
services/web/app/coffee/Features/Editor/EditorRealTimeController.coffee

@@ -1,14 +1,10 @@
 Settings = require 'settings-sharelatex'
-redis = require("redis-sharelatex")
-rclientPub = redis.createClient(Settings.redis.web)
-rclientSub = redis.createClient(Settings.redis.web)
+RedisWrapper = require("../../infrastructure/RedisWrapper")
+rclient = RedisWrapper.client("realtime")
 
 module.exports = EditorRealTimeController =
-	rclientPub: rclientPub
-	rclientSub: rclientSub
-
 	emitToRoom: (room_id, message, payload...) ->
-		@rclientPub.publish "editor-events", JSON.stringify
+		rclient.publish "editor-events", JSON.stringify
 			room_id: room_id
 			message: message
 			payload: payload

+ 3 - 0
services/web/app/coffee/Features/Errors/ErrorController.coffee

@@ -22,6 +22,9 @@ module.exports = ErrorController =
 		if error instanceof Errors.NotFoundError
 			logger.warn {err: error, url: req.url}, "not found error"
 			ErrorController.notFound req, res
+		else if error instanceof Errors.TooManyRequestsError
+			logger.warn {err: error, url: req.url}, "too many requests error"
+			res.sendStatus(429)
 		else
 			logger.error err: error, url:req.url, method:req.method, user:user, "error passed to top level next middlewear"
 			ErrorController.serverError req, res

+ 10 - 0
services/web/app/coffee/Features/Errors/Errors.coffee

@@ -11,8 +11,18 @@ ServiceNotConfiguredError = (message) ->
 	error.name = "ServiceNotConfiguredError"
 	error.__proto__ = ServiceNotConfiguredError.prototype
 	return error
+ServiceNotConfiguredError.prototype.__proto__ = Error.prototype
+
+
+TooManyRequestsError = (message) ->
+	error = new Error(message)
+	error.name = "TooManyRequestsError"
+	error.__proto__ = TooManyRequestsError.prototype
+	return error
+TooManyRequestsError.prototype.__proto__ = Error.prototype
 
 
 module.exports = Errors =
 	NotFoundError: NotFoundError
 	ServiceNotConfiguredError: ServiceNotConfiguredError
+	TooManyRequestsError: TooManyRequestsError

+ 8 - 6
services/web/app/coffee/Features/HealthCheck/HealthCheckController.coffee

@@ -1,8 +1,8 @@
 Mocha = require "mocha"
 Base = require("mocha/lib/reporters/base")
-redis = require("redis-sharelatex")
+RedisWrapper = require("../../infrastructure/RedisWrapper")
+rclient = RedisWrapper.client("health_check")
 settings = require("settings-sharelatex")
-redisCheck = redis.activeHealthCheckRedis(settings.redis.web)
 logger = require "logger-sharelatex"
 domain = require "domain"
 
@@ -31,10 +31,12 @@ module.exports = HealthCheckController =
 				delete require.cache[path]
 
 	checkRedis: (req, res, next)->
-		if redisCheck.isAlive()
-			res.sendStatus 200
-		else
-			res.sendStatus 500
+		rclient.healthCheck (error) ->
+			if error?
+				logger.err {err: error}, "failed redis health check"
+				res.sendStatus 500
+			else
+				res.sendStatus 200
 		
 Reporter = (res) ->
 	(runner) ->

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

@@ -15,6 +15,8 @@ docComparitor = require('./DocLinesComparitor')
 projectUpdateHandler = require('./ProjectUpdateHandler')
 DocstoreManager = require "../Docstore/DocstoreManager"
 ProjectGetter = require "./ProjectGetter"
+CooldownManager = require '../Cooldown/CooldownManager'
+
 
 module.exports = ProjectEntityHandler =
 	getAllFolders: (project_id,  callback) ->
@@ -522,6 +524,7 @@ module.exports = ProjectEntityHandler =
 		ProjectEntityHandler._countElements project, (err, count)->
 			if count > settings.maxEntitiesPerProject
 				logger.warn project_id:project._id, "project too big, stopping insertions"
+				CooldownManager.putProjectOnCooldown(project._id)
 				return callback("project_has_to_many_files")
 			projectLocator.findElement {project:project, element_id:folder_id, type:"folders"}, (err, folder, path)=>
 				if err?

+ 2 - 2
services/web/app/coffee/Features/Security/OneTimeTokenHandler.coffee

@@ -1,6 +1,6 @@
 Settings = require('settings-sharelatex')
-redis = require("redis-sharelatex")
-rclient = redis.createClient(Settings.redis.web)
+RedisWrapper = require("../../infrastructure/RedisWrapper")
+rclient = RedisWrapper.client("one_time_token")
 crypto = require("crypto")
 logger = require("logger-sharelatex")
 

+ 11 - 4
services/web/app/coffee/Features/ThirdPartyDataStore/TpdsUpdateHandler.coffee

@@ -5,6 +5,8 @@ projectCreationHandler = require('../Project/ProjectCreationHandler')
 projectDeleter = require('../Project/ProjectDeleter')
 ProjectRootDocManager   = require "../Project/ProjectRootDocManager"
 FileTypeManager = require('../Uploads/FileTypeManager')
+CooldownManager = require('../Cooldown/CooldownManager')
+Errors = require('../Errors/Errors')
 
 commitMessage = "Before update from Dropbox"
 
@@ -24,10 +26,15 @@ module.exports =
 					cb err, project
 		getOrCreateProject (err, project)->
 			return callback(err) if err?
-			FileTypeManager.shouldIgnore path, (err, shouldIgnore)->
-				if shouldIgnore
-					return callback()
-				updateMerger.mergeUpdate user_id, project._id, path, updateRequest, source, callback
+			CooldownManager.isProjectOnCooldown project._id, (err, projectIsOnCooldown) ->
+				return callback(err) if err?
+				if projectIsOnCooldown
+					logger.log {projectId: project._id}, "project is on cooldown, denying request"
+					return callback(new Errors.TooManyRequestsError('project on cooldown'))
+				FileTypeManager.shouldIgnore path, (err, shouldIgnore)->
+					if shouldIgnore
+						return callback()
+					updateMerger.mergeUpdate user_id, project._id, path, updateRequest, source, callback
 
 
 	deleteUpdate: (user_id, projectName, path, source, callback)->	

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

@@ -3,7 +3,6 @@ logger = require("logger-sharelatex")
 Async = require('async')
 _ = require('underscore')
 UserSessionsRedis = require('./UserSessionsRedis')
-
 rclient = UserSessionsRedis.client()
 
 module.exports = UserSessionsManager =

+ 3 - 15
services/web/app/coffee/Features/User/UserSessionsRedis.coffee

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

+ 8 - 8
services/web/app/coffee/infrastructure/LockManager.coffee

@@ -1,7 +1,7 @@
 metrics = require('metrics-sharelatex')
 Settings = require('settings-sharelatex')
-redis = require("redis-sharelatex")
-rclient = redis.createClient(Settings.redis.web)
+RedisWrapper = require("./RedisWrapper")
+rclient = RedisWrapper.client("lock")
 logger = require "logger-sharelatex"
 
 module.exports = LockManager =
@@ -9,17 +9,17 @@ module.exports = LockManager =
 	MAX_LOCK_WAIT_TIME: 10000 # 10s maximum time to spend trying to get the lock
 	REDIS_LOCK_EXPIRY: 30 # seconds. Time until lock auto expires in redis.
 
-	_blockingKey : (key)-> "Blocking:"+key
+	_blockingKey : (key)-> "lock:web:{#{key}}"
 
 	tryLock : (key, callback = (err, isFree)->)->
 		rclient.set LockManager._blockingKey(key), "locked", "EX", LockManager.REDIS_LOCK_EXPIRY, "NX", (err, gotLock)->
 			return callback(err) if err?
 			if gotLock == "OK"
-				metrics.inc "doc-not-blocking"
+				metrics.inc "lock-not-blocking"
 				callback err, true
 			else
-				metrics.inc "doc-blocking"
-				logger.log key: key, redis_response: gotLock, "doc is locked"
+				metrics.inc "lock-blocking"
+				logger.log key: key, redis_response: gotLock, "lock is locked"
 				callback err, false
 
 	getLock: (key, callback = (error) ->) ->
@@ -42,10 +42,10 @@ module.exports = LockManager =
 			return callback(err) if err?
 			exists = parseInt replys[0]
 			if exists == 1
-				metrics.inc "doc-blocking"
+				metrics.inc "lock-blocking"
 				callback err, false
 			else
-				metrics.inc "doc-not-blocking"
+				metrics.inc "lock-not-blocking"
 				callback err, true
 
 	releaseLock: (key, callback)->

+ 1 - 15
services/web/app/coffee/infrastructure/RedisWrapper.coffee

@@ -1,28 +1,14 @@
 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)
+		rclient = redis.createClient(redisFeatureSettings)
 		return rclient

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

@@ -32,6 +32,7 @@ ChatController = require("./Features/Chat/ChatController")
 BlogController = require("./Features/Blog/BlogController")
 Modules = require "./infrastructure/Modules"
 RateLimiterMiddlewear = require('./Features/Security/RateLimiterMiddlewear')
+CooldownMiddlewear = require('./Features/Cooldown/CooldownMiddlewear')
 RealTimeProxyRouter = require('./Features/RealTimeProxy/RealTimeProxyRouter')
 InactiveProjectController = require("./Features/InactiveData/InactiveProjectController")
 ContactRouter = require("./Features/Contacts/ContactRouter")

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

@@ -16,7 +16,7 @@ div.full-size(
 			'rp-state-current-file-expanded': (reviewPanel.subView === SubViews.CUR_FILE && ui.reviewPanelOpen),\
 			'rp-state-current-file-mini': (reviewPanel.subView === SubViews.CUR_FILE && !ui.reviewPanelOpen),\
 			'rp-state-overview': (reviewPanel.subView === SubViews.OVERVIEW),\
-			'rp-size-mini': (!ui.reviewPanelOpen && reviewPanel.hasEntries),\
+			'rp-size-mini': ui.miniReviewPanelVisible,\
 			'rp-size-expanded': ui.reviewPanelOpen,\
 			'rp-layout-left': reviewPanel.layoutToLeft,\
 			'rp-loading-threads': reviewPanel.loadingThreads,\

+ 1 - 1
services/web/app/views/project/editor/header.pug

@@ -15,7 +15,7 @@ header.toolbar.toolbar-header.toolbar-with-labels(
 			i.fa.fa-fw.fa-level-up
 
 	span(ng-controller="PdfViewToggleController")
-		a(
+		a.btn.btn-full-height.btn-full-height-no-border(
 			href,
 			ng-show="ui.pdfLayout == 'flat' && fileTreeClosed",
 			tooltip="PDF",

+ 19 - 7
services/web/app/views/project/list/project-list.pug

@@ -140,23 +140,35 @@
 					ng-repeat="project in visibleProjects | orderBy:predicate:reverse",
 					ng-controller="ProjectListItemController"
 				)
-					.row
+					.row(select-row)
 						.col-xs-6
 							input.select-item(
 								select-individual,
 								type="checkbox",
 								ng-model="project.selected"
+								stop-propagation="click"
 							)
 							span
-								a.projectName(href="/project/{{project.id}}") {{project.name}}
+								a.projectName(
+									href="/project/{{project.id}}"
+									stop-propagation="click"
+								) {{project.name}}
 								span(
 									ng-controller="TagListController"
 								)
-									a.label.label-default.tag-label(
-										href,
-										ng-repeat='tag in project.tags',
-										ng-click="selectTag(tag)"
-									) {{tag.name}}
+									.tag-label(
+										ng-repeat='tag in project.tags'
+										stop-propagation="click"
+									)
+										a.label.label-default.tag-label-name(
+											href,
+											ng-click="selectTag(tag)"
+										) {{tag.name}} 
+										a.label.label-default.tag-label-remove(
+											href
+											ng-click="removeProjectFromTag(project, tag)"
+										) &times;
+
 						.col-xs-2
 							span.owner {{ownerName()}}
 						.col-xs-4

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

@@ -79,6 +79,15 @@
 					li
 						a(href, ng-click="deleteTag(tag)", stop-propagation="click")
 							| #{translate("delete")}
+		li.tag.untagged(
+			ng-if="tags.length",
+			ng-cloak,
+			ng-click="selectUntagged()"
+			ng-class="{active: filter === 'untagged'}",
+		) 	
+			a.tag-name(href)
+				| #{translate("uncategorized")}
+				span.subdued  ({{ nUntagged }})
 		li(ng-cloak)
 			a.tag(href, ng-click="openNewTagModal()")
 				i.fa.fa-fw.fa-plus

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

@@ -58,6 +58,16 @@ module.exports = settings =
 		# 		{host: 'localhost', port: 7005}
 		# 	]
 
+		# cooldown:
+		# 	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"

+ 29 - 30
services/web/npm-shrinkwrap.json

@@ -740,12 +740,6 @@
       "from": "double-ended-queue@>=2.1.0-0 <3.0.0",
       "resolved": "https://registry.npmjs.org/double-ended-queue/-/double-ended-queue-2.1.0-0.tgz"
     },
-    "dtrace-provider": {
-      "version": "0.2.8",
-      "from": "dtrace-provider@0.2.8",
-      "resolved": "https://registry.npmjs.org/dtrace-provider/-/dtrace-provider-0.2.8.tgz",
-      "optional": true
-    },
     "each-series": {
       "version": "1.0.0",
       "from": "each-series@>=1.0.0 <2.0.0",
@@ -2742,18 +2736,18 @@
       }
     },
     "redis-sharelatex": {
-      "version": "0.0.9",
-      "from": "redis-sharelatex@0.0.9",
-      "resolved": "https://registry.npmjs.org/redis-sharelatex/-/redis-sharelatex-0.0.9.tgz",
+      "version": "1.0.2",
+      "from": "git+https://github.com/sharelatex/redis-sharelatex.git#v1.0.2",
+      "resolved": "git+https://github.com/sharelatex/redis-sharelatex.git#143b7eb192675f36d835080e534a4ac4899f918a",
       "dependencies": {
         "ansi-regex": {
           "version": "0.2.1",
-          "from": "ansi-regex@^0.2.0",
+          "from": "ansi-regex@>=0.2.0 <0.3.0",
           "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-0.2.1.tgz"
         },
         "ansi-styles": {
           "version": "1.1.0",
-          "from": "ansi-styles@^1.1.0",
+          "from": "ansi-styles@>=1.1.0 <2.0.0",
           "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-1.1.0.tgz"
         },
         "assertion-error": {
@@ -2761,6 +2755,11 @@
           "from": "assertion-error@1.0.0",
           "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.0.0.tgz"
         },
+        "async": {
+          "version": "2.4.0",
+          "from": "async@>=2.4.0 <3.0.0",
+          "resolved": "https://registry.npmjs.org/async/-/async-2.4.0.tgz"
+        },
         "chai": {
           "version": "1.9.1",
           "from": "chai@1.9.1",
@@ -2768,7 +2767,7 @@
         },
         "chalk": {
           "version": "0.5.1",
-          "from": "chalk@~0.5.0",
+          "from": "chalk@>=0.5.0 <0.6.0",
           "resolved": "https://registry.npmjs.org/chalk/-/chalk-0.5.1.tgz"
         },
         "coffee-script": {
@@ -2793,7 +2792,7 @@
           "dependencies": {
             "mkdirp": {
               "version": "0.5.1",
-              "from": "mkdirp@^0.5.0",
+              "from": "mkdirp@>=0.5.0 <0.6.0",
               "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz"
             }
           }
@@ -2815,8 +2814,13 @@
           "dependencies": {
             "coffee-script": {
               "version": "1.7.1",
-              "from": "coffee-script@~1.7.0",
+              "from": "coffee-script@>=1.7.0 <1.8.0",
               "resolved": "https://registry.npmjs.org/coffee-script/-/coffee-script-1.7.1.tgz"
+            },
+            "lodash": {
+              "version": "2.4.2",
+              "from": "lodash@>=2.4.1 <2.5.0",
+              "resolved": "https://registry.npmjs.org/lodash/-/lodash-2.4.2.tgz"
             }
           }
         },
@@ -2827,7 +2831,7 @@
         },
         "has-ansi": {
           "version": "0.1.0",
-          "from": "has-ansi@^0.1.0",
+          "from": "has-ansi@>=0.1.0 <0.2.0",
           "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-0.1.0.tgz"
         },
         "jade": {
@@ -2852,15 +2856,10 @@
           "from": "jsonfile@>=2.0.0 <3.0.0",
           "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-2.4.0.tgz"
         },
-        "lodash": {
-          "version": "2.4.2",
-          "from": "lodash@~2.4.1",
-          "resolved": "https://registry.npmjs.org/lodash/-/lodash-2.4.2.tgz"
-        },
         "minimatch": {
-          "version": "3.0.3",
+          "version": "3.0.4",
           "from": "minimatch@>=3.0.2 <4.0.0",
-          "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.3.tgz"
+          "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz"
         },
         "mkdirp": {
           "version": "0.3.5",
@@ -2884,7 +2883,7 @@
             },
             "minimatch": {
               "version": "0.2.14",
-              "from": "minimatch@~0.2.11",
+              "from": "minimatch@>=0.2.11 <0.3.0",
               "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-0.2.14.tgz"
             }
           }
@@ -2904,11 +2903,6 @@
           "from": "rimraf@>=2.2.8 <3.0.0",
           "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.1.tgz"
         },
-        "samsam": {
-          "version": "1.1.3",
-          "from": "samsam@>=1.1.0 <1.2.0",
-          "resolved": "https://registry.npmjs.org/samsam/-/samsam-1.1.3.tgz"
-        },
         "sandboxed-module": {
           "version": "1.0.1",
           "from": "sandboxed-module@1.0.1",
@@ -2921,12 +2915,12 @@
         },
         "strip-ansi": {
           "version": "0.3.0",
-          "from": "strip-ansi@^0.3.0",
+          "from": "strip-ansi@>=0.3.0 <0.4.0",
           "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-0.3.0.tgz"
         },
         "supports-color": {
           "version": "0.2.0",
-          "from": "supports-color@^0.2.0",
+          "from": "supports-color@>=0.2.0 <0.3.0",
           "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-0.2.0.tgz"
         },
         "underscore": {
@@ -3036,6 +3030,11 @@
       "resolved": "https://registry.npmjs.org/safe-json-stringify/-/safe-json-stringify-1.0.4.tgz",
       "optional": true
     },
+    "samsam": {
+      "version": "1.1.2",
+      "from": "samsam@1.1.2",
+      "resolved": "https://registry.npmjs.org/samsam/-/samsam-1.1.2.tgz"
+    },
     "sanitizer": {
       "version": "0.1.1",
       "from": "sanitizer@0.1.1",

+ 1 - 1
services/web/package.json

@@ -49,7 +49,7 @@
     "passport-ldapauth": "^0.6.0",
     "passport-local": "^1.0.0",
     "redis": "0.10.1",
-    "redis-sharelatex": "0.0.9",
+    "redis-sharelatex": "git+https://github.com/sharelatex/redis-sharelatex.git#v1.0.2",
     "request": "^2.69.0",
     "requests": "^0.1.7",
     "rimraf": "2.2.6",

+ 14 - 0
services/web/public/coffee/directives/selectAll.coffee

@@ -58,4 +58,18 @@ define [
 					scope.$apply () ->
 						scope.ngModel = false
 					ignoreChanges = false
+
+				scope.$on "select-all:row-clicked", () ->
+					ignoreChanges = true
+					scope.$apply () ->
+						scope.ngModel = !scope.ngModel
+					ignoreChanges = false
+		}
+
+	App.directive "selectRow", () ->
+		return {
+			scope: true
+			link: (scope, element, attrs) ->
+				element.on "click", (e) ->
+					scope.$broadcast "select-all:row-clicked"
 		}

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

@@ -70,6 +70,7 @@ define [
 			chatOpen: false
 			pdfLayout: 'sideBySide'
 			reviewPanelOpen: localStorage("ui.reviewPanelOpen.#{window.project_id}")
+			miniReviewPanelVisible: false
 		}
 		$scope.user = window.user
 		

+ 10 - 7
services/web/public/coffee/ide/review-panel/controllers/ReviewPanelController.coffee

@@ -126,6 +126,12 @@ define [
 			else
 				$reviewPanelEl.css "right", "0"
 
+		$scope.$watch "!ui.reviewPanelOpen && reviewPanel.hasEntries", (open, prevVal) ->
+			return if !open?
+			$scope.ui.miniReviewPanelVisible = open
+			if open != prevVal
+				$timeout () -> $scope.$broadcast "review-panel:toggle"
+
 		$scope.$watch "ui.reviewPanelOpen", (open) ->
 			return if !open?
 			if !open
@@ -135,7 +141,10 @@ define [
 			else
 				# Reset back to what we had when previously open
 				$scope.reviewPanel.subView = $scope.reviewPanel.openSubView
-		
+			$timeout () ->
+				$scope.$broadcast "review-panel:toggle"
+				$scope.$broadcast "review-panel:layout", false
+
 		$scope.$watch "reviewPanel.subView", (view) ->
 			return if !view?
 			updateScrollbar()
@@ -165,12 +174,6 @@ define [
 		), (nEntries) ->
 			$scope.reviewPanel.hasEntries = nEntries > 0 and $scope.project.features.trackChangesVisible
 
-		$scope.$watch "ui.reviewPanelOpen", (reviewPanelOpen) ->
-			return if !reviewPanelOpen?
-			$timeout () ->
-				$scope.$broadcast "review-panel:toggle"
-				$scope.$broadcast "review-panel:layout", false
-
 		regenerateTrackChangesId = (doc) ->
 			old_id = getChangeTracker(doc.doc_id).getIdSeed()
 			new_id = RangesTracker.generateIdSeed()

+ 47 - 2
services/web/public/coffee/main/project-list/project-list.coffee

@@ -2,7 +2,7 @@ define [
 	"base"
 ], (App) ->
 
-	App.controller "ProjectPageController", ($scope, $modal, $q, $window, queuedHttp, event_tracking, $timeout) ->
+	App.controller "ProjectPageController", ($scope, $modal, $q, $window, queuedHttp, event_tracking, $timeout, localStorage) ->
 		$scope.projects = window.data.projects
 		$scope.tags = window.data.tags
 		$scope.notifications = window.data.notifications
@@ -10,6 +10,7 @@ define [
 		$scope.selectedProjects = []
 		$scope.filter = "all"
 		$scope.predicate = "lastUpdated"
+		$scope.nUntagged = 0
 		$scope.reverse = true
 		$scope.searchText = 
 			value : ""
@@ -18,6 +19,12 @@ define [
 			recalculateProjectListHeight()
 		, 10
 
+		$scope.$watch((
+			() -> $scope.projects.filter((project) -> !project.tags? or project.tags.length == 0).length
+		), (newVal) -> $scope.nUntagged = newVal)
+
+		storedUIOpts = JSON.parse(localStorage("project_list"))
+
 		recalculateProjectListHeight = () ->
 			topOffset = $(".project-list-card")?.offset()?.top
 			bottomOffset = $("footer").outerHeight() + 25
@@ -52,6 +59,13 @@ define [
 					project.tags ||= []
 					project.tags.push tag
 
+		markTagAsSelected = (id) ->
+			for tag in $scope.tags
+				if tag._id == id
+					tag.selected = true
+				else
+					tag.selected = false
+		
 		$scope.changePredicate = (newPredicate)->
 			if $scope.predicate == newPredicate
 				$scope.reverse = !$scope.reverse
@@ -104,6 +118,10 @@ define [
 				if $scope.filter == "tag" and selectedTag? and project.id not in selectedTag.project_ids
 					visible = false
 
+				# Hide tagged projects if we only want to see the uncategorized ones
+				if $scope.filter == "untagged" and project.tags?.length > 0
+					visible = false
+
 				# Hide projects we own if we only want to see shared projects
 				if $scope.filter == "shared" and project.accessLevel == "owner"
 					visible = false
@@ -126,6 +144,11 @@ define [
 				else
 					# We don't want hidden selections
 					project.selected = false
+
+			localStorage("project_list", JSON.stringify({ 
+				filter: $scope.filter,
+				selectedTagId: selectedTag?._id
+			}))
 			$scope.updateSelectedProjects()
 
 		$scope.getSelectedTag = () ->
@@ -178,6 +201,23 @@ define [
 			# the projects from view
 			$scope.updateVisibleProjects()
 
+		$scope.removeProjectFromTag = (project, tag) ->
+			tag.showWhenEmpty = true
+
+			project.tags ||= []
+			index = project.tags.indexOf tag
+
+			if index > -1
+				$scope._removeProjectIdsFromTagArray(tag, [ project.id ])
+				project.tags.splice(index, 1)
+				queuedHttp({
+					method: "DELETE"
+					url: "/tag/#{tag._id}/project/#{project.id}"
+					headers:
+						"X-CSRF-Token": window.csrfToken
+				})
+				$scope.updateVisibleProjects()
+
 		$scope.addSelectedProjectsToTag = (tag) ->
 			selected_projects = $scope.getSelectedProjects()
 			event_tracking.send 'project-list-page-interaction', 'project action', 'addSelectedProjectsToTag'
@@ -425,7 +465,12 @@ define [
 
 			window.location = path
 			
-		$scope.updateVisibleProjects()
+		if storedUIOpts?.filter?
+			if storedUIOpts.filter == "tag" and storedUIOpts.selectedTagId?
+				markTagAsSelected(storedUIOpts.selectedTagId)
+			$scope.setFilter(storedUIOpts.filter)
+		else
+			$scope.updateVisibleProjects()
 
 	App.controller "ProjectListItemController", ($scope) ->
 		$scope.ownerName = () ->

+ 5 - 1
services/web/public/coffee/main/project-list/tag-controllers.coffee

@@ -15,7 +15,11 @@ define [
 			$scope._clearTags()
 			tag.selected = true
 			$scope.setFilter("tag")
-		
+
+		$scope.selectUntagged = () ->
+			$scope._clearTags()
+			$scope.setFilter("untagged")
+
 		$scope.deleteTag = (tag) ->
 			modalInstance = $modal.open(
 				templateUrl: "deleteTagModalTemplate"

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

@@ -65,6 +65,10 @@
 			right: 4px;
 		}
 	}
+	.btn-full-height-no-border {
+		border-right: 0;
+		border-left: 0;
+	}
 
 	.toolbar-left {
 		float: left;

+ 30 - 2
services/web/public/stylesheets/app/project-list.less

@@ -124,6 +124,20 @@ ul.folders-menu {
 				}
 			}
 		}
+		&.untagged {
+			font-style: italic;
+			margin-bottom: @line-height-computed / 4;
+			a {
+				line-height: 1.7;
+				&:hover,
+				&:focus {
+					text-decoration: none;
+				}
+			}
+			span.subdued {
+				font-style: normal;
+			}
+		}
 		&:hover {
 			&:not(.active) {
 				background-color: darken(@gray-lightest, 2%);
@@ -246,10 +260,24 @@ ul.project-list {
 			margin-left: @line-height-computed / 4;
 			position: relative;
 			top: -2px;
-			padding-top: 0.25em;
 			display: inline-block;
-			color: white;
 		}
+			.tag-label-name,
+			.tag-label-remove {
+				display: inline-block;
+				padding-top: 0.3em;
+				color: #FFF;
+			}
+			.tag-label-name {
+				padding-right: 0.3em;
+				border-top-right-radius: 0;
+				border-bottom-right-radius: 0;	
+			}
+			.tag-label-remove {
+				padding-left: 0.3em;
+				border-top-left-radius: 0;
+				border-bottom-left-radius: 0;
+			}
 	}
 	i.tablesort {
 		padding-left: 8px;

+ 2 - 2
services/web/test/UnitTests/coffee/Compile/ClsiCookieManagerTests.coffee

@@ -34,8 +34,8 @@ describe "ClsiCookieManager", ->
 				ttl:Math.random()
 				key: "coooookie"
 		@requires = 
-			"redis-sharelatex" :
-				createClient: =>
+			"../../infrastructure/RedisWrapper":
+				client: =>
 					@redis
 			"settings-sharelatex": @settings
 			"request": @request

+ 2 - 2
services/web/test/UnitTests/coffee/Compile/CompileManagerTests.coffee

@@ -15,8 +15,8 @@ describe "CompileManager", ->
 		@CompileManager = SandboxedModule.require modulePath, requires:
 			"settings-sharelatex": @settings =
 				redis: web: {host: "localhost", port: 42}
-			"redis-sharelatex":
-				createClient: () => @rclient = { auth: () -> }
+			"../../infrastructure/RedisWrapper":
+				client: () => @rclient = { auth: () -> }
 			"../DocumentUpdater/DocumentUpdaterHandler": @DocumentUpdaterHandler = {}
 			"../Project/ProjectRootDocManager": @ProjectRootDocManager = {}
 			"../../models/Project": Project: @Project = {}

+ 120 - 0
services/web/test/UnitTests/coffee/Cooldown/CooldownManagerTests.coffee

@@ -0,0 +1,120 @@
+SandboxedModule = require('sandboxed-module')
+sinon = require('sinon')
+require('chai').should()
+expect = require('chai').expect
+modulePath = require('path').join __dirname, '../../../../app/js/Features/Cooldown/CooldownManager'
+
+
+describe "CooldownManager", ->
+
+	beforeEach ->
+		@projectId = 'abcdefg'
+		@rclient = {set: sinon.stub(), get: sinon.stub()}
+		@RedisWrapper =
+			client: () => @rclient
+		@CooldownManager = SandboxedModule.require modulePath, requires:
+			'../../infrastructure/RedisWrapper': @RedisWrapper
+			'logger-sharelatex': {log: sinon.stub()}
+
+	describe '_buildKey', ->
+
+		it 'should build a properly formatted redis key', ->
+			expect(@CooldownManager._buildKey('ABC')).to.equal 'Cooldown:{ABC}'
+
+	describe 'isProjectOnCooldown', ->
+		beforeEach ->
+			@call = (cb) =>
+				@CooldownManager.isProjectOnCooldown @projectId, cb
+
+		describe 'when project is on cooldown', ->
+			beforeEach ->
+				@rclient.get = sinon.stub().callsArgWith(1, null, '1')
+
+			it 'should fetch key from redis', (done) ->
+				@call (err, result) =>
+					@rclient.get.callCount.should.equal 1
+					@rclient.get.calledWith('Cooldown:{abcdefg}').should.equal true
+					done()
+
+			it 'should not produce an error', (done) ->
+				@call (err, result) =>
+					expect(err).to.equal null
+					done()
+
+			it 'should produce a true result', (done) ->
+				@call (err, result) =>
+					expect(result).to.equal true
+					done()
+
+		describe 'when project is not on cooldown', ->
+			beforeEach ->
+				@rclient.get = sinon.stub().callsArgWith(1, null, null)
+
+			it 'should fetch key from redis', (done) ->
+				@call (err, result) =>
+					@rclient.get.callCount.should.equal 1
+					@rclient.get.calledWith('Cooldown:{abcdefg}').should.equal true
+					done()
+
+			it 'should not produce an error', (done) ->
+				@call (err, result) =>
+					expect(err).to.equal null
+					done()
+
+			it 'should produce a false result', (done) ->
+				@call (err, result) =>
+					expect(result).to.equal false
+					done()
+
+		describe 'when rclient.get produces an error', ->
+			beforeEach ->
+				@rclient.get = sinon.stub().callsArgWith(1, new Error('woops'))
+
+			it 'should fetch key from redis', (done) ->
+				@call (err, result) =>
+					@rclient.get.callCount.should.equal 1
+					@rclient.get.calledWith('Cooldown:{abcdefg}').should.equal true
+					done()
+
+			it 'should produce an error', (done) ->
+				@call (err, result) =>
+					expect(err).to.not.equal null
+					expect(err).to.be.instanceof Error
+					done()
+
+	describe 'putProjectOnCooldown', ->
+
+		beforeEach ->
+			@call = (cb) =>
+				@CooldownManager.putProjectOnCooldown @projectId, cb
+
+		describe 'when rclient.set does not produce an error', ->
+			beforeEach ->
+				@rclient.set = sinon.stub().callsArgWith(4, null)
+
+			it 'should set a key in redis', (done) ->
+				@call (err) =>
+					@rclient.set.callCount.should.equal 1
+					@rclient.set.calledWith('Cooldown:{abcdefg}').should.equal true
+					done()
+
+			it 'should not produce an error', (done) ->
+				@call (err) =>
+					expect(err).to.equal null
+					done()
+
+		describe 'when rclient.set produces an error', ->
+			beforeEach ->
+				@rclient.set = sinon.stub().callsArgWith(4, new Error('woops'))
+
+			it 'should set a key in redis', (done) ->
+				@call (err) =>
+					@rclient.set.callCount.should.equal 1
+					@rclient.set.calledWith('Cooldown:{abcdefg}').should.equal true
+					done()
+
+			it 'produce an error', (done) ->
+				@call (err) =>
+					expect(err).to.not.equal null
+					expect(err).to.be.instanceof Error
+					done()

+ 88 - 0
services/web/test/UnitTests/coffee/Cooldown/CooldownMiddlewearTests.coffee

@@ -0,0 +1,88 @@
+SandboxedModule = require('sandboxed-module')
+sinon = require('sinon')
+require('chai').should()
+expect = require('chai').expect
+modulePath = require('path').join __dirname, '../../../../app/js/Features/Cooldown/CooldownMiddlewear'
+
+
+describe "CooldownMiddlewear", ->
+
+	beforeEach ->
+		@CooldownManager =
+			isProjectOnCooldown: sinon.stub()
+		@CooldownMiddlewear = SandboxedModule.require modulePath, requires:
+			'./CooldownManager': @CooldownManager
+			'logger-sharelatex': {log: sinon.stub()}
+
+	describe 'freezeProject', ->
+
+		describe 'when project is on cooldown', ->
+			beforeEach ->
+				@CooldownManager.isProjectOnCooldown = sinon.stub().callsArgWith(1, null, true)
+				@req = {params: {Project_id: 'abc'}}
+				@res = {sendStatus: sinon.stub()}
+				@next = sinon.stub()
+
+			it 'should call CooldownManager.isProjectOnCooldown', ->
+				@CooldownMiddlewear.freezeProject @req, @res, @next
+				@CooldownManager.isProjectOnCooldown.callCount.should.equal 1
+				@CooldownManager.isProjectOnCooldown.calledWith('abc').should.equal true
+
+			it 'should not produce an error', ->
+				@CooldownMiddlewear.freezeProject @req, @res, @next
+				@next.callCount.should.equal 0
+
+			it 'should send a 429 status', ->
+				@CooldownMiddlewear.freezeProject @req, @res, @next
+				@res.sendStatus.callCount.should.equal 1
+				@res.sendStatus.calledWith(429).should.equal true
+
+		describe 'when project is not on cooldown', ->
+			beforeEach ->
+				@CooldownManager.isProjectOnCooldown = sinon.stub().callsArgWith(1, null, false)
+				@req = {params: {Project_id: 'abc'}}
+				@res = {sendStatus: sinon.stub()}
+				@next = sinon.stub()
+
+			it 'should call CooldownManager.isProjectOnCooldown', ->
+				@CooldownMiddlewear.freezeProject @req, @res, @next
+				@CooldownManager.isProjectOnCooldown.callCount.should.equal 1
+				@CooldownManager.isProjectOnCooldown.calledWith('abc').should.equal true
+
+			it 'call next with no arguments', ->
+				@CooldownMiddlewear.freezeProject @req, @res, @next
+				@next.callCount.should.equal 1
+				expect(@next.lastCall.args.length).to.equal 0
+
+		describe 'when isProjectOnCooldown produces an error', ->
+			beforeEach ->
+				@CooldownManager.isProjectOnCooldown = sinon.stub().callsArgWith(1, new Error('woops'))
+				@req = {params: {Project_id: 'abc'}}
+				@res = {sendStatus: sinon.stub()}
+				@next = sinon.stub()
+
+			it 'should call CooldownManager.isProjectOnCooldown', ->
+				@CooldownMiddlewear.freezeProject @req, @res, @next
+				@CooldownManager.isProjectOnCooldown.callCount.should.equal 1
+				@CooldownManager.isProjectOnCooldown.calledWith('abc').should.equal true
+
+			it 'call next with an error', ->
+				@CooldownMiddlewear.freezeProject @req, @res, @next
+				@next.callCount.should.equal 1
+				expect(@next.lastCall.args[0]).to.be.instanceof Error
+
+		describe 'when projectId is not part of route', ->
+			beforeEach ->
+				@CooldownManager.isProjectOnCooldown = sinon.stub().callsArgWith(1, null, true)
+				@req = {params: {lol: 'abc'}}
+				@res = {sendStatus: sinon.stub()}
+				@next = sinon.stub()
+
+			it 'call next with an error', ->
+				@CooldownMiddlewear.freezeProject @req, @res, @next
+				@next.callCount.should.equal 1
+				expect(@next.lastCall.args[0]).to.be.instanceof Error
+
+			it 'should not call CooldownManager.isProjectOnCooldown', ->
+				@CooldownMiddlewear.freezeProject @req, @res, @next
+				@CooldownManager.isProjectOnCooldown.callCount.should.equal 0

+ 35 - 0
services/web/test/UnitTests/coffee/Docstore/DocstoreManagerTests.coffee

@@ -3,6 +3,7 @@ chai.should()
 sinon = require("sinon")
 modulePath = "../../../../app/js/Features/Docstore/DocstoreManager"
 SandboxedModule = require('sandboxed-module')
+Errors = require "../../../../app/js/Features/Errors/Errors.js"
 
 describe "DocstoreManager", ->
 	beforeEach ->
@@ -52,6 +53,23 @@ describe "DocstoreManager", ->
 					}, "error deleting doc in docstore")
 					.should.equal true
 
+		describe "with a missing (404) response code", ->
+			beforeEach ->
+				@request.del = sinon.stub().callsArgWith(1, null, statusCode: 404, "")
+				@DocstoreManager.deleteDoc @project_id, @doc_id, @callback
+
+			it "should call the callback with an error", ->
+				@callback.calledWith(new Errors.NotFoundError("tried to delete doc not in docstore")).should.equal true
+
+			it "should log the error", ->
+				@logger.error
+					.calledWith({
+						err: new Errors.NotFoundError("tried to delete doc not in docstore")
+						project_id: @project_id
+						doc_id: @doc_id
+					}, "tried to delete doc not in docstore")
+					.should.equal true
+
 	describe "updateDoc", ->
 		beforeEach ->
 			@lines = ["mock", "doc", "lines"]
@@ -153,6 +171,23 @@ describe "DocstoreManager", ->
 			it "should call the callback with the lines, version and rev", ->
 				@callback.calledWith(null, @lines, @rev, @version, @ranges).should.equal true
 
+		describe "with a missing (404) response code", ->
+			beforeEach ->
+				@request.get = sinon.stub().callsArgWith(1, null, statusCode: 404, "")
+				@DocstoreManager.getDoc @project_id, @doc_id, @callback
+
+			it "should call the callback with an error", ->
+				@callback.calledWith(new Errors.NotFoundError("doc not found in docstore")).should.equal true
+
+			it "should log the error", ->
+				@logger.error
+					.calledWith({
+						err: new Errors.NotFoundError("doc not found in docstore")
+						project_id: @project_id
+						doc_id: @doc_id
+					}, "doc not found in docstore")
+					.should.equal true
+
 	describe "getAllDocs", ->
 		describe "with a successful response code", ->
 			beforeEach ->

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

@@ -20,10 +20,8 @@ describe 'DocumentUpdaterHandler', ->
 
 		@request = {}
 		@projectEntityHandler = {}
-		@rclient = {auth:->}
 		@settings = 
 			apis : documentupdater: url : "http://something.com"
-			redis:{web:{}}
 		@handler = SandboxedModule.require modulePath, requires:
 			'request': defaults:=> return @request
 			'settings-sharelatex':@settings
@@ -31,52 +29,10 @@ describe 'DocumentUpdaterHandler', ->
 			'../Project/ProjectEntityHandler':@projectEntityHandler
 			"../../models/Project": Project: @Project={}
 			'../../Features/Project/ProjectLocator':{}
-			'redis-sharelatex' : createClient: () => @rclient
 			"metrics-sharelatex": 
 				Timer:->
 					done:->
 
-	describe 'queueChange', ->
-		beforeEach ->
-			@change = {
-				"action":"removeText",
-				"range":{"start":{"row":2,"column":2},"end":{"row":2,"column":3}},
-				"text":"e"
-			}
-			@rclient.multi = sinon.stub().returns @rclient
-			@rclient.exec = sinon.stub().callsArg(0)
-			@rclient.rpush = sinon.stub()
-			@rclient.sadd = sinon.stub()
-			@callback = sinon.stub()
-
-		describe "successfully", ->
-			beforeEach ->
-				@handler.queueChange(@project_id, @doc_id, @change, @callback)
-
-			it "should push the change", ->
-				@rclient.rpush
-					.calledWith("PendingUpdates:#{@doc_id}", JSON.stringify(@change))
-					.should.equal true
-
-			it "should notify the doc updater of the change via the pending-updates-list queue", ->
-				@rclient.rpush
-					.calledWith("pending-updates-list", "#{@project_id}:#{@doc_id}")
-					.should.equal true
-
-			it "should push the doc id into the pending updates set", ->
-				@rclient.sadd
-					.calledWith("DocsWithPendingUpdates", "#{@project_id}:#{@doc_id}")
-					.should.equal true
-
-		describe "with error connecting to redis during exec", ->
-			beforeEach ->
-				@rclient.exec = sinon.stub().callsArgWith(0, new Error("something went wrong"))
-				@handler.queueChange(@project_id, @doc_id, @change, @callback)
-
-			it "should return an error", ->
-				@callback.calledWithExactly(sinon.match(Error)).should.equal true
-
-
 	describe 'flushProjectToMongo', ->
 		beforeEach ->
 			@callback = sinon.stub()

+ 5 - 8
services/web/test/UnitTests/coffee/Editor/EditorRealTimeControllerTests.coffee

@@ -5,16 +5,13 @@ modulePath = require('path').join __dirname, '../../../../app/js/Features/Editor
 
 describe "EditorRealTimeController", ->
 	beforeEach ->
+		@rclient =
+			publish: sinon.stub()
 		@EditorRealTimeController = SandboxedModule.require modulePath, requires:
-			"redis-sharelatex": 
-				createClient: () ->
-					auth:->
+			"../../infrastructure/RedisWrapper": 
+				client: () => @rclient
 			"../../infrastructure/Server" : io: @io = {}
 			"settings-sharelatex":{redis:{}}
-		@EditorRealTimeController.rclientPub = publish: sinon.stub()
-		@EditorRealTimeController.rclientSub =
-			subscribe: sinon.stub()
-			on: sinon.stub()
 		
 		@room_id = "room-id"
 		@message = "message-to-editor"
@@ -25,7 +22,7 @@ describe "EditorRealTimeController", ->
 			@EditorRealTimeController.emitToRoom(@room_id, @message, @payload...)
 
 		it "should publish the message to redis", ->
-			@EditorRealTimeController.rclientPub.publish
+			@rclient.publish
 				.calledWith("editor-events", JSON.stringify(
 					room_id: @room_id,
 					message: @message

+ 2 - 2
services/web/test/UnitTests/coffee/Security/OneTimeTokenHandlerTests.coffee

@@ -23,8 +23,8 @@ describe "OneTimeTokenHandler", ->
 			exec:sinon.stub()
 		self = @
 		@OneTimeTokenHandler = SandboxedModule.require modulePath, requires:
-			"redis-sharelatex" :
-				createClient: =>
+			"../../infrastructure/RedisWrapper" :
+				client: =>
 					auth:->
 					multi: -> return self.redisMulti
 

+ 36 - 0
services/web/test/UnitTests/coffee/ThirdPartyDataStore/TpdsUpdateHandlerTests.coffee

@@ -1,6 +1,7 @@
 SandboxedModule = require('sandboxed-module')
 sinon = require('sinon')
 require('chai').should()
+expect = require('chai').expect
 modulePath = require('path').join __dirname, '../../../../app/js/Features/ThirdPartyDataStore/TpdsUpdateHandler.js'
 
 describe 'TpdsUpdateHandler', ->
@@ -19,6 +20,8 @@ describe 'TpdsUpdateHandler', ->
 		@rootDocManager = setRootDocAutomatically:sinon.stub()
 		@FileTypeManager =
 			shouldIgnore: sinon.stub().callsArgWith(1, null, false)
+		@CooldownManager =
+			isProjectOnCooldown: sinon.stub().callsArgWith(1, null, false)
 		@handler = SandboxedModule.require modulePath, requires:
 			'./UpdateMerger': @updateMerger
 			'./Editor/EditorController': @editorController
@@ -27,6 +30,7 @@ describe 'TpdsUpdateHandler', ->
 			'../Project/ProjectDeleter': @projectDeleter
 			"../Project/ProjectRootDocManager" : @rootDocManager
 			'../Uploads/FileTypeManager': @FileTypeManager
+			'../Cooldown/CooldownManager': @CooldownManager
 			'logger-sharelatex': log:->
 		@user_id = "dsad29jlkjas"
 		@source = "dropbox"
@@ -67,6 +71,38 @@ describe 'TpdsUpdateHandler', ->
 				@updateMerger.mergeUpdate.called.should.equal false
 				done()
 
+		it 'should check if the project is on cooldown', (done) ->
+			@CooldownManager.isProjectOnCooldown = sinon.stub().callsArgWith(1, null, false)
+			@projectLocator.findUsersProjectByName = sinon.stub().callsArgWith(2)
+			path = "/path/here"
+			update = {}
+			@updateMerger.mergeUpdate = sinon.stub()
+			@updateMerger.mergeUpdate.withArgs(@user_id, @project_id, path, update, @source).callsArg(5)
+			@handler.newUpdate @user_id, @project.name, path, update, @source, (err) =>
+				expect(err).to.be.oneOf [null, undefined]
+				@CooldownManager.isProjectOnCooldown.callCount.should.equal 1
+				@CooldownManager.isProjectOnCooldown.calledWith(@project_id).should.equal true
+				@FileTypeManager.shouldIgnore.callCount.should.equal 1
+				@updateMerger.mergeUpdate.callCount.should.equal 1
+				done()
+
+		it 'should return error and not proceed with update if project is on cooldown', (done) ->
+			@CooldownManager.isProjectOnCooldown = sinon.stub().callsArgWith(1, null, true)
+			@projectLocator.findUsersProjectByName = sinon.stub().callsArgWith(2)
+			@FileTypeManager.shouldIgnore = sinon.stub().callsArgWith(1, null, false)
+			path = "/path/here"
+			update = {}
+			@updateMerger.mergeUpdate = sinon.stub()
+			@updateMerger.mergeUpdate.withArgs(@user_id, @project_id, path, update, @source).callsArg(5)
+			@handler.newUpdate @user_id, @project.name, path, update, @source, (err) =>
+				expect(err).to.not.be.oneOf [null, undefined]
+				expect(err).to.be.instanceof Error
+				@CooldownManager.isProjectOnCooldown.callCount.should.equal 1
+				@CooldownManager.isProjectOnCooldown.calledWith(@project_id).should.equal true
+				@FileTypeManager.shouldIgnore.callCount.should.equal 0
+				@updateMerger.mergeUpdate.callCount.should.equal 0
+				done()
+
 	describe 'getting a delete :', ->
 		it 'should call deleteEntity in the collaberation manager', (done)->
 			path = "/delete/this"

+ 4 - 4
services/web/test/UnitTests/coffee/infrastructure/LockManager/CheckingTheLock.coffee

@@ -4,10 +4,10 @@ path = require('path')
 modulePath = path.join __dirname, '../../../../../app/js/infrastructure/LockManager.js'
 project_id = 1234
 doc_id     = 5678
-blockingKey = "Blocking:#{doc_id}"
+blockingKey = "lock:web:{#{doc_id}}"
 SandboxedModule = require('sandboxed-module')
 
-describe 'Lock Manager - checking the lock', ()->
+describe 'LockManager - checking the lock', ()->
 
 	existsStub = sinon.stub()
 	setStub = sinon.stub()
@@ -17,8 +17,8 @@ describe 'Lock Manager - checking the lock', ()->
 	mocks =
 		"logger-sharelatex": log:->
 
-		"redis-sharelatex":
-			createClient : ()->
+		"./RedisWrapper":
+			client: ()->
 				auth:->
 				multi: ->
 					exists: existsStub

+ 3 - 3
services/web/test/UnitTests/coffee/infrastructure/LockManager/ReleasingTheLock.coffee

@@ -12,8 +12,8 @@ describe 'LockManager - releasing the lock', ()->
 	mocks =
 		"logger-sharelatex": log:->
 
-		"redis-sharelatex":
-			createClient : ()->
+		"./RedisWrapper":
+			client: ()->
 				auth:->
 				del:deleteStub
 	
@@ -21,6 +21,6 @@ describe 'LockManager - releasing the lock', ()->
 
 	it 'should put a all data into memory', (done)->
 		LockManager.releaseLock doc_id, ->
-			deleteStub.calledWith("Blocking:#{doc_id}").should.equal true
+			deleteStub.calledWith("lock:web:{#{doc_id}}").should.equal true
 			done()
 

+ 2 - 2
services/web/test/UnitTests/coffee/infrastructure/LockManager/getLockTests.coffee

@@ -9,8 +9,8 @@ describe 'LockManager - getting the lock', ->
 	beforeEach ->
 		@LockManager = SandboxedModule.require modulePath, requires:
 			"logger-sharelatex": log:->
-			"redis-sharelatex":
-				createClient : () =>
+			"./RedisWrapper":
+				client: ()->
 					auth:->
 			"settings-sharelatex":{redis:{}}
 			"metrics-sharelatex": inc:->

+ 3 - 3
services/web/test/UnitTests/coffee/infrastructure/LockManager/tryLockTests.coffee

@@ -9,8 +9,8 @@ describe 'LockManager - trying the lock', ->
 	beforeEach ->
 		@LockManager = SandboxedModule.require modulePath, requires:
 			"logger-sharelatex": log:->
-			"redis-sharelatex":
-				createClient : () =>
+			"./RedisWrapper":
+				client: () =>
 					auth:->
 					set: @set = sinon.stub()
 			"settings-sharelatex":{redis:{}}
@@ -24,7 +24,7 @@ describe 'LockManager - trying the lock', ->
 			@LockManager.tryLock @doc_id, @callback
 
 		it "should set the lock key with an expiry if it is not set", ->
-			@set.calledWith("Blocking:#{@doc_id}", "locked", "EX", 30, "NX")
+			@set.calledWith("lock:web:{#{@doc_id}}", "locked", "EX", 30, "NX")
 				.should.equal true
 
 		it "should return the callback with true", ->

+ 19 - 49
services/web/test/UnitTests/coffee/infrastructure/RedisWrapperTests.coffee

@@ -9,58 +9,28 @@ 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
+		@settings = { redis: {} }
 		@redis =
-			createClient: sinon.stub().returns(@normalRedisInstance)
-		@ioredis =
-			Cluster: sinon.stub().returns(@clusterRedisInstance)
-		@logger = {log: sinon.stub()}
-
+			createClient: 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
+		it "should use the feature settings if present", ->
+			@settings.redis =
+				my_feature:
+					port:"23456"
+					host:"otherhost"
+					password: "banana"
+			@RedisWrapper.client("my_feature")
+			@redis.createClient.calledWith(@settings.redis.my_feature).should.equal true
+
+		it "should use the web settings if feature not present", ->
+			@settings.redis =
+				web:
+					port:"43"
+					host:"otherhost"
+					password: "banana"
+			@RedisWrapper.client("my_feature")
+			@redis.createClient.calledWith(@settings.redis.web).should.equal true