Merge branch 'master' into sk-pug

This commit is contained in:
Shane Kilkelly 2017-01-30 14:36:10 +00:00
commit 4e9426e6bf
51 changed files with 1212 additions and 596 deletions

View file

@ -1,5 +1,6 @@
fs = require "fs"
PackageVersions = require "./app/coffee/infrastructure/PackageVersions"
require('es6-promise').polyfill()
module.exports = (grunt) ->
grunt.loadNpmTasks 'grunt-contrib-coffee'
@ -18,6 +19,7 @@ module.exports = (grunt) ->
grunt.loadNpmTasks 'grunt-contrib-watch'
grunt.loadNpmTasks 'grunt-parallel'
grunt.loadNpmTasks 'grunt-exec'
grunt.loadNpmTasks 'grunt-postcss'
# grunt.loadNpmTasks 'grunt-contrib-imagemin'
# grunt.loadNpmTasks 'grunt-sprity'
@ -136,8 +138,14 @@ module.exports = (grunt) ->
files:
"public/stylesheets/style.css": "public/stylesheets/style.less"
postcss:
options:
map: true,
processors: [
require('autoprefixer')({browsers: [ 'last 2 versions', 'ie >= 10' ]})
]
dist:
src: 'public/stylesheets/style.css'
env:
run:
@ -366,7 +374,7 @@ module.exports = (grunt) ->
grunt.registerTask 'compile:server', 'Compile the server side coffee script', ['clean:app', 'coffee:app', 'coffee:app_dir', 'compile:modules:server']
grunt.registerTask 'compile:client', 'Compile the client side coffee script', ['coffee:client', 'coffee:sharejs', 'wrap_sharejs', "compile:modules:client", 'compile:modules:inject_clientside_includes']
grunt.registerTask 'compile:css', 'Compile the less files to css', ['less']
grunt.registerTask 'compile:css', 'Compile the less files to css', ['less', 'postcss:dist']
grunt.registerTask 'compile:minify', 'Concat and minify the client side js', ['requirejs', "file_append", "exec:cssmin",]
grunt.registerTask 'compile:unit_tests', 'Compile the unit tests', ['clean:unit_tests', 'coffee:unit_tests']
grunt.registerTask 'compile:acceptance_tests', 'Compile the acceptance tests', ['clean:acceptance_tests', 'coffee:acceptance_tests']

View file

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

View file

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

View file

@ -58,4 +58,25 @@ module.exports = ChatApiHandler =
ChatApiHandler._apiRequest {
url: "#{settings.apis.chat.internal_url}/project/#{project_id}/thread/#{thread_id}/reopen"
method: "POST"
}, callback
}, callback
deleteThread: (project_id, thread_id, callback = (error) ->) ->
ChatApiHandler._apiRequest {
url: "#{settings.apis.chat.internal_url}/project/#{project_id}/thread/#{thread_id}"
method: "DELETE"
}, callback
editMessage: (project_id, thread_id, message_id, content, callback = (error) ->) ->
ChatApiHandler._apiRequest {
url: "#{settings.apis.chat.internal_url}/project/#{project_id}/thread/#{thread_id}/messages/#{message_id}/edit"
method: "POST"
json:
content: content
}, callback
deleteMessage: (project_id, thread_id, message_id, callback = (error) ->) ->
ChatApiHandler._apiRequest {
url: "#{settings.apis.chat.internal_url}/project/#{project_id}/thread/#{thread_id}/messages/#{message_id}"
method: "DELETE"
}, callback

View file

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

View file

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

View file

@ -4,6 +4,7 @@ logger = require("logger-sharelatex")
AuthenticationController = require('../Authentication/AuthenticationController')
UserInfoManager = require('../User/UserInfoManager')
UserInfoController = require('../User/UserInfoController')
DocumentUpdaterHandler = require "../DocumentUpdater/DocumentUpdaterHandler"
async = require "async"
module.exports = CommentsController =
@ -50,6 +51,33 @@ module.exports = CommentsController =
return next(err) if err?
EditorRealTimeController.emitToRoom project_id, "reopen-thread", thread_id, (err)->
res.send 204
deleteThread: (req, res, next) ->
{project_id, doc_id, thread_id} = req.params
logger.log {project_id, doc_id, thread_id}, "deleting comment thread"
DocumentUpdaterHandler.deleteThread project_id, doc_id, thread_id, (err) ->
return next(err) if err?
ChatApiHandler.deleteThread project_id, thread_id, (err, threads) ->
return next(err) if err?
EditorRealTimeController.emitToRoom project_id, "delete-thread", thread_id, (err)->
res.send 204
editMessage: (req, res, next) ->
{project_id, thread_id, message_id} = req.params
{content} = req.body
logger.log {project_id, thread_id, message_id}, "editing message thread"
ChatApiHandler.editMessage project_id, thread_id, message_id, content, (err) ->
return next(err) if err?
EditorRealTimeController.emitToRoom project_id, "edit-message", thread_id, message_id, content, (err)->
res.send 204
deleteMessage: (req, res, next) ->
{project_id, thread_id, message_id} = req.params
logger.log {project_id, thread_id, message_id}, "deleting message"
ChatApiHandler.deleteMessage project_id, thread_id, message_id, (err, threads) ->
return next(err) if err?
EditorRealTimeController.emitToRoom project_id, "delete-message", thread_id, message_id, (err)->
res.send 204
_injectUserInfoIntoThreads: (threads, callback = (error, threads) ->) ->
userCache = {}

View file

@ -153,6 +153,22 @@ module.exports = DocumentUpdaterHandler =
logger.error {project_id, doc_id, change_id}, "doc updater returned a non-success status code: #{res.statusCode}"
callback new Error("doc updater returned a non-success status code: #{res.statusCode}")
deleteThread: (project_id, doc_id, thread_id, callback = (error) ->) ->
timer = new metrics.Timer("delete-thread")
url = "#{settings.apis.documentupdater.url}/project/#{project_id}/doc/#{doc_id}/comment/#{thread_id}"
logger.log {project_id, doc_id, thread_id}, "deleting comment range in document updater"
request.del url, (error, res, body)->
timer.done()
if error?
logger.error {err:error, project_id, doc_id, thread_id}, "error deleting comment range in doc updater"
return callback(error)
if res.statusCode >= 200 and res.statusCode < 300
logger.log {project_id, doc_id, thread_id}, "deleted comment rangee in document updater"
return callback(null)
else
logger.error {project_id, doc_id, thread_id}, "doc updater returned a non-success status code: #{res.statusCode}"
callback new Error("doc updater returned a non-success status code: #{res.statusCode}")
PENDINGUPDATESKEY = "PendingUpdates"
DOCLINESKEY = "doclines"
DOCIDSWITHPENDINGUPDATES = "DocsWithPendingUpdates"

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -189,6 +189,7 @@ module.exports = (app, webRouter, apiRouter)->
return AuthenticationController.isUserLoggedIn(req)
res.locals.getSessionUser = ->
return AuthenticationController.getSessionUser(req)
next()
webRouter.use (req, res, next) ->

View file

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

View file

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

View file

@ -7,7 +7,6 @@ crawlerLogger = require('./CrawlerLogger')
expressLocals = require('./ExpressLocals')
Router = require('../router')
metrics.inc("startup")
redis = require("redis-sharelatex")
UserSessionsRedis = require('../Features/User/UserSessionsRedis')
sessionsRedisClient = UserSessionsRedis.client()

View file

@ -2,7 +2,7 @@ Project = require('./Project').Project
Settings = require 'settings-sharelatex'
_ = require('underscore')
mongoose = require('mongoose')
uuid = require('node-uuid')
uuid = require('uuid')
Schema = mongoose.Schema
ObjectId = Schema.ObjectId

View file

@ -238,6 +238,9 @@ module.exports = class Router
webRouter.get "/project/:project_id/threads", AuthorizationMiddlewear.ensureUserCanReadProject, CommentsController.getThreads
webRouter.post "/project/:project_id/thread/:thread_id/resolve", AuthorizationMiddlewear.ensureUserCanWriteProjectContent, CommentsController.resolveThread
webRouter.post "/project/:project_id/thread/:thread_id/reopen", AuthorizationMiddlewear.ensureUserCanWriteProjectContent, CommentsController.reopenThread
webRouter.delete "/project/:project_id/doc/:doc_id/thread/:thread_id", AuthorizationMiddlewear.ensureUserCanWriteProjectContent, CommentsController.deleteThread
webRouter.post "/project/:project_id/thread/:thread_id/messages/:message_id/edit", AuthorizationMiddlewear.ensureUserCanWriteProjectContent, CommentsController.editMessage
webRouter.delete "/project/:project_id/thread/:thread_id/messages/:message_id", AuthorizationMiddlewear.ensureUserCanWriteProjectContent, CommentsController.deleteMessage
webRouter.post "/project/:Project_id/references/index", AuthorizationMiddlewear.ensureUserCanReadProject, ReferencesController.index
webRouter.post "/project/:Project_id/references/indexAll", AuthorizationMiddlewear.ensureUserCanReadProject, ReferencesController.indexAll

View file

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

View file

@ -1,13 +1,22 @@
#review-panel
a.rp-track-changes-indicator(
href
ng-if="editor.wantTrackChanges"
ng-click="toggleReviewPanel();"
ng-class="{ 'rp-track-changes-indicator-on-dark' : darkTheme }"
) Track changes is
strong on
.review-panel-toolbar
resolved-comments-dropdown(
class="rp-flex-block"
entries="reviewPanel.resolvedComments"
threads="reviewPanel.commentThreads"
resolved-ids="reviewPanel.resolvedThreadIds"
docs="docs"
on-open="refreshResolvedCommentsDropdown();"
on-unresolve="unresolveComment(threadId);"
on-delete="deleteComment(entryId, threadId);"
on-delete="deleteThread(entryId, docId, threadId);"
is-loading="reviewPanel.dropdown.loading"
permissions="permissions"
)
@ -32,7 +41,6 @@
.rp-entry-list-inner
.rp-entry-wrapper(
ng-repeat="(entry_id, entry) in reviewPanel.entries[editor.open_doc_id]"
ng-if="!(entry.type === 'comment' && reviewPanel.commentThreads[entry.thread_id].resolved === true)"
)
div(ng-if="entry.type === 'insert' || entry.type === 'delete'")
change-entry(
@ -51,6 +59,8 @@
on-resolve="resolveComment(entry, entry_id)"
on-reply="submitReply(entry, entry_id);"
on-indicator-click="toggleReviewPanel();"
on-save-edit="saveEdit(entry.thread_id, comment)"
on-delete="deleteComment(entry.thread_id, comment)"
permissions="permissions"
ng-if="!reviewPanel.loadingThreads"
)
@ -94,6 +104,8 @@
entry="entry"
threads="reviewPanel.commentThreads"
on-reply="submitReply(entry, entry_id);"
on-save-edit="saveEdit(entry.thread_id, comment)"
on-delete="deleteComment(entry.thread_id, comment)"
on-indicator-click="toggleReviewPanel();"
ng-click="gotoEntry(doc.doc.id, entry)"
permissions="permissions"
@ -175,21 +187,48 @@ script(type='text/ng-template', id='commentEntryTemplate')
.rp-entry.rp-entry-comment(
ng-class="{ 'rp-entry-focused': entry.focused, 'rp-entry-comment-resolving': state.animating }"
)
.rp-loading(ng-if="!threads[entry.thread_id].submitting && (!threads[entry.thread_id] || threads[entry.thread_id].messages.length == 0)")
| No comments
div
.rp-comment(
ng-repeat="comment in threads[entry.thread_id].messages track by comment.id"
)
p.rp-comment-content
span.rp-entry-user(
style="color: hsl({{ comment.user.hue }}, 70%, 40%);"
) {{ comment.user.name }}:&nbsp;
| {{ comment.content }}
.rp-entry-metadata
| {{ comment.timestamp | date : 'MMM d, y h:mm a' }}
.rp-loading(ng-if="!threads[entry.thread_id] || threads[entry.thread_id].submitting")
p.rp-comment-content
span(ng-if="!comment.editing")
span.rp-entry-user(
style="color: hsl({{ comment.user.hue }}, 70%, 40%);",
) {{ comment.user.name }}:&nbsp;
| {{ comment.content }}
textarea.rp-comment-input(
expandable-text-area
ng-if="comment.editing"
ng-model="comment.content"
ng-keypress="saveEditOnEnter($event, comment);"
ng-blur="saveEdit(comment)"
autofocus
stop-propagation="click"
)
.rp-entry-metadata(ng-if="!comment.editing")
span(ng-if="!comment.deleting") {{ comment.timestamp | date : 'MMM d, y h:mm a' }}
span.rp-comment-actions(ng-if="comment.user.isSelf && !comment.deleting")
| &nbsp;&bull;&nbsp;
a(href, ng-click="startEditing(comment)") Edit
span(ng-if="threads[entry.thread_id].messages.length > 1")
| &nbsp;&bull;&nbsp;
a(href, ng-click="confirmDelete(comment)") Delete
span.rp-confim-delete(ng-if="comment.user.isSelf && comment.deleting")
| Are you sure?
| &bull;&nbsp;
a(href, ng-click="doDelete(comment)") Delete
| &nbsp;&bull;&nbsp;
a(href, ng-click="cancelDelete(comment)") Cancel
.rp-loading(ng-if="threads[entry.thread_id].submitting")
i.fa.fa-spinner.fa-spin
.rp-comment-reply(ng-if="permissions.comment")
textarea.rp-comment-input(
expandable-text-area
ng-model="entry.replyContent"
ng-keypress="handleCommentReplyKeyPress($event);"
stop-propagation="click"
@ -249,11 +288,11 @@ script(type='text/ng-template', id='resolvedCommentEntryTemplate')
ng-click="onUnresolve({ 'threadId': thread.threadId });"
)
| &nbsp;Re-open
//- a.rp-entry-button(
//- href
//- ng-click="onDelete({ 'entryId': thread.entryId, 'threadId': thread.threadId });"
//- )
//- | &nbsp;Delete
a.rp-entry-button(
href
ng-click="onDelete({ 'entryId': thread.entryId, 'docId': thread.docId, 'threadId': thread.threadId });"
)
| &nbsp;Delete
script(type='text/ng-template', id='addCommentEntryTemplate')
@ -280,6 +319,7 @@ script(type='text/ng-template', id='addCommentEntryTemplate')
div(ng-if="state.isAdding")
.rp-new-comment
textarea.rp-comment-input(
expandable-text-area
ng-model="state.content"
ng-keypress="handleCommentKeyPress($event);"
placeholder="Add your comment here"
@ -324,7 +364,7 @@ script(type='text/ng-template', id='resolvedCommentsDropdownTemplate')
ng-repeat="thread in resolvedComments | orderBy:'resolved_at':true"
thread="thread"
on-unresolve="handleUnresolve(threadId);"
on-delete="handleDelete(entryId, threadId);"
on-delete="handleDelete(entryId, docId, threadId);"
permissions="permissions"
)
.rp-loading(ng-if="!resolvedComments.length")

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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