diff --git a/services/web/app/coffee/Features/Chat/ChatController.coffee b/services/web/app/coffee/Features/Chat/ChatController.coffee index b163df8158..fa1f3216e2 100644 --- a/services/web/app/coffee/Features/Chat/ChatController.coffee +++ b/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 + # 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 + if thread.resolved + user_ids[thread.resolved_by_user_id] = true + for message in thread.messages + user_ids[message.user_id] = true jobs = [] - for thread_id, thread of threads - do (thread) -> - if thread.resolved - jobs.push (cb) -> - getUserDetails thread.resolved_by_user_id, (error, user) -> - cb(error) if error? - thread.resolved_by_user = user - cb() - for message in thread.messages - do (message) -> - jobs.push (cb) -> - getUserDetails message.user_id, (error, user) -> - cb(error) if error? - message.user = user - cb() - + 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 \ No newline at end of file diff --git a/services/web/app/coffee/Features/Compile/ClsiCookieManager.coffee b/services/web/app/coffee/Features/Compile/ClsiCookieManager.coffee index c237f212c0..c19c5e02ea 100644 --- a/services/web/app/coffee/Features/Compile/ClsiCookieManager.coffee +++ b/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" diff --git a/services/web/app/coffee/Features/Compile/CompileManager.coffee b/services/web/app/coffee/Features/Compile/CompileManager.coffee index 3bfbf8df7d..ad8a2459ec 100755 --- a/services/web/app/coffee/Features/Compile/CompileManager.coffee +++ b/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" diff --git a/services/web/app/coffee/Features/Cooldown/CooldownManager.coffee b/services/web/app/coffee/Features/Cooldown/CooldownManager.coffee new file mode 100644 index 0000000000..9797cbf04d --- /dev/null +++ b/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") + diff --git a/services/web/app/coffee/Features/Cooldown/CooldownMiddlewear.coffee b/services/web/app/coffee/Features/Cooldown/CooldownMiddlewear.coffee new file mode 100644 index 0000000000..6d1e158699 --- /dev/null +++ b/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() diff --git a/services/web/app/coffee/Features/Docstore/DocstoreManager.coffee b/services/web/app/coffee/Features/Docstore/DocstoreManager.coffee index 06dd14c17b..927121a6a1 100644 --- a/services/web/app/coffee/Features/Docstore/DocstoreManager.coffee +++ b/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" diff --git a/services/web/app/coffee/Features/DocumentUpdater/DocumentUpdaterHandler.coffee b/services/web/app/coffee/Features/DocumentUpdater/DocumentUpdaterHandler.coffee index 595fe07971..805f063808 100644 --- a/services/web/app/coffee/Features/DocumentUpdater/DocumentUpdaterHandler.coffee +++ b/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") diff --git a/services/web/app/coffee/Features/Editor/EditorRealTimeController.coffee b/services/web/app/coffee/Features/Editor/EditorRealTimeController.coffee index bd4f6af8e8..5062533cbb 100644 --- a/services/web/app/coffee/Features/Editor/EditorRealTimeController.coffee +++ b/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 diff --git a/services/web/app/coffee/Features/Errors/ErrorController.coffee b/services/web/app/coffee/Features/Errors/ErrorController.coffee index 861a1362b2..45a743f282 100644 --- a/services/web/app/coffee/Features/Errors/ErrorController.coffee +++ b/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 diff --git a/services/web/app/coffee/Features/Errors/Errors.coffee b/services/web/app/coffee/Features/Errors/Errors.coffee index 92228f7b0b..56c0ada7d5 100644 --- a/services/web/app/coffee/Features/Errors/Errors.coffee +++ b/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 diff --git a/services/web/app/coffee/Features/HealthCheck/HealthCheckController.coffee b/services/web/app/coffee/Features/HealthCheck/HealthCheckController.coffee index 51acdbd2f0..544933b5fc 100644 --- a/services/web/app/coffee/Features/HealthCheck/HealthCheckController.coffee +++ b/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) -> diff --git a/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee b/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee index 9948c7ec3d..5868d6941a 100644 --- a/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee +++ b/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? diff --git a/services/web/app/coffee/Features/Security/OneTimeTokenHandler.coffee b/services/web/app/coffee/Features/Security/OneTimeTokenHandler.coffee index b84e1c9b33..3824703efb 100644 --- a/services/web/app/coffee/Features/Security/OneTimeTokenHandler.coffee +++ b/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") diff --git a/services/web/app/coffee/Features/ThirdPartyDataStore/TpdsUpdateHandler.coffee b/services/web/app/coffee/Features/ThirdPartyDataStore/TpdsUpdateHandler.coffee index 7605f6911b..c78b588e8a 100644 --- a/services/web/app/coffee/Features/ThirdPartyDataStore/TpdsUpdateHandler.coffee +++ b/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)-> diff --git a/services/web/app/coffee/Features/User/UserSessionsManager.coffee b/services/web/app/coffee/Features/User/UserSessionsManager.coffee index 2cd3b17e7f..273c7fe4a9 100644 --- a/services/web/app/coffee/Features/User/UserSessionsManager.coffee +++ b/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 = diff --git a/services/web/app/coffee/Features/User/UserSessionsRedis.coffee b/services/web/app/coffee/Features/User/UserSessionsRedis.coffee index 89ab7ed192..0c460b4604 100644 --- a/services/web/app/coffee/Features/User/UserSessionsRedis.coffee +++ b/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}}" diff --git a/services/web/app/coffee/infrastructure/LockManager.coffee b/services/web/app/coffee/infrastructure/LockManager.coffee index 3e40f9d9dc..3064fffabf 100644 --- a/services/web/app/coffee/infrastructure/LockManager.coffee +++ b/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)-> diff --git a/services/web/app/coffee/infrastructure/RedisWrapper.coffee b/services/web/app/coffee/infrastructure/RedisWrapper.coffee index 5d8b5836b5..523785427e 100644 --- a/services/web/app/coffee/infrastructure/RedisWrapper.coffee +++ b/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 diff --git a/services/web/app/coffee/router.coffee b/services/web/app/coffee/router.coffee index 0556c23c5a..fa799a2dff 100644 --- a/services/web/app/coffee/router.coffee +++ b/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") diff --git a/services/web/app/views/project/editor/editor.pug b/services/web/app/views/project/editor/editor.pug index 6c062f3cbe..e8217a9319 100644 --- a/services/web/app/views/project/editor/editor.pug +++ b/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,\ diff --git a/services/web/app/views/project/editor/header.pug b/services/web/app/views/project/editor/header.pug index a1c13032f9..1046244713 100644 --- a/services/web/app/views/project/editor/header.pug +++ b/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", diff --git a/services/web/app/views/project/list/project-list.pug b/services/web/app/views/project/list/project-list.pug index 0bb93e8336..629c3dc62d 100644 --- a/services/web/app/views/project/list/project-list.pug +++ b/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)" + ) × + .col-xs-2 span.owner {{ownerName()}} .col-xs-4 diff --git a/services/web/app/views/project/list/side-bar.pug b/services/web/app/views/project/list/side-bar.pug index 1fb0e2bda9..b3a3f11948 100644 --- a/services/web/app/views/project/list/side-bar.pug +++ b/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 diff --git a/services/web/config/settings.defaults.coffee b/services/web/config/settings.defaults.coffee index b24c2568ab..8fb00aff31 100644 --- a/services/web/config/settings.defaults.coffee +++ b/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" diff --git a/services/web/npm-shrinkwrap.json b/services/web/npm-shrinkwrap.json index 6904ca8272..f22ecf0219 100644 --- a/services/web/npm-shrinkwrap.json +++ b/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", diff --git a/services/web/package.json b/services/web/package.json index 85da8a3765..9c7c7b58de 100644 --- a/services/web/package.json +++ b/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", diff --git a/services/web/public/coffee/directives/selectAll.coffee b/services/web/public/coffee/directives/selectAll.coffee index d5be2639e6..6400d2d0ad 100644 --- a/services/web/public/coffee/directives/selectAll.coffee +++ b/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" } \ No newline at end of file diff --git a/services/web/public/coffee/ide.coffee b/services/web/public/coffee/ide.coffee index 9fbbfe9937..dabf166578 100644 --- a/services/web/public/coffee/ide.coffee +++ b/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 diff --git a/services/web/public/coffee/ide/review-panel/controllers/ReviewPanelController.coffee b/services/web/public/coffee/ide/review-panel/controllers/ReviewPanelController.coffee index bf3f96ffc3..cb17d53e04 100644 --- a/services/web/public/coffee/ide/review-panel/controllers/ReviewPanelController.coffee +++ b/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() diff --git a/services/web/public/coffee/main/project-list/project-list.coffee b/services/web/public/coffee/main/project-list/project-list.coffee index 161598d32d..ad9d0f3582 100644 --- a/services/web/public/coffee/main/project-list/project-list.coffee +++ b/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 = () -> diff --git a/services/web/public/coffee/main/project-list/tag-controllers.coffee b/services/web/public/coffee/main/project-list/tag-controllers.coffee index cc3415e32e..6a75e7491b 100644 --- a/services/web/public/coffee/main/project-list/tag-controllers.coffee +++ b/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" diff --git a/services/web/public/stylesheets/app/editor/toolbar.less b/services/web/public/stylesheets/app/editor/toolbar.less index a30b2897c4..5f84f64682 100644 --- a/services/web/public/stylesheets/app/editor/toolbar.less +++ b/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; diff --git a/services/web/public/stylesheets/app/project-list.less b/services/web/public/stylesheets/app/project-list.less index 17f6b7bb25..13ca60e181 100644 --- a/services/web/public/stylesheets/app/project-list.less +++ b/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; diff --git a/services/web/test/UnitTests/coffee/Compile/ClsiCookieManagerTests.coffee b/services/web/test/UnitTests/coffee/Compile/ClsiCookieManagerTests.coffee index 01d4ad0002..c54f405bb9 100644 --- a/services/web/test/UnitTests/coffee/Compile/ClsiCookieManagerTests.coffee +++ b/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 diff --git a/services/web/test/UnitTests/coffee/Compile/CompileManagerTests.coffee b/services/web/test/UnitTests/coffee/Compile/CompileManagerTests.coffee index de56443707..3964acea41 100644 --- a/services/web/test/UnitTests/coffee/Compile/CompileManagerTests.coffee +++ b/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 = {} diff --git a/services/web/test/UnitTests/coffee/Cooldown/CooldownManagerTests.coffee b/services/web/test/UnitTests/coffee/Cooldown/CooldownManagerTests.coffee new file mode 100644 index 0000000000..96e27a8bf7 --- /dev/null +++ b/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() diff --git a/services/web/test/UnitTests/coffee/Cooldown/CooldownMiddlewearTests.coffee b/services/web/test/UnitTests/coffee/Cooldown/CooldownMiddlewearTests.coffee new file mode 100644 index 0000000000..646e7ffbde --- /dev/null +++ b/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 diff --git a/services/web/test/UnitTests/coffee/Docstore/DocstoreManagerTests.coffee b/services/web/test/UnitTests/coffee/Docstore/DocstoreManagerTests.coffee index abcc55a0b9..ec7a8ce451 100644 --- a/services/web/test/UnitTests/coffee/Docstore/DocstoreManagerTests.coffee +++ b/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 -> diff --git a/services/web/test/UnitTests/coffee/DocumentUpdater/DocumentUpdaterHandlerTests.coffee b/services/web/test/UnitTests/coffee/DocumentUpdater/DocumentUpdaterHandlerTests.coffee index f5519ffa68..ee92d00ae3 100644 --- a/services/web/test/UnitTests/coffee/DocumentUpdater/DocumentUpdaterHandlerTests.coffee +++ b/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() diff --git a/services/web/test/UnitTests/coffee/Editor/EditorRealTimeControllerTests.coffee b/services/web/test/UnitTests/coffee/Editor/EditorRealTimeControllerTests.coffee index bbad9b1ae5..a06bd032dc 100644 --- a/services/web/test/UnitTests/coffee/Editor/EditorRealTimeControllerTests.coffee +++ b/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 diff --git a/services/web/test/UnitTests/coffee/Security/OneTimeTokenHandlerTests.coffee b/services/web/test/UnitTests/coffee/Security/OneTimeTokenHandlerTests.coffee index a6305db27c..046acaa720 100644 --- a/services/web/test/UnitTests/coffee/Security/OneTimeTokenHandlerTests.coffee +++ b/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 diff --git a/services/web/test/UnitTests/coffee/ThirdPartyDataStore/TpdsUpdateHandlerTests.coffee b/services/web/test/UnitTests/coffee/ThirdPartyDataStore/TpdsUpdateHandlerTests.coffee index 04fd34671e..dedd3ea7c8 100644 --- a/services/web/test/UnitTests/coffee/ThirdPartyDataStore/TpdsUpdateHandlerTests.coffee +++ b/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" diff --git a/services/web/test/UnitTests/coffee/infrastructure/LockManager/CheckingTheLock.coffee b/services/web/test/UnitTests/coffee/infrastructure/LockManager/CheckingTheLock.coffee index 3f56c346ac..cf56778ec8 100644 --- a/services/web/test/UnitTests/coffee/infrastructure/LockManager/CheckingTheLock.coffee +++ b/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 diff --git a/services/web/test/UnitTests/coffee/infrastructure/LockManager/ReleasingTheLock.coffee b/services/web/test/UnitTests/coffee/infrastructure/LockManager/ReleasingTheLock.coffee index 8eb0f4d75c..8ccab0a757 100644 --- a/services/web/test/UnitTests/coffee/infrastructure/LockManager/ReleasingTheLock.coffee +++ b/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() diff --git a/services/web/test/UnitTests/coffee/infrastructure/LockManager/getLockTests.coffee b/services/web/test/UnitTests/coffee/infrastructure/LockManager/getLockTests.coffee index ec0d0d4950..16ae0a8b8c 100644 --- a/services/web/test/UnitTests/coffee/infrastructure/LockManager/getLockTests.coffee +++ b/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:-> diff --git a/services/web/test/UnitTests/coffee/infrastructure/LockManager/tryLockTests.coffee b/services/web/test/UnitTests/coffee/infrastructure/LockManager/tryLockTests.coffee index 98f624c70b..5397e18cb2 100644 --- a/services/web/test/UnitTests/coffee/infrastructure/LockManager/tryLockTests.coffee +++ b/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", -> diff --git a/services/web/test/UnitTests/coffee/infrastructure/RedisWrapperTests.coffee b/services/web/test/UnitTests/coffee/infrastructure/RedisWrapperTests.coffee index 83ea202dcd..4837711f56 100644 --- a/services/web/test/UnitTests/coffee/infrastructure/RedisWrapperTests.coffee +++ b/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', -> + 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 - 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 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