diff --git a/services/web/Makefile b/services/web/Makefile
index 474155a478..26534b8612 100644
--- a/services/web/Makefile
+++ b/services/web/Makefile
@@ -196,8 +196,11 @@ test_frontend: test_clean # stop service
test_acceptance: test_acceptance_app test_acceptance_modules
-test_acceptance_app: test_acceptance_app_start_service test_acceptance_app_run
- $(MAKE) test_acceptance_app_stop_service
+test_acceptance_app:
+ @set -e; \
+ $(MAKE) test_acceptance_app_start_service; \
+ $(MAKE) test_acceptance_app_run; \
+ $(MAKE) test_acceptance_app_stop_service;
test_acceptance_app_start_service: test_clean # stop service and clear dbs
$(MAKE) compile
@@ -208,10 +211,12 @@ test_acceptance_app_stop_service:
test_acceptance_app_run:
@docker-compose ${DOCKER_COMPOSE_FLAGS} exec -T test_acceptance npm -q run test:acceptance -- ${MOCHA_ARGS}; \
- if [ $$? -eq 137 ]; then \
- echo "\nOh dear, it looks like the web process crashed! To see why, run:\n\n\tdocker-compose logs test_acceptance\n"; \
- exit 1; \
- fi
+ result=$$?; \
+ if [ $$result -eq 137 ]; then \
+ docker-compose logs --tail=50 test_acceptance; \
+ echo "\nOh dear, it looks like the web process crashed! Some logs are above, but to see them all, run:\n\n\tdocker-compose logs test_acceptance\n"; \
+ fi; \
+ exit $$result
test_acceptance_modules:
@set -e; \
diff --git a/services/web/app/coffee/Features/Compile/ClsiManager.coffee b/services/web/app/coffee/Features/Compile/ClsiManager.coffee
index c27bc2fe90..d1dfe21dad 100755
--- a/services/web/app/coffee/Features/Compile/ClsiManager.coffee
+++ b/services/web/app/coffee/Features/Compile/ClsiManager.coffee
@@ -14,6 +14,7 @@ async = require("async")
ClsiFormatChecker = require("./ClsiFormatChecker")
DocumentUpdaterHandler = require "../DocumentUpdater/DocumentUpdaterHandler"
Metrics = require('metrics-sharelatex')
+Errors = require ('../Errors/Errors')
module.exports = ClsiManager =
@@ -35,24 +36,16 @@ module.exports = ClsiManager =
else
return callback(error)
logger.log project_id: project_id, "sending compile to CLSI"
- ClsiFormatChecker.checkRecoursesForProblems req.compile?.resources, (err, validationProblems)->
- if err?
- logger.err err, project_id, "could not check resources for potential problems before sending to clsi"
- return callback(err)
- if validationProblems?
- logger.log project_id:project_id, validationProblems:validationProblems, "problems with users latex before compile was attempted"
- return callback(null, "validation-problems", null, null, validationProblems)
- ClsiManager._postToClsi project_id, user_id, req, options.compileGroup, (error, response) ->
- if error?
- logger.err err:error, project_id:project_id, "error sending request to clsi"
- return callback(error)
- logger.log project_id: project_id, outputFilesLength: response?.outputFiles?.length, status: response?.status, compile_status: response?.compile?.status, "received compile response from CLSI"
- ClsiCookieManager._getServerId project_id, (err, clsiServerId)->
- if err?
- logger.err err:err, project_id:project_id, "error getting server id"
- return callback(err)
- outputFiles = ClsiManager._parseOutputFiles(project_id, response?.compile?.outputFiles)
- callback(null, response?.compile?.status, outputFiles, clsiServerId)
+ ClsiManager._sendBuiltRequest project_id, user_id, req, options, (error, status, result...) ->
+ return callback(error) if error?
+ callback(error, status, result...)
+
+ # for public API requests where there is no project id
+ sendExternalRequest: (submission_id, clsi_request, options = {}, callback = (error, status, outputFiles, clsiServerId, validationProblems) ->) ->
+ logger.log submission_id: submission_id, "sending external compile to CLSI", clsi_request
+ ClsiManager._sendBuiltRequest submission_id, null, clsi_request, options, (error, status, result...) ->
+ return callback(error) if error?
+ callback(error, status, result...)
stopCompile: (project_id, user_id, options, callback = (error) ->) ->
compilerUrl = @_getCompilerUrl(options?.compileGroup, project_id, user_id, "compile/stop")
@@ -74,6 +67,26 @@ module.exports = ClsiManager =
return callback(error) if error?
callback()
+ _sendBuiltRequest: (project_id, user_id, req, options = {}, callback = (error, status, outputFiles, clsiServerId, validationProblems) ->) ->
+ ClsiFormatChecker.checkRecoursesForProblems req.compile?.resources, (err, validationProblems)->
+ if err?
+ logger.err err, project_id, "could not check resources for potential problems before sending to clsi"
+ return callback(err)
+ if validationProblems?
+ logger.log project_id:project_id, validationProblems:validationProblems, "problems with users latex before compile was attempted"
+ return callback(null, "validation-problems", null, null, validationProblems)
+ ClsiManager._postToClsi project_id, user_id, req, options.compileGroup, (error, response) ->
+ if error?
+ logger.err err:error, project_id:project_id, "error sending request to clsi"
+ return callback(error)
+ logger.log project_id: project_id, outputFilesLength: response?.outputFiles?.length, status: response?.status, compile_status: response?.compile?.status, "received compile response from CLSI"
+ ClsiCookieManager._getServerId project_id, (err, clsiServerId)->
+ if err?
+ logger.err err:err, project_id:project_id, "error getting server id"
+ return callback(err)
+ outputFiles = ClsiManager._parseOutputFiles(project_id, response?.compile?.outputFiles)
+ callback(null, response?.compile?.status, outputFiles, clsiServerId)
+
_makeRequest: (project_id, opts, callback)->
ClsiCookieManager.getCookieJar project_id, (err, jar)->
if err?
@@ -87,7 +100,7 @@ module.exports = ClsiManager =
ClsiCookieManager.setServerId project_id, response, (err)->
if err?
logger.warn err:err, project_id:project_id, "error setting server id"
-
+
return callback err, response, body
_getCompilerUrl: (compileGroup, project_id, user_id, action) ->
@@ -169,6 +182,14 @@ module.exports = ClsiManager =
return callback(error) if error?
callback(null, projectStateHash, docs)
+ getOutputFileStream: (project_id, user_id, build_id, output_file_path, callback=(err, readStream)->) ->
+ url = "#{Settings.apis.clsi.url}/project/#{project_id}/user/#{user_id}/build/#{build_id}/output/#{output_file_path}"
+ ClsiCookieManager.getCookieJar project_id, (err, jar)->
+ return callback(err) if err?
+ options = { url: url, method: "GET", timeout: 60 * 1000, jar : jar }
+ readStream = request(options)
+ callback(null, readStream)
+
_buildRequestFromDocupdater: (project_id, options, project, projectStateHash, docUpdaterDocs, callback = (error, request) ->) ->
ProjectEntityHandler.getAllDocPathsFromProject project, (error, docPath) ->
return callback(error) if error?
diff --git a/services/web/app/coffee/Features/Compile/CompileController.coffee b/services/web/app/coffee/Features/Compile/CompileController.coffee
index decfac515b..ee3e20fdc9 100755
--- a/services/web/app/coffee/Features/Compile/CompileController.coffee
+++ b/services/web/app/coffee/Features/Compile/CompileController.coffee
@@ -55,6 +55,33 @@ module.exports = CompileController =
return next(error) if error?
res.status(200).send()
+ # Used for submissions through the public API
+ compileSubmission: (req, res, next = (error) ->) ->
+ res.setTimeout(5 * 60 * 1000)
+ submission_id = req.params.submission_id
+ options = {}
+ if req.body?.rootResourcePath?
+ options.rootResourcePath = req.body.rootResourcePath
+ if req.body?.compiler
+ options.compiler = req.body.compiler
+ if req.body?.draft
+ options.draft = req.body.draft
+ if req.body?.check in ['validate', 'error', 'silent']
+ options.check = req.body.check
+ options.compileGroup = req.body?.compileGroup || Settings.defaultFeatures.compileGroup
+ options.timeout = req.body?.timeout || Settings.defaultFeatures.compileTimeout
+ logger.log {options:options, submission_id:submission_id}, "got compileSubmission request"
+ ClsiManager.sendExternalRequest submission_id, req.body, options, (error, status, outputFiles, clsiServerId, validationProblems) ->
+ return next(error) if error?
+ logger.log {submission_id:submission_id, files:outputFiles}, "compileSubmission output files"
+ res.contentType("application/json")
+ res.status(200).send JSON.stringify {
+ status: status
+ outputFiles: outputFiles
+ clsiServerId: clsiServerId
+ validationProblems: validationProblems
+ }
+
_compileAsUser: (req, callback) ->
# callback with user_id if per-user, undefined otherwise
if not Settings.disablePerUserCompiles
@@ -139,6 +166,12 @@ module.exports = CompileController =
url = CompileController._getFileUrl project_id, user_id, req.params.build_id, req.params.file
CompileController.proxyToClsi(project_id, url, req, res, next)
+ getFileFromClsiWithoutUser: (req, res, next = (error) ->) ->
+ submission_id = req.params.submission_id
+ url = CompileController._getFileUrl submission_id, null, req.params.build_id, req.params.file
+ limits = { compileGroup: req.body?.compileGroup || Settings.defaultFeatures.compileGroup }
+ CompileController.proxyToClsiWithLimits(submission_id, url, limits, req, res, next)
+
# compute a GET file url for a given project, user (optional), build (optional) and file
_getFileUrl: (project_id, user_id, build_id, file) ->
if user_id? and build_id?
diff --git a/services/web/app/coffee/Features/Email/Bodies/SingleCTAEmailBody.coffee b/services/web/app/coffee/Features/Email/Bodies/SingleCTAEmailBody.coffee
index cf4adf8cd7..bffa485f07 100644
--- a/services/web/app/coffee/Features/Email/Bodies/SingleCTAEmailBody.coffee
+++ b/services/web/app/coffee/Features/Email/Bodies/SingleCTAEmailBody.coffee
@@ -4,13 +4,17 @@ settings = require "settings-sharelatex"
module.exports = _.template """
-
- <%= title %>
-
+ <% if (title) { %>
+
+ <%= title %>
+
+ <% } %>
-
- <%= greeting %>
-
+ <% if (greeting) { %>
+
+ <%= greeting %>
+
+ <% } %>
<%= message %>
diff --git a/services/web/app/coffee/Features/Email/Bodies/ol-SingleCTAEmailBody.coffee b/services/web/app/coffee/Features/Email/Bodies/ol-SingleCTAEmailBody.coffee
index 45bc00383c..ee9907c019 100644
--- a/services/web/app/coffee/Features/Email/Bodies/ol-SingleCTAEmailBody.coffee
+++ b/services/web/app/coffee/Features/Email/Bodies/ol-SingleCTAEmailBody.coffee
@@ -4,13 +4,17 @@ settings = require "settings-sharelatex"
module.exports = _.template """
-
- <%= title %>
-
+ <% if (title) { %>
+
+ <%= title %>
+
+ <% } %>
-
- <%= greeting %>
-
+ <% if (greeting) { %>
+
+ <%= greeting %>
+
+ <% } %>
<%= message %>
diff --git a/services/web/app/coffee/Features/Email/EmailBuilder.coffee b/services/web/app/coffee/Features/Email/EmailBuilder.coffee
index 8952e7b864..a33f02e695 100644
--- a/services/web/app/coffee/Features/Email/EmailBuilder.coffee
+++ b/services/web/app/coffee/Features/Email/EmailBuilder.coffee
@@ -1,5 +1,6 @@
_ = require('underscore')
settings = require("settings-sharelatex")
+marked = require('marked')
PersonalEmailLayout = require("./Layouts/PersonalEmailLayout")
NotificationEmailLayout = require("./Layouts/NotificationEmailLayout")
@@ -7,169 +8,113 @@ BaseWithHeaderEmailLayout = require("./Layouts/" + settings.brandPrefix + "BaseW
SingleCTAEmailBody = require("./Bodies/" + settings.brandPrefix + "SingleCTAEmailBody")
+CTAEmailTemplate = (content) ->
+ content.greeting ?= () -> 'Hi,'
+ content.secondaryMessage ?= () -> ""
+ return {
+ subject: (opts) -> content.subject(opts),
+ layout: BaseWithHeaderEmailLayout,
+ plainTextTemplate: (opts) -> """
+#{content.greeting(opts)}
+
+#{content.message(opts).trim()}
+
+#{content.ctaText(opts)}: #{content.ctaURL(opts)}
+
+#{content.secondaryMessage?(opts).trim() or ""}
+
+Regards,
+The #{settings.appName} Team - #{settings.siteUrl}
+ """
+ compiledTemplate: (opts) ->
+ SingleCTAEmailBody({
+ title: content.title?(opts)
+ greeting: content.greeting(opts)
+ message: marked(content.message(opts).trim())
+ secondaryMessage: marked(content.secondaryMessage(opts).trim())
+ ctaText: content.ctaText(opts)
+ ctaURL: content.ctaURL(opts)
+ gmailGoToAction: content.gmailGoToAction?(opts)
+ })
+ }
+
templates = {}
-templates.registered =
- subject: _.template "Activate your #{settings.appName} Account"
- layout: PersonalEmailLayout
- type: "notification"
- plainTextTemplate: _.template """
-Congratulations, you've just had an account created for you on #{settings.appName} with the email address "<%= to %>".
+templates.registered = CTAEmailTemplate({
+ subject: () -> "Activate your #{settings.appName} Account"
+ message: (opts) -> """
+Congratulations, you've just had an account created for you on #{settings.appName} with the email address '#{opts.to}'.
-Click here to set your password and log in: <%= setNewPasswordUrl %>
-
-If you have any questions or problems, please contact #{settings.adminEmail}
+Click here to set your password and log in:
"""
- compiledTemplate: _.template """
-Congratulations, you've just had an account created for you on #{settings.appName} with the email address "<%= to %>".
+ secondaryMessage: () -> "If you have any questions or problems, please contact #{settings.adminEmail}"
+ ctaText: () -> "Set password"
+ ctaURL: (opts) -> opts.setNewPasswordUrl
+})
-Click here to set your password and log in.
-
-If you have any questions or problems, please contact #{settings.adminEmail}.
+templates.canceledSubscription = CTAEmailTemplate({
+ subject: () -> "#{settings.appName} thoughts"
+ message: () -> """
+I'm sorry to see you cancelled your #{settings.appName} premium account. Would you mind giving us some feedback on what the site is lacking at the moment via this quick survey?
"""
+ secondaryMessage: () -> "Thank you in advance!"
+ ctaText: () -> "Leave Feedback"
+ ctaURL: (opts) -> "https://sharelatex.typeform.com/to/f5lBiZ"
+})
-
-templates.canceledSubscription =
- subject: _.template "ShareLaTeX thoughts"
- layout: PersonalEmailLayout
- type:"lifecycle"
- plainTextTemplate: _.template """
-Hi <%= first_name %>,
-
-I'm sorry to see you cancelled your ShareLaTeX premium account. Would you mind giving me some advice on what the site is lacking at the moment via this survey?:
-
-https://sharelatex.typeform.com/to/f5lBiZ
-
-Thank you in advance.
-
-Henry
-
-ShareLaTeX Co-founder
-"""
- compiledTemplate: _.template '''
-Hi <%= first_name %>,
-
-I'm sorry to see you cancelled your ShareLaTeX premium account. Would you mind giving me some advice on what the site is lacking at the moment via this survey?
-
-Thank you in advance.
-
-
-Henry
-ShareLaTeX Co-founder
-
-'''
-
-
-templates.passwordResetRequested =
- subject: _.template "Password Reset - #{settings.appName}"
- layout: BaseWithHeaderEmailLayout
- type:"notification"
- plainTextTemplate: _.template """
-Password Reset
-
-We got a request to reset your #{settings.appName} password.
-
-Click this link to reset your password: <%= setNewPasswordUrl %>
-
+templates.passwordResetRequested = CTAEmailTemplate({
+ subject: () -> "Password Reset - #{settings.appName}"
+ title: () -> "Password Reset"
+ message: () -> "We got a request to reset your #{settings.appName} password."
+ secondaryMessage: () -> """
If you ignore this message, your password won't be changed.
If you didn't request a password reset, let us know.
-
-Thank you
-
-#{settings.appName} - <%= siteUrl %>
"""
- compiledTemplate: (opts) ->
- SingleCTAEmailBody({
- title: "Password Reset"
- greeting: "Hi,"
- message: "We got a request to reset your #{settings.appName} password."
- secondaryMessage: "If you ignore this message, your password won't be changed. If you didn't request a password reset, let us know."
- ctaText: "Reset password"
- ctaURL: opts.setNewPasswordUrl
- gmailGoToAction: null
- })
+ ctaText: () -> "Reset password"
+ ctaURL: (opts) -> opts.setNewPasswordUrl
+})
+templates.confirmEmail = CTAEmailTemplate({
+ subject: () -> "Confirm Email - #{settings.appName}"
+ title: () -> "Confirm Email"
+ message: () -> "Please confirm your email on #{settings.appName}."
+ ctaText: () -> "Confirm Email"
+ ctaURL: (opts) -> opts.confirmEmailUrl
+})
-templates.projectInvite =
- subject: _.template "<%= project.name %> - shared by <%= owner.email %>"
- layout: BaseWithHeaderEmailLayout
- type:"notification"
- plainTextTemplate: _.template """
-Hi, <%= owner.email %> wants to share '<%= project.name %>' with you.
+templates.projectInvite = CTAEmailTemplate({
+ subject: (opts) -> "#{opts.project.name} - shared by #{opts.owner.email}"
+ title: (opts) -> "#{ opts.project.name } - shared by #{ opts.owner.email }"
+ message: (opts) -> "#{ opts.owner.email } wants to share '#{ opts.project.name }' with you."
+ ctaText: () -> "View project"
+ ctaURL: (opts) -> opts.inviteUrl
+ gmailGoToAction: (opts) ->
+ target: opts.inviteUrl
+ name: "View project"
+ description: "Join #{ opts.project.name } at #{ settings.appName }"
+})
-Follow this link to view the project: <%= inviteUrl %>
-
-Thank you
-
-#{settings.appName} - <%= siteUrl %>
-"""
- compiledTemplate: (opts) ->
- SingleCTAEmailBody({
- title: "#{ opts.project.name } – shared by #{ opts.owner.email }"
- greeting: "Hi,"
- message: "#{ opts.owner.email } wants to share “#{ opts.project.name }” with you."
- secondaryMessage: null
- ctaText: "View project"
- ctaURL: opts.inviteUrl
- gmailGoToAction:
- target: opts.inviteUrl
- name: "View project"
- description: "Join #{ opts.project.name } at ShareLaTeX"
- })
-
-
-templates.verifyEmailToJoinTeam =
- subject: _.template "<%= inviterName %> has invited you to join a team on #{settings.appName}"
- layout: BaseWithHeaderEmailLayout
- type:"notification"
- plainTextTemplate: _.template """
-
-Please click the button below to join the team and enjoy the benefits of an upgraded <%= appName %> account.
-
-<%= acceptInviteUrl %>
-
-Thank You
-
-#{settings.appName} - <%= siteUrl %>
-"""
- compiledTemplate: (opts) ->
- SingleCTAEmailBody({
- title: "#{opts.inviterName} has invited you to join a team on #{settings.appName}"
- greeting: "Hi,"
- message: "Please click the button below to join the team and enjoy the benefits of an upgraded #{ opts.appName } account."
- secondaryMessage: null
- ctaText: "Verify now"
- ctaURL: opts.acceptInviteUrl
- 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
- })
+templates.verifyEmailToJoinTeam = CTAEmailTemplate({
+ subject: (opts) -> "#{ opts.inviterName } has invited you to join a team on #{settings.appName}"
+ title: (opts) -> "#{opts.inviterName} has invited you to join a team on #{settings.appName}"
+ message: (opts) -> "Please click the button below to join the team and enjoy the benefits of an upgraded #{ settings.appName } account."
+ ctaText: (opts) -> "Join now"
+ ctaURL: (opts) -> opts.acceptInviteUrl
+})
+templates.testEmail = CTAEmailTemplate({
+ subject: () -> "A Test Email from #{settings.appName}"
+ title: () -> "A Test Email from #{settings.appName}"
+ greeting: () -> "Hi,"
+ message: () -> "This is a test Email from #{settings.appName}"
+ ctaText: () -> "Open #{settings.appName}"
+ ctaURL: () -> settings.siteUrl
+})
module.exports =
templates: templates
-
+ CTAEmailTemplate: CTAEmailTemplate
buildEmail: (templateName, opts)->
template = templates[templateName]
opts.siteUrl = settings.siteUrl
@@ -180,5 +125,4 @@ module.exports =
subject : template.subject(opts)
html: template.layout(opts)
text: template?.plainTextTemplate?(opts)
- type:template.type
}
diff --git a/services/web/app/coffee/Features/Errors/Errors.coffee b/services/web/app/coffee/Features/Errors/Errors.coffee
index af1e437d4b..25b3f3e06d 100644
--- a/services/web/app/coffee/Features/Errors/Errors.coffee
+++ b/services/web/app/coffee/Features/Errors/Errors.coffee
@@ -61,6 +61,13 @@ ProjectHistoryDisabledError = (message) ->
return error
ProjectHistoryDisabledError.prototype.__proto___ = Error.prototype
+V1ConnectionError = (message) ->
+ error = new Error(message)
+ error.name = "V1ConnectionError"
+ error.__proto__ = V1ConnectionError.prototype
+ return error
+V1ConnectionError.prototype.__proto___ = Error.prototype
+
module.exports = Errors =
NotFoundError: NotFoundError
ServiceNotConfiguredError: ServiceNotConfiguredError
@@ -71,3 +78,4 @@ module.exports = Errors =
UnsupportedExportRecordsError: UnsupportedExportRecordsError
V1HistoryNotSyncedError: V1HistoryNotSyncedError
ProjectHistoryDisabledError: ProjectHistoryDisabledError
+ V1ConnectionError: V1ConnectionError
diff --git a/services/web/app/coffee/Features/LinkedFiles/LinkedFilesController.coffee b/services/web/app/coffee/Features/LinkedFiles/LinkedFilesController.coffee
index 13de54e947..34f77fde20 100644
--- a/services/web/app/coffee/Features/LinkedFiles/LinkedFilesController.coffee
+++ b/services/web/app/coffee/Features/LinkedFiles/LinkedFilesController.coffee
@@ -4,11 +4,27 @@ ProjectLocator = require '../Project/ProjectLocator'
Settings = require 'settings-sharelatex'
logger = require 'logger-sharelatex'
_ = require 'underscore'
+LinkedFilesHandler = require './LinkedFilesHandler'
+{
+
+ UrlFetchFailedError,
+ InvalidUrlError,
+ OutputFileFetchFailedError,
+ AccessDeniedError,
+ BadEntityTypeError,
+ BadDataError,
+ ProjectNotFoundError,
+ V1ProjectNotFoundError,
+ SourceFileNotFoundError,
+} = require './LinkedFilesErrors'
+
module.exports = LinkedFilesController = {
+
Agents: {
url: require('./UrlAgent'),
- project_file: require('./ProjectFileAgent')
+ project_file: require('./ProjectFileAgent'),
+ project_output_file: require('./ProjectOutputFileAgent')
}
_getAgent: (provider) ->
@@ -18,15 +34,6 @@ module.exports = LinkedFilesController = {
return null
LinkedFilesController.Agents[provider]
- _getFileById: (project_id, file_id, callback=(err, file)->) ->
- ProjectLocator.findElement {
- project_id,
- element_id: file_id,
- type: 'file'
- }, (err, file, path, parentFolder) ->
- return callback(err) if err?
- callback(null, file, path, parentFolder)
-
createLinkedFile: (req, res, next) ->
{project_id} = req.params
{name, provider, data, parent_folder_id} = req.body
@@ -37,23 +44,23 @@ module.exports = LinkedFilesController = {
if !Agent?
return res.sendStatus(400)
- linkedFileData = Agent.sanitizeData(data)
- linkedFileData.provider = provider
+ data.provider = provider
- if !Agent.canCreate(linkedFileData)
- return res.status(403).send('Cannot create linked file')
-
- LinkedFilesController._doImport(
- req, res, next, Agent, project_id, user_id,
- parent_folder_id, name, linkedFileData
- )
+ Agent.createLinkedFile project_id,
+ data,
+ name,
+ parent_folder_id,
+ user_id,
+ (err, newFileId) ->
+ return LinkedFilesController.handleError(err, req, res, next) if err?
+ res.json(new_file_id: newFileId)
refreshLinkedFile: (req, res, next) ->
{project_id, file_id} = req.params
user_id = AuthenticationController.getLoggedInUserId(req)
logger.log {project_id, file_id, user_id}, 'refresh linked file request'
- LinkedFilesController._getFileById project_id, file_id, (err, file, path, parentFolder) ->
+ LinkedFilesHandler.getFileById project_id, file_id, (err, file, path, parentFolder) ->
return next(err) if err?
return res.sendStatus(404) if !file?
name = file.name
@@ -65,37 +72,51 @@ module.exports = LinkedFilesController = {
Agent = LinkedFilesController._getAgent(provider)
if !Agent?
return res.sendStatus(400)
- LinkedFilesController._doImport(
- req, res, next, Agent, project_id, user_id,
- parent_folder_id, name, linkedFileData
+
+ Agent.refreshLinkedFile project_id,
+ linkedFileData,
+ name,
+ parent_folder_id,
+ user_id,
+ (err, newFileId) ->
+ return LinkedFilesController.handleError(err, req, res, next) if err?
+ res.json(new_file_id: newFileId)
+
+ handleError: (error, req, res, next) ->
+ if error instanceof BadDataError
+ res.status(400).send("The submitted data is not valid")
+
+ else if error instanceof AccessDeniedError
+ res.status(403).send("You do not have access to this project")
+
+ else if error instanceof BadDataError
+ res.status(400).send("The submitted data is not valid")
+
+ else if error instanceof BadEntityTypeError
+ res.status(400).send("The file is the wrong type")
+
+ else if error instanceof SourceFileNotFoundError
+ res.status(404).send("Source file not found")
+
+ else if error instanceof ProjectNotFoundError
+ res.status(404).send("Project not found")
+
+ else if error instanceof V1ProjectNotFoundError
+ res.status(409).send("Sorry, the source project is not yet imported to Overleaf v2. Please import it to Overleaf v2 to refresh this file")
+
+ else if error instanceof OutputFileFetchFailedError
+ res.status(404).send("Could not get output file")
+
+ else if error instanceof UrlFetchFailedError
+ res.status(422).send(
+ "Your URL could not be reached (#{error.statusCode} status code). Please check it and try again."
)
- _doImport: (req, res, next, Agent, project_id, user_id, parent_folder_id, name, linkedFileData) ->
- Agent.checkAuth project_id, linkedFileData, user_id, (err, allowed) ->
- return Agent.handleError(err, req, res, next) if err?
- return res.sendStatus(403) if !allowed
- Agent.decorateLinkedFileData linkedFileData, (err, newLinkedFileData) ->
- return Agent.handleError(err) if err?
- linkedFileData = newLinkedFileData
- Agent.writeIncomingFileToDisk project_id,
- linkedFileData,
- user_id,
- (error, fsPath) ->
- if error?
- logger.error(
- {err: error, project_id, name, linkedFileData, parent_folder_id, user_id},
- 'error writing linked file to disk'
- )
- return Agent.handleError(error, req, res, next)
- EditorController.upsertFile project_id,
- parent_folder_id,
- name,
- fsPath,
- linkedFileData,
- "upload",
- user_id,
- (error, file) ->
- return next(error) if error?
- res.json(new_file_id: file._id) # created
+ else if error instanceof InvalidUrlError
+ res.status(422).send(
+ "Your URL is not valid. Please check it and try again."
+ )
- }
+ else
+ next(error)
+}
diff --git a/services/web/app/coffee/Features/LinkedFiles/LinkedFilesErrors.coffee b/services/web/app/coffee/Features/LinkedFiles/LinkedFilesErrors.coffee
new file mode 100644
index 0000000000..8cf671702e
--- /dev/null
+++ b/services/web/app/coffee/Features/LinkedFiles/LinkedFilesErrors.coffee
@@ -0,0 +1,84 @@
+UrlFetchFailedError = (message) ->
+ error = new Error(message)
+ error.name = 'UrlFetchFailedError'
+ error.__proto__ = UrlFetchFailedError.prototype
+ return error
+UrlFetchFailedError.prototype.__proto__ = Error.prototype
+
+
+InvalidUrlError = (message) ->
+ error = new Error(message)
+ error.name = 'InvalidUrlError'
+ error.__proto__ = InvalidUrlError.prototype
+ return error
+InvalidUrlError.prototype.__proto__ = Error.prototype
+
+
+OutputFileFetchFailedError = (message) ->
+ error = new Error(message)
+ error.name = 'OutputFileFetchFailedError'
+ error.__proto__ = OutputFileFetchFailedError.prototype
+ return error
+OutputFileFetchFailedError.prototype.__proto__ = Error.prototype
+
+
+AccessDeniedError = (message) ->
+ error = new Error(message)
+ error.name = 'AccessDenied'
+ error.__proto__ = AccessDeniedError.prototype
+ return error
+AccessDeniedError.prototype.__proto__ = Error.prototype
+
+
+BadEntityTypeError = (message) ->
+ error = new Error(message)
+ error.name = 'BadEntityType'
+ error.__proto__ = BadEntityTypeError.prototype
+ return error
+BadEntityTypeError.prototype.__proto__ = Error.prototype
+
+
+BadDataError = (message) ->
+ error = new Error(message)
+ error.name = 'BadData'
+ error.__proto__ = BadDataError.prototype
+ return error
+BadDataError.prototype.__proto__ = Error.prototype
+
+
+ProjectNotFoundError = (message) ->
+ error = new Error(message)
+ error.name = 'ProjectNotFound'
+ error.__proto__ = ProjectNotFoundError.prototype
+ return error
+ProjectNotFoundError.prototype.__proto__ = Error.prototype
+
+
+V1ProjectNotFoundError = (message) ->
+ error = new Error(message)
+ error.name = 'V1ProjectNotFound'
+ error.__proto__ = V1ProjectNotFoundError.prototype
+ return error
+V1ProjectNotFoundError.prototype.__proto__ = Error.prototype
+
+
+SourceFileNotFoundError = (message) ->
+ error = new Error(message)
+ error.name = 'SourceFileNotFound'
+ error.__proto__ = SourceFileNotFoundError.prototype
+ return error
+SourceFileNotFoundError.prototype.__proto__ = Error.prototype
+
+
+module.exports = {
+
+ UrlFetchFailedError,
+ InvalidUrlError,
+ OutputFileFetchFailedError,
+ AccessDeniedError,
+ BadEntityTypeError,
+ BadDataError,
+ ProjectNotFoundError,
+ V1ProjectNotFoundError,
+ SourceFileNotFoundError,
+}
diff --git a/services/web/app/coffee/Features/LinkedFiles/LinkedFilesHandler.coffee b/services/web/app/coffee/Features/LinkedFiles/LinkedFilesHandler.coffee
new file mode 100644
index 0000000000..6262f0a5ab
--- /dev/null
+++ b/services/web/app/coffee/Features/LinkedFiles/LinkedFilesHandler.coffee
@@ -0,0 +1,86 @@
+FileWriter = require '../../infrastructure/FileWriter'
+EditorController = require '../Editor/EditorController'
+ProjectLocator = require '../Project/ProjectLocator'
+Project = require("../../models/Project").Project
+ProjectGetter = require("../Project/ProjectGetter")
+_ = require 'underscore'
+{
+ ProjectNotFoundError,
+ V1ProjectNotFoundError,
+ BadDataError
+} = require './LinkedFilesErrors'
+
+
+module.exports = LinkedFilesHandler =
+
+ getFileById: (project_id, file_id, callback=(err, file)->) ->
+ ProjectLocator.findElement {
+ project_id,
+ element_id: file_id,
+ type: 'file'
+ }, (err, file, path, parentFolder) ->
+ return callback(err) if err?
+ callback(null, file, path, parentFolder)
+
+ getSourceProject: (data, callback=(err, project)->) ->
+ projection = {_id: 1, name: 1}
+ if data.v1_source_doc_id?
+ Project.findOne {'overleaf.id': data.v1_source_doc_id}, projection, (err, project) ->
+ return callback(err) if err?
+ if !project?
+ return callback(new V1ProjectNotFoundError())
+ callback(null, project)
+ else if data.source_project_id?
+ ProjectGetter.getProject data.source_project_id, projection, (err, project) ->
+ return callback(err) if err?
+ if !project?
+ return callback(new ProjectNotFoundError())
+ callback(null, project)
+ else
+ callback(new BadDataError('neither v1 nor v2 id present'))
+
+ importFromStream: (
+ project_id,
+ readStream,
+ linkedFileData,
+ name,
+ parent_folder_id,
+ user_id,
+ callback=(err, file)->
+ ) ->
+ callback = _.once(callback)
+ FileWriter.writeStreamToDisk project_id, readStream, (err, fsPath) ->
+ return callback(err) if err?
+ EditorController.upsertFile project_id,
+ parent_folder_id,
+ name,
+ fsPath,
+ linkedFileData,
+ "upload",
+ user_id,
+ (err, file) =>
+ return callback(err) if err?
+ callback(null, file)
+
+ importContent: (
+ project_id,
+ content,
+ linkedFileData,
+ name,
+ parent_folder_id,
+ user_id,
+ callback=(err, file)->
+ ) ->
+ callback = _.once(callback)
+ FileWriter.writeContentToDisk project_id, content, (err, fsPath) ->
+ return callback(err) if err?
+ EditorController.upsertFile project_id,
+ parent_folder_id,
+ name,
+ fsPath,
+ linkedFileData,
+ "upload",
+ user_id,
+ (err, file) =>
+ return callback(err) if err?
+ callback(null, file)
diff --git a/services/web/app/coffee/Features/LinkedFiles/ProjectFileAgent.coffee b/services/web/app/coffee/Features/LinkedFiles/ProjectFileAgent.coffee
index 9ba8b5f1e7..77196ef1d4 100644
--- a/services/web/app/coffee/Features/LinkedFiles/ProjectFileAgent.coffee
+++ b/services/web/app/coffee/Features/LinkedFiles/ProjectFileAgent.coffee
@@ -1,68 +1,94 @@
-FileWriter = require('../../infrastructure/FileWriter')
AuthorizationManager = require('../Authorization/AuthorizationManager')
ProjectLocator = require('../Project/ProjectLocator')
ProjectGetter = require('../Project/ProjectGetter')
-Project = require("../../models/Project").Project
DocstoreManager = require('../Docstore/DocstoreManager')
FileStoreHandler = require('../FileStore/FileStoreHandler')
-FileWriter = require('../../infrastructure/FileWriter')
_ = require "underscore"
Settings = require 'settings-sharelatex'
+LinkedFilesHandler = require './LinkedFilesHandler'
+{
+ BadDataError,
+ AccessDeniedError,
+ BadEntityTypeError,
+ SourceFileNotFoundError,
+ ProjectNotFoundError,
+ V1ProjectNotFoundError
+} = require './LinkedFilesErrors'
+module.exports = ProjectFileAgent = {
-AccessDeniedError = (message) ->
- error = new Error(message)
- error.name = 'AccessDenied'
- error.__proto__ = AccessDeniedError.prototype
- return error
-AccessDeniedError.prototype.__proto__ = Error.prototype
+ createLinkedFile: (project_id, linkedFileData, name, parent_folder_id, user_id, callback) ->
+ if !@_canCreate(linkedFileData)
+ return callback(new AccessDeniedError())
+ @_go(project_id, linkedFileData, name, parent_folder_id, user_id, callback)
+ refreshLinkedFile: (project_id, linkedFileData, name, parent_folder_id, user_id, callback) ->
+ @_go project_id, linkedFileData, name, parent_folder_id, user_id, callback
-BadEntityTypeError = (message) ->
- error = new Error(message)
- error.name = 'BadEntityType'
- error.__proto__ = BadEntityTypeError.prototype
- return error
-BadEntityTypeError.prototype.__proto__ = Error.prototype
+ _prepare: (project_id, linkedFileData, user_id, callback=(err, linkedFileData)->) ->
+ @_checkAuth project_id, linkedFileData, user_id, (err, allowed) =>
+ return callback(err) if err?
+ return callback(new AccessDeniedError()) if !allowed
+ if !@_validate(linkedFileData)
+ return callback(new BadDataError())
+ callback(null, linkedFileData)
+ _go: (project_id, linkedFileData, name, parent_folder_id, user_id, callback) ->
+ linkedFileData = @_sanitizeData(linkedFileData)
+ @_prepare project_id, linkedFileData, user_id, (err, linkedFileData) =>
+ return callback(err) if err?
+ if !@_validate(linkedFileData)
+ return callback(new BadDataError())
+ @_getEntity linkedFileData, user_id, (err, source_project, entity, type) =>
+ return callback(err) if err?
+ if type == 'doc'
+ DocstoreManager.getDoc source_project._id, entity._id, (err, lines) ->
+ return callback(err) if err?
+ LinkedFilesHandler.importContent project_id,
+ lines.join('\n'),
+ linkedFileData,
+ name,
+ parent_folder_id,
+ user_id,
+ (err, file) ->
+ return callback(err) if err?
+ callback(null, file._id) # Created
+ else if type == 'file'
+ FileStoreHandler.getFileStream source_project._id, entity._id, null, (err, fileStream) ->
+ return callback(err) if err?
+ LinkedFilesHandler.importFromStream project_id,
+ fileStream,
+ linkedFileData,
+ name,
+ parent_folder_id,
+ user_id,
+ (err, file) ->
+ return callback(err) if err?
+ callback(null, file._id) # Created
+ else
+ callback(new BadEntityTypeError())
-BadDataError = (message) ->
- error = new Error(message)
- error.name = 'BadData'
- error.__proto__ = BadDataError.prototype
- return error
-BadDataError.prototype.__proto__ = Error.prototype
+ _getEntity:
+ (linkedFileData, current_user_id, callback = (err, entity, type) ->) ->
+ callback = _.once(callback)
+ { source_entity_path } = linkedFileData
+ @_getSourceProject linkedFileData, (err, project) ->
+ return callback(err) if err?
+ source_project_id = project._id
+ ProjectLocator.findElementByPath {
+ project_id: source_project_id,
+ path: source_entity_path
+ }, (err, entity, type) ->
+ if err?
+ if err.toString().match(/^not found.*/)
+ err = new SourceFileNotFoundError()
+ return callback(err)
+ callback(null, project, entity, type)
-
-ProjectNotFoundError = (message) ->
- error = new Error(message)
- error.name = 'ProjectNotFound'
- error.__proto__ = ProjectNotFoundError.prototype
- return error
-ProjectNotFoundError.prototype.__proto__ = Error.prototype
-
-
-V1ProjectNotFoundError = (message) ->
- error = new Error(message)
- error.name = 'V1ProjectNotFound'
- error.__proto__ = V1ProjectNotFoundError.prototype
- return error
-V1ProjectNotFoundError.prototype.__proto__ = Error.prototype
-
-
-SourceFileNotFoundError = (message) ->
- error = new Error(message)
- error.name = 'SourceFileNotFound'
- error.__proto__ = SourceFileNotFoundError.prototype
- return error
-SourceFileNotFoundError.prototype.__proto__ = Error.prototype
-
-
-module.exports = ProjectFileAgent =
-
- sanitizeData: (data) ->
+ _sanitizeData: (data) ->
return _.pick(
data,
+ 'provider',
'source_project_id',
'v1_source_doc_id',
'source_entity_path'
@@ -74,34 +100,13 @@ module.exports = ProjectFileAgent =
data.source_entity_path?
)
- canCreate: (data) ->
+ _canCreate: (data) ->
# Don't allow creation of linked-files with v1 doc ids
!data.v1_source_doc_id?
- _getSourceProject: (data, callback=(err, project)->) ->
- projection = {_id: 1, name: 1}
- if data.v1_source_doc_id?
- Project.findOne {'overleaf.id': data.v1_source_doc_id}, projection, (err, project) ->
- return callback(err) if err?
- if !project?
- return callback(new V1ProjectNotFoundError())
- callback(null, project)
- else if data.source_project_id?
- ProjectGetter.getProject data.source_project_id, projection, (err, project) ->
- return callback(err) if err?
- if !project?
- return callback(new ProjectNotFoundError())
- callback(null, project)
- else
- callback(new BadDataError('neither v1 nor v2 id present'))
+ _getSourceProject: LinkedFilesHandler.getSourceProject
- decorateLinkedFileData: (data, callback = (err, newData) ->) ->
- callback = _.once(callback)
- @_getSourceProject data, (err, project) ->
- return callback(err) if err?
- callback(err, _.extend(data, {source_project_display_name: project.name}))
-
- checkAuth: (project_id, data, current_user_id, callback = (error, allowed)->) ->
+ _checkAuth: (project_id, data, current_user_id, callback = (error, allowed)->) ->
callback = _.once(callback)
if !ProjectFileAgent._validate(data)
return callback(new BadDataError())
@@ -110,52 +115,4 @@ module.exports = ProjectFileAgent =
AuthorizationManager.canUserReadProject current_user_id, project._id, null, (err, canRead) ->
return callback(err) if err?
callback(null, canRead)
-
- writeIncomingFileToDisk:
- (project_id, data, current_user_id, callback = (error, fsPath) ->) ->
- callback = _.once(callback)
- if !ProjectFileAgent._validate(data)
- return callback(new BadDataError())
- { source_entity_path } = data
- @_getSourceProject data, (err, project) ->
- return callback(err) if err?
- source_project_id = project._id
- ProjectLocator.findElementByPath {
- project_id: source_project_id,
- path: source_entity_path
- }, (err, entity, type) ->
- if err?
- if err.toString().match(/^not found.*/)
- err = new SourceFileNotFoundError()
- return callback(err)
- ProjectFileAgent._writeEntityToDisk source_project_id, entity._id, type, callback
-
- _writeEntityToDisk: (project_id, entity_id, type, callback=(err, location)->) ->
- callback = _.once(callback)
- if type == 'doc'
- DocstoreManager.getDoc project_id, entity_id, (err, lines) ->
- return callback(err) if err?
- FileWriter.writeLinesToDisk entity_id, lines, callback
- else if type == 'file'
- FileStoreHandler.getFileStream project_id, entity_id, null, (err, fileStream) ->
- return callback(err) if err?
- FileWriter.writeStreamToDisk entity_id, fileStream, callback
- else
- callback(new BadEntityTypeError())
-
- handleError: (error, req, res, next) ->
- if error instanceof AccessDeniedError
- res.status(403).send("You do not have access to this project")
- else if error instanceof BadDataError
- res.status(400).send("The submitted data is not valid")
- else if error instanceof BadEntityTypeError
- res.status(400).send("The file is the wrong type")
- else if error instanceof SourceFileNotFoundError
- res.status(404).send("Source file not found")
- else if error instanceof ProjectNotFoundError
- res.status(404).send("Project not found")
- else if error instanceof V1ProjectNotFoundError
- res.status(409).send("Sorry, the source project is not yet imported to Overleaf v2. Please import it to Overleaf v2 to refresh this file")
- else
- next(error)
- next()
+}
diff --git a/services/web/app/coffee/Features/LinkedFiles/ProjectOutputFileAgent.coffee b/services/web/app/coffee/Features/LinkedFiles/ProjectOutputFileAgent.coffee
new file mode 100644
index 0000000000..5be0866ee2
--- /dev/null
+++ b/services/web/app/coffee/Features/LinkedFiles/ProjectOutputFileAgent.coffee
@@ -0,0 +1,158 @@
+AuthorizationManager = require('../Authorization/AuthorizationManager')
+ProjectGetter = require('../Project/ProjectGetter')
+Settings = require 'settings-sharelatex'
+CompileManager = require '../Compile/CompileManager'
+ClsiManager = require '../Compile/ClsiManager'
+ProjectFileAgent = require './ProjectFileAgent'
+_ = require "underscore"
+{
+ BadDataError,
+ AccessDeniedError,
+ BadEntityTypeError,
+ OutputFileFetchFailedError
+} = require './LinkedFilesErrors'
+LinkedFilesHandler = require './LinkedFilesHandler'
+logger = require 'logger-sharelatex'
+
+
+module.exports = ProjectOutputFileAgent = {
+
+ _prepare: (project_id, linkedFileData, user_id, callback=(err, linkedFileData)->) ->
+ @_checkAuth project_id, linkedFileData, user_id, (err, allowed) =>
+ return callback(err) if err?
+ return callback(new AccessDeniedError()) if !allowed
+ if !@_validate(linkedFileData)
+ return callback(new BadDataError())
+ callback(null, linkedFileData)
+
+ createLinkedFile: (project_id, linkedFileData, name, parent_folder_id, user_id, callback) ->
+ if !@_canCreate(linkedFileData)
+ return callback(new AccessDeniedError())
+ linkedFileData = @_sanitizeData(linkedFileData)
+ @_prepare project_id, linkedFileData, user_id, (err, linkedFileData) =>
+ return callback(err) if err?
+ @_getFileStream linkedFileData, user_id, (err, readStream) =>
+ return callback(err) if err?
+ readStream.on "error", callback
+ readStream.on "response", (response) =>
+ if 200 <= response.statusCode < 300
+ readStream.resume()
+ LinkedFilesHandler.importFromStream project_id,
+ readStream,
+ linkedFileData,
+ name,
+ parent_folder_id,
+ user_id,
+ (err, file) ->
+ return callback(err) if err?
+ callback(null, file._id) # Created
+ else
+ err = new OutputFileFetchFailedError(
+ "Output file fetch failed: #{linkedFileData.build_id}, #{linkedFileData.source_output_file_path}"
+ )
+ err.statusCode = response.statusCode
+ callback(err)
+
+ refreshLinkedFile: (project_id, linkedFileData, name, parent_folder_id, user_id, callback) ->
+ @_prepare project_id, linkedFileData, user_id, (err, linkedFileData) =>
+ return callback(err) if err?
+ @_compileAndGetFileStream linkedFileData, user_id, (err, readStream, new_build_id) =>
+ return callback(err) if err?
+ readStream.on "error", callback
+ readStream.on "response", (response) =>
+ if 200 <= response.statusCode < 300
+ readStream.resume()
+ linkedFileData.build_id = new_build_id
+ LinkedFilesHandler.importFromStream project_id,
+ readStream,
+ linkedFileData,
+ name,
+ parent_folder_id,
+ user_id,
+ (err, file) ->
+ return callback(err) if err?
+ callback(null, file._id) # Created
+ else
+ err = new OutputFileFetchFailedError(
+ "Output file fetch failed: #{linkedFileData.build_id}, #{linkedFileData.source_output_file_path}"
+ )
+ err.statusCode = response.statusCode
+ callback(err)
+
+
+ _sanitizeData: (data) ->
+ return {
+ provider: data.provider,
+ source_project_id: data.source_project_id,
+ source_output_file_path: data.source_output_file_path,
+ build_id: data.build_id
+ }
+
+ _canCreate: ProjectFileAgent._canCreate
+
+ _getSourceProject: LinkedFilesHandler.getSourceProject
+
+ _validate: (data) ->
+ return (
+ (data.source_project_id? || data.v1_source_doc_id?) &&
+ data.source_output_file_path? &&
+ data.build_id?
+ )
+
+ _checkAuth: (project_id, data, current_user_id, callback = (err, allowed)->) ->
+ callback = _.once(callback)
+ if !@_validate(data)
+ return callback(new BadDataError())
+ @_getSourceProject data, (err, project) ->
+ return callback(err) if err?
+ AuthorizationManager.canUserReadProject current_user_id,
+ project._id,
+ null,
+ (err, canRead) ->
+ return callback(err) if err?
+ callback(null, canRead)
+
+ _getFileStream: (linkedFileData, user_id, callback=(err, fileStream)->) ->
+ callback = _.once(callback)
+ { source_output_file_path, build_id } = linkedFileData
+ @_getSourceProject linkedFileData, (err, project) ->
+ return callback(err) if err?
+ source_project_id = project._id
+ ClsiManager.getOutputFileStream source_project_id,
+ user_id,
+ build_id,
+ source_output_file_path,
+ (err, readStream) ->
+ return callback(err) if err?
+ readStream.pause()
+ callback(null, readStream)
+
+ _compileAndGetFileStream: (linkedFileData, user_id, callback=(err, stream, build_id)->) ->
+ callback = _.once(callback)
+ { source_output_file_path } = linkedFileData
+ @_getSourceProject linkedFileData, (err, project) ->
+ return callback(err) if err?
+ source_project_id = project._id
+ CompileManager.compile source_project_id,
+ user_id,
+ {},
+ (err, status, outputFiles) ->
+ return callback(err) if err?
+ if status != 'success'
+ return callback(new OutputFileFetchFailedError())
+ outputFile = _.find(
+ outputFiles,
+ (o) => o.path == source_output_file_path
+ )
+ if !outputFile?
+ return callback(new OutputFileFetchFailedError())
+ build_id = outputFile.build
+ ClsiManager.getOutputFileStream source_project_id,
+ user_id,
+ build_id,
+ source_output_file_path,
+ (err, readStream) ->
+ return callback(err) if err?
+ readStream.pause()
+ callback(null, readStream, build_id)
+}
diff --git a/services/web/app/coffee/Features/LinkedFiles/UrlAgent.coffee b/services/web/app/coffee/Features/LinkedFiles/UrlAgent.coffee
index d3748cf8d2..b5a6e49020 100644
--- a/services/web/app/coffee/Features/LinkedFiles/UrlAgent.coffee
+++ b/services/web/app/coffee/Features/LinkedFiles/UrlAgent.coffee
@@ -1,67 +1,53 @@
request = require 'request'
-FileWriter = require('../../infrastructure/FileWriter')
_ = require "underscore"
urlValidator = require 'valid-url'
Settings = require 'settings-sharelatex'
+{ InvalidUrlError, UrlFetchFailedError } = require './LinkedFilesErrors'
+LinkedFilesHandler = require './LinkedFilesHandler'
-UrlFetchFailedError = (message) ->
- error = new Error(message)
- error.name = 'UrlFetchFailedError'
- error.__proto__ = UrlFetchFailedError.prototype
- return error
-UrlFetchFailedError.prototype.__proto__ = Error.prototype
-
-InvalidUrlError = (message) ->
- error = new Error(message)
- error.name = 'InvalidUrlError'
- error.__proto__ = InvalidUrlError.prototype
- return error
-InvalidUrlError.prototype.__proto__ = Error.prototype
module.exports = UrlAgent = {
- UrlFetchFailedError: UrlFetchFailedError
- InvalidUrlError: InvalidUrlError
- sanitizeData: (data) ->
+ createLinkedFile: (project_id, linkedFileData, name, parent_folder_id, user_id, callback) ->
+ linkedFileData = @._sanitizeData(linkedFileData)
+ @_getUrlStream project_id, linkedFileData, user_id, (err, readStream) ->
+ return callback(err) if err?
+ readStream.on "error", callback
+ readStream.on "response", (response) ->
+ if 200 <= response.statusCode < 300
+ readStream.resume()
+ LinkedFilesHandler.importFromStream project_id,
+ readStream,
+ linkedFileData,
+ name,
+ parent_folder_id,
+ user_id,
+ (err, file) ->
+ return callback(err) if err?
+ callback(null, file._id) # Created
+ else
+ error = new UrlFetchFailedError("url fetch failed: #{linkedFileData.url}")
+ error.statusCode = response.statusCode
+ callback(error)
+
+ refreshLinkedFile: (project_id, linkedFileData, name, parent_folder_id, user_id, callback) ->
+ @createLinkedFile project_id, linkedFileData, name, parent_folder_id, user_id, callback
+
+ _sanitizeData: (data) ->
return {
+ provider: data.provider
url: @._prependHttpIfNeeded(data.url)
}
- canCreate: (data) -> true
-
- decorateLinkedFileData: (data, callback = (err, newData) ->) ->
- return callback(null, data)
-
- checkAuth: (project_id, data, current_user_id, callback = (error, allowed)->) ->
- callback(null, true)
-
- writeIncomingFileToDisk: (project_id, data, current_user_id, callback = (error, fsPath) ->) ->
+ _getUrlStream: (project_id, data, current_user_id, callback = (error, fsPath) ->) ->
callback = _.once(callback)
url = data.url
if !urlValidator.isWebUri(url)
return callback(new InvalidUrlError("invalid url: #{url}"))
- url = UrlAgent._wrapWithProxy(url)
+ url = @_wrapWithProxy(url)
readStream = request.get(url)
- readStream.on "error", callback
- readStream.on "response", (response) ->
- if 200 <= response.statusCode < 300
- FileWriter.writeStreamToDisk project_id, readStream, callback
- else
- error = new UrlFetchFailedError("url fetch failed: #{url}")
- error.statusCode = response.statusCode
- callback(error)
-
- handleError: (error, req, res, next) ->
- if error instanceof UrlFetchFailedError
- res.status(422).send(
- "Your URL could not be reached (#{error.statusCode} status code). Please check it and try again."
- )
- else if error instanceof InvalidUrlError
- res.status(422).send(
- "Your URL is not valid. Please check it and try again."
- )
- else
- next(error)
+ readStream.pause()
+ callback(null, readStream)
_prependHttpIfNeeded: (url) ->
if !url.match('://')
diff --git a/services/web/app/coffee/Features/PasswordReset/PasswordResetHandler.coffee b/services/web/app/coffee/Features/PasswordReset/PasswordResetHandler.coffee
index 3947b63004..d383e8b669 100644
--- a/services/web/app/coffee/Features/PasswordReset/PasswordResetHandler.coffee
+++ b/services/web/app/coffee/Features/PasswordReset/PasswordResetHandler.coffee
@@ -14,7 +14,7 @@ module.exports =
if !user? or user.holdingAccount
logger.err email:email, "user could not be found for password reset"
return callback(null, false)
- OneTimeTokenHandler.getNewToken user._id, (err, token)->
+ OneTimeTokenHandler.getNewToken 'password', user._id, (err, token)->
if err then return callback(err)
emailOptions =
to : email
@@ -24,7 +24,7 @@ module.exports =
callback null, true
setNewUserPassword: (token, password, callback = (error, found, user_id) ->)->
- OneTimeTokenHandler.getValueFromTokenAndExpire token, (err, user_id)->
+ OneTimeTokenHandler.getValueFromTokenAndExpire 'password', token, (err, user_id)->
if err then return callback(err)
if !user_id?
return callback null, false, null
diff --git a/services/web/app/coffee/Features/Project/ProjectController.coffee b/services/web/app/coffee/Features/Project/ProjectController.coffee
index 8ea2dc3189..e2c71a956e 100644
--- a/services/web/app/coffee/Features/Project/ProjectController.coffee
+++ b/services/web/app/coffee/Features/Project/ProjectController.coffee
@@ -27,6 +27,7 @@ CollaboratorsHandler = require '../Collaborators/CollaboratorsHandler'
Modules = require '../../infrastructure/Modules'
ProjectEntityHandler = require './ProjectEntityHandler'
crypto = require 'crypto'
+{ V1ConnectionError } = require '../Errors/Errors'
module.exports = ProjectController =
@@ -179,11 +180,14 @@ module.exports = ProjectController =
ProjectGetter.findAllUsersProjects user_id, 'name lastUpdated publicAccesLevel archived owner_ref tokens', cb
v1Projects: (cb) ->
Modules.hooks.fire "findAllV1Projects", user_id, (error, projects = []) ->
- if error? and error.message == 'No V1 connection'
+ if error? and error instanceof V1ConnectionError
return cb(null, projects: [], tags: [], noConnection: true)
return cb(error, projects[0]) # hooks.fire returns an array of results, only need first
hasSubscription: (cb)->
- LimitationsManager.userHasSubscriptionOrIsGroupMember currentUser, cb
+ LimitationsManager.userHasSubscriptionOrIsGroupMember currentUser, (error, hasSub) ->
+ if error? and error instanceof V1ConnectionError
+ return cb(null, true)
+ return cb(error, hasSub)
user: (cb) ->
User.findById user_id, "featureSwitches overleaf awareOfV2 features", cb
}, (err, results)->
@@ -210,7 +214,7 @@ module.exports = ProjectController =
tags: tags
notifications: notifications or []
user: user
- hasSubscription: results.hasSubscription[0]
+ hasSubscription: results.hasSubscription
isShowingV1Projects: results.v1Projects?
warnings: warnings
}
diff --git a/services/web/app/coffee/Features/Project/ProjectEntityUpdateHandler.coffee b/services/web/app/coffee/Features/Project/ProjectEntityUpdateHandler.coffee
index 22034200f5..b7be80cb47 100644
--- a/services/web/app/coffee/Features/Project/ProjectEntityUpdateHandler.coffee
+++ b/services/web/app/coffee/Features/Project/ProjectEntityUpdateHandler.coffee
@@ -55,7 +55,10 @@ module.exports = ProjectEntityUpdateHandler = self =
logger.err { project_id, folder_id, originalProject_id, origonalFileRef }, "file trying to copy is null"
return callback()
# convert any invalid characters in original file to '_'
- fileRef = new File name : SafePath.clean(origonalFileRef.name)
+ fileProperties = name : SafePath.clean(origonalFileRef.name)
+ if origonalFileRef.linkedFileData?
+ fileProperties.linkedFileData = origonalFileRef.linkedFileData
+ fileRef = new File(fileProperties)
FileStoreHandler.copyFile originalProject_id, origonalFileRef._id, project._id, fileRef._id, (err, fileStoreUrl)->
if err?
logger.err { err, project_id, folder_id, originalProject_id, origonalFileRef }, "error coping file in s3"
diff --git a/services/web/app/coffee/Features/Security/OneTimeTokenHandler.coffee b/services/web/app/coffee/Features/Security/OneTimeTokenHandler.coffee
index 3824703efb..69c9f5b0e9 100644
--- a/services/web/app/coffee/Features/Security/OneTimeTokenHandler.coffee
+++ b/services/web/app/coffee/Features/Security/OneTimeTokenHandler.coffee
@@ -1,34 +1,50 @@
Settings = require('settings-sharelatex')
-RedisWrapper = require("../../infrastructure/RedisWrapper")
-rclient = RedisWrapper.client("one_time_token")
crypto = require("crypto")
logger = require("logger-sharelatex")
+{db} = require "../../infrastructure/mongojs"
+Errors = require "../Errors/Errors"
ONE_HOUR_IN_S = 60 * 60
-buildKey = (token)-> return "password_token:#{token}"
-
module.exports =
-
- getNewToken: (value, options = {}, callback)->
+ getNewToken: (use, data, options = {}, callback = (error, data) ->)->
# options is optional
if typeof options == "function"
callback = options
options = {}
expiresIn = options.expiresIn or ONE_HOUR_IN_S
- logger.log value:value, "generating token for password reset"
+ createdAt = new Date()
+ expiresAt = new Date(createdAt.getTime() + expiresIn * 1000)
token = crypto.randomBytes(32).toString("hex")
- multi = rclient.multi()
- multi.set buildKey(token), value
- multi.expire buildKey(token), expiresIn
- multi.exec (err)->
- callback(err, token)
+ logger.log {data, expiresIn, token_start: token.slice(0,8)}, "generating token for #{use}"
+ db.tokens.insert {
+ use: use
+ token: token,
+ data: data,
+ createdAt: createdAt,
+ expiresAt: expiresAt
+ }, (error) ->
+ return callback(error) if error?
+ callback null, token
- getValueFromTokenAndExpire: (token, callback)->
- logger.log token:token, "getting user id from password token"
- multi = rclient.multi()
- multi.get buildKey(token)
- multi.del buildKey(token)
- multi.exec (err, results)->
- callback err, results?[0]
+ getValueFromTokenAndExpire: (use, token, callback = (error, data) ->)->
+ logger.log token_start: token.slice(0,8), "getting data from #{use} token"
+ now = new Date()
+ db.tokens.findAndModify {
+ query: {
+ use: use,
+ token: token,
+ expiresAt: { $gt: now },
+ usedAt: { $exists: false }
+ },
+ update: {
+ $set: {
+ usedAt: now
+ }
+ }
+ }, (error, token) ->
+ return callback(error) if error?
+ if !token?
+ return callback(new Errors.NotFoundError('no token found'))
+ return callback null, token.data
diff --git a/services/web/app/coffee/Features/Subscription/TeamInvitesHandler.coffee b/services/web/app/coffee/Features/Subscription/TeamInvitesHandler.coffee
index e09e99c94a..c3b3b11089 100644
--- a/services/web/app/coffee/Features/Subscription/TeamInvitesHandler.coffee
+++ b/services/web/app/coffee/Features/Subscription/TeamInvitesHandler.coffee
@@ -56,6 +56,20 @@ module.exports = TeamInvitesHandler =
return callback(error) if error?
createInvite(subscription, email, inviterName, callback)
+ importInvite: (subscription, inviterName, email, token, sentAt, callback) ->
+ checkIfInviteIsPossible subscription, email, (error, possible, reason) ->
+ return callback(error) if error?
+ return callback(reason) unless possible
+
+ subscription.teamInvites.push({
+ email: email
+ inviterName: inviterName
+ token: token
+ sentAt: sentAt
+ })
+
+ subscription.save callback
+
acceptInvite: (token, userId, callback) ->
logger.log {userId}, "Accepting invite"
TeamInvitesHandler.getInvite token, (err, invite, subscription) ->
@@ -93,7 +107,6 @@ createInvite = (subscription, email, inviterName, callback) ->
return callback(error) if error?
return callback(reason) unless possible
-
invite = subscription.teamInvites.find (invite) -> invite.email == email
if !invite?
diff --git a/services/web/app/coffee/Features/Subscription/V1SubscriptionManager.coffee b/services/web/app/coffee/Features/Subscription/V1SubscriptionManager.coffee
index dcf23a3453..857881df4d 100644
--- a/services/web/app/coffee/Features/Subscription/V1SubscriptionManager.coffee
+++ b/services/web/app/coffee/Features/Subscription/V1SubscriptionManager.coffee
@@ -2,6 +2,7 @@ UserGetter = require "../User/UserGetter"
request = require "request"
settings = require "settings-sharelatex"
logger = require "logger-sharelatex"
+{ V1ConnectionError } = require "../Errors/Errors"
module.exports = V1SubscriptionManager =
# Returned planCode = 'v1_pro' | 'v1_pro_plus' | 'v1_student' | 'v1_free' | null
@@ -58,7 +59,10 @@ module.exports = V1SubscriptionManager =
json: true,
timeout: 5 * 1000
}, (error, response, body) ->
- return callback(error) if error?
+ if error?
+ # Specially handle no connection err, so warning can be shown
+ error = new V1ConnectionError('No V1 connection') if error.code == 'ECONNREFUSED'
+ return callback(error)
if 200 <= response.statusCode < 300
return callback null, body
else
diff --git a/services/web/app/coffee/Features/User/UserEmailsConfirmationHandler.coffee b/services/web/app/coffee/Features/User/UserEmailsConfirmationHandler.coffee
new file mode 100644
index 0000000000..dd87570450
--- /dev/null
+++ b/services/web/app/coffee/Features/User/UserEmailsConfirmationHandler.coffee
@@ -0,0 +1,36 @@
+EmailHelper = require "../Helpers/EmailHelper"
+EmailHandler = require "../Email/EmailHandler"
+OneTimeTokenHandler = require "../Security/OneTimeTokenHandler"
+settings = require 'settings-sharelatex'
+Errors = require "../Errors/Errors"
+logger = require "logger-sharelatex"
+UserUpdater = require "./UserUpdater"
+
+ONE_YEAR_IN_S = 365 * 24 * 60 * 60
+
+module.exports = UserEmailsConfirmationHandler =
+ sendConfirmationEmail: (user_id, email, emailTemplate, callback = (error) ->) ->
+ if arguments.length == 3
+ callback = emailTemplate
+ emailTemplate = 'confirmEmail'
+ email = EmailHelper.parseEmail(email)
+ return callback(new Error('invalid email')) if !email?
+ data = {user_id, email}
+ OneTimeTokenHandler.getNewToken 'email_confirmation', data, {expiresIn: ONE_YEAR_IN_S}, (err, token)->
+ return callback(err) if err?
+ emailOptions =
+ to: email
+ confirmEmailUrl: "#{settings.siteUrl}/user/emails/confirm?token=#{token}"
+ EmailHandler.sendEmail emailTemplate, emailOptions, callback
+
+ confirmEmailFromToken: (token, callback = (error) ->) ->
+ logger.log {token_start: token.slice(0,8)}, 'confirming email from token'
+ OneTimeTokenHandler.getValueFromTokenAndExpire 'email_confirmation', token, (error, data) ->
+ return callback(error) if error?
+ if !data?
+ return callback(new Errors.NotFoundError('no token found'))
+ {user_id, email} = data
+ logger.log {data, user_id, email, token_start: token.slice(0,8)}, 'found data for email confirmation'
+ if !user_id? or email != EmailHelper.parseEmail(email)
+ return callback(new Errors.NotFoundError('invalid data'))
+ UserUpdater.confirmEmail user_id, email, callback
diff --git a/services/web/app/coffee/Features/User/UserEmailsController.coffee b/services/web/app/coffee/Features/User/UserEmailsController.coffee
index 8f49dce82b..3c574d6088 100644
--- a/services/web/app/coffee/Features/User/UserEmailsController.coffee
+++ b/services/web/app/coffee/Features/User/UserEmailsController.coffee
@@ -2,43 +2,72 @@ AuthenticationController = require('../Authentication/AuthenticationController')
UserGetter = require("./UserGetter")
UserUpdater = require("./UserUpdater")
EmailHelper = require("../Helpers/EmailHelper")
+UserEmailsConfirmationHandler = require "./UserEmailsConfirmationHandler"
logger = require("logger-sharelatex")
+Errors = require "../Errors/Errors"
module.exports = UserEmailsController =
- list: (req, res) ->
+ list: (req, res, next) ->
userId = AuthenticationController.getLoggedInUserId(req)
UserGetter.getUserFullEmails userId, (error, fullEmails) ->
- return res.sendStatus 500 if error?
+ return next(error) if error?
res.json fullEmails
- add: (req, res) ->
+ add: (req, res, next) ->
userId = AuthenticationController.getLoggedInUserId(req)
email = EmailHelper.parseEmail(req.body.email)
return res.sendStatus 422 unless email?
- UserUpdater.addEmailAddress userId, email, (error)->
- return res.sendStatus 500 if error?
- res.sendStatus 200
+ affiliationOptions =
+ university: req.body.university
+ role: req.body.role
+ department: req.body.department
+ UserUpdater.addEmailAddress userId, email, affiliationOptions, (error)->
+ return next(error) if error?
+ UserEmailsConfirmationHandler.sendConfirmationEmail userId, email, (err) ->
+ return next(error) if error?
+ res.sendStatus 204
- remove: (req, res) ->
+ remove: (req, res, next) ->
userId = AuthenticationController.getLoggedInUserId(req)
logger.log req.params
email = EmailHelper.parseEmail(req.params.email)
return res.sendStatus 422 unless email?
UserUpdater.removeEmailAddress userId, email, (error)->
- return res.sendStatus 500 if error?
+ return next(error) if error?
res.sendStatus 200
- setDefault: (req, res) ->
+ setDefault: (req, res, next) ->
userId = AuthenticationController.getLoggedInUserId(req)
email = EmailHelper.parseEmail(req.body.email)
return res.sendStatus 422 unless email?
UserUpdater.setDefaultEmailAddress userId, email, (error)->
- return res.sendStatus 500 if error?
+ return next(error) if error?
res.sendStatus 200
+
+ showConfirm: (req, res, next) ->
+ res.render 'user/confirm_email', {
+ token: req.query.token,
+ title: 'confirm_email'
+ }
+
+ confirm: (req, res, next) ->
+ token = req.body.token
+ if !token?
+ return res.sendStatus 422
+ UserEmailsConfirmationHandler.confirmEmailFromToken token, (error) ->
+ if error?
+ if error instanceof Errors.NotFoundError
+ res.status(404).json({
+ message: 'Sorry, your confirmation token is invalid or has expired. Please request a new email confirmation link.'
+ })
+ else
+ next(error)
+ else
+ res.sendStatus 200
diff --git a/services/web/app/coffee/Features/User/UserGetter.coffee b/services/web/app/coffee/Features/User/UserGetter.coffee
index 59e45e1795..21969656a0 100644
--- a/services/web/app/coffee/Features/User/UserGetter.coffee
+++ b/services/web/app/coffee/Features/User/UserGetter.coffee
@@ -3,6 +3,8 @@ metrics = require('metrics-sharelatex')
logger = require('logger-sharelatex')
db = mongojs.db
ObjectId = mongojs.ObjectId
+settings = require "settings-sharelatex"
+request = require "request"
module.exports = UserGetter =
getUser: (query, projection, callback = (error, user) ->) ->
@@ -30,11 +32,9 @@ module.exports = UserGetter =
return callback error if error?
return callback new Error('User not Found') unless user
- fullEmails = user.emails.map (emailData) ->
- emailData.default = emailData.email == user.email
- emailData
-
- callback null, fullEmails
+ getAffiliations userId, (error, affiliationsData) ->
+ return callback error if error?
+ callback null, decorateFullEmails(user.email, user.emails, affiliationsData)
getUserByMainEmail: (email, projection, callback = (error, user) ->) ->
email = email.trim()
@@ -81,6 +81,35 @@ module.exports = UserGetter =
return callback(message: 'alread_exists') if user?
callback(error)
+decorateFullEmails = (defaultEmail, emailsData, affiliationsData) ->
+ emailsData.map (emailData) ->
+ emailData.default = emailData.email == defaultEmail
+
+ affiliation = affiliationsData.find (aff) -> aff.email == emailData.email
+ if affiliation?
+ { institution, inferred, role, department } = affiliation
+ emailData.affiliation = { institution, inferred, role, department }
+ else
+ emailsData.affiliation = null
+
+ emailData
+
+getAffiliations = (userId, callback = (error) ->) ->
+ return callback(null, []) unless settings?.apis?.v1?.url # service is not configured
+ request {
+ method: 'GET'
+ url: "#{settings.apis.v1.url}/api/v2/users/#{userId.toString()}/affiliations"
+ auth: { user: settings.apis.v1.user, pass: settings.apis.v1.pass }
+ json: true,
+ timeout: 20 * 1000
+ }, (error, response, body) ->
+ return callback(error) if error?
+ unless 200 <= response.statusCode < 300
+ errorMessage = "Couldn't get affiliations: #{response.statusCode}"
+ return callback(new Error(errorMessage))
+
+ callback(null, body)
+
[
'getUser',
'getUserEmail',
diff --git a/services/web/app/coffee/Features/User/UserRegistrationHandler.coffee b/services/web/app/coffee/Features/User/UserRegistrationHandler.coffee
index 85cdabdbdf..df7fe93218 100644
--- a/services/web/app/coffee/Features/User/UserRegistrationHandler.coffee
+++ b/services/web/app/coffee/Features/User/UserRegistrationHandler.coffee
@@ -74,7 +74,7 @@ module.exports = UserRegistrationHandler =
logger.log {email}, "user already exists, resending welcome email"
ONE_WEEK = 7 * 24 * 60 * 60 # seconds
- OneTimeTokenHandler.getNewToken user._id, { expiresIn: ONE_WEEK }, (err, token)->
+ OneTimeTokenHandler.getNewToken 'password', user._id, { expiresIn: ONE_WEEK }, (err, token)->
return callback(err) if err?
setNewPasswordUrl = "#{settings.siteUrl}/user/activate?token=#{token}&user_id=#{user._id}"
diff --git a/services/web/app/coffee/Features/User/UserUpdater.coffee b/services/web/app/coffee/Features/User/UserUpdater.coffee
index 22b31239bd..8bb6015597 100644
--- a/services/web/app/coffee/Features/User/UserUpdater.coffee
+++ b/services/web/app/coffee/Features/User/UserUpdater.coffee
@@ -5,6 +5,10 @@ db = mongojs.db
async = require("async")
ObjectId = mongojs.ObjectId
UserGetter = require("./UserGetter")
+EmailHelper = require "../Helpers/EmailHelper"
+Errors = require "../Errors/Errors"
+settings = require "settings-sharelatex"
+request = require "request"
module.exports = UserUpdater =
updateUser: (query, update, callback = (error) ->) ->
@@ -42,30 +46,43 @@ module.exports = UserUpdater =
# Add a new email address for the user. Email cannot be already used by this
# or any other user
- addEmailAddress: (userId, newEmail, callback) ->
+ addEmailAddress: (userId, newEmail, affiliationOptions, callback) ->
+ unless callback? # affiliationOptions is optional
+ callback = affiliationOptions
+ affiliationOptions = {}
+
UserGetter.ensureUniqueEmailAddress newEmail, (error) =>
return callback(error) if error?
- update = $push: emails: email: newEmail, createdAt: new Date()
- @updateUser userId, update, (error) ->
+ addAffiliation userId, newEmail, affiliationOptions, (error) =>
if error?
- logger.err error: error, 'problem updating users emails'
+ logger.err error: error, 'problem adding affiliation'
return callback(error)
- callback()
+ update = $push: emails: email: newEmail, createdAt: new Date()
+ @updateUser userId, update, (error) ->
+ if error?
+ logger.err error: error, 'problem updating users emails'
+ return callback(error)
+ callback()
# remove one of the user's email addresses. The email cannot be the user's
# default email address
removeEmailAddress: (userId, email, callback) ->
- query = _id: userId, email: $ne: email
- update = $pull: emails: email: email
- @updateUser query, update, (error, res) ->
+ removeAffiliation userId, email, (error) =>
if error?
- logger.err error:error, 'problem removing users email'
+ logger.err error: error, 'problem removing affiliation'
return callback(error)
- if res.nMatched == 0
- return callback(new Error('Cannot remove default email'))
- callback()
+
+ query = _id: userId, email: $ne: email
+ update = $pull: emails: email: email
+ @updateUser query, update, (error, res) ->
+ if error?
+ logger.err error:error, 'problem removing users email'
+ return callback(error)
+ if res.n == 0
+ return callback(new Error('Cannot remove email'))
+ callback()
# set the default email address by setting the `email` attribute. The email
@@ -77,10 +94,67 @@ module.exports = UserUpdater =
if error?
logger.err error:error, 'problem setting default emails'
return callback(error)
- if res.nMatched == 0
+ if res.n == 0 # TODO: Check n or nMatched?
return callback(new Error('Default email does not belong to user'))
callback()
+ confirmEmail: (userId, email, callback) ->
+ email = EmailHelper.parseEmail(email)
+ return callback(new Error('invalid email')) if !email?
+ logger.log {userId, email}, 'confirming user email'
+ query =
+ _id: userId
+ 'emails.email': email
+ update =
+ $set:
+ 'emails.$.confirmedAt': new Date()
+ @updateUser query, update, (error, res) ->
+ return callback(error) if error?
+ logger.log {res, userId, email}, "tried to confirm email"
+ if res.n == 0
+ return callback(new Errors.NotFoundError('user id and email do no match'))
+ callback()
+
+addAffiliation = (userId, email, { university, department, role }, callback = (error) ->) ->
+ makeAffiliationRequest {
+ method: 'POST'
+ path: "/api/v2/users/#{userId.toString()}/affiliations"
+ body: { email, university, department, role }
+ defaultErrorMessage: "Couldn't create affiliation"
+ }, callback
+
+removeAffiliation = (userId, email, callback = (error) ->) ->
+ email = encodeURIComponent(email)
+ makeAffiliationRequest {
+ method: 'DELETE'
+ path: "/api/v2/users/#{userId.toString()}/affiliations/#{email}"
+ extraSuccessStatusCodes: [404] # `Not Found` responses are considered successful
+ defaultErrorMessage: "Couldn't remove affiliation"
+ }, callback
+
+makeAffiliationRequest = (requestOptions, callback = (error) ->) ->
+ return callback(null) unless settings?.apis?.v1?.url # service is not configured
+ requestOptions.extraSuccessStatusCodes ||= []
+ request {
+ method: requestOptions.method
+ url: "#{settings.apis.v1.url}#{requestOptions.path}"
+ body: requestOptions.body
+ auth: { user: settings.apis.v1.user, pass: settings.apis.v1.pass }
+ json: true,
+ timeout: 20 * 1000
+ }, (error, response, body) ->
+ return callback(error) if error?
+ isSuccess = 200 <= response.statusCode < 300
+ isSuccess ||= response.statusCode in requestOptions.extraSuccessStatusCodes
+ unless isSuccess
+ if body?.errors
+ errorMessage = "#{response.statusCode}: #{body.errors}"
+ else
+ errorMessage = "#{requestOptions.defaultErrorMessage}: #{response.statusCode}"
+ return callback(new Error(errorMessage))
+
+ callback(null)
+
[
'updateUser'
'changeEmailAddress'
diff --git a/services/web/app/coffee/infrastructure/FileWriter.coffee b/services/web/app/coffee/infrastructure/FileWriter.coffee
index 27b1f16921..4e37273338 100644
--- a/services/web/app/coffee/infrastructure/FileWriter.coffee
+++ b/services/web/app/coffee/infrastructure/FileWriter.coffee
@@ -15,11 +15,14 @@ module.exports = FileWriter =
callback(null)
writeLinesToDisk: (identifier, lines, callback = (error, fsPath)->) ->
+ FileWriter.writeContentToDisk(identifier, lines.join('\n'), callback)
+
+ writeContentToDisk: (identifier, content, callback = (error, fsPath)->) ->
callback = _.once(callback)
fsPath = "#{Settings.path.dumpFolder}/#{identifier}_#{uuid.v4()}"
FileWriter._ensureDumpFolderExists (error) ->
return callback(error) if error?
- fs.writeFile fsPath, lines.join('\n'), (error) ->
+ fs.writeFile fsPath, content, (error) ->
return callback(error) if error?
callback(null, fsPath)
diff --git a/services/web/app/coffee/infrastructure/OldAssetProxy.coffee b/services/web/app/coffee/infrastructure/OldAssetProxy.coffee
deleted file mode 100644
index 7912f0ed14..0000000000
--- a/services/web/app/coffee/infrastructure/OldAssetProxy.coffee
+++ /dev/null
@@ -1,16 +0,0 @@
-settings = require("settings-sharelatex")
-logger = require("logger-sharelatex")
-request = require("request")
-
-module.exports = (req, res, next)->
- requestedUrl = req.url
-
- redirectUrl = settings.proxyUrls[requestedUrl]
- if redirectUrl?
- logger.log redirectUrl:redirectUrl, reqUrl:req.url, "proxying url"
- upstream = request(redirectUrl)
- upstream.on "error", (error) ->
- logger.error err: error, "error in OldAssetProxy"
- upstream.pipe(res)
- else
- next()
diff --git a/services/web/app/coffee/infrastructure/ProxyManager.coffee b/services/web/app/coffee/infrastructure/ProxyManager.coffee
new file mode 100644
index 0000000000..3895ef6e2f
--- /dev/null
+++ b/services/web/app/coffee/infrastructure/ProxyManager.coffee
@@ -0,0 +1,36 @@
+settings = require 'settings-sharelatex'
+logger = require 'logger-sharelatex'
+request = require 'request'
+URL = require 'url'
+
+module.exports = ProxyManager =
+ call: (req, res, next) ->
+ requestUrl = URL.parse(req.url)
+ requestPath = requestUrl.pathname # ignore the query part
+
+ target = settings.proxyUrls[requestPath]
+ return next() unless target? # nothing to proxy
+
+ targetUrl = makeTargetUrl(target, req.query)
+ logger.log targetUrl: targetUrl, reqUrl: req.url, "proxying url"
+
+ upstream = request(targetUrl)
+ upstream.on "error", (error) ->
+ logger.error err: error, "error in ProxyManager"
+
+ # TODO: better handling of status code
+ # see https://github.com/overleaf/write_latex/wiki/Streams-and-pipes-in-Node.js
+ upstream.pipe(res)
+
+# make a URL from a proxy target.
+# if the query is specified, set/replace the target's query with the given query
+makeTargetUrl = (target, query) ->
+ targetUrl = URL.parse(parseSettingUrl(target))
+ if query? and Object.keys(query).length > 0
+ targetUrl.query = query
+ targetUrl.search = null # clear `search` as it takes precedence over `query`
+ targetUrl.format()
+
+parseSettingUrl = (target) ->
+ return target if typeof target is 'string'
+ "#{target.baseUrl}#{target.path or ''}"
diff --git a/services/web/app/coffee/infrastructure/Server.coffee b/services/web/app/coffee/infrastructure/Server.coffee
index 39fc3c131b..37359ac797 100644
--- a/services/web/app/coffee/infrastructure/Server.coffee
+++ b/services/web/app/coffee/infrastructure/Server.coffee
@@ -32,7 +32,7 @@ Mongoose = require("./Mongoose")
oneDayInMilliseconds = 86400000
ReferalConnect = require('../Features/Referal/ReferalConnect')
RedirectManager = require("./RedirectManager")
-OldAssetProxy = require("./OldAssetProxy")
+ProxyManager = require("./ProxyManager")
translations = require("translations-sharelatex").setup(Settings.i18n)
Modules = require "./Modules"
@@ -74,7 +74,7 @@ app.use methodOverride()
app.use metrics.http.monitor(logger)
app.use RedirectManager
-app.use OldAssetProxy
+app.use ProxyManager.call
webRouter.use cookieParser(Settings.security.sessionSecret)
diff --git a/services/web/app/coffee/infrastructure/mongojs.coffee b/services/web/app/coffee/infrastructure/mongojs.coffee
index f1ed213435..8492b9023c 100644
--- a/services/web/app/coffee/infrastructure/mongojs.coffee
+++ b/services/web/app/coffee/infrastructure/mongojs.coffee
@@ -1,6 +1,6 @@
Settings = require "settings-sharelatex"
mongojs = require "mongojs"
-db = mongojs(Settings.mongo.url, ["projects", "users", "userstubs"])
+db = mongojs(Settings.mongo.url, ["projects", "users", "userstubs", "tokens"])
module.exports =
db: db
ObjectId: mongojs.ObjectId
diff --git a/services/web/app/coffee/models/User.coffee b/services/web/app/coffee/models/User.coffee
index 71982ba40b..730b3668c6 100644
--- a/services/web/app/coffee/models/User.coffee
+++ b/services/web/app/coffee/models/User.coffee
@@ -10,7 +10,8 @@ UserSchema = new Schema
email : {type : String, default : ''}
emails: [{
email: { type : String, default : '' },
- createdAt: { type : Date, default: () -> new Date() }
+ createdAt: { type : Date, default: () -> new Date() },
+ confirmedAt: { type: Date }
}],
first_name : {type : String, default : ''}
last_name : {type : String, default : ''}
diff --git a/services/web/app/coffee/router.coffee b/services/web/app/coffee/router.coffee
index bfe1c60ffe..4e7c3d9ddb 100644
--- a/services/web/app/coffee/router.coffee
+++ b/services/web/app/coffee/router.coffee
@@ -120,6 +120,10 @@ module.exports = class Router
webRouter.post '/user/emails/default',
AuthenticationController.requireLogin(),
UserEmailsController.setDefault
+ webRouter.get '/user/emails/confirm',
+ UserEmailsController.showConfirm
+ webRouter.post '/user/emails/confirm',
+ UserEmailsController.confirm
webRouter.get '/user/sessions',
AuthenticationController.requireLogin(),
@@ -295,6 +299,22 @@ module.exports = class Router
webRouter.post "/confirm-password", AuthenticationController.requireLogin(), SudoModeController.submitPassword
+ # New "api" endpoints. Started as a way for v1 to call over to v2 (for
+ # long-term features, as opposed to the nominally temporary ones in the
+ # overleaf-integration module), but may expand beyond that role.
+ publicApiRouter.post '/api/clsi/compile/:submission_id', AuthenticationController.httpAuth, CompileController.compileSubmission
+ publicApiRouter.get /^\/api\/clsi\/compile\/([^\/]*)\/build\/([0-9a-f-]+)\/output\/(.*)$/,
+ ((req, res, next) ->
+ params =
+ "submission_id": req.params[0]
+ "build_id": req.params[1]
+ "file": req.params[2]
+ req.params = params
+ next()
+ ),
+ AuthenticationController.httpAuth,
+ CompileController.getFileFromClsiWithoutUser
+
#Admin Stuff
webRouter.get '/admin', AuthorizationMiddlewear.ensureUserIsSiteAdmin, AdminController.index
webRouter.get '/admin/user', AuthorizationMiddlewear.ensureUserIsSiteAdmin, (req, res)-> res.redirect("/admin/register") #this gets removed by admin-panel addon
diff --git a/services/web/app/views/project/editor/binary-file.pug b/services/web/app/views/project/editor/binary-file.pug
index f4ccf8c9dc..e7cd1e32ed 100644
--- a/services/web/app/views/project/editor/binary-file.pug
+++ b/services/web/app/views/project/editor/binary-file.pug
@@ -38,6 +38,7 @@ div.binary-file.full-size(
) #{translate("no_preview_available")}
div.binary-file-footer
+ // Linked Files: URL
div(ng-if="openFile.linkedFileData.provider == 'url'")
p
i.fa.fa-fw.fa-external-link-square.fa-rotate-180.linked-file-icon
@@ -47,6 +48,7 @@ div.binary-file.full-size(
|
| at {{ openFile.created | formatDate:'h:mm a' }} {{ openFile.created | relativeDate }}
+ // Linked Files: Project File
div(ng-if="openFile.linkedFileData.provider == 'project_file'")
p
i.fa.fa-fw.fa-external-link-square.fa-rotate-180.linked-file-icon
@@ -54,14 +56,30 @@ div.binary-file.full-size(
|
a(ng-if='!openFile.linkedFileData.v1_source_doc_id'
ng-href='/project/{{openFile.linkedFileData.source_project_id}}' target="_blank")
- | {{ openFile.linkedFileData.source_project_display_name }}
+ | Another project
span(ng-if='openFile.linkedFileData.v1_source_doc_id')
- | {{ openFile.linkedFileData.source_project_display_name }}
+ | Another project
| /{{ openFile.linkedFileData.source_entity_path.slice(1) }},
|
| at {{ openFile.created | formatDate:'h:mm a' }} {{ openFile.created | relativeDate }}
- span(ng-if="openFile.linkedFileData.provider == 'url' || openFile.linkedFileData.provider == 'project_file'")
+ // Linked Files: Project Output File
+ div(ng-if="openFile.linkedFileData.provider == 'project_output_file'")
+ p
+ i.fa.fa-fw.fa-external-link-square.fa-rotate-180.linked-file-icon
+ | Imported from the output of
+ |
+ a(ng-if='!openFile.linkedFileData.v1_source_doc_id'
+ ng-href='/project/{{openFile.linkedFileData.source_project_id}}' target="_blank")
+ | Another project
+ span(ng-if='openFile.linkedFileData.v1_source_doc_id')
+ | Another project
+ | : {{ openFile.linkedFileData.source_output_file_path }},
+ |
+ | at {{ openFile.created | formatDate:'h:mm a' }} {{ openFile.created | relativeDate }}
+
+ // Bottom Controls
+ span(ng-if="openFile.linkedFileData.provider")
button.btn.btn-success(
href, ng-click="refreshFile(openFile)",
ng-disabled="refreshing"
diff --git a/services/web/app/views/project/editor/file-tree.pug b/services/web/app/views/project/editor/file-tree.pug
index d16258d1c9..d877421a9d 100644
--- a/services/web/app/views/project/editor/file-tree.pug
+++ b/services/web/app/views/project/editor/file-tree.pug
@@ -308,165 +308,10 @@ script(type='text/ng-template', id='entityListItemTemplate')
ng-repeat="child in entity.children | orderBy:[orderByFoldersFirst, 'name']"
)
-script(type='text/ng-template', id='newDocModalTemplate')
- .modal-header
- h3 #{translate("new_file")}
- .modal-body
- form(novalidate, name="newDocForm")
- div.alert.alert-danger(ng-if="error")
- div(ng-switch="error")
- span(ng-switch-when="already exists") #{translate("file_already_exists")}
- span(ng-switch-default) {{error}}
- input.form-control(
- type="text",
- placeholder="File Name",
- required,
- ng-model="inputs.name",
- on-enter="create()",
- select-name-on="open",
- valid-file,
- name="name"
- )
- .text-danger.row-spaced-small(ng-show="newDocForm.name.$error.validFile")
- | #{translate('files_cannot_include_invalid_characters')}
- .modal-footer
- button.btn.btn-default(
- ng-disabled="state.inflight"
- ng-click="cancel()"
- ) #{translate("cancel")}
- button.btn.btn-primary(
- ng-disabled="newDocForm.$invalid || state.inflight"
- ng-click="create()"
- )
- span(ng-hide="state.inflight") #{translate("create")}
- span(ng-show="state.inflight") #{translate("creating")}...
-
-
-// Project Linked Files Modal
-script(type='text/ng-template', id='projectLinkedFileModalTemplate')
- .modal-header
- h3 New file from Project
-
- .modal-body
- div
- div.alert.alert-danger(ng-if="state.error") Error, something went wrong!
- div
- form
- .form-controls
- label(for="project-select") Select a Project
- span(ng-show="state.inFlight.projects")
- |
- i.fa.fa-spinner.fa-spin
- select.form-control(
- name="project-select"
- ng-model="data.selectedProjectId"
- ng-disabled="!shouldEnableProjectSelect()"
- )
- option(value="" disabled selected) - Please Select a Project
- option(
- ng-repeat="project in data.projects"
- value="{{ project._id }}"
- ) {{ project.name }}
-
- br
- .form-controls
- label(for="project-entity-select") Select a File
- span(ng-show="state.inFlight.entities")
- |
- i.fa.fa-spinner.fa-spin
- select.form-control(
- name="project-entity-select"
- ng-model="data.selectedProjectEntity"
- ng-disabled="!shouldEnableProjectEntitySelect()"
- )
- option(value="" disabled selected) - Please Select a File
- option(
- ng-repeat="projectEntity in data.projectEntities"
- value="{{ projectEntity.path }}"
- ) {{ projectEntity.path.slice(1) }}
- br
-
- .form-controls
- label(for="name") File Name In This Project
- input.form-control(
- type="text"
- placeholder="example.tex"
- required
- ng-model="data.name"
- name="name"
- )
- br
-
- .modal-footer
- span(ng-show="state.inFlight.create")
- i.fa.fa-spinner.fa-spin
- |
- button.btn.btn-default(
- ng-disabled="state.inflight"
- ng-click="cancel()"
- ) #{translate("cancel")}
- button.btn.btn-primary(
- ng-disabled="!shouldEnableCreateButton()"
- ng-click="create()"
- )
- span(ng-hide="state.inflight") #{translate("create")}
- span(ng-show="state.inflight") #{translate("creating")}...
-
-
-script(type='text/ng-template', id='linkedFileModalTemplate')
- .modal-header
- h3 New file from URL
- .modal-body
- form(novalidate, name="newLinkedFileForm")
- div.alert.alert-danger(ng-if="error")
- div(ng-switch="error")
- span(ng-switch-when="already exists") #{translate("file_already_exists")}
- span(ng-switch-default) {{error}}
- label(for="url") URL to fetch the file from
- input.form-control(
- type="text",
- placeholder="www.example.com/my_file",
- required,
- ng-model="inputs.url",
- focus-on="open",
- on-enter="create()",
- name="url"
- )
- .row-spaced
- label(for="name") File name in this project
- input.form-control(
- type="text",
- placeholder="my_file",
- required,
- ng-model="inputs.name",
- ng-change="nameChangedByUser = true"
- valid-file,
- on-enter="create()",
- name="name"
- )
- .text-danger.row-spaced-small(ng-show="newDocForm.name.$error.validFile")
- | #{translate('files_cannot_include_invalid_characters')}
- .modal-footer
- button.btn.btn-default(
- ng-disabled="state.inflight"
- ng-click="cancel()"
- ) #{translate("cancel")}
- button.btn.btn-primary(
- ng-disabled="newLinkedFileForm.$invalid || state.inflight"
- ng-click="create()"
- )
- span(ng-hide="state.inflight") #{translate("create")}
- span(ng-show="state.inflight") #{translate("creating")}...
-
-
script(type='text/ng-template', id='newFolderModalTemplate')
.modal-header
h3 #{translate("new_folder")}
.modal-body
- div.alert.alert-danger(ng-if="error")
- div(ng-switch="error")
- span(ng-switch-when="already exists") #{translate("file_already_exists")}
- span(ng-switch-default) {{error}}
form(novalidate, name="newFolderForm")
input.form-control(
type="text",
@@ -478,8 +323,12 @@ script(type='text/ng-template', id='newFolderModalTemplate')
valid-file,
name="name"
)
- .text-danger.row-spaced-small(ng-show="newFolderForm.name.$error.validFile")
- | #{translate('files_cannot_include_invalid_characters')}
+ div.alert.alert-danger.row-spaced-small(ng-show="newFolderForm.name.$error.validFile")
+ | #{translate('files_cannot_include_invalid_characters')}
+ div.alert.alert-danger.row-spaced-small(ng-if="error")
+ div(ng-switch="error")
+ span(ng-switch-when="already exists") #{translate("file_already_exists")}
+ span(ng-switch-default) {{error}}
.modal-footer
button.btn.btn-default(
ng-disabled="state.inflight"
@@ -492,70 +341,7 @@ script(type='text/ng-template', id='newFolderModalTemplate')
span(ng-hide="state.inflight") #{translate("create")}
span(ng-show="state.inflight") #{translate("creating")}...
-
-script(type="text/template", id="qq-file-uploader-template")
- div.qq-uploader-selector
- div(qq-hide-dropzone="").qq-upload-drop-area-selector.qq-upload-drop-area
- span.qq-upload-drop-area-text-selector #{translate('drop_files_here_to_upload')}
- div.qq-upload-button-selector.btn.btn-primary.btn-lg
- div #{translate('upload')}
- span.or.btn-lg #{translate('or')}
- span.drag-here.btn-lg #{translate('drag_here')}
- span.qq-drop-processing-selector
- span #{translate('processing')}
- span.qq-drop-processing-spinner-selector
- ul.qq-upload-list-selector
- li
- div.qq-progress-bar-container-selector
- div(
- role="progressbar"
- aria-valuenow="0"
- aria-valuemin="0"
- aria-valuemax="100"
- class="qq-progress-bar-selector qq-progress-bar"
- )
- span.qq-upload-file-selector.qq-upload-file
- span.qq-upload-size-selector.qq-upload-size
- a(type="button").qq-btn.qq-upload-cancel-selector.qq-upload-cancel #{translate('cancel')}
- button(type="button").qq-btn.qq-upload-retry-selector.qq-upload-retry #{translate('retry')}
- span(role="status").qq-upload-status-text-selector.qq-upload-status-text
-
-script(type="text/ng-template", id="uploadFileModalTemplate")
- .modal-header
- h3 #{translate("upload_files")}
- .alert.alert-warning.small.modal-alert(ng-if="tooManyFiles") #{translate("maximum_files_uploaded_together", {max:"{{max_files}}"})}
- .alert.alert-warning.small.modal-alert(ng-if="rateLimitHit") #{translate("too_many_files_uploaded_throttled_short_period")}
- .alert.alert-warning.small.modal-alert(ng-if="notLoggedIn") #{translate("session_expired_redirecting_to_login", {seconds:"{{secondsToRedirect}}"})}
- .alert.alert-warning.small.modal-alert(ng-if="conflicts.length > 0")
- p.text-center
- | The following files already exist in this project:
- ul.text-center.list-unstyled.row-spaced-small
- li(ng-repeat="conflict in conflicts"): strong {{ conflict }}
- p.text-center.row-spaced-small
- | Do you want to overwrite them?
- p.text-center
- a(href, ng-click="doUpload()").btn.btn-primary Overwrite
- |
- a(href, ng-click="cancel()").btn.btn-default Cancel
-
- .modal-body(
- fine-upload
- endpoint="/project/{{ project_id }}/upload"
- template-id="qq-file-uploader-template"
- multiple="true"
- auto-upload="false"
- on-complete-callback="onComplete"
- on-upload-callback="onUpload"
- on-validate-batch="onValidateBatch"
- on-error-callback="onError"
- on-submit-callback="onSubmit"
- on-cancel-callback="onCancel"
- control="control"
- params="{'folder_id': parent_folder_id}"
- )
- .modal-footer
- button.btn.btn-default(ng-click="cancel()") #{translate("cancel")}
-
+include ./new-file-modal
script(type='text/ng-template', id='deleteEntityModalTemplate')
.modal-header
diff --git a/services/web/app/views/project/editor/left-menu.pug b/services/web/app/views/project/editor/left-menu.pug
index d85a45723b..2cedc0b37e 100644
--- a/services/web/app/views/project/editor/left-menu.pug
+++ b/services/web/app/views/project/editor/left-menu.pug
@@ -69,14 +69,6 @@ aside#left-menu.full-size(
a(href="#" ng-click="richText()")
i.fa.fa-exclamation.fa-fw
| Rich Text
- li
- a(href="#" ng-click="openProjectLinkedFileModal()")
- i.fa.fa-exclamation.fa-fw
- | Project-Linked-File Modal
- li
- a(href="#" ng-click="openLinkedFileModal()")
- i.fa.fa-exclamation.fa-fw
- | URL-Linked-File Modal
h4(ng-show="!anonymous") #{translate("settings")}
diff --git a/services/web/app/views/project/editor/new-file-modal.pug b/services/web/app/views/project/editor/new-file-modal.pug
new file mode 100644
index 0000000000..3dcd55bfb9
--- /dev/null
+++ b/services/web/app/views/project/editor/new-file-modal.pug
@@ -0,0 +1,217 @@
+script(type='text/ng-template', id='newFileModalTemplate')
+ .modal-header
+ h3 Add Files
+ .modal-body.modal-new-file
+ table
+ tr
+ td.modal-new-file--list
+ ul.list-unstyled
+ li(ng-class="type == 'doc' ? 'active' : null")
+ a(href, ng-click="type = 'doc'")
+ i.fa.fa-fw.fa-file
+ |
+ | New File
+ li(ng-class="type == 'upload' ? 'active' : null")
+ a(href, ng-click="type = 'upload'")
+ i.fa.fa-fw.fa-upload
+ |
+ | Upload
+ li(ng-class="type == 'project' ? 'active' : null")
+ a(href, ng-click="type = 'project'")
+ i.fa.fa-fw.fa-folder-open
+ |
+ | From Another Project
+ li(ng-class="type == 'url' ? 'active' : null")
+ a(href, ng-click="type = 'url'")
+ i.fa.fa-fw.fa-globe
+ |
+ | From External URL
+ td(class="modal-new-file--body modal-new-file--body-{{type}}")
+ div(ng-if="type == 'doc'", ng-controller="NewDocModalController")
+ form(novalidate, name="newDocForm")
+ label(for="name") File Name
+ input.form-control(
+ type="text",
+ placeholder="File Name",
+ required,
+ ng-model="inputs.name",
+ on-enter="create()",
+ select-name-on="open",
+ valid-file,
+ name="name"
+ )
+ div.alert.alert-danger.row-spaced-small(ng-if="error")
+ div(ng-switch="error")
+ span(ng-switch-when="already exists") #{translate("file_already_exists")}
+ span(ng-switch-default) {{error}}
+ div.alert.alert-danger.row-spaced-small(ng-show="newDocForm.name.$error.validFile")
+ | #{translate('files_cannot_include_invalid_characters')}
+ div(ng-if="type == 'upload'", ng-controller="UploadFileModalController")
+ .alert.alert-warning.small.modal(ng-if="tooManyFiles") #{translate("maximum_files_uploaded_together", {max:"{{max_files}}"})}
+ .alert.alert-warning.small.modal(ng-if="rateLimitHit") #{translate("too_many_files_uploaded_throttled_short_period")}
+ .alert.alert-warning.small.modal(ng-if="notLoggedIn") #{translate("session_expired_redirecting_to_login", {seconds:"{{secondsToRedirect}}"})}
+ .alert.alert-warning.small.modal(ng-if="conflicts.length > 0")
+ p.text-center
+ | The following files already exist in this project:
+ ul.text-center.list-unstyled.row-spaced-small
+ li(ng-repeat="conflict in conflicts"): strong {{ conflict }}
+ p.text-center.row-spaced-small
+ | Do you want to overwrite them?
+ p.text-center
+ a(href, ng-click="doUpload()").btn.btn-primary Overwrite
+ |
+ a(href, ng-click="cancel()").btn.btn-default Cancel
+ div(
+ fine-upload
+ endpoint="/project/{{ project_id }}/upload"
+ template-id="qq-file-uploader-template"
+ multiple="true"
+ auto-upload="false"
+ on-complete-callback="onComplete"
+ on-upload-callback="onUpload"
+ on-validate-batch="onValidateBatch"
+ on-error-callback="onError"
+ on-submit-callback="onSubmit"
+ on-cancel-callback="onCancel"
+ control="control"
+ params="{'folder_id': parent_folder_id}"
+ )
+ div(ng-if="type == 'project'", ng-controller="ProjectLinkedFileModalController")
+ div
+ form
+ .form-controls
+ label(for="project-select") Select a Project
+ span(ng-show="state.inFlight.projects")
+ |
+ i.fa.fa-spinner.fa-spin
+ select.form-control(
+ name="project-select"
+ ng-model="data.selectedProjectId"
+ ng-disabled="!shouldEnableProjectSelect()"
+ )
+ option(value="" disabled selected) - Please Select a Project
+ option(
+ ng-repeat="project in data.projects"
+ value="{{ project._id }}"
+ ) {{ project.name }}
+
+ .form-controls.row-spaced-small(ng-if="!state.isOutputFilesMode")
+ label(for="project-entity-select") Select a File
+ span(ng-show="state.inFlight.entities")
+ |
+ i.fa.fa-spinner.fa-spin
+ select.form-control(
+ name="project-entity-select"
+ ng-model="data.selectedProjectEntity"
+ ng-disabled="!shouldEnableProjectEntitySelect()"
+ )
+ option(value="" disabled selected) - Please Select a File
+ option(
+ ng-repeat="projectEntity in data.projectEntities"
+ value="{{ projectEntity.path }}"
+ ) {{ projectEntity.path.slice(1) }}
+
+ .form-controls.row-spaced-small(ng-if="state.isOutputFilesMode")
+ label(for="project-entity-select") Select an Output File
+ span(ng-show="state.inFlight.compile")
+ |
+ i.fa.fa-spinner.fa-spin
+ select.form-control(
+ name="project-output-file-select"
+ ng-model="data.selectedProjectOutputFile"
+ ng-disabled="!shouldEnableProjectOutputFileSelect()"
+ )
+ option(value="" disabled selected) - Please Select an Output File
+ option(
+ ng-repeat="outputFile in data.projectOutputFiles"
+ value="{{ outputFile.path }}"
+ ) {{ outputFile.path }}
+ div.toggle-output-files-button
+ | or
+ a(
+ href="#"
+ ng-click="toggleOutputFilesMode()"
+ )
+ span(ng-show="state.isOutputFilesMode") select from source files
+ span(ng-show="!state.isOutputFilesMode") select from output files
+
+ .form-controls.row-spaced-small
+ label(for="name") File Name In This Project
+ input.form-control(
+ type="text"
+ placeholder="example.tex"
+ required
+ ng-model="data.name"
+ name="name"
+ )
+ div.alert.alert-danger.row-spaced-small(ng-if="state.error") Error, something went wrong!
+ div(ng-if="type == 'url'", ng-controller="UrlLinkedFileModalController")
+ form(novalidate, name="newLinkedFileForm")
+ label(for="url") URL to fetch the file from
+ input.form-control(
+ type="text",
+ placeholder="www.example.com/my_file",
+ required,
+ ng-model="inputs.url",
+ focus-on="open",
+ on-enter="create()",
+ name="url"
+ )
+ .row-spaced.small
+ label(for="name") File name in this project
+ input.form-control(
+ type="text",
+ placeholder="my_file",
+ required,
+ ng-model="inputs.name",
+ ng-change="nameChangedByUser = true"
+ valid-file,
+ on-enter="create()",
+ name="name"
+ )
+ .text-danger.row-spaced-small(ng-show="newDocForm.name.$error.validFile")
+ | #{translate('files_cannot_include_invalid_characters')}
+ div.alert.alert-danger.row-spaced-small(ng-if="error")
+ div(ng-switch="error")
+ span(ng-switch-when="already exists") #{translate("file_already_exists")}
+ span(ng-switch-default) {{error}}
+ .modal-footer
+ button.btn.btn-default(
+ ng-disabled="state.inflight"
+ ng-click="cancel()"
+ ) #{translate("cancel")}
+ button.btn.btn-primary(
+ ng-disabled="state.inflight || !state.valid"
+ ng-click="create()"
+ ng-hide="type == 'upload'"
+ )
+ span(ng-hide="state.inflight") #{translate("create")}
+ span(ng-show="state.inflight") #{translate("creating")}...
+
+script(type="text/template", id="qq-file-uploader-template")
+ div.qq-uploader-selector
+ div(qq-hide-dropzone="").qq-upload-drop-area-selector.qq-upload-drop-area
+ span.qq-upload-drop-area-text-selector #{translate('drop_files_here_to_upload')}
+ div Drag here
+ div.row-spaced-small.small #{translate('or')}
+ div.row-spaced-small
+ div.qq-upload-button-selector.btn.btn-primary
+ | Select from your computer
+ span.qq-drop-processing-selector
+ span #{translate('processing')}
+ span.qq-drop-processing-spinner-selector
+ ul.qq-upload-list-selector
+ li
+ div.qq-progress-bar-container-selector
+ div(
+ role="progressbar"
+ aria-valuenow="0"
+ aria-valuemin="0"
+ aria-valuemax="100"
+ class="qq-progress-bar-selector qq-progress-bar"
+ )
+ span.qq-upload-file-selector.qq-upload-file
+ span.qq-upload-size-selector.qq-upload-size
+ a(type="button").qq-btn.qq-upload-cancel-selector.qq-upload-cancel #{translate('cancel')}
+ button(type="button").qq-btn.qq-upload-retry-selector.qq-upload-retry #{translate('retry')}
+ span(role="status").qq-upload-status-text-selector.qq-upload-status-text
diff --git a/services/web/app/views/subscriptions/_plans_page_details_more.pug b/services/web/app/views/subscriptions/_plans_page_details_more.pug
index 17c683201e..d4867c2811 100644
--- a/services/web/app/views/subscriptions/_plans_page_details_more.pug
+++ b/services/web/app/views/subscriptions/_plans_page_details_more.pug
@@ -87,7 +87,7 @@ div
+plan_switch('table')
.col-md-3.text-right
+currency_dropdown
- .row(event-tracking="features-table-viewed" event-tracking-ga="subscription-funnel" event-tracking-trigger="scroll" event-tracking-send-once="true")
+ .row(event-tracking="features-table-viewed" event-tracking-ga="subscription-funnel" event-tracking-trigger="scroll" event-tracking-send-once="true" event-tracking-label=`exp-{{plansVariant}}`)
.col-sm-12(ng-if="ui.view != 'student'")
+table_premium
.col-sm-12(ng-if="ui.view == 'student'")
diff --git a/services/web/app/views/subscriptions/new.pug b/services/web/app/views/subscriptions/new.pug
index 4eae442f1e..d3d99168d1 100644
--- a/services/web/app/views/subscriptions/new.pug
+++ b/services/web/app/views/subscriptions/new.pug
@@ -10,6 +10,7 @@ block scripts
block content
.content.content-alt
+
.container(ng-controller="NewSubscriptionController" ng-cloak)
.row.card-group
.col-md-5.col-md-push-4
@@ -35,6 +36,15 @@ block content
.row(ng-if="plansVariant == 'more-details' && planCode == 'student-annual' || plansVariant == 'more-details' && planCode == 'student-monthly'")
.col-xs-12
p.student-disclaimer #{translate('student_disclaimer')}
+
+ if plan_code == 'collaborator_free_trial_7_days' && !!settings.overleaf
+ .row
+ .col-xs-12
+ p.small.row-spaced-small
+ | The Collaborator plan is a new Overleaf v2 plan which includes track-changes, references search, and Dropbox & GitHub integration. While Overleaf v2 is in beta, you can also still subscribe to a
+ |
+ a(href=settings.overleaf.host + "/plans") legacy plan in Overleaf v1
+ | .
hr.thin
.row
.col-md-12.text-center
@@ -239,7 +249,7 @@ block content
hr
- p.small.text-center We're confident that you'll love ShareLaTeX, but if not you can cancel anytime. We'll give you your money back, no questions asked, if you let us know within 30 days.
+ p.small.text-center We're confident that you'll love #{settings.appName}, but if not you can cancel anytime. We'll give you your money back, no questions asked, if you let us know within 30 days.
hr
span
a(href="https://www.positivessl.com" style="font-family: arial; font-size: 10px; color: #212121; text-decoration: none;")
diff --git a/services/web/app/views/subscriptions/successful_subscription.pug b/services/web/app/views/subscriptions/successful_subscription.pug
index 184c89c0d2..6fb4fc7958 100644
--- a/services/web/app/views/subscriptions/successful_subscription.pug
+++ b/services/web/app/views/subscriptions/successful_subscription.pug
@@ -18,17 +18,11 @@ block content
p.letter-from-founders
p #{translate("thanks_for_subscribing_you_help_sl", {planName:subscription.name})}
p #{translate("need_anything_contact_us_at")}
- a(href='mailto:support@sharelatex.com') support@sharelatex.com
- | . #{translate("goes_straight_to_our_inboxes")}.
+ a(href='mailto:support@sharelatex.com') #{settings.adminEmail}
+ | .
p #{translate("regards")},
br
- | Henry and James
- .portraits
- span.img-circle
- img(src=buildImgPath("about/henry_oswald.jpg"))
- |
- span.img-circle
- img(src=buildImgPath("about/james_allen.jpg"))
+ | The #{settings.appName} Team
p
a.btn.btn-primary(href="/project") < #{translate("back_to_your_projects")}
diff --git a/services/web/app/views/user/confirm_email.pug b/services/web/app/views/user/confirm_email.pug
new file mode 100644
index 0000000000..388a0a0252
--- /dev/null
+++ b/services/web/app/views/user/confirm_email.pug
@@ -0,0 +1,28 @@
+extends ../layout
+
+block content
+ .content.content-alt
+ .container
+ .row
+ .col-md-6.col-md-offset-3.col-lg-4.col-lg-offset-4
+ .card
+ .page-header
+ h1 #{translate("confirm_email")}
+ form(
+ async-form="confirm-email",
+ name="confirmEmailForm"
+ action="/user/emails/confirm",
+ method="POST",
+ id="confirmEmailForm",
+ auto-submit="true",
+ ng-cloak
+ )
+ input(type="hidden", name="_csrf", value=csrfToken)
+ input(type="hidden", name="token", value=token)
+ form-messages(for="confirmEmailForm")
+ .alert.alert-success(ng-show="confirmEmailForm.response.success")
+ | Thank you, your email is now confirmed
+ p.text-center(ng-show="!confirmEmailForm.response.success && !confirmEmailForm.response.error")
+ i.fa.fa-fw.fa-spin.fa-spinner
+ |
+ | Confirming your email...
diff --git a/services/web/docker-compose.yml b/services/web/docker-compose.yml
index 9596fe5126..f2966da141 100644
--- a/services/web/docker-compose.yml
+++ b/services/web/docker-compose.yml
@@ -17,7 +17,7 @@ services:
PROJECT_HISTORY_ENABLED: 'true'
ENABLED_LINKED_FILE_TYPES: 'url'
LINKED_URL_PROXY: 'http://localhost:6543'
- ENABLED_LINKED_FILE_TYPES: 'url,project_file'
+ ENABLED_LINKED_FILE_TYPES: 'url,project_file,project_output_file'
SHARELATEX_CONFIG: /app/test/acceptance/config/settings.test.coffee
depends_on:
- redis
diff --git a/services/web/public/coffee/directives/asyncForm.coffee b/services/web/public/coffee/directives/asyncForm.coffee
index 15e3c9b3fe..acafec563d 100644
--- a/services/web/public/coffee/directives/asyncForm.coffee
+++ b/services/web/public/coffee/directives/asyncForm.coffee
@@ -15,11 +15,6 @@ define [
scope[attrs.name].response = response = {}
scope[attrs.name].inflight = false
- element.on "submit", (e) ->
- e.preventDefault()
- validateCaptchaIfEnabled (response) ->
- submitRequest response
-
validateCaptchaIfEnabled = (callback = (response) ->) ->
if attrs.captcha?
validateCaptcha callback
@@ -84,6 +79,17 @@ define [
text: data.message?.text or data.message or "Something went wrong talking to the server :(. Please try again."
type: 'error'
ga('send', 'event', formName, 'failure', data.message)
+
+ submit = () ->
+ validateCaptchaIfEnabled (response) ->
+ submitRequest response
+
+ element.on "submit", (e) ->
+ e.preventDefault()
+ submit()
+
+ if attrs.autoSubmit
+ submit()
}
App.directive "formMessages", () ->
diff --git a/services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/AutoCompleteManager.coffee b/services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/AutoCompleteManager.coffee
index a10dcf618b..638ab20b30 100644
--- a/services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/AutoCompleteManager.coffee
+++ b/services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/AutoCompleteManager.coffee
@@ -44,13 +44,11 @@ define [
GraphicsCompleter =
getCompletions: (editor, session, pos, prefix, callback) ->
- context = Helpers.getContext(editor, pos)
- {commandFragment, closingBrace} = context
+ {commandFragment} = Helpers.getContext(editor, pos)
if commandFragment
match = commandFragment.match(/^~?\\(includegraphics(?:\[.*])?){([^}]*, *)?(\w*)/)
if match
- commandName = match[1]
- currentArg = match[3]
+ [_, commandName, _, currentArg] = match
graphicsPaths = Preamble.getGraphicsPaths()
result = []
for graphic in Graphics.getGraphicsFiles()
@@ -60,8 +58,8 @@ define [
path = path.slice(graphicsPath.length)
break
result.push {
- caption: "\\#{commandName}{#{path}#{closingBrace}"
- value: "\\#{commandName}{#{path}#{closingBrace}"
+ caption: "\\#{commandName}{#{path}}"
+ value: "\\#{commandName}{#{path}}"
meta: "graphic"
score: 50
}
@@ -70,20 +68,18 @@ define [
metadataManager = @metadataManager
FilesCompleter =
getCompletions: (editor, session, pos, prefix, callback) =>
- context = Helpers.getContext(editor, pos)
- {commandFragment, closingBrace} = context
+ {commandFragment} = Helpers.getContext(editor, pos)
if commandFragment
match = commandFragment.match(/^\\(input|include){(\w*)/)
if match
- commandName = match[1]
- currentArg = match[2]
+ [_, commandName, currentArg] = match
result = []
for file in Files.getTeXFiles()
if file.id != @$scope.docId
path = file.path
result.push {
- caption: "\\#{commandName}{#{path}#{closingBrace}"
- value: "\\#{commandName}{#{path}#{closingBrace}"
+ caption: "\\#{commandName}{#{path}}"
+ value: "\\#{commandName}{#{path}}"
meta: "file"
score: 50
}
@@ -91,13 +87,11 @@ define [
LabelsCompleter =
getCompletions: (editor, session, pos, prefix, callback) ->
- context = Helpers.getContext(editor, pos)
- {commandFragment, closingBrace} = context
+ {commandFragment} = Helpers.getContext(editor, pos)
if commandFragment
refMatch = commandFragment.match(/^~?\\([a-z]*ref){([^}]*, *)?(\w*)/)
if refMatch
- commandName = refMatch[1]
- currentArg = refMatch[2]
+ [_, commandName, currentArg] = refMatch
result = []
if commandName != 'ref' # ref is in top 100 commands
result.push {
@@ -108,8 +102,8 @@ define [
}
for label in metadataManager.getAllLabels()
result.push {
- caption: "\\#{commandName}{#{label}#{closingBrace}"
- value: "\\#{commandName}{#{label}#{closingBrace}"
+ caption: "\\#{commandName}{#{label}}"
+ value: "\\#{commandName}{#{label}}"
meta: "cross-reference"
score: 50
}
@@ -118,17 +112,14 @@ define [
references = @$scope.$root._references
ReferencesCompleter =
getCompletions: (editor, session, pos, prefix, callback) ->
- context = Helpers.getContext(editor, pos)
- {commandFragment, closingBrace} = context
+ {commandFragment} = Helpers.getContext(editor, pos)
if commandFragment
citeMatch = commandFragment.match(
/^~?\\([a-z]*cite[a-z]*(?:\[.*])?){([^}]*, *)?(\w*)/
)
if citeMatch
- commandName = citeMatch[1]
- previousArgs = citeMatch[2]
- currentArg = citeMatch[3]
- if previousArgs == undefined
+ [_, commandName, previousArgs, currentArg] = citeMatch
+ if !previousArgs?
previousArgs = ""
previousArgsCaption = if previousArgs.length > 8 then "…," else previousArgs
result = []
@@ -140,10 +131,10 @@ define [
}
if references.keys and references.keys.length > 0
references.keys.forEach (key) ->
- if !(key in [null, undefined])
+ if key?
result.push({
- caption: "\\#{commandName}{#{previousArgsCaption}#{key}#{closingBrace}"
- value: "\\#{commandName}{#{previousArgs}#{key}#{closingBrace}"
+ caption: "\\#{commandName}{#{previousArgsCaption}#{key}}"
+ value: "\\#{commandName}{#{previousArgs}#{key}}"
meta: "reference"
score: 50
})
@@ -170,8 +161,7 @@ define [
onChange: (change) ->
cursorPosition = @editor.getCursorPosition()
end = change.end
- context = Helpers.getContext(@editor, end)
- {lineUpToCursor, commandFragment} = context
+ {lineUpToCursor, commandFragment} = Helpers.getContext(@editor, end)
if lineUpToCursor.match(/.*%.*/)
return
lastCharIsBackslash = lineUpToCursor.slice(-1) == "\\"
@@ -188,10 +178,19 @@ define [
end.row == cursorPosition.row and
end.column == cursorPosition.column + 1
)
- if (commandFragment? and commandFragment.length > 2) or lastCharIsBackslash
+ if commandFragment?.length > 2 or lastCharIsBackslash
setTimeout () =>
@editor.execCommand("startAutocomplete")
, 0
+ if (
+ change.action == "insert" and
+ change.lines[0].match(/\\(\w+){}/)?[1].match(
+ /(begin|end|[a-z]*ref|usepackage|[a-z]*cite[a-z]*|input|include)/
+ )
+ )
+ setTimeout () =>
+ @editor.execCommand("startAutocomplete")
+ , 0
monkeyPatchAutocomplete: () ->
Autocomplete = ace.require("ace/autocomplete").Autocomplete
@@ -209,7 +208,7 @@ define [
# If we are in \begin{it|}, then we need to remove the trailing }
# since it will be adding in with the autocomplete of \begin{item}...
- if this.completions.filterText.match(/^\\begin\{/) and nextChar == "}"
+ if this.completions.filterText.match(/^\\\w+{/) and nextChar == "}"
editor.session.remove(range)
# Provide our own `insertMatch` implementation.
diff --git a/services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/CommandManager.coffee b/services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/CommandManager.coffee
index d194c7fb4a..d786dbb9ec 100644
--- a/services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/CommandManager.coffee
+++ b/services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/CommandManager.coffee
@@ -154,14 +154,14 @@ define [
totalArgs = squareArgsNo + curlyArgsNo
if totalArgs == 0
completionBeforeCursor = completionBase
- completionAfterCurspr = ""
+ completionAfterCursor = ""
else
completionBeforeCursor = completionBase + args[0]
completionAfterCursor = args.slice(1)
return {
- base: base,
- completion: completionBase + args,
+ base: base
+ completion: completionBase + args
completionBeforeCursor: completionBeforeCursor
completionAfterCursor: completionAfterCursor
}
diff --git a/services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/EnvironmentManager.coffee b/services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/EnvironmentManager.coffee
index 383bf1bd2e..156389e73d 100644
--- a/services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/EnvironmentManager.coffee
+++ b/services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/EnvironmentManager.coffee
@@ -160,14 +160,10 @@ define () ->
hasDocumentEnvironment = (text) ->
re = /^\\begin{document}/m
- envs = []
- iterations = 0
return re.exec(text) != null
hasBibliographyEnvironment = (text) ->
re = /^\\begin{thebibliography}/m
- envs = []
- iterations = 0
return re.exec(text) != null
class EnvironmentManager
diff --git a/services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/Helpers.coffee b/services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/Helpers.coffee
index 87a41f06d3..c8bcbdb6ab 100644
--- a/services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/Helpers.coffee
+++ b/services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/Helpers.coffee
@@ -33,14 +33,11 @@ define [
commandName = Helpers.getCommandNameFromFragment(commandFragment)
beyondCursorRange = new Range(pos.row, pos.column, pos.row, 99999)
lineBeyondCursor = editor.getSession().getTextRange(beyondCursorRange)
- needsClosingBrace = !lineBeyondCursor.match(/^[^{]*}/)
- closingBrace = if needsClosingBrace then '}' else ''
return {
lineUpToCursor,
commandFragment,
commandName,
- lineBeyondCursor,
- closingBrace
+ lineBeyondCursor
}
return Helpers
diff --git a/services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/PackageManager.coffee b/services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/PackageManager.coffee
index 4ab1b6933c..12bcc26a8f 100644
--- a/services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/PackageManager.coffee
+++ b/services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/PackageManager.coffee
@@ -1,6 +1,4 @@
-define [
- "./Helpers"
-], (Helpers) ->
+define [], () ->
packages = [
'inputenc', 'graphicx', 'amsmath', 'geometry', 'amssymb', 'hyperref',
'babel', 'color', 'xcolor', 'url', 'natbib', 'fontenc', 'fancyhdr',
@@ -26,14 +24,13 @@ define [
constructor: (@metadataManager) ->
getCompletions: (editor, session, pos, prefix, callback) ->
- {closingBrace} = Helpers.getContext(editor, pos)
usedPackages = Object.keys(@metadataManager.getAllPackages())
packageSnippets = []
for pkg in packages
if pkg not in usedPackages
packageSnippets.push {
- caption: "\\usepackage{#{pkg}#{closingBrace}"
- snippet: "\\usepackage{#{pkg}#{closingBrace}"
+ caption: "\\usepackage{#{pkg}}"
+ snippet: "\\usepackage{#{pkg}}"
meta: "pkg"
}
diff --git a/services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/top_hundred_snippets.coffee b/services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/top_hundred_snippets.coffee
index 5c3de75af5..56060448a5 100644
--- a/services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/top_hundred_snippets.coffee
+++ b/services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/top_hundred_snippets.coffee
@@ -73,6 +73,11 @@ define -> [{
"snippet": "\\documentclass{$1}",
"meta": "cmd",
"score": 1.4425339817971206
+}, {
+ "caption": "\\ref{}",
+ "snippet": "\\ref{$1}",
+ "meta": "cross-reference",
+ "score": 0.014379554883991673
}, {
"caption": "\\frac{}{}",
"snippet": "\\frac{$1}{$2}",
diff --git a/services/web/public/coffee/ide/file-tree/controllers/FileTreeController.coffee b/services/web/public/coffee/ide/file-tree/controllers/FileTreeController.coffee
index 010e00476f..8573b3742d 100644
--- a/services/web/public/coffee/ide/file-tree/controllers/FileTreeController.coffee
+++ b/services/web/public/coffee/ide/file-tree/controllers/FileTreeController.coffee
@@ -4,10 +4,12 @@ define [
App.controller "FileTreeController", ["$scope", "$modal", "ide", "$rootScope", ($scope, $modal, ide, $rootScope) ->
$scope.openNewDocModal = () ->
$modal.open(
- templateUrl: "newDocModalTemplate"
- controller: "NewDocModalController"
+ templateUrl: "newFileModalTemplate"
+ controller: "NewFileModalController"
+ size: 'lg'
resolve: {
parent_folder: () -> ide.fileTreeManager.getCurrentFolder()
+ type: () -> 'doc'
}
)
@@ -22,37 +24,12 @@ define [
$scope.openUploadFileModal = () ->
$modal.open(
- templateUrl: "uploadFileModalTemplate"
- controller: "UploadFileModalController"
- scope: $scope
- resolve: {
- parent_folder: () -> ide.fileTreeManager.getCurrentFolder()
- }
- )
-
- $scope.openLinkedFileModal = window.openLinkedFileModal = () ->
- unless 'url' in window.data.enabledLinkedFileTypes
- console.warn("Url linked files are not enabled")
- return
- $modal.open(
- templateUrl: "linkedFileModalTemplate"
- controller: "LinkedFileModalController"
- scope: $scope
- resolve: {
- parent_folder: () -> ide.fileTreeManager.getCurrentFolder()
- }
- )
-
- $scope.openProjectLinkedFileModal = window.openProjectLinkedFileModal = () ->
- unless 'project_file' in window.data.enabledLinkedFileTypes
- console.warn("Project linked files are not enabled")
- return
- $modal.open(
- templateUrl: "projectLinkedFileModalTemplate"
- controller: "ProjectLinkedFileModalController"
- scope: $scope
+ templateUrl: "newFileModalTemplate"
+ controller: "NewFileModalController"
+ size: 'lg'
resolve: {
parent_folder: () -> ide.fileTreeManager.getCurrentFolder()
+ type: () -> 'upload'
}
)
@@ -67,42 +44,10 @@ define [
$scope.$broadcast "delete:selected"
]
- App.controller "NewDocModalController", [
- "$scope", "ide", "$modalInstance", "$timeout", "parent_folder",
- ($scope, ide, $modalInstance, $timeout, parent_folder) ->
- $scope.inputs =
- name: "name.tex"
- $scope.state =
- inflight: false
-
- $modalInstance.opened.then () ->
- $timeout () ->
- $scope.$broadcast "open"
- , 200
-
- $scope.create = () ->
- name = $scope.inputs.name
- if !name? or name.length == 0
- return
- $scope.state.inflight = true
- ide.fileTreeManager
- .createDoc(name, parent_folder)
- .then () ->
- $scope.state.inflight = false
- $modalInstance.close()
- .catch (response)->
- { data } = response
- $scope.error = data
- $scope.state.inflight = false
-
- $scope.cancel = () ->
- $modalInstance.dismiss('cancel')
- ]
-
App.controller "NewFolderModalController", [
"$scope", "ide", "$modalInstance", "$timeout", "parent_folder",
($scope, ide, $modalInstance, $timeout, parent_folder) ->
- $scope.inputs =
+ $scope.inputs =
name: "name"
$scope.state =
inflight: false
@@ -118,10 +63,10 @@ define [
return
$scope.state.inflight = true
ide.fileTreeManager
- .createFolder(name, parent_folder)
+ .createFolder(name, $scope.parent_folder)
.then () ->
$scope.state.inflight = false
- $modalInstance.close()
+ $modalInstance.dismiss('done')
.catch (response)->
{ data } = response
$scope.error = data
@@ -131,10 +76,60 @@ define [
$modalInstance.dismiss('cancel')
]
+ App.controller "NewFileModalController", [
+ "$scope", "type", "parent_folder", "$modalInstance"
+ ($scope, type, parent_folder, $modalInstance) ->
+ $scope.type = type
+ $scope.parent_folder = parent_folder
+ $scope.state = {
+ inflight: false
+ valid: true
+ }
+ $scope.cancel = () ->
+ $modalInstance.dismiss('cancel')
+ $scope.create = () ->
+ $scope.$broadcast 'create'
+ $scope.$on 'done', () ->
+ $modalInstance.dismiss('done')
+ ]
+
+ App.controller "NewDocModalController", [
+ "$scope", "ide", "$timeout"
+ ($scope, ide, $timeout) ->
+ $scope.inputs =
+ name: "name.tex"
+
+ validate = () ->
+ name = $scope.inputs.name
+ $scope.state.valid = (name? and name.length > 0)
+ $scope.$watch 'inputs.name', validate
+
+ $timeout () ->
+ $scope.$broadcast "open"
+ , 200
+
+ $scope.$on 'create', () ->
+ name = $scope.inputs.name
+ if !name? or name.length == 0
+ return
+ $scope.state.inflight = true
+ ide.fileTreeManager
+ .createDoc(name, $scope.parent_folder)
+ .then () ->
+ $scope.state.inflight = false
+ $scope.$emit 'done'
+ .catch (response)->
+ { data } = response
+ $scope.error = data
+ $scope.state.inflight = false
+
+ ]
+
App.controller "UploadFileModalController", [
- "$scope", "$rootScope", "ide", "$modalInstance", "$timeout", "parent_folder", "$window"
- ($scope, $rootScope, ide, $modalInstance, $timeout, parent_folder, $window) ->
- $scope.parent_folder_id = parent_folder?.id
+ "$scope", "$rootScope", "ide", "$timeout", "$window"
+ ($scope, $rootScope, ide, $timeout, $window) ->
+ $scope.parent_folder_id = $scope.parent_folder?.id
+ $scope.project_id = ide.project_id
$scope.tooManyFiles = false
$scope.rateLimitHit = false
$scope.secondsToRedirect = 10
@@ -162,7 +157,7 @@ define [
if response.success
$rootScope.$broadcast 'file:upload:complete', response
if uploadCount == 0 and response? and response.success
- $modalInstance.close("done")
+ $scope.$emit 'done'
), 250
$scope.onValidateBatch = (files)->
@@ -210,30 +205,44 @@ define [
$scope.doUpload = () ->
$scope.control?.q?.uploadStoredFiles()
- $scope.cancel = () ->
- $modalInstance.dismiss('cancel')
]
App.controller "ProjectLinkedFileModalController", [
- "$scope", "ide", "$modalInstance", "$timeout", "parent_folder",
- ($scope, ide, $modalInstance, $timeout, parent_folder) ->
+ "$scope", "ide", "$timeout",
+ ($scope, ide, $timeout) ->
+
$scope.data =
projects: null # or []
selectedProjectId: null
projectEntities: null # or []
+ projectOutputFiles: null # or []
selectedProjectEntity: null
+ selectedProjectOutputFile: null
+ buildId: null
name: null
- $scope.state =
- inFlight:
- projects: false
- entities: false
- create: false
- error: false
+ $scope.state.inFlight =
+ projects: false
+ entities: false
+ compile: false
+ $scope.state.isOutputFilesMode = false
+ $scope.state.error = false
$scope.$watch 'data.selectedProjectId', (newVal, oldVal) ->
return if !newVal
$scope.data.selectedProjectEntity = null
- $scope.getProjectEntities($scope.data.selectedProjectId)
+ $scope.data.selectedProjectOutputFile = null
+ if $scope.state.isOutputFilesMode
+ $scope.compileProjectAndGetOutputFiles($scope.data.selectedProjectId)
+ else
+ $scope.getProjectEntities($scope.data.selectedProjectId)
+
+ $scope.$watch 'state.isOutputFilesMode', (newVal, oldVal) ->
+ return if !newVal and !oldVal
+ $scope.data.selectedProjectOutputFile = null
+ if newVal == true
+ $scope.compileProjectAndGetOutputFiles($scope.data.selectedProjectId)
+ else
+ $scope.getProjectEntities($scope.data.selectedProjectId)
# auto-set filename based on selected file
$scope.$watch 'data.selectedProjectEntity', (newVal, oldVal) ->
@@ -242,15 +251,31 @@ define [
if fileName
$scope.data.name = fileName
+ # auto-set filename based on selected file
+ $scope.$watch 'data.selectedProjectOutputFile', (newVal, oldVal) ->
+ return if !newVal
+ if newVal == 'output.pdf'
+ project = _.find($scope.data.projects, (p) -> p._id == $scope.data.selectedProjectId)
+ $scope.data.name = if project?.name? then "#{project.name}.pdf" else 'output.pdf'
+ else
+ fileName = newVal.split('/').reverse()[0]
+ if fileName
+ $scope.data.name = fileName
+
_setInFlight = (type) ->
$scope.state.inFlight[type] = true
_reset = (opts) ->
isError = opts.err == true
inFlight = $scope.state.inFlight
- inFlight.projects = inFlight.entities = inFlight.create = false
+ inFlight.projects = inFlight.entities = inFlight.compile = false
+ $scope.state.inflight = false
$scope.state.error = isError
+ $scope.toggleOutputFilesMode = () ->
+ return if !$scope.data.selectedProjectId
+ $scope.state.isOutputFilesMode = !$scope.state.isOutputFilesMode
+
$scope.shouldEnableProjectSelect = () ->
{ state, data } = $scope
return !state.inFlight.projects && data.projects
@@ -259,16 +284,33 @@ define [
{ state, data } = $scope
return !state.inFlight.projects && !state.inFlight.entities && data.projects && data.selectedProjectId
- $scope.shouldEnableCreateButton = () ->
+ $scope.shouldEnableProjectOutputFileSelect = () ->
+ { state, data } = $scope
+ return !state.inFlight.projects && !state.inFlight.compile && data.projects && data.selectedProjectId
+
+
+ validate = () ->
state = $scope.state
data = $scope.data
- return !state.inFlight.projects &&
+ $scope.state.valid = !state.inFlight.projects &&
!state.inFlight.entities &&
data.projects &&
data.selectedProjectId &&
- data.projectEntities &&
- data.selectedProjectEntity &&
+ (
+ (
+ !$scope.state.isOutputFilesMode &&
+ data.projectEntities &&
+ data.selectedProjectEntity
+ ) ||
+ (
+ $scope.state.isOutputFilesMode &&
+ data.projectOutputFiles &&
+ data.selectedProjectOutputFile
+ )
+ ) &&
data.name
+ $scope.$watch 'state', validate, true
+ $scope.$watch 'data', validate, true
$scope.getUserProjects = () ->
_setInFlight('projects')
@@ -295,50 +337,84 @@ define [
.catch (err) ->
_reset(err: true)
+ $scope.compileProjectAndGetOutputFiles = (project_id) =>
+ _setInFlight('compile')
+ ide.$http.post("/project/#{project_id}/compile", {
+ check: "silent",
+ draft: false,
+ incrementalCompilesEnabled: false
+ _csrf: window.csrfToken
+ })
+ .then (resp) ->
+ if resp.data.status == 'success'
+ filteredFiles = resp.data.outputFiles.filter (f) ->
+ f.path.match(/.*\.(pdf|png|jpeg|jpg|gif)/)
+ $scope.data.projectOutputFiles = filteredFiles
+ $scope.data.buildId = filteredFiles?[0]?.build
+ console.log ">> build_id", $scope.data.buildId
+ _reset(err: false)
+ else
+ $scope.data.projectOutputFiles = null
+ _reset(err: true)
+ .catch (err) ->
+ console.error(err)
+ _reset(err: true)
+
$scope.init = () ->
$scope.getUserProjects()
$timeout($scope.init, 0)
- $scope.create = () ->
+ $scope.$on 'create', () ->
projectId = $scope.data.selectedProjectId
- path = $scope.data.selectedProjectEntity
name = $scope.data.name
- if !name || !path || !projectId
- _reset(err: true)
- return
+ if $scope.state.isOutputFilesMode
+ provider = 'project_output_file'
+ payload = {
+ source_project_id: projectId,
+ source_output_file_path: $scope.data.selectedProjectOutputFile,
+ build_id: $scope.data.buildId
+ }
+ else
+ provider = 'project_file'
+ payload = {
+ source_project_id: projectId,
+ source_entity_path: $scope.data.selectedProjectEntity
+ }
_setInFlight('create')
ide.fileTreeManager
- .createLinkedFile(name, parent_folder, 'project_file', {
- source_project_id: projectId,
- source_entity_path: path
- })
+ .createLinkedFile(name, $scope.parent_folder, provider, payload)
.then () ->
_reset(err: false)
- $modalInstance.close()
+ $scope.$emit 'done'
.catch (response)->
{ data } = response
_reset(err: true)
- $scope.cancel = () ->
- $modalInstance.dismiss('cancel')
]
- # TODO: rename all this to UrlLinkedFilModalController
- App.controller "LinkedFileModalController", [
- "$scope", "ide", "$modalInstance", "$timeout", "parent_folder",
- ($scope, ide, $modalInstance, $timeout, parent_folder) ->
+ App.controller "UrlLinkedFileModalController", [
+ "$scope", "ide", "$timeout"
+ ($scope, ide, $timeout) ->
$scope.inputs =
name: ""
url: ""
$scope.nameChangedByUser = false
- $scope.state =
- inflight: false
- $modalInstance.opened.then () ->
- $timeout () ->
- $scope.$broadcast "open"
- , 200
+ $timeout () ->
+ $scope.$broadcast "open"
+ , 200
+
+ validate = () ->
+ {name, url} = $scope.inputs
+ if !name? or name.length == 0
+ $scope.state.valid = false
+ else if !url? or url.length == 0
+ $scope.state.valid = false
+ else
+ $scope.state.valid = true
+ $scope.$watch 'inputs.name', validate
+ $scope.$watch 'inputs.url', validate
$scope.$watch "inputs.url", (url) ->
if url? and url != "" and !$scope.nameChangedByUser
@@ -347,7 +423,7 @@ define [
if parts.length > 1 # Wait for at one /
$scope.inputs.name = parts[0]
- $scope.create = () ->
+ $scope.$on 'create', () ->
{name, url} = $scope.inputs
if !name? or name.length == 0
return
@@ -355,15 +431,13 @@ define [
return
$scope.state.inflight = true
ide.fileTreeManager
- .createLinkedFile(name, parent_folder, 'url', {url})
+ .createLinkedFile(name, $scope.parent_folder, 'url', {url})
.then () ->
$scope.state.inflight = false
- $modalInstance.close()
+ $scope.$emit 'done'
.catch (response)->
{ data } = response
$scope.error = data
$scope.state.inflight = false
- $scope.cancel = () ->
- $modalInstance.dismiss('cancel')
]
diff --git a/services/web/public/coffee/ide/test-controls/controllers/TestControlsController.coffee b/services/web/public/coffee/ide/test-controls/controllers/TestControlsController.coffee
index ae7db45905..881d03d3cb 100644
--- a/services/web/public/coffee/ide/test-controls/controllers/TestControlsController.coffee
+++ b/services/web/public/coffee/ide/test-controls/controllers/TestControlsController.coffee
@@ -4,12 +4,6 @@ define [
], (App) ->
App.controller "TestControlsController", ($scope) ->
- $scope.openProjectLinkedFileModal = () ->
- window.openProjectLinkedFileModal()
-
- $scope.openLinkedFileModal = () ->
- window.openLinkedFileModal()
-
$scope.richText = () ->
current = window.location.toString()
target = "#{current}#{if window.location.search then '&' else '?'}rt=true"
diff --git a/services/web/public/coffee/main/plans.coffee b/services/web/public/coffee/main/plans.coffee
index 2915425dc0..1637d79374 100644
--- a/services/web/public/coffee/main/plans.coffee
+++ b/services/web/public/coffee/main/plans.coffee
@@ -153,6 +153,7 @@ define [
if $scope.shouldABTestPlans
sixpack.participate 'plans-details', ['default', 'more-details'], (chosenVariation, rawResponse)->
$scope.plansVariant = chosenVariation
+ event_tracking.send 'subscription-funnel', 'plans-page-loaded', chosenVariation
$scope.showPlans = true
diff --git a/services/web/public/stylesheets/app/editor/file-tree.less b/services/web/public/stylesheets/app/editor/file-tree.less
index 4e26e4751d..845cc74a93 100644
--- a/services/web/public/stylesheets/app/editor/file-tree.less
+++ b/services/web/public/stylesheets/app/editor/file-tree.less
@@ -261,3 +261,43 @@
}
}
}
+
+.modal-new-file {
+ padding: 0;
+ table {
+ width: 100%;
+ td {
+ vertical-align: top;
+ }
+ }
+ .toggle-output-files-button {
+ font-size: 80%;
+ }
+}
+ .modal-new-file--list {
+ background-color: @modal-footer-background-color;
+ width: 220px;
+ ul {
+ li {
+ padding: (@line-height-computed / 4);
+ a {
+ color: @text-color;
+ }
+ }
+ li.active {
+ background-color: white;
+ a {
+ color: @link-color;
+ }
+ }
+ }
+ }
+
+ .modal-new-file--body {
+ padding: 20px;
+ padding-top: (@line-height-computed / 4);
+ }
+
+ .modal-new-file--body-upload {
+ padding-top: 20px;
+ }
diff --git a/services/web/public/stylesheets/app/project-list.less b/services/web/public/stylesheets/app/project-list.less
index 569035ad72..a1a0dd9cd6 100644
--- a/services/web/public/stylesheets/app/project-list.less
+++ b/services/web/public/stylesheets/app/project-list.less
@@ -58,6 +58,10 @@
background-color: @sidebar-bg;
padding-top: @content-margin-vertical;
padding-bottom: @content-margin-vertical;
+ color: @sidebar-color;
+ .small {
+ color: @sidebar-color;
+ }
}
.project-list-sidebar when (@is-overleaf) {
@@ -109,9 +113,6 @@
}
p {
margin-bottom: @line-height-computed / 4;
- &.small {
- color: @sidebar-color;
- }
}
}
diff --git a/services/web/public/stylesheets/components/alerts.less b/services/web/public/stylesheets/components/alerts.less
index 6ed93d29d4..ecdcba07a8 100755
--- a/services/web/public/stylesheets/components/alerts.less
+++ b/services/web/public/stylesheets/components/alerts.less
@@ -65,3 +65,10 @@
.alert-danger {
.alert-variant(@alert-danger-bg; @alert-danger-border; @alert-danger-text);
}
+
+.alert when (@is-overleaf = true) {
+ a {
+ color: white;
+ text-decoration: underline;
+ }
+}
\ No newline at end of file
diff --git a/services/web/public/stylesheets/components/fineupload.less b/services/web/public/stylesheets/components/fineupload.less
index ee1e96164f..bb037b4087 100644
--- a/services/web/public/stylesheets/components/fineupload.less
+++ b/services/web/public/stylesheets/components/fineupload.less
@@ -10,13 +10,17 @@
}
.qq-uploader-selector {
text-align: center;
- .drag-here {
- border: 1px dashed #666;
- vertical-align: middle;
- }
+ border: 1px dashed #666;
+ border-radius: 6px;
+ vertical-align: middle;
.help {
margin-top: 6px;
}
+ min-height: 200px;
+ padding: 20px;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
}
/*.qq-upload-button-selector {
display: block;
diff --git a/services/web/test/acceptance/coffee/ApiClsiTests.coffee b/services/web/test/acceptance/coffee/ApiClsiTests.coffee
new file mode 100644
index 0000000000..d19e11f933
--- /dev/null
+++ b/services/web/test/acceptance/coffee/ApiClsiTests.coffee
@@ -0,0 +1,88 @@
+expect = require("chai").expect
+request = require './helpers/request'
+Settings = require "settings-sharelatex"
+
+auth = new Buffer('sharelatex:password').toString("base64")
+authed_request = request.defaults
+ headers:
+ Authorization: "Basic #{auth}"
+
+
+describe 'ApiClsiTests', ->
+ describe 'compile', ->
+ before (done) ->
+ @compileSpec =
+ compile:
+ options:
+ compiler: 'pdflatex'
+ timeout: 60
+ rootResourcePath: 'main.tex'
+ resources: [
+ path: 'main/tex'
+ content: "\\documentclass{article}\n\\begin{document}\nHello World\n\\end{document}"
+ ,
+ path: 'image.png'
+ url: 'www.example.com/image.png'
+ modified: 123456789
+ ]
+ done()
+
+ describe 'valid request', ->
+ it 'returns success and a list of output files', (done) ->
+ authed_request.post {
+ uri: '/api/clsi/compile/abcd'
+ json: @compileSpec
+ }, (error, response, body) ->
+ throw error if error?
+ expect(response.statusCode).to.equal 200
+ expect(response.body).to.deep.equal {
+ status: 'success'
+ outputFiles: [
+ path: 'project.pdf'
+ url: '/project/abcd/build/1234/output/project.pdf'
+ type: 'pdf'
+ build: 1234
+ ,
+ path: 'project.log'
+ url: '/project/abcd/build/1234/output/project.log'
+ type: 'log'
+ build: 1234
+ ]
+ }
+ done()
+
+ describe 'unauthorized', ->
+ it 'returns 401', (done) ->
+ request.post {
+ uri: '/api/clsi/compile/abcd'
+ json: @compileSpec
+ }, (error, response, body) ->
+ throw error if error?
+ expect(response.statusCode).to.equal 401
+ expect(response.body).to.equal 'Unauthorized'
+ done()
+
+ describe 'get output', ->
+ describe 'valid file', ->
+ it 'returns the file', (done) ->
+ authed_request.get '/api/clsi/compile/abcd/build/1234/output/project.pdf', (error, response, body) ->
+ throw error if error?
+ expect(response.statusCode).to.equal 200
+ expect(response.body).to.equal 'mock-pdf'
+ done()
+
+ describe 'invalid file', ->
+ it 'returns 404', (done) ->
+ authed_request.get '/api/clsi/compile/abcd/build/1234/output/project.aux', (error, response, body) ->
+ throw error if error?
+ expect(response.statusCode).to.equal 404
+ expect(response.body).to.not.equal 'mock-pdf'
+ done()
+
+ describe 'unauthorized', ->
+ it 'returns 401', (done) ->
+ request.get '/api/clsi/compile/abcd/build/1234/output/project.pdf', (error, response, body) ->
+ throw error if error?
+ expect(response.statusCode).to.equal 401
+ expect(response.body).to.not.equal 'mock-pdf'
+ done()
diff --git a/services/web/test/acceptance/coffee/LinkedFilesTests.coffee b/services/web/test/acceptance/coffee/LinkedFilesTests.coffee
index 875e887d81..0c369097cd 100644
--- a/services/web/test/acceptance/coffee/LinkedFilesTests.coffee
+++ b/services/web/test/acceptance/coffee/LinkedFilesTests.coffee
@@ -8,6 +8,8 @@ MockFileStoreApi = require './helpers/MockFileStoreApi'
request = require "./helpers/request"
User = require "./helpers/User"
+MockClsiApi = require "./helpers/MockClsiApi"
+
express = require("express")
LinkedUrlProxy = express()
@@ -116,7 +118,6 @@ describe "LinkedFiles", ->
provider: 'project_file',
source_project_id: @project_two_id,
source_entity_path: "/#{@source_doc_name}",
- source_project_display_name: "plf-test-two"
}
expect(firstFile.name).to.equal('test-link.txt')
done()
@@ -149,7 +150,7 @@ describe "LinkedFiles", ->
source_entity_path: "/#{@source_doc_name}",
}, (error, response, body) =>
expect(response.statusCode).to.equal 403
- expect(body).to.equal 'Cannot create linked file'
+ expect(body).to.equal 'You do not have access to this project'
done()
describe "with a linked project_file from a v1 project that has not been imported", ->
@@ -344,3 +345,105 @@ describe "LinkedFiles", ->
# TODO: Add test for asking for host that return ENOTFOUND
# (This will probably end up handled by the proxy)
+
+ describe "creating a linked output file", ->
+ before (done) ->
+ async.series [
+ (cb) =>
+ @owner.createProject 'output-test-one', {template: 'blank'}, (error, project_id) =>
+ @project_one_id = project_id
+ cb(error)
+ (cb) =>
+ @owner.getProject @project_one_id, (error, project) =>
+ @project_one = project
+ @project_one_root_folder_id = project.rootFolder[0]._id.toString()
+ cb(error)
+ (cb) =>
+ @owner.createProject 'output-test-two', {template: 'blank'}, (error, project_id) =>
+ @project_two_id = project_id
+ cb(error)
+ (cb) =>
+ @owner.getProject @project_two_id, (error, project) =>
+ @project_two = project
+ @project_two_root_folder_id = project.rootFolder[0]._id.toString()
+ cb(error)
+ ], done
+
+ it 'should import the project.pdf file from the source project', (done) ->
+ @owner.request.post {
+ url: "/project/#{@project_one_id}/linked_file",
+ json:
+ name: 'test.pdf',
+ parent_folder_id: @project_one_root_folder_id,
+ provider: 'project_output_file',
+ data:
+ source_project_id: @project_two_id,
+ source_output_file_path: "project.pdf",
+ build_id: '1234-abcd'
+ }, (error, response, body) =>
+ new_file_id = body.new_file_id
+ @existing_file_id = new_file_id
+ expect(new_file_id).to.exist
+ @owner.getProject @project_one_id, (error, project) =>
+ return done(error) if error?
+ firstFile = project.rootFolder[0].fileRefs[0]
+ expect(firstFile._id.toString()).to.equal(new_file_id.toString())
+ expect(firstFile.linkedFileData).to.deep.equal {
+ provider: 'project_output_file',
+ source_project_id: @project_two_id,
+ source_output_file_path: "project.pdf",
+ build_id: '1234-abcd'
+ }
+ expect(firstFile.name).to.equal('test.pdf')
+ done()
+
+ it 'should refresh the file', (done) ->
+ @owner.request.post {
+ url: "/project/#{@project_one_id}/linked_file/#{@existing_file_id}/refresh",
+ json: true
+ }, (error, response, body) =>
+ new_file_id = body.new_file_id
+ expect(new_file_id).to.exist
+ expect(new_file_id).to.not.equal @existing_file_id
+ @refreshed_file_id = new_file_id
+ @owner.getProject @project_one_id, (error, project) =>
+ return done(error) if error?
+ firstFile = project.rootFolder[0].fileRefs[0]
+ expect(firstFile._id.toString()).to.equal(new_file_id.toString())
+ expect(firstFile.name).to.equal('test.pdf')
+ done()
+
+ describe "with a linked project_output_file from a v1 project that has not been imported", ->
+ before (done) ->
+ async.series [
+ (cb) =>
+ @owner.createProject 'output-v1-test-one', {template: 'blank'}, (error, project_id) =>
+ @project_one_id = project_id
+ cb(error)
+ (cb) =>
+ @owner.getProject @project_one_id, (error, project) =>
+ @project_one = project
+ @project_one_root_folder_id = project.rootFolder[0]._id.toString()
+ @project_one.rootFolder[0].fileRefs.push {
+ linkedFileData: {
+ provider: "project_output_file",
+ v1_source_doc_id: 9999999, # We won't find this id in the database
+ source_output_file_path: "project.pdf",
+ build_id: '123'
+ },
+ _id: "abcdef",
+ rev: 0,
+ created: new Date(),
+ name: "whatever.pdf"
+ }
+ @owner.saveProject @project_one, cb
+ ], done
+
+ it 'should refuse to refresh', (done) ->
+ @owner.request.post {
+ url: "/project/#{@project_one_id}/linked_file/abcdef/refresh",
+ json: true
+ }, (error, response, body) =>
+ expect(response.statusCode).to.equal 409
+ expect(body).to.equal "Sorry, the source project is not yet imported to Overleaf v2. Please import it to Overleaf v2 to refresh this file"
+ done()
diff --git a/services/web/test/acceptance/coffee/UserEmailsTests.coffee b/services/web/test/acceptance/coffee/UserEmailsTests.coffee
new file mode 100644
index 0000000000..137932e878
--- /dev/null
+++ b/services/web/test/acceptance/coffee/UserEmailsTests.coffee
@@ -0,0 +1,200 @@
+expect = require("chai").expect
+async = require("async")
+User = require "./helpers/User"
+request = require "./helpers/request"
+settings = require "settings-sharelatex"
+{db, ObjectId} = require("../../../app/js/infrastructure/mongojs")
+MockV1Api = require "./helpers/MockV1Api"
+
+describe "UserEmails", ->
+ beforeEach (done) ->
+ @timeout(20000)
+ @user = new User()
+ @user.login done
+
+ describe 'confirming an email', ->
+ it 'should confirm the email', (done) ->
+ token = null
+ async.series [
+ (cb) =>
+ @user.request {
+ method: 'POST',
+ url: '/user/emails',
+ json:
+ email: 'newly-added-email@example.com'
+ }, (error, response, body) =>
+ return done(error) if error?
+ expect(response.statusCode).to.equal 204
+ cb()
+ (cb) =>
+ @user.request { url: '/user/emails', json: true }, (error, response, body) ->
+ expect(response.statusCode).to.equal 200
+ expect(body[0].confirmedAt).to.not.exist
+ expect(body[1].confirmedAt).to.not.exist
+ cb()
+ (cb) =>
+ db.tokens.find {
+ use: 'email_confirmation',
+ 'data.user_id': @user._id,
+ usedAt: { $exists: false }
+ }, (error, tokens) =>
+ # There should only be one confirmation token at the moment
+ expect(tokens.length).to.equal 1
+ expect(tokens[0].data.email).to.equal 'newly-added-email@example.com'
+ expect(tokens[0].data.user_id).to.equal @user._id
+ token = tokens[0].token
+ cb()
+ (cb) =>
+ @user.request {
+ method: 'POST',
+ url: '/user/emails/confirm',
+ json:
+ token: token
+ }, (error, response, body) =>
+ return done(error) if error?
+ expect(response.statusCode).to.equal 200
+ cb()
+ (cb) =>
+ @user.request { url: '/user/emails', json: true }, (error, response, body) ->
+ expect(response.statusCode).to.equal 200
+ expect(body[0].confirmedAt).to.not.exist
+ expect(body[1].confirmedAt).to.exist
+ cb()
+ (cb) =>
+ db.tokens.find {
+ use: 'email_confirmation',
+ 'data.user_id': @user._id,
+ usedAt: { $exists: false }
+ }, (error, tokens) =>
+ # Token should be deleted after use
+ expect(tokens.length).to.equal 0
+ cb()
+ ], done
+
+ it 'should not allow confirmation of the email if the user has changed', (done) ->
+ token1 = null
+ token2 = null
+ @user2 = new User()
+ @email = 'duplicate-email@example.com'
+ async.series [
+ (cb) => @user2.login cb
+ (cb) =>
+ # Create email for first user
+ @user.request {
+ method: 'POST',
+ url: '/user/emails',
+ json: {@email}
+ }, cb
+ (cb) =>
+ db.tokens.find {
+ use: 'email_confirmation',
+ 'data.user_id': @user._id,
+ usedAt: { $exists: false }
+ }, (error, tokens) =>
+ # There should only be one confirmation token at the moment
+ expect(tokens.length).to.equal 1
+ expect(tokens[0].data.email).to.equal @email
+ expect(tokens[0].data.user_id).to.equal @user._id
+ token1 = tokens[0].token
+ cb()
+ (cb) =>
+ # Delete the email from the first user
+ @user.request {
+ method: 'DELETE',
+ url: '/user/emails',
+ json: {@email}
+ }, cb
+ (cb) =>
+ # Create email for second user
+ @user2.request {
+ method: 'POST',
+ url: '/user/emails',
+ json: {@email}
+ }, cb
+ (cb) =>
+ # Original confirmation token should no longer work
+ @user.request {
+ method: 'POST',
+ url: '/user/emails/confirm',
+ json:
+ token: token1
+ }, (error, response, body) =>
+ return done(error) if error?
+ expect(response.statusCode).to.equal 404
+ cb()
+ (cb) =>
+ db.tokens.find {
+ use: 'email_confirmation',
+ 'data.user_id': @user2._id,
+ usedAt: { $exists: false }
+ }, (error, tokens) =>
+ # The first token has been used, so this should be token2 now
+ expect(tokens.length).to.equal 1
+ expect(tokens[0].data.email).to.equal @email
+ expect(tokens[0].data.user_id).to.equal @user2._id
+ token2 = tokens[0].token
+ cb()
+ (cb) =>
+ # Second user should be able to confirm the email
+ @user2.request {
+ method: 'POST',
+ url: '/user/emails/confirm',
+ json:
+ token: token2
+ }, (error, response, body) =>
+ return done(error) if error?
+ expect(response.statusCode).to.equal 200
+ cb()
+ (cb) =>
+ @user2.request { url: '/user/emails', json: true }, (error, response, body) ->
+ expect(response.statusCode).to.equal 200
+ expect(body[0].confirmedAt).to.not.exist
+ expect(body[1].confirmedAt).to.exist
+ cb()
+ ], done
+
+ describe "with an expired token", ->
+ it 'should not confirm the email', (done) ->
+ token = null
+ async.series [
+ (cb) =>
+ @user.request {
+ method: 'POST',
+ url: '/user/emails',
+ json:
+ email: @email = 'expired-token-email@example.com'
+ }, (error, response, body) =>
+ return done(error) if error?
+ expect(response.statusCode).to.equal 204
+ cb()
+ (cb) =>
+ db.tokens.find {
+ use: 'email_confirmation',
+ 'data.user_id': @user._id,
+ usedAt: { $exists: false }
+ }, (error, tokens) =>
+ # There should only be one confirmation token at the moment
+ expect(tokens.length).to.equal 1
+ expect(tokens[0].data.email).to.equal @email
+ expect(tokens[0].data.user_id).to.equal @user._id
+ token = tokens[0].token
+ cb()
+ (cb) =>
+ db.tokens.update {
+ token: token
+ }, {
+ $set: {
+ expiresAt: new Date(Date.now() - 1000000)
+ }
+ }, cb
+ (cb) =>
+ @user.request {
+ method: 'POST',
+ url: '/user/emails/confirm',
+ json:
+ token: token
+ }, (error, response, body) =>
+ return done(error) if error?
+ expect(response.statusCode).to.equal 404
+ cb()
+ ], done
diff --git a/services/web/test/acceptance/coffee/helpers/MockClsiApi.coffee b/services/web/test/acceptance/coffee/helpers/MockClsiApi.coffee
new file mode 100644
index 0000000000..0711ac613f
--- /dev/null
+++ b/services/web/test/acceptance/coffee/helpers/MockClsiApi.coffee
@@ -0,0 +1,50 @@
+express = require("express")
+bodyParser = require "body-parser"
+app = express()
+
+module.exports = MockClsiApi =
+ run: () ->
+
+ compile = (req, res, next) =>
+ res.status(200).send {
+ compile:
+ status: 'success'
+ error: null
+ outputFiles: [
+ url: "/project/#{req.params.project_id}/build/1234/output/project.pdf"
+ path: 'project.pdf'
+ type: 'pdf'
+ build: 1234
+ ,
+ url: "/project/#{req.params.project_id}/build/1234/output/project.log"
+ path: 'project.log'
+ type: 'log'
+ build: 1234
+ ]
+ }
+
+ app.post "/project/:project_id/compile", compile
+ app.post "/project/:project_id/user/:user_id/compile", compile
+
+ app.get "/project/:project_id/build/:build_id/output/*", (req, res, next) ->
+ filename = req.params[0]
+ if filename == 'project.pdf'
+ res.status(200).send 'mock-pdf'
+ else if filename == 'project.log'
+ res.status(200).send 'mock-log'
+ else
+ res.sendStatus(404)
+
+ app.get "/project/:project_id/user/:user_id/build/:build_id/output/:output_path", (req, res, next) =>
+ res.status(200).send("hello")
+
+ app.get "/project/:project_id/status", (req, res, next) =>
+ res.status(200).send()
+
+ app.listen 3013, (error) ->
+ throw error if error?
+ .on "error", (error) ->
+ console.error "error starting MockClsiApi:", error.message
+ process.exit(1)
+
+MockClsiApi.run()
diff --git a/services/web/test/acceptance/coffee/helpers/MockV1Api.coffee b/services/web/test/acceptance/coffee/helpers/MockV1Api.coffee
index 389ecd1762..91e3f0b25c 100644
--- a/services/web/test/acceptance/coffee/helpers/MockV1Api.coffee
+++ b/services/web/test/acceptance/coffee/helpers/MockV1Api.coffee
@@ -42,6 +42,14 @@ module.exports = MockV1Api =
@exportParams = Object.assign({}, req.body)
res.json exportId: @exportId
+ app.get "/api/v2/users/:userId/affiliations", (req, res, next) =>
+ res.json []
+
+ app.post "/api/v2/users/:userId/affiliations", (req, res, next) =>
+ res.sendStatus 201
+
+ app.delete "/api/v2/users/:userId/affiliations/:email", (req, res, next) =>
+ res.sendStatus 204
app.listen 5000, (error) ->
throw error if error?
diff --git a/services/web/test/unit/coffee/Compile/ClsiManagerTests.coffee b/services/web/test/unit/coffee/Compile/ClsiManagerTests.coffee
index bd02af2f28..c8c39d9952 100644
--- a/services/web/test/unit/coffee/Compile/ClsiManagerTests.coffee
+++ b/services/web/test/unit/coffee/Compile/ClsiManagerTests.coffee
@@ -8,7 +8,7 @@ SandboxedModule = require('sandboxed-module')
describe "ClsiManager", ->
beforeEach ->
@jar = {cookie:"stuff"}
- @ClsiCookieManager =
+ @ClsiCookieManager =
getCookieJar: sinon.stub().callsArgWith(1, null, @jar)
setServerId: sinon.stub().callsArgWith(2)
_getServerId:sinon.stub()
@@ -99,8 +99,8 @@ describe "ClsiManager", ->
status: @status = "failure"
})
@ClsiManager.sendRequest @project_id, @user_id, {}, @callback
-
- it "should call the callback with a failure statue", ->
+
+ it "should call the callback with a failure status", ->
@callback.calledWith(null, @status).should.equal true
describe "with a sync conflict", ->
@@ -137,11 +137,82 @@ describe "ClsiManager", ->
it "should call the callback with an error", ->
@callback.calledWithExactly(new Error("failed")).should.equal true
+ describe "sendExternalRequest", ->
+ beforeEach ->
+ @submission_id = "submission-id"
+ @clsi_request = "mock-request"
+ @ClsiCookieManager._getServerId.callsArgWith(1, null, "clsi3")
+
+ describe "with a successful compile", ->
+ beforeEach ->
+ @ClsiManager._postToClsi = sinon.stub().callsArgWith(4, null, {
+ compile:
+ status: @status = "success"
+ outputFiles: [{
+ url: "#{@settings.apis.clsi.url}/project/#{@submission_id}/build/1234/output/output.pdf"
+ path: "output.pdf"
+ type: "pdf"
+ build: 1234
+ },{
+ url: "#{@settings.apis.clsi.url}/project/#{@submission_id}/build/1234/output/output.log"
+ path: "output.log"
+ type: "log"
+ build: 1234
+ }]
+ })
+ @ClsiManager.sendExternalRequest @submission_id, @clsi_request, {compileGroup:"standard"}, @callback
+
+ it "should send the request to the CLSI", ->
+ @ClsiManager._postToClsi
+ .calledWith(@submission_id, null, @clsi_request, "standard")
+ .should.equal true
+
+ it "should call the callback with the status and output files", ->
+ outputFiles = [{
+ url: "/project/#{@submission_id}/build/1234/output/output.pdf"
+ path: "output.pdf"
+ type: "pdf"
+ build: 1234
+ },{
+ url: "/project/#{@submission_id}/build/1234/output/output.log"
+ path: "output.log"
+ type: "log"
+ build: 1234
+ }]
+ @callback.calledWith(null, @status, outputFiles).should.equal true
+
+ describe "with a failed compile", ->
+ beforeEach ->
+ @ClsiManager._postToClsi = sinon.stub().callsArgWith(4, null, {
+ compile:
+ status: @status = "failure"
+ })
+ @ClsiManager.sendExternalRequest @submission_id, @clsi_request, {}, @callback
+
+ it "should call the callback with a failure status", ->
+ @callback.calledWith(null, @status).should.equal true
+
+ describe "when the resources fail the precompile check", ->
+ beforeEach ->
+ @ClsiFormatChecker.checkRecoursesForProblems = sinon.stub().callsArgWith(1, new Error("failed"))
+ @ClsiManager._postToClsi = sinon.stub().callsArgWith(4, null, {
+ compile:
+ status: @status = "failure"
+ })
+ @ClsiManager.sendExternalRequest @submission_id, @clsi_request, {}, @callback
+
+ it "should call the callback only once", ->
+ @callback.calledOnce.should.equal true
+
+ it "should call the callback with an error", ->
+ @callback.calledWithExactly(new Error("failed")).should.equal true
+
+
describe "deleteAuxFiles", ->
beforeEach ->
@ClsiManager._makeRequest = sinon.stub().callsArg(2)
@DocumentUpdaterHandler.clearProjectState = sinon.stub().callsArg(1)
-
+
describe "with the standard compileGroup", ->
beforeEach ->
@ClsiManager.deleteAuxFiles @project_id, @user_id, {compileGroup: "standard"}, @callback
@@ -158,7 +229,7 @@ describe "ClsiManager", ->
it "should call the callback", ->
@callback.called.should.equal true
-
+
describe "_buildRequest", ->
beforeEach ->
@@ -441,7 +512,7 @@ describe "ClsiManager", ->
it "should call the callback", ->
@callback.called.should.equal true
-
+
describe "with param file", ->
beforeEach ->
@ClsiManager.wordCount @project_id, @user_id, "main.tex", {}, @callback
@@ -450,7 +521,7 @@ describe "ClsiManager", ->
@ClsiManager._makeRequest
.calledWith(@project_id, { method: "GET", url: "http://clsi.example.com/project/#{@project_id}/user/#{@user_id}/wordcount", qs:{file:"main.tex",image:undefined}})
.should.equal true
-
+
describe "with image", ->
beforeEach ->
@req.compile.options.imageName = @image = "example.com/mock/image"
@@ -468,7 +539,7 @@ describe "ClsiManager", ->
beforeEach ->
@response = {there:"something"}
@request.callsArgWith(1, null, @response)
- @opts =
+ @opts =
method: "SOMETHIGN"
url: "http://a place on the web"
diff --git a/services/web/test/unit/coffee/Compile/CompileControllerTests.coffee b/services/web/test/unit/coffee/Compile/CompileControllerTests.coffee
index 8a09ac866e..ced3260412 100644
--- a/services/web/test/unit/coffee/Compile/CompileControllerTests.coffee
+++ b/services/web/test/unit/coffee/Compile/CompileControllerTests.coffee
@@ -29,6 +29,9 @@ describe "CompileController", ->
url: "clsi.example.com"
clsi_priority:
url: "clsi-priority.example.com"
+ defaultFeatures:
+ compileGroup: 'standard'
+ compileTimeout: 60
@jar = {cookie:"stuff"}
@ClsiCookieManager =
getCookieJar:sinon.stub().callsArgWith(1, null, @jar)
@@ -109,6 +112,62 @@ describe "CompileController", ->
.calledWith(@project_id, @user_id, { isAutoCompile: false, draft: true })
.should.equal true
+ describe "compileSubmission", ->
+ beforeEach ->
+ @submission_id = 'sub-1234'
+ @req.params =
+ submission_id: @submission_id
+ @req.body = {}
+ @ClsiManager.sendExternalRequest = sinon.stub()
+ .callsArgWith(3, null, @status = "success", @outputFiles = ["mock-output-files"], \
+ @clsiServerId = "mock-server-id", @validationProblems = null)
+
+ it "should set the content-type of the response to application/json", ->
+ @CompileController.compileSubmission @req, @res, @next
+ @res.contentType
+ .calledWith("application/json")
+ .should.equal true
+
+ it "should send a successful response reporting the status and files", ->
+ @CompileController.compileSubmission @req, @res, @next
+ @res.statusCode.should.equal 200
+ @res.body.should.equal JSON.stringify({
+ status: @status
+ outputFiles: @outputFiles
+ clsiServerId: 'mock-server-id'
+ validationProblems: null
+ })
+
+ describe "with compileGroup and timeout", ->
+ beforeEach ->
+ @req.body =
+ compileGroup: 'special'
+ timeout: 600
+ @CompileController.compileSubmission @req, @res, @next
+
+ it "should use the supplied values", ->
+ @ClsiManager.sendExternalRequest
+ .calledWith(@submission_id, { compileGroup: 'special', timeout: 600 }, \
+ { compileGroup: 'special', timeout: 600 })
+ .should.equal true
+
+ describe "with other supported options but not compileGroup and timeout", ->
+ beforeEach ->
+ @req.body =
+ rootResourcePath: 'main.tex'
+ compiler: 'lualatex'
+ draft: true
+ check: 'validate'
+ @CompileController.compileSubmission @req, @res, @next
+
+ it "should use the other options but default values for compileGroup and timeout", ->
+ @ClsiManager.sendExternalRequest
+ .calledWith(@submission_id, \
+ {rootResourcePath: 'main.tex', compiler: 'lualatex', draft: true, check: 'validate'}, \
+ {rootResourcePath: 'main.tex', compiler: 'lualatex', draft: true, check: 'validate', \
+ compileGroup: 'standard', timeout: 60})
+ .should.equal true
+
describe "downloadPdf", ->
beforeEach ->
@req.params =
@@ -167,6 +226,38 @@ describe "CompileController", ->
done()
@CompileController.downloadPdf @req, @res
+ describe "getFileFromClsiWithoutUser", ->
+ beforeEach ->
+ @submission_id = 'sub-1234'
+ @build_id = 123456
+ @file = 'project.pdf'
+ @req.params =
+ submission_id: @submission_id
+ build_id: @build_id
+ file: @file
+ @req.body = {}
+ @expected_url = "/project/#{@submission_id}/build/#{@build_id}/output/#{@file}"
+ @CompileController.proxyToClsiWithLimits = sinon.stub()
+
+ describe "without limits specified", ->
+ beforeEach ->
+ @CompileController.getFileFromClsiWithoutUser @req, @res, @next
+
+ it "should proxy to CLSI with correct URL and default limits", ->
+ @CompileController.proxyToClsiWithLimits
+ .calledWith(@submission_id, @expected_url, {compileGroup: 'standard'})
+ .should.equal true
+
+ describe "with limits specified", ->
+ beforeEach ->
+ @req.body = {compileTimeout: 600, compileGroup: 'special'}
+ @CompileController.getFileFromClsiWithoutUser @req, @res, @next
+
+ it "should proxy to CLSI with correct URL and specified limits", ->
+ @CompileController.proxyToClsiWithLimits
+ .calledWith(@submission_id, @expected_url, {compileGroup: 'special'})
+ .should.equal true
+
describe "proxyToClsi", ->
beforeEach ->
@request.returns(@proxy = {
diff --git a/services/web/test/unit/coffee/PasswordReset/PasswordResetHandlerTests.coffee b/services/web/test/unit/coffee/PasswordReset/PasswordResetHandlerTests.coffee
index 261f5582dd..7ee82fbd7e 100644
--- a/services/web/test/unit/coffee/PasswordReset/PasswordResetHandlerTests.coffee
+++ b/services/web/test/unit/coffee/PasswordReset/PasswordResetHandlerTests.coffee
@@ -41,7 +41,7 @@ describe "PasswordResetHandler", ->
it "should check the user exists", (done)->
@UserGetter.getUserByMainEmail.callsArgWith(1)
- @OneTimeTokenHandler.getNewToken.callsArgWith(1)
+ @OneTimeTokenHandler.getNewToken.yields()
@PasswordResetHandler.generateAndEmailResetToken @user.email, (err, exists)=>
exists.should.equal false
done()
@@ -50,7 +50,7 @@ describe "PasswordResetHandler", ->
it "should send the email with the token", (done)->
@UserGetter.getUserByMainEmail.callsArgWith(1, null, @user)
- @OneTimeTokenHandler.getNewToken.callsArgWith(1, null, @token)
+ @OneTimeTokenHandler.getNewToken.yields(null, @token)
@EmailHandler.sendEmail.callsArgWith(2)
@PasswordResetHandler.generateAndEmailResetToken @user.email, (err, exists)=>
@EmailHandler.sendEmail.called.should.equal true
@@ -63,7 +63,7 @@ describe "PasswordResetHandler", ->
it "should return exists = false for a holdingAccount", (done) ->
@user.holdingAccount = true
@UserGetter.getUserByMainEmail.callsArgWith(1, null, @user)
- @OneTimeTokenHandler.getNewToken.callsArgWith(1)
+ @OneTimeTokenHandler.getNewToken.yields()
@PasswordResetHandler.generateAndEmailResetToken @user.email, (err, exists)=>
exists.should.equal false
done()
@@ -71,14 +71,14 @@ describe "PasswordResetHandler", ->
describe "setNewUserPassword", ->
it "should return false if no user id can be found", (done)->
- @OneTimeTokenHandler.getValueFromTokenAndExpire.callsArgWith(1)
+ @OneTimeTokenHandler.getValueFromTokenAndExpire.yields()
@PasswordResetHandler.setNewUserPassword @token, @password, (err, found) =>
found.should.equal false
@AuthenticationManager.setUserPassword.called.should.equal false
done()
it "should set the user password", (done)->
- @OneTimeTokenHandler.getValueFromTokenAndExpire.callsArgWith(1, null, @user_id)
+ @OneTimeTokenHandler.getValueFromTokenAndExpire.yields(null, @user_id)
@AuthenticationManager.setUserPassword.callsArgWith(2)
@PasswordResetHandler.setNewUserPassword @token, @password, (err, found, user_id) =>
found.should.equal true
diff --git a/services/web/test/unit/coffee/Project/ProjectControllerTests.coffee b/services/web/test/unit/coffee/Project/ProjectControllerTests.coffee
index 1b66ba9bf9..bfd67e42d2 100644
--- a/services/web/test/unit/coffee/Project/ProjectControllerTests.coffee
+++ b/services/web/test/unit/coffee/Project/ProjectControllerTests.coffee
@@ -5,6 +5,7 @@ path = require('path')
sinon = require('sinon')
modulePath = path.join __dirname, "../../../../app/js/Features/Project/ProjectController"
expect = require("chai").expect
+Errors = require "../../../../app/js/Features/Errors/Errors"
describe "ProjectController", ->
@@ -100,6 +101,7 @@ describe "ProjectController", ->
"../Collaborators/CollaboratorsHandler": @CollaboratorsHandler
"../../infrastructure/Modules": @Modules
"./ProjectEntityHandler": @ProjectEntityHandler
+ "../Errors/Errors": Errors
@projectName = "£12321jkj9ujkljds"
@req =
@@ -303,6 +305,20 @@ describe "ProjectController", ->
done()
@ProjectController.projectListPage @req, @res
+ it 'should send hasSubscription == false when no subscription', (done) ->
+ @res.render = (pageName, opts)=>
+ opts.hasSubscription.should.equal false
+ done()
+ @ProjectController.projectListPage @req, @res
+
+ it 'should send hasSubscription == true when there is a subscription', (done) ->
+ @LimitationsManager.userHasSubscriptionOrIsGroupMember = sinon.stub().callsArgWith(1, null, true)
+ @res.render = (pageName, opts)=>
+ opts.hasSubscription.should.equal true
+ done()
+ @ProjectController.projectListPage @req, @res
+
+
describe 'front widget', (done) ->
beforeEach ->
@settings.overleaf =
diff --git a/services/web/test/unit/coffee/Project/ProjectEntityUpdateHandlerTests.coffee b/services/web/test/unit/coffee/Project/ProjectEntityUpdateHandlerTests.coffee
index 3a5b7a58bd..3de4546e6d 100644
--- a/services/web/test/unit/coffee/Project/ProjectEntityUpdateHandlerTests.coffee
+++ b/services/web/test/unit/coffee/Project/ProjectEntityUpdateHandlerTests.coffee
@@ -44,6 +44,8 @@ describe 'ProjectEntityUpdateHandler', ->
else
@._id = file_id
@rev = 0
+ if options.linkedFileData?
+ @linkedFileData = options.linkedFileData
@docName = "doc-name"
@docLines = ['1234','abc']
@@ -121,6 +123,35 @@ describe 'ProjectEntityUpdateHandler', ->
.calledWithMatch(project_id, projectHistoryId, userId, changesMatcher)
.should.equal true
+ describe 'copyFileFromExistingProjectWithProject, with linkedFileData', ->
+
+ beforeEach ->
+ @oldProject_id = "123kljadas"
+ @oldFileRef = {
+ _id:"oldFileRef",
+ name:@fileName,
+ linkedFileData: @linkedFileData
+ }
+ @ProjectEntityMongoUpdateHandler._confirmFolder = sinon.stub().yields(folder_id)
+ @ProjectEntityMongoUpdateHandler._putElement = sinon.stub().yields(null, {path:{fileSystem: @fileSystemPath}})
+
+ @ProjectEntityUpdateHandler.copyFileFromExistingProjectWithProject @project, folder_id, @oldProject_id, @oldFileRef, userId, @callback
+
+ it 'should copy the file in FileStoreHandler', ->
+ @FileStoreHandler.copyFile
+ .calledWith(@oldProject_id, @oldFileRef._id, project_id, file_id)
+ .should.equal true
+
+ it 'should put file into folder by calling put element, with the linkedFileData', ->
+ @ProjectEntityMongoUpdateHandler._putElement
+ .calledWithMatch(
+ @project,
+ folder_id,
+ { _id: file_id, name: @fileName, linkedFileData: @linkedFileData},
+ "file"
+ )
+ .should.equal true
+
describe 'updateDocLines', ->
beforeEach ->
@path = "/somewhere/something.tex"
@@ -285,7 +316,7 @@ describe 'ProjectEntityUpdateHandler', ->
beforeEach ->
@path = "/path/to/file"
- @newFile = {_id: file_id, rev: 0, name: @fileName}
+ @newFile = {_id: file_id, rev: 0, name: @fileName, linkedFileData: @linkedFileData}
@TpdsUpdateSender.addFile = sinon.stub().yields()
@ProjectEntityMongoUpdateHandler.addFile = sinon.stub().yields(null, {path: fileSystem: @path}, @project)
@ProjectEntityUpdateHandler.addFile project_id, folder_id, @fileName, @fileSystemPath, @linkedFileData, userId, @callback
@@ -330,7 +361,7 @@ describe 'ProjectEntityUpdateHandler', ->
@newFileUrl = "new-file-url"
@FileStoreHandler.uploadFileFromDisk = sinon.stub().yields(null, @newFileUrl)
- @newFile = _id: new_file_id, name: "dummy-upload-filename", rev: 0
+ @newFile = _id: new_file_id, name: "dummy-upload-filename", rev: 0, linkedFileData: @linkedFileData
@oldFile = _id: file_id
@path = "/path/to/file"
@ProjectEntityMongoUpdateHandler._insertDeletedFileReference = sinon.stub().yields()
diff --git a/services/web/test/unit/coffee/Security/OneTimeTokenHandlerTests.coffee b/services/web/test/unit/coffee/Security/OneTimeTokenHandlerTests.coffee
index 1940f34d07..c0e6d9cc13 100644
--- a/services/web/test/unit/coffee/Security/OneTimeTokenHandlerTests.coffee
+++ b/services/web/test/unit/coffee/Security/OneTimeTokenHandlerTests.coffee
@@ -5,64 +5,99 @@ path = require('path')
sinon = require('sinon')
modulePath = path.join __dirname, "../../../../app/js/Features/Security/OneTimeTokenHandler"
expect = require("chai").expect
+Errors = require "../../../../app/js/Features/Errors/Errors"
+tk = require("timekeeper")
describe "OneTimeTokenHandler", ->
-
beforeEach ->
- @value = "user id here"
- @stubbedToken = require("crypto").randomBytes(32)
-
- @settings =
- redis:
- web:{}
- @redisMulti =
- set:sinon.stub()
- get:sinon.stub()
- del:sinon.stub()
- expire:sinon.stub()
- exec:sinon.stub()
- self = @
+ tk.freeze Date.now() # freeze the time for these tests
+ @stubbedToken = "mock-token"
+ @callback = sinon.stub()
@OneTimeTokenHandler = SandboxedModule.require modulePath, requires:
- "../../infrastructure/RedisWrapper" :
- client: =>
- auth:->
- multi: -> return self.redisMulti
-
"settings-sharelatex":@settings
"logger-sharelatex": log:->
"crypto": randomBytes: () => @stubbedToken
+ "../Errors/Errors": Errors
+ "../../infrastructure/mongojs": db: @db = tokens: {}
+ afterEach ->
+ tk.reset()
describe "getNewToken", ->
+ beforeEach ->
+ @db.tokens.insert = sinon.stub().yields()
- it "should set a new token into redis with a ttl", (done)->
- @redisMulti.exec.callsArgWith(0)
- @OneTimeTokenHandler.getNewToken @value, (err, token) =>
- @redisMulti.set.calledWith("password_token:#{@stubbedToken.toString("hex")}", @value).should.equal true
- @redisMulti.expire.calledWith("password_token:#{@stubbedToken.toString("hex")}", 60 * 60).should.equal true
- done()
+ describe 'normally', ->
+ beforeEach ->
+ @OneTimeTokenHandler.getNewToken 'password', 'mock-data-to-store', @callback
- it "should return if there was an error", (done)->
- @redisMulti.exec.callsArgWith(0, "error")
- @OneTimeTokenHandler.getNewToken @value, (err, token)=>
- err.should.exist
- done()
+ it "should insert a generated token with a 1 hour expiry", ->
+ @db.tokens.insert
+ .calledWith({
+ use: 'password'
+ token: @stubbedToken,
+ createdAt: new Date(),
+ expiresAt: new Date(Date.now() + 60 * 60 * 1000)
+ data: 'mock-data-to-store'
+ })
+ .should.equal true
- it "should allow the expiry time to be overridden", (done) ->
- @redisMulti.exec.callsArgWith(0)
- @ttl = 42
- @OneTimeTokenHandler.getNewToken @value, {expiresIn: @ttl}, (err, token) =>
- @redisMulti.expire.calledWith("password_token:#{@stubbedToken.toString("hex")}", @ttl).should.equal true
- done()
+ it 'should call the callback with the token', ->
+ @callback.calledWith(null, @stubbedToken).should.equal true
+
+ describe 'with an optional expiresIn parameter', ->
+ beforeEach ->
+ @OneTimeTokenHandler.getNewToken 'password', 'mock-data-to-store', { expiresIn: 42 }, @callback
+
+ it "should insert a generated token with a custom expiry", ->
+ @db.tokens.insert
+ .calledWith({
+ use: 'password'
+ token: @stubbedToken,
+ createdAt: new Date(),
+ expiresAt: new Date(Date.now() + 42 * 1000)
+ data: 'mock-data-to-store'
+ })
+ .should.equal true
+
+ it 'should call the callback with the token', ->
+ @callback.calledWith(null, @stubbedToken).should.equal true
describe "getValueFromTokenAndExpire", ->
+ describe 'successfully', ->
+ beforeEach ->
+ @db.tokens.findAndModify = sinon.stub().yields(null, { data: 'mock-data' })
+ @OneTimeTokenHandler.getValueFromTokenAndExpire 'password', 'mock-token', @callback
+
+ it 'should expire the token', ->
+ @db.tokens.findAndModify
+ .calledWith({
+ query: {
+ use: 'password'
+ token: 'mock-token',
+ expiresAt: { $gt: new Date() },
+ usedAt: { $exists: false }
+ },
+ update: {
+ $set: { usedAt: new Date() }
+ }
+ })
+ .should.equal true
+
+ it 'should return the data', ->
+ @callback.calledWith(null, 'mock-data').should.equal true
+
+ describe 'when a valid token is not found', ->
+ beforeEach ->
+ @db.tokens.findAndModify = sinon.stub().yields(null, null)
+ @OneTimeTokenHandler.getValueFromTokenAndExpire 'password', 'mock-token', @callback
+
+ it 'should return a NotFoundError', ->
+ @callback
+ .calledWith(sinon.match.instanceOf(Errors.NotFoundError))
+ .should.equal true
+
+
- it "should get and delete the token", (done)->
- @redisMulti.exec.callsArgWith(0, null, [@value])
- @OneTimeTokenHandler.getValueFromTokenAndExpire @stubbedToken, (err, value)=>
- value.should.equal @value
- @redisMulti.get.calledWith("password_token:#{@stubbedToken}").should.equal true
- @redisMulti.del.calledWith("password_token:#{@stubbedToken}").should.equal true
- done()
diff --git a/services/web/test/unit/coffee/Subscription/TeamInvitesHandlerTests.coffee b/services/web/test/unit/coffee/Subscription/TeamInvitesHandlerTests.coffee
index 527ba8a143..d23d1fc3a6 100644
--- a/services/web/test/unit/coffee/Subscription/TeamInvitesHandlerTests.coffee
+++ b/services/web/test/unit/coffee/Subscription/TeamInvitesHandlerTests.coffee
@@ -175,6 +175,22 @@ describe "TeamInvitesHandler", ->
).should.equal true
done()
+ describe "importInvite", ->
+ beforeEach ->
+ @sentAt = new Date()
+
+ it "can imports an invite from v1", ->
+ @TeamInvitesHandler.importInvite @subscription, "A-Team", "hannibal@a-team.org",
+ "secret", @sentAt, (error) =>
+ expect(error).not.to.exist
+
+ @subscription.save.calledOnce.should.eq true
+
+ invite = @subscription.teamInvites.find (i) -> i.email == "hannibal@a-team.org"
+ expect(invite.token).to.eq("secret")
+ expect(invite.sentAt).to.eq(@sentAt)
+
+
describe "acceptInvite", ->
beforeEach ->
@user = {
diff --git a/services/web/test/unit/coffee/User/UserEmailsConfirmationHandlerTests.coffee b/services/web/test/unit/coffee/User/UserEmailsConfirmationHandlerTests.coffee
new file mode 100644
index 0000000000..95f72e0907
--- /dev/null
+++ b/services/web/test/unit/coffee/User/UserEmailsConfirmationHandlerTests.coffee
@@ -0,0 +1,125 @@
+should = require('chai').should()
+SandboxedModule = require('sandboxed-module')
+assert = require('assert')
+path = require('path')
+sinon = require('sinon')
+modulePath = path.join __dirname, "../../../../app/js/Features/User/UserEmailsConfirmationHandler"
+expect = require("chai").expect
+Errors = require "../../../../app/js/Features/Errors/Errors"
+EmailHelper = require "../../../../app/js/Features/Helpers/EmailHelper"
+
+describe "UserEmailsConfirmationHandler", ->
+ beforeEach ->
+ @UserEmailsConfirmationHandler = SandboxedModule.require modulePath, requires:
+ "settings-sharelatex": @settings =
+ siteUrl: "emails.example.com"
+ "logger-sharelatex": @logger = { log: sinon.stub() }
+ "../Security/OneTimeTokenHandler": @OneTimeTokenHandler = {}
+ "../Errors/Errors": Errors
+ "./UserUpdater": @UserUpdater = {}
+ "../Email/EmailHandler": @EmailHandler = {}
+ "../Helpers/EmailHelper": EmailHelper
+ @user_id = "mock-user-id"
+ @email = "mock@example.com"
+ @callback = sinon.stub()
+
+ describe "sendConfirmationEmail", ->
+ beforeEach ->
+ @OneTimeTokenHandler.getNewToken = sinon.stub().yields(null, @token = "new-token")
+ @EmailHandler.sendEmail = sinon.stub().yields()
+
+ describe 'successfully', ->
+ beforeEach ->
+ @UserEmailsConfirmationHandler.sendConfirmationEmail @user_id, @email, @callback
+
+ it "should generate a token for the user which references their id and email", ->
+ @OneTimeTokenHandler.getNewToken
+ .calledWith(
+ 'email_confirmation',
+ {@user_id, @email},
+ { expiresIn: 365 * 24 * 60 * 60 }
+ )
+ .should.equal true
+
+ it 'should send an email to the user', ->
+ @EmailHandler.sendEmail
+ .calledWith('confirmEmail', {
+ to: @email,
+ confirmEmailUrl: 'emails.example.com/user/emails/confirm?token=new-token'
+ })
+ .should.equal true
+
+ it 'should call the callback', ->
+ @callback.called.should.equal true
+
+ describe 'with invalid email', ->
+ beforeEach ->
+ @UserEmailsConfirmationHandler.sendConfirmationEmail @user_id, '!"£$%^&*()', @callback
+
+ it 'should return an error', ->
+ @callback.calledWith(sinon.match.instanceOf(Error)).should.equal true
+
+ describe 'a custom template', ->
+ beforeEach ->
+ @UserEmailsConfirmationHandler.sendConfirmationEmail @user_id, @email, 'myCustomTemplate', @callback
+
+ it 'should send an email with the given template', ->
+ @EmailHandler.sendEmail
+ .calledWith('myCustomTemplate')
+ .should.equal true
+
+ describe "confirmEmailFromToken", ->
+ beforeEach ->
+ @OneTimeTokenHandler.getValueFromTokenAndExpire = sinon.stub().yields(
+ null,
+ {@user_id, @email}
+ )
+ @UserUpdater.confirmEmail = sinon.stub().yields()
+
+ describe "successfully", ->
+ beforeEach ->
+ @UserEmailsConfirmationHandler.confirmEmailFromToken @token = 'mock-token', @callback
+
+ it "should call getValueFromTokenAndExpire", ->
+ @OneTimeTokenHandler.getValueFromTokenAndExpire
+ .calledWith('email_confirmation', @token)
+ .should.equal true
+
+ it "should confirm the email of the user_id", ->
+ @UserUpdater.confirmEmail
+ .calledWith(@user_id, @email)
+ .should.equal true
+
+ it "should call the callback", ->
+ @callback.called.should.equal true
+
+ describe 'with an expired token', ->
+ beforeEach ->
+ @OneTimeTokenHandler.getValueFromTokenAndExpire = sinon.stub().yields(null, null)
+ @UserEmailsConfirmationHandler.confirmEmailFromToken @token = 'mock-token', @callback
+
+ it "should call the callback with a NotFoundError", ->
+ @callback.calledWith(sinon.match.instanceOf(Errors.NotFoundError)).should.equal true
+
+ describe 'with no user_id in the token', ->
+ beforeEach ->
+ @OneTimeTokenHandler.getValueFromTokenAndExpire = sinon.stub().yields(
+ null,
+ {@email}
+ )
+ @UserEmailsConfirmationHandler.confirmEmailFromToken @token = 'mock-token', @callback
+
+ it "should call the callback with a NotFoundError", ->
+ @callback.calledWith(sinon.match.instanceOf(Errors.NotFoundError)).should.equal true
+
+ describe 'with no email in the token', ->
+ beforeEach ->
+ @OneTimeTokenHandler.getValueFromTokenAndExpire = sinon.stub().yields(
+ null,
+ {@user_id}
+ )
+ @UserEmailsConfirmationHandler.confirmEmailFromToken @token = 'mock-token', @callback
+
+ it "should call the callback with a NotFoundError", ->
+ @callback.calledWith(sinon.match.instanceOf(Errors.NotFoundError)).should.equal true
+
diff --git a/services/web/test/unit/coffee/User/UserEmailsControllerTests.coffee b/services/web/test/unit/coffee/User/UserEmailsControllerTests.coffee
index 5b4a32fb38..d55b7fd963 100644
--- a/services/web/test/unit/coffee/User/UserEmailsControllerTests.coffee
+++ b/services/web/test/unit/coffee/User/UserEmailsControllerTests.coffee
@@ -8,6 +8,7 @@ modulePath = "../../../../app/js/Features/User/UserEmailsController.js"
SandboxedModule = require('sandboxed-module')
MockRequest = require "../helpers/MockRequest"
MockResponse = require "../helpers/MockResponse"
+Errors = require("../../../../app/js/Features/Errors/Errors")
describe "UserEmailsController", ->
beforeEach ->
@@ -30,6 +31,8 @@ describe "UserEmailsController", ->
"./UserGetter": @UserGetter
"./UserUpdater": @UserUpdater
"../Helpers/EmailHelper": @EmailHelper
+ "./UserEmailsConfirmationHandler": @UserEmailsConfirmationHandler = {}
+ "../Errors/Errors": Errors
"logger-sharelatex":
log: -> console.log(arguments)
err: ->
@@ -47,47 +50,48 @@ describe "UserEmailsController", ->
assertCalledWith @UserGetter.getUserFullEmails, @user._id
done()
- it 'handles error', (done) ->
- @UserGetter.getUserFullEmails.callsArgWith 1, new Error('Oups')
-
- @UserEmailsController.list @req,
- sendStatus: (code) =>
- code.should.equal 500
- done()
-
describe 'Add', ->
beforeEach ->
@newEmail = 'new_email@baz.com'
- @req.body.email = @newEmail
+ @req.body =
+ email: @newEmail
+ university: { name: 'University Name' }
+ department: 'Department'
+ role: 'Role'
@EmailHelper.parseEmail.returns @newEmail
+ @UserEmailsConfirmationHandler.sendConfirmationEmail = sinon.stub().yields()
+ @UserUpdater.addEmailAddress.callsArgWith 3, null
it 'adds new email', (done) ->
- @UserUpdater.addEmailAddress.callsArgWith 2, null
-
@UserEmailsController.add @req,
sendStatus: (code) =>
- code.should.equal 200
+ code.should.equal 204
assertCalledWith @EmailHelper.parseEmail, @newEmail
assertCalledWith @UserUpdater.addEmailAddress, @user._id, @newEmail
+
+ affiliationOptions = @UserUpdater.addEmailAddress.lastCall.args[2]
+ Object.keys(affiliationOptions).length.should.equal 3
+ affiliationOptions.university.should.equal @req.body.university
+ affiliationOptions.department.should.equal @req.body.department
+ affiliationOptions.role.should.equal @req.body.role
+
+ done()
+
+ it 'sends an email confirmation', (done) ->
+ @UserEmailsController.add @req,
+ sendStatus: (code) =>
+ code.should.equal 204
+ assertCalledWith @UserEmailsConfirmationHandler.sendConfirmationEmail, @user._id, @newEmail
done()
it 'handles email parse error', (done) ->
@EmailHelper.parseEmail.returns null
-
@UserEmailsController.add @req,
sendStatus: (code) =>
code.should.equal 422
assertNotCalled @UserUpdater.addEmailAddress
done()
- it 'handles error', (done) ->
- @UserUpdater.addEmailAddress.callsArgWith 2, new Error('Oups')
-
- @UserEmailsController.add @req,
- sendStatus: (code) =>
- code.should.equal 500
- done()
-
describe 'remove', ->
beforeEach ->
@email = 'email_to_remove@bar.com'
@@ -113,15 +117,6 @@ describe "UserEmailsController", ->
assertNotCalled @UserUpdater.removeEmailAddress
done()
- it 'handles error', (done) ->
- @UserUpdater.removeEmailAddress.callsArgWith 2, new Error('Oups')
-
- @UserEmailsController.remove @req,
- sendStatus: (code) =>
- code.should.equal 500
- done()
-
-
describe 'setDefault', ->
beforeEach ->
@email = "email_to_set_default@bar.com"
@@ -147,11 +142,50 @@ describe "UserEmailsController", ->
assertNotCalled @UserUpdater.setDefaultEmailAddress
done()
- it 'handles error', (done) ->
- @UserUpdater.setDefaultEmailAddress.callsArgWith 2, new Error('Oups')
+ describe 'confirm', ->
+ beforeEach ->
+ @UserEmailsConfirmationHandler.confirmEmailFromToken = sinon.stub().yields()
+ @res =
+ sendStatus: sinon.stub()
+ json: sinon.stub()
+ @res.status = sinon.stub().returns(@res)
+ @next = sinon.stub()
+ @token = 'mock-token'
+ @req.body = token: @token
+
+ describe 'successfully', ->
+ beforeEach ->
+ @UserEmailsController.confirm @req, @res, @next
+
+ it 'should confirm the email from the token', ->
+ @UserEmailsConfirmationHandler.confirmEmailFromToken
+ .calledWith(@token)
+ .should.equal true
+
+ it 'should return a 200 status', ->
+ @res.sendStatus.calledWith(200).should.equal true
+
+ describe 'without a token', ->
+ beforeEach ->
+ @req.body.token = null
+ @UserEmailsController.confirm @req, @res, @next
+
+ it 'should return a 422 status', ->
+ @res.sendStatus.calledWith(422).should.equal true
+
+ describe 'when confirming fails', ->
+ beforeEach ->
+ @UserEmailsConfirmationHandler.confirmEmailFromToken = sinon.stub().yields(
+ new Errors.NotFoundError('not found')
+ )
+ @UserEmailsController.confirm @req, @res, @next
+
+ it 'should return a 404 error code with a message', ->
+ @res.status.calledWith(404).should.equal true
+ @res.json.calledWith({
+ message: 'Sorry, your confirmation token is invalid or has expired. Please request a new email confirmation link.'
+ }).should.equal true
+
+
- @UserEmailsController.setDefault @req,
- sendStatus: (code) =>
- code.should.equal 500
- done()
diff --git a/services/web/test/unit/coffee/User/UserGetterTests.coffee b/services/web/test/unit/coffee/User/UserGetterTests.coffee
index f2b7895b57..6b80b5a2a7 100644
--- a/services/web/test/unit/coffee/User/UserGetterTests.coffee
+++ b/services/web/test/unit/coffee/User/UserGetterTests.coffee
@@ -20,11 +20,15 @@ describe "UserGetter", ->
@Mongo =
db: users: findOne: @findOne
ObjectId: (id) -> return id
+ settings = apis: { v1: { url: 'v1.url', user: '', pass: '' } }
+ @request = sinon.stub()
@UserGetter = SandboxedModule.require modulePath, requires:
"logger-sharelatex": log:->
"../../infrastructure/mongojs": @Mongo
"metrics-sharelatex": timeAsyncMethod: sinon.stub()
+ 'settings-sharelatex': settings
+ 'request': @request
describe "getUser", ->
it "should get user", (done)->
@@ -42,6 +46,9 @@ describe "UserGetter", ->
done()
describe "getUserFullEmails", -
+ beforeEach ->
+ @request.callsArgWith(1, null, { statusCode: 200 }, [])
+
it "should get user", (done)->
@UserGetter.getUser = sinon.stub().callsArgWith(2, null, @fakeUser)
projection = email: 1, emails: 1
@@ -59,6 +66,33 @@ describe "UserGetter", ->
]
done()
+ it "should merge affiliation data", (done)->
+ @UserGetter.getUser = sinon.stub().callsArgWith(2, null, @fakeUser)
+ affiliationsData = [
+ {
+ email: 'email1@foo.bar'
+ role: 'Prof'
+ department: 'Maths'
+ inferred: false
+ institution: { name: 'University Name', isUniversity: true }
+ }
+ ]
+ @request.callsArgWith(1, null, { statusCode: 200 }, affiliationsData)
+ @UserGetter.getUserFullEmails @fakeUser._id, (error, fullEmails) =>
+ assert.deepEqual fullEmails, [
+ {
+ email: 'email1@foo.bar'
+ default: false
+ affiliation:
+ institution: affiliationsData[0].institution
+ inferred: affiliationsData[0].inferred
+ department: affiliationsData[0].department
+ role: affiliationsData[0].role
+ }
+ { email: 'email2@foo.bar', default: true }
+ ]
+ done()
+
describe "getUserbyMainEmail", ->
it "query user by main email", (done)->
email = 'hello@world.com'
diff --git a/services/web/test/unit/coffee/User/UserRegistrationHandlerTests.coffee b/services/web/test/unit/coffee/User/UserRegistrationHandlerTests.coffee
index 3a8801bc08..e9a6c568dd 100644
--- a/services/web/test/unit/coffee/User/UserRegistrationHandlerTests.coffee
+++ b/services/web/test/unit/coffee/User/UserRegistrationHandlerTests.coffee
@@ -152,7 +152,7 @@ describe "UserRegistrationHandler", ->
beforeEach ->
@email = "email@example.com"
@crypto.randomBytes = sinon.stub().returns({toString: () => @password = "mock-password"})
- @OneTimeTokenHandler.getNewToken.callsArgWith(2, null, @token = "mock-token")
+ @OneTimeTokenHandler.getNewToken.yields(null, @token = "mock-token")
@handler.registerNewUser = sinon.stub()
@callback = sinon.stub()
@@ -171,7 +171,7 @@ describe "UserRegistrationHandler", ->
it "should generate a new password reset token", ->
@OneTimeTokenHandler.getNewToken
- .calledWith(@user_id, expiresIn: 7 * 24 * 60 * 60)
+ .calledWith('password', @user_id, expiresIn: 7 * 24 * 60 * 60)
.should.equal true
it "should send a registered email", ->
diff --git a/services/web/test/unit/coffee/User/UserUpdaterTests.coffee b/services/web/test/unit/coffee/User/UserUpdaterTests.coffee
index 7b3be3df2b..a2348b54e2 100644
--- a/services/web/test/unit/coffee/User/UserUpdaterTests.coffee
+++ b/services/web/test/unit/coffee/User/UserUpdaterTests.coffee
@@ -5,12 +5,12 @@ path = require('path')
sinon = require('sinon')
modulePath = path.join __dirname, "../../../../app/js/Features/User/UserUpdater"
expect = require("chai").expect
+tk = require('timekeeper')
describe "UserUpdater", ->
beforeEach ->
-
- @settings = {}
+ tk.freeze(Date.now())
@mongojs =
db:{}
ObjectId:(id)-> return id
@@ -19,12 +19,15 @@ describe "UserUpdater", ->
getUserByAnyEmail: sinon.stub()
ensureUniqueEmailAddress: sinon.stub()
@logger = err: sinon.stub(), log: ->
+ settings = apis: { v1: { url: 'v1.url', user: '', pass: '' } }
+ @request = sinon.stub()
@UserUpdater = SandboxedModule.require modulePath, requires:
- "settings-sharelatex":@settings
"logger-sharelatex": @logger
"./UserGetter": @UserGetter
"../../infrastructure/mongojs":@mongojs
"metrics-sharelatex": timeAsyncMethod: sinon.stub()
+ 'settings-sharelatex': settings
+ 'request': @request
@stubbedUser =
_id: "3131231"
@@ -32,6 +35,9 @@ describe "UserUpdater", ->
email:"hello@world.com"
@newEmail = "bob@bob.com"
+ afterEach ->
+ tk.reset()
+
describe 'changeEmailAddress', ->
beforeEach ->
@UserGetter.getUserEmail.callsArgWith(1, null, @stubbedUser.email)
@@ -62,10 +68,10 @@ describe "UserUpdater", ->
describe 'addEmailAddress', ->
beforeEach ->
@UserGetter.ensureUniqueEmailAddress = sinon.stub().callsArgWith(1)
+ @UserUpdater.updateUser = sinon.stub().callsArgWith(2, null)
+ @request.callsArgWith(1, null, { statusCode: 201 })
it 'add email', (done)->
- @UserUpdater.updateUser = sinon.stub().callsArgWith(2, null)
-
@UserUpdater.addEmailAddress @stubbedUser._id, @newEmail, (err)=>
@UserGetter.ensureUniqueEmailAddress.called.should.equal true
should.not.exist(err)
@@ -75,6 +81,27 @@ describe "UserUpdater", ->
).should.equal true
done()
+ it 'add affiliation', (done)->
+ affiliationOptions =
+ university: { id: 1 }
+ role: 'Prof'
+ department: 'Math'
+ @UserUpdater.addEmailAddress @stubbedUser._id, @newEmail, affiliationOptions, (err)=>
+ should.not.exist(err)
+ @request.calledOnce.should.equal true
+ requestOptions = @request.lastCall.args[0]
+ expectedUrl = "v1.url/api/v2/users/#{@stubbedUser._id}/affiliations"
+ requestOptions.url.should.equal expectedUrl
+ requestOptions.method.should.equal 'POST'
+
+ body = requestOptions.body
+ Object.keys(body).length.should.equal 4
+ body.email.should.equal @newEmail
+ body.university.should.equal affiliationOptions.university
+ body.department.should.equal affiliationOptions.department
+ body.role.should.equal affiliationOptions.role
+ done()
+
it 'handle error', (done)->
@UserUpdater.updateUser = sinon.stub().callsArgWith(2, new Error('nope'))
@@ -83,10 +110,21 @@ describe "UserUpdater", ->
should.exist(err)
done()
- describe 'removeEmailAddress', ->
- it 'remove email', (done)->
- @UserUpdater.updateUser = sinon.stub().callsArgWith(2, null, nMatched: 1)
+ it 'handle affiliation error', (done)->
+ body = errors: 'affiliation error message'
+ @request.callsArgWith(1, null, { statusCode: 422 }, body)
+ @UserUpdater.addEmailAddress @stubbedUser._id, @newEmail, (err)=>
+ err.message.should.have.string 422
+ err.message.should.have.string body.errors
+ @UserUpdater.updateUser.called.should.equal false
+ done()
+ describe 'removeEmailAddress', ->
+ beforeEach ->
+ @UserUpdater.updateUser = sinon.stub().callsArgWith(2, null, nMatched: 1)
+ @request.callsArgWith(1, null, { statusCode: 404 })
+
+ it 'remove email', (done)->
@UserUpdater.removeEmailAddress @stubbedUser._id, @newEmail, (err)=>
should.not.exist(err)
@UserUpdater.updateUser.calledWith(
@@ -95,6 +133,17 @@ describe "UserUpdater", ->
).should.equal true
done()
+ it 'remove affiliation', (done)->
+ @UserUpdater.removeEmailAddress @stubbedUser._id, @newEmail, (err)=>
+ should.not.exist(err)
+ @request.calledOnce.should.equal true
+ requestOptions = @request.lastCall.args[0]
+ expectedUrl = "v1.url/api/v2/users/#{@stubbedUser._id}/affiliations/"
+ expectedUrl += encodeURIComponent(@newEmail)
+ requestOptions.url.should.equal expectedUrl
+ requestOptions.method.should.equal 'DELETE'
+ done()
+
it 'handle error', (done)->
@UserUpdater.updateUser = sinon.stub().callsArgWith(2, new Error('nope'))
@@ -103,15 +152,22 @@ describe "UserUpdater", ->
done()
it 'handle missed update', (done)->
- @UserUpdater.updateUser = sinon.stub().callsArgWith(2, null, nMatched: 0)
+ @UserUpdater.updateUser = sinon.stub().callsArgWith(2, null, n: 0)
@UserUpdater.removeEmailAddress @stubbedUser._id, @newEmail, (err)=>
should.exist(err)
done()
+ it 'handle affiliation error', (done)->
+ @request.callsArgWith(1, null, { statusCode: 500 })
+ @UserUpdater.removeEmailAddress @stubbedUser._id, @newEmail, (err)=>
+ err.message.should.exist
+ @UserUpdater.updateUser.called.should.equal false
+ done()
+
describe 'setDefaultEmailAddress', ->
it 'set default', (done)->
- @UserUpdater.updateUser = sinon.stub().callsArgWith(2, null, nMatched: 1)
+ @UserUpdater.updateUser = sinon.stub().callsArgWith(2, null, n: 1)
@UserUpdater.setDefaultEmailAddress @stubbedUser._id, @newEmail, (err)=>
should.not.exist(err)
@@ -129,10 +185,37 @@ describe "UserUpdater", ->
done()
it 'handle missed update', (done)->
- @UserUpdater.updateUser = sinon.stub().callsArgWith(2, null, nMatched: 0)
+ @UserUpdater.updateUser = sinon.stub().callsArgWith(2, null, n: 0)
@UserUpdater.setDefaultEmailAddress @stubbedUser._id, @newEmail, (err)=>
should.exist(err)
done()
+ describe 'confirmEmail', ->
+ it 'should update the email record', (done)->
+ @UserUpdater.updateUser = sinon.stub().callsArgWith(2, null, n: 1)
+
+ @UserUpdater.confirmEmail @stubbedUser._id, @newEmail, (err)=>
+ should.not.exist(err)
+ @UserUpdater.updateUser.calledWith(
+ { _id: @stubbedUser._id, 'emails.email': @newEmail },
+ $set: { 'emails.$.confirmedAt': new Date() }
+ ).should.equal true
+ done()
+
+ it 'handle error', (done)->
+ @UserUpdater.updateUser = sinon.stub().callsArgWith(2, new Error('nope'))
+
+ @UserUpdater.confirmEmail @stubbedUser._id, @newEmail, (err)=>
+ should.exist(err)
+ done()
+
+ it 'handle missed update', (done)->
+ @UserUpdater.updateUser = sinon.stub().callsArgWith(2, null, n: 0)
+
+ @UserUpdater.confirmEmail @stubbedUser._id, @newEmail, (err)=>
+ should.exist(err)
+ done()
+
+
diff --git a/services/web/test/unit/coffee/infrastructure/ProxyManagerTests.coffee b/services/web/test/unit/coffee/infrastructure/ProxyManagerTests.coffee
new file mode 100644
index 0000000000..89a9c0c6f2
--- /dev/null
+++ b/services/web/test/unit/coffee/infrastructure/ProxyManagerTests.coffee
@@ -0,0 +1,66 @@
+sinon = require('sinon')
+assertCalledWith = sinon.assert.calledWith
+chai = require('chai')
+should = chai.should()
+expect = chai.expect
+modulePath = '../../../../app/js/infrastructure/ProxyManager'
+SandboxedModule = require('sandboxed-module')
+MockRequest = require "../helpers/MockRequest"
+MockResponse = require "../helpers/MockResponse"
+
+describe "ProxyManager", ->
+ before ->
+ @settings = proxyUrls: {}
+ @request = sinon.stub().returns(
+ on: ()->
+ pipe: ()->
+ )
+ @proxyManager = SandboxedModule.require modulePath, requires:
+ 'settings-sharelatex': @settings
+ 'logger-sharelatex': log: ->
+ 'request': @request
+ @proxyPath = '/foo/bar'
+ @req = new MockRequest()
+ @res = new MockResponse()
+ @next = sinon.stub()
+
+ describe 'proxyUrls', ->
+ beforeEach ->
+ @req.url = @proxyPath
+ @req.query = {}
+ @settings.proxyUrls = {}
+
+ afterEach ->
+ @next.reset()
+ @request.reset()
+
+ it 'calls next when no match', ->
+ @proxyManager.call(@req, @res, @next)
+ sinon.assert.called(@next)
+ sinon.assert.notCalled(@request)
+
+ it 'does not calls next when match', ->
+ @settings.proxyUrls[@proxyPath] = '/'
+ @proxyManager.call(@req, @res, @next)
+ sinon.assert.notCalled(@next)
+ sinon.assert.called(@request)
+
+ it 'proxy full URL', ->
+ targetUrl = 'https://user:pass@foo.bar:123/pa/th.ext?query#hash'
+ @settings.proxyUrls[@proxyPath] = targetUrl
+ @proxyManager.call(@req)
+ assertCalledWith(@request, targetUrl)
+
+ it 'overwrite query', ->
+ targetUrl = 'foo.bar/baz?query'
+ @req.query = { requestQuery: 'important' }
+ @settings.proxyUrls[@proxyPath] = targetUrl
+ @proxyManager.call(@req)
+ newTargetUrl = 'foo.bar/baz?requestQuery=important'
+ assertCalledWith(@request, newTargetUrl)
+
+ it 'handles target objects', ->
+ targetUrl = { baseUrl: 'api.v1', path: '/pa/th'}
+ @settings.proxyUrls[@proxyPath] = targetUrl
+ @proxyManager.call(@req, @res, @next)
+ assertCalledWith(@request, 'api.v1/pa/th')
|
|---|
|
|
|---|
|