From 24f60bf79123d1992021d15d2962906d10530355 Mon Sep 17 00:00:00 2001 From: James Allen Date: Wed, 5 Sep 2018 10:35:25 +0100 Subject: [PATCH 01/44] Don't include the license name twice in invite emails --- .../Subscription/TeamInvitesHandler.coffee | 4 +++- .../TeamInvitesHandlerTests.coffee | 21 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/services/web/app/coffee/Features/Subscription/TeamInvitesHandler.coffee b/services/web/app/coffee/Features/Subscription/TeamInvitesHandler.coffee index 7bbfd78780..b089f943ce 100644 --- a/services/web/app/coffee/Features/Subscription/TeamInvitesHandler.coffee +++ b/services/web/app/coffee/Features/Subscription/TeamInvitesHandler.coffee @@ -50,7 +50,9 @@ module.exports = TeamInvitesHandler = email = EmailHelper.parseEmail(user.email) return callback(new Error('invalid email')) if !email? logger.log {licence, email: email}, "Creating domain team invite" - inviterName = licence.name.replace(/\s+licence$/i, licence.name) + # If name == 'Uni of X License', make the email read only + # 'Uni of X has invited you...' + inviterName = licence.name.replace(/\s+(site\s+)?licence$/i, '') SubscriptionLocator.getSubscription licence.subscription_id, (error, subscription) -> return callback(error) if error? diff --git a/services/web/test/unit/coffee/Subscription/TeamInvitesHandlerTests.coffee b/services/web/test/unit/coffee/Subscription/TeamInvitesHandlerTests.coffee index e6f404516e..40cb18e41f 100644 --- a/services/web/test/unit/coffee/Subscription/TeamInvitesHandlerTests.coffee +++ b/services/web/test/unit/coffee/Subscription/TeamInvitesHandlerTests.coffee @@ -176,6 +176,27 @@ describe "TeamInvitesHandler", -> ).should.equal true done() + it "stripe licence from name", (done) -> + @licence.name = 'Foo Licence' + @TeamInvitesHandler.createDomainInvite @user, @licence, (err, invite) => + @EmailHandler.sendEmail.calledWith("verifyEmailToJoinTeam", + sinon.match({ + inviterName: 'Foo' + }) + ).should.equal true + done() + + + it "stripe site licence from name", (done) -> + @licence.name = 'Foo Site Licence' + @TeamInvitesHandler.createDomainInvite @user, @licence, (err, invite) => + @EmailHandler.sendEmail.calledWith("verifyEmailToJoinTeam", + sinon.match({ + inviterName: 'Foo' + }) + ).should.equal true + done() + describe "importInvite", -> beforeEach -> @sentAt = new Date() From 5c69b6d12c9a17c43ee6ba3b496d2d178b423ab9 Mon Sep 17 00:00:00 2001 From: Brian Gough Date: Wed, 19 Sep 2018 10:51:35 +0100 Subject: [PATCH 02/44] temporarily disable syntax checking --- .../public/coffee/ide/pdf/controllers/PdfController.coffee | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/services/web/public/coffee/ide/pdf/controllers/PdfController.coffee b/services/web/public/coffee/ide/pdf/controllers/PdfController.coffee index 7ceb316b53..001178c5dd 100644 --- a/services/web/public/coffee/ide/pdf/controllers/PdfController.coffee +++ b/services/web/public/coffee/ide/pdf/controllers/PdfController.coffee @@ -204,6 +204,11 @@ define [ when options.try then "silent" # allow use to try compile once when $scope.stop_on_validation_error then "error" # try to compile else "silent" # ignore errors + # FIXME: Temporarily disable syntax checking as it is causing + # excessive support requests for projects migrated from v1 + # https://github.com/overleaf/sharelatex/issues/911 + if checkType == "error" + checkType = "silent" return $http.post url, { rootDoc_id: options.rootDocOverride_id or null draft: $scope.draft From 7fc0ab36b317376fc5575c04aeceb2b8c4e63885 Mon Sep 17 00:00:00 2001 From: Jessica Lawshe Date: Fri, 21 Sep 2018 17:09:08 -0500 Subject: [PATCH 03/44] CMS pages margin --- services/web/public/stylesheets/app/cms-page.less | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/web/public/stylesheets/app/cms-page.less b/services/web/public/stylesheets/app/cms-page.less index 1e7f0aa4ed..bf5c2ff437 100644 --- a/services/web/public/stylesheets/app/cms-page.less +++ b/services/web/public/stylesheets/app/cms-page.less @@ -45,8 +45,8 @@ All content from CMS is in .row-spaced. Margin below is to fix extra whitespace for first rows */ - .container > .row:nth-child(2) { - //- first child is page header, don't correct margin + .container > .row:nth-child(2), .content-container > .row:first-child { + //- .container first child is page header, don't correct margin margin-top: 0; } .tab-pane { From b8fb750c16558cdc4f10224313bf4dda0948f07c Mon Sep 17 00:00:00 2001 From: Jessica Lawshe Date: Fri, 21 Sep 2018 17:11:47 -0500 Subject: [PATCH 04/44] Bottom margin for tab nav only on portals --- services/web/public/stylesheets/app/portals.less | 4 ++++ services/web/public/stylesheets/components/tabs.less | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/services/web/public/stylesheets/app/portals.less b/services/web/public/stylesheets/app/portals.less index 5e987f9d58..7ef506c6a0 100644 --- a/services/web/public/stylesheets/app/portals.less +++ b/services/web/public/stylesheets/app/portals.less @@ -90,6 +90,10 @@ } // End Actions + .nav-tabs { + margin-bottom: @margin-md; + } + /* Begin Print */ diff --git a/services/web/public/stylesheets/components/tabs.less b/services/web/public/stylesheets/components/tabs.less index 71143cbefe..7d03e0223d 100644 --- a/services/web/public/stylesheets/components/tabs.less +++ b/services/web/public/stylesheets/components/tabs.less @@ -2,7 +2,7 @@ // Overrides for nav.less .nav-tabs { border: 0!important; - margin-bottom: @margin-md; + margin-bottom: 0; margin-top: -@line-height-computed; //- adjusted for portal-name padding: @padding-lg 0 @padding-md; text-align: center; From c0b32f031eac7a7cd06283a8e6fb7a884337a916 Mon Sep 17 00:00:00 2001 From: hugh-obrien Date: Sun, 23 Sep 2018 12:38:28 +0100 Subject: [PATCH 05/44] force gallery items to use legacy OL v1 texlive image --- .../Templates/TemplatesController.coffee | 27 ++++++++++++------- .../Templates/TemplatesControllerTests.coffee | 5 +++- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/services/web/app/coffee/Features/Templates/TemplatesController.coffee b/services/web/app/coffee/Features/Templates/TemplatesController.coffee index fce6c9502c..8dbd274f32 100644 --- a/services/web/app/coffee/Features/Templates/TemplatesController.coffee +++ b/services/web/app/coffee/Features/Templates/TemplatesController.coffee @@ -45,6 +45,7 @@ module.exports = TemplatesController = docId: req.body.docId templateId: req.body.templateId templateVersionId: req.body.templateVersionId + image: 'wl_texlive:2018.1' }, req, res @@ -63,18 +64,26 @@ module.exports = TemplatesController = logger.err err:err, zipReq:zipReq, "problem building project from zip" return res.sendStatus 500 setCompiler project._id, options.compiler, -> - fs.unlink dumpPath, -> - delete req.session.templateData - conditions = {_id:project._id} - update = { - fromV1TemplateId:options.templateId, - fromV1TemplateVersionId:options.templateVersionId - } - Project.update conditions, update, {}, (err)-> - res.redirect "/project/#{project._id}" + setImage project._id, options.image, -> + fs.unlink dumpPath, -> + delete req.session.templateData + conditions = {_id:project._id} + update = { + fromV1TemplateId:options.templateId, + fromV1TemplateVersionId:options.templateVersionId + } + Project.update conditions, update, {}, (err)-> + res.redirect "/project/#{project._id}" setCompiler = (project_id, compiler, callback)-> if compiler? ProjectOptionsHandler.setCompiler project_id, compiler, callback else callback() + +setImage = (project_id, imageName, callback)-> + console.log(project_id, imageName) + if imageName? + ProjectOptionsHandler.setImageName project_id, imageName, callback + else + callback() diff --git a/services/web/test/unit/coffee/Templates/TemplatesControllerTests.coffee b/services/web/test/unit/coffee/Templates/TemplatesControllerTests.coffee index 5cf52eca39..8a90245373 100644 --- a/services/web/test/unit/coffee/Templates/TemplatesControllerTests.coffee +++ b/services/web/test/unit/coffee/Templates/TemplatesControllerTests.coffee @@ -22,7 +22,10 @@ describe 'TemplatesController', -> } @ProjectUploadManager = {createProjectFromZipArchive : sinon.stub().callsArgWith(3, null, {_id:project_id})} @dumpFolder = "dump/path" - @ProjectOptionsHandler = {setCompiler:sinon.stub().callsArgWith(2)} + @ProjectOptionsHandler = { + setCompiler:sinon.stub().callsArgWith(2) + setImageName:sinon.stub().callsArgWith(2) + } @uuid = "1234" @ProjectDetailsHandler = getProjectDescription:sinon.stub() From 5f4a36ca26669773ac52b2c40519f3aac118ed4e Mon Sep 17 00:00:00 2001 From: hugh-obrien Date: Mon, 24 Sep 2018 08:51:00 +0100 Subject: [PATCH 06/44] remove debug line --- .../web/app/coffee/Features/Templates/TemplatesController.coffee | 1 - 1 file changed, 1 deletion(-) diff --git a/services/web/app/coffee/Features/Templates/TemplatesController.coffee b/services/web/app/coffee/Features/Templates/TemplatesController.coffee index 8dbd274f32..c4525f1012 100644 --- a/services/web/app/coffee/Features/Templates/TemplatesController.coffee +++ b/services/web/app/coffee/Features/Templates/TemplatesController.coffee @@ -82,7 +82,6 @@ setCompiler = (project_id, compiler, callback)-> callback() setImage = (project_id, imageName, callback)-> - console.log(project_id, imageName) if imageName? ProjectOptionsHandler.setImageName project_id, imageName, callback else From 2692090f3f8c234f1118b82a77ddda70056986d7 Mon Sep 17 00:00:00 2001 From: Brian Gough Date: Mon, 24 Sep 2018 11:59:55 +0100 Subject: [PATCH 07/44] support a mainFile parameter for templates --- .../Project/ProjectRootDocManager.coffee | 20 ++++++ .../Templates/TemplatesController.coffee | 30 +++++--- .../project/editor/new_from_template.pug | 1 + .../Project/ProjectRootDocManagerTests.coffee | 71 +++++++++++++++++++ 4 files changed, 112 insertions(+), 10 deletions(-) diff --git a/services/web/app/coffee/Features/Project/ProjectRootDocManager.coffee b/services/web/app/coffee/Features/Project/ProjectRootDocManager.coffee index 4530fb67d3..d9e1d745df 100644 --- a/services/web/app/coffee/Features/Project/ProjectRootDocManager.coffee +++ b/services/web/app/coffee/Features/Project/ProjectRootDocManager.coffee @@ -31,3 +31,23 @@ module.exports = ProjectRootDocManager = ProjectEntityUpdateHandler.setRootDoc project_id, root_doc_id, callback else callback() + + setRootDocFromName: (project_id, rootDocName, callback = (error) ->) -> + ProjectEntityHandler.getAllDocPathsFromProject project_id, (error, docPaths) -> + return callback(error) if error? + # find the root doc from the filename + root_doc_id = null + for doc_id, path of docPaths + # docpaths have a leading / so allow matching "folder/filename" and "/folder/filename" + if path == rootDocName or path == "/#{rootDocName}" + root_doc_id = doc_id + # try a basename match if there was no match + if !root_doc_id + for doc_id, path of docPaths + if Path.basename(path) == Path.basename(rootDocName) + root_doc_id = doc_id + # set the root doc id if we found a match + if root_doc_id? + ProjectEntityUpdateHandler.setRootDoc project_id, root_doc_id, callback + else + callback() \ No newline at end of file diff --git a/services/web/app/coffee/Features/Templates/TemplatesController.coffee b/services/web/app/coffee/Features/Templates/TemplatesController.coffee index fce6c9502c..682e0649c9 100644 --- a/services/web/app/coffee/Features/Templates/TemplatesController.coffee +++ b/services/web/app/coffee/Features/Templates/TemplatesController.coffee @@ -24,6 +24,7 @@ module.exports = TemplatesController = data.templateId = templateId data.name = req.query.templateName data.compiler = req.query.latexEngine + data.mainFile = req.query.mainFile res.render path.resolve(__dirname, "../../../views/project/editor/new_from_template"), data createProjectFromV1Template: (req, res)-> @@ -43,6 +44,7 @@ module.exports = TemplatesController = currentUserId: currentUserId, compiler: req.body.compiler docId: req.body.docId + mainFile: req.body.mainFile templateId: req.body.templateId templateVersionId: req.body.templateVersionId }, @@ -62,19 +64,27 @@ module.exports = TemplatesController = if err? logger.err err:err, zipReq:zipReq, "problem building project from zip" return res.sendStatus 500 - setCompiler project._id, options.compiler, -> - fs.unlink dumpPath, -> - delete req.session.templateData - conditions = {_id:project._id} - update = { - fromV1TemplateId:options.templateId, - fromV1TemplateVersionId:options.templateVersionId - } - Project.update conditions, update, {}, (err)-> - res.redirect "/project/#{project._id}" + setMainFile project._id, options.mainFile, -> + # ignore any errors setting main file + setCompiler project._id, options.compiler, -> + fs.unlink dumpPath, -> + delete req.session.templateData + conditions = {_id:project._id} + update = { + fromV1TemplateId:options.templateId, + fromV1TemplateVersionId:options.templateVersionId + } + Project.update conditions, update, {}, (err)-> + res.redirect "/project/#{project._id}" setCompiler = (project_id, compiler, callback)-> if compiler? ProjectOptionsHandler.setCompiler project_id, compiler, callback else callback() + +setMainFile = (project_id, mainFile, callback) -> + if mainFile? + ProjectRootDocManager.setRootDocFromName project_id, mainFile, callback + else + callback() \ No newline at end of file diff --git a/services/web/app/views/project/editor/new_from_template.pug b/services/web/app/views/project/editor/new_from_template.pug index 6dc27a4241..b81d23904c 100644 --- a/services/web/app/views/project/editor/new_from_template.pug +++ b/services/web/app/views/project/editor/new_from_template.pug @@ -24,3 +24,4 @@ block content input(type="hidden" name="templateVersionId" value=templateVersionId) input(type="hidden" name="templateName" value=name) input(type="hidden" name="compiler" value=compiler) + input(type="hidden" name="mainFile" value=mainFile) diff --git a/services/web/test/unit/coffee/Project/ProjectRootDocManagerTests.coffee b/services/web/test/unit/coffee/Project/ProjectRootDocManagerTests.coffee index 9a8cde3ff5..8ced7d8d98 100644 --- a/services/web/test/unit/coffee/Project/ProjectRootDocManagerTests.coffee +++ b/services/web/test/unit/coffee/Project/ProjectRootDocManagerTests.coffee @@ -75,3 +75,74 @@ describe 'ProjectRootDocManager', -> it "should not set the root doc to the doc containing a documentclass", -> @ProjectEntityUpdateHandler.setRootDoc.called.should.equal false + describe "setRootDocFromName", -> + describe "when there is a suitable root doc", -> + beforeEach (done)-> + @docPaths = + "doc-id-1": "/chapter1.tex" + "doc-id-2": "/main.tex" + "doc-id-3": "/nested/chapter1a.tex" + "doc-id-4": "/nested/chapter1b.tex" + @ProjectEntityHandler.getAllDocPathsFromProject = sinon.stub().callsArgWith(1, null, @docPaths) + @ProjectEntityUpdateHandler.setRootDoc = sinon.stub().callsArgWith(2) + @ProjectRootDocManager.setRootDocFromName @project_id, '/main.tex', done + + it "should check the docs of the project", -> + @ProjectEntityHandler.getAllDocPathsFromProject.calledWith(@project_id) + .should.equal true + + it "should set the root doc to main.tex", -> + @ProjectEntityUpdateHandler.setRootDoc.calledWith(@project_id, "doc-id-2") + .should.equal true + + describe "when there is a suitable root doc but the leading slash is missing", -> + beforeEach (done)-> + @docPaths = + "doc-id-1": "/chapter1.tex" + "doc-id-2": "/main.tex" + "doc-id-3": "/nested/chapter1a.tex" + "doc-id-4": "/nested/chapter1b.tex" + @ProjectEntityHandler.getAllDocPathsFromProject = sinon.stub().callsArgWith(1, null, @docPaths) + @ProjectEntityUpdateHandler.setRootDoc = sinon.stub().callsArgWith(2) + @ProjectRootDocManager.setRootDocFromName @project_id, 'main.tex', done + + it "should check the docs of the project", -> + @ProjectEntityHandler.getAllDocPathsFromProject.calledWith(@project_id) + .should.equal true + + it "should set the root doc to main.tex", -> + @ProjectEntityUpdateHandler.setRootDoc.calledWith(@project_id, "doc-id-2") + .should.equal true + + describe "when there is a suitable root doc with a basename match", -> + beforeEach (done)-> + @docPaths = + "doc-id-1": "/chapter1.tex" + "doc-id-2": "/main.tex" + "doc-id-3": "/nested/chapter1a.tex" + "doc-id-4": "/nested/chapter1b.tex" + @ProjectEntityHandler.getAllDocPathsFromProject = sinon.stub().callsArgWith(1, null, @docPaths) + @ProjectEntityUpdateHandler.setRootDoc = sinon.stub().callsArgWith(2) + @ProjectRootDocManager.setRootDocFromName @project_id, 'chapter1a.tex', done + + it "should check the docs of the project", -> + @ProjectEntityHandler.getAllDocPathsFromProject.calledWith(@project_id) + .should.equal true + + it "should set the root doc using the basename", -> + @ProjectEntityUpdateHandler.setRootDoc.calledWith(@project_id, "doc-id-3") + .should.equal true + + describe "when there is no suitable root doc", -> + beforeEach (done)-> + @docPaths = + "doc-id-1": "/chapter1.tex" + "doc-id-2": "/main.tex" + "doc-id-3": "/nested/chapter1a.tex" + "doc-id-4": "/nested/chapter1b.tex" + @ProjectEntityHandler.getAllDocPathsFromProject = sinon.stub().callsArgWith(1, null, @docPaths) + @ProjectEntityUpdateHandler.setRootDoc = sinon.stub().callsArgWith(2) + @ProjectRootDocManager.setRootDocFromName @project_id, "other.tex", done + + it "should not set the root doc", -> + @ProjectEntityUpdateHandler.setRootDoc.called.should.equal false From 586e3814fe03133265ac1755aa2d2df0392b7269 Mon Sep 17 00:00:00 2001 From: Brian Gough Date: Mon, 24 Sep 2018 15:26:11 +0100 Subject: [PATCH 08/44] add missing require --- .../web/app/coffee/Features/Templates/TemplatesController.coffee | 1 + 1 file changed, 1 insertion(+) diff --git a/services/web/app/coffee/Features/Templates/TemplatesController.coffee b/services/web/app/coffee/Features/Templates/TemplatesController.coffee index 682e0649c9..3b066682bf 100644 --- a/services/web/app/coffee/Features/Templates/TemplatesController.coffee +++ b/services/web/app/coffee/Features/Templates/TemplatesController.coffee @@ -2,6 +2,7 @@ path = require('path') Project = require('../../../js/models/Project').Project ProjectUploadManager = require('../../../js/Features/Uploads/ProjectUploadManager') ProjectOptionsHandler = require('../../../js/Features/Project/ProjectOptionsHandler') +ProjectRootDocManager = require('../../../js/Features/Project/ProjectRootDocManager') AuthenticationController = require('../../../js/Features/Authentication/AuthenticationController') settings = require('settings-sharelatex') fs = require('fs') From 5954e450165731f5e2aa94bdee6d8d72b5586a96 Mon Sep 17 00:00:00 2001 From: Brian Gough Date: Mon, 24 Sep 2018 15:42:50 +0100 Subject: [PATCH 09/44] add missing require --- .../unit/coffee/Templates/TemplatesControllerTests.coffee | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/services/web/test/unit/coffee/Templates/TemplatesControllerTests.coffee b/services/web/test/unit/coffee/Templates/TemplatesControllerTests.coffee index 5cf52eca39..0cdc90cbda 100644 --- a/services/web/test/unit/coffee/Templates/TemplatesControllerTests.coffee +++ b/services/web/test/unit/coffee/Templates/TemplatesControllerTests.coffee @@ -24,6 +24,9 @@ describe 'TemplatesController', -> @dumpFolder = "dump/path" @ProjectOptionsHandler = {setCompiler:sinon.stub().callsArgWith(2)} @uuid = "1234" + @ProjectRootDocManager = { + setRootDocFromName: sinon.stub().callsArgWith(2) + } @ProjectDetailsHandler = getProjectDescription:sinon.stub() @Project = @@ -31,6 +34,7 @@ describe 'TemplatesController', -> @controller = SandboxedModule.require modulePath, requires: '../../../js/Features/Uploads/ProjectUploadManager':@ProjectUploadManager '../../../js/Features/Project/ProjectOptionsHandler':@ProjectOptionsHandler + '../../../js/Features/Project/ProjectRootDocManager':@ProjectRootDocManager '../../../js/Features/Authentication/AuthenticationController': @AuthenticationController = {getLoggedInUserId: sinon.stub()} './TemplatesPublisher':@TemplatesPublisher "logger-sharelatex": From 418bc10a189cfb2f7a15e7050e477fd6aba24649 Mon Sep 17 00:00:00 2001 From: Brian Gough Date: Mon, 24 Sep 2018 16:04:23 +0100 Subject: [PATCH 10/44] allow getting doc paths by project id --- .../Features/Project/ProjectEntityHandler.coffee | 6 ++++++ .../Features/Project/ProjectRootDocManager.coffee | 2 +- .../Project/ProjectRootDocManagerTests.coffee | 14 +++++++------- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee b/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee index 5994521b8e..2160fbc419 100644 --- a/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee +++ b/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee @@ -65,6 +65,12 @@ module.exports = ProjectEntityHandler = self = files.push({path: path.join(folderPath, file.name), file:file}) callback null, docs, files + getAllDocPathsFromProjectById: (project_id, callback) -> + ProjectGetter.getProjectWithoutDocLines project_id, (err, project) -> + return callback(err) if err? + return callback(Errors.NotFoundError("no project")) if !project? + self.getAllDocPathsFromProject project, callback + getAllDocPathsFromProject: (project, callback) -> logger.log project:project, "getting all docs for project" self._getAllFoldersFromProject project, (err, folders = {}) -> diff --git a/services/web/app/coffee/Features/Project/ProjectRootDocManager.coffee b/services/web/app/coffee/Features/Project/ProjectRootDocManager.coffee index d9e1d745df..b331c38aa6 100644 --- a/services/web/app/coffee/Features/Project/ProjectRootDocManager.coffee +++ b/services/web/app/coffee/Features/Project/ProjectRootDocManager.coffee @@ -33,7 +33,7 @@ module.exports = ProjectRootDocManager = callback() setRootDocFromName: (project_id, rootDocName, callback = (error) ->) -> - ProjectEntityHandler.getAllDocPathsFromProject project_id, (error, docPaths) -> + ProjectEntityHandler.getAllDocPathsFromProjectById project_id, (error, docPaths) -> return callback(error) if error? # find the root doc from the filename root_doc_id = null diff --git a/services/web/test/unit/coffee/Project/ProjectRootDocManagerTests.coffee b/services/web/test/unit/coffee/Project/ProjectRootDocManagerTests.coffee index 8ced7d8d98..d65b92043a 100644 --- a/services/web/test/unit/coffee/Project/ProjectRootDocManagerTests.coffee +++ b/services/web/test/unit/coffee/Project/ProjectRootDocManagerTests.coffee @@ -83,12 +83,12 @@ describe 'ProjectRootDocManager', -> "doc-id-2": "/main.tex" "doc-id-3": "/nested/chapter1a.tex" "doc-id-4": "/nested/chapter1b.tex" - @ProjectEntityHandler.getAllDocPathsFromProject = sinon.stub().callsArgWith(1, null, @docPaths) + @ProjectEntityHandler.getAllDocPathsFromProjectById = sinon.stub().callsArgWith(1, null, @docPaths) @ProjectEntityUpdateHandler.setRootDoc = sinon.stub().callsArgWith(2) @ProjectRootDocManager.setRootDocFromName @project_id, '/main.tex', done it "should check the docs of the project", -> - @ProjectEntityHandler.getAllDocPathsFromProject.calledWith(@project_id) + @ProjectEntityHandler.getAllDocPathsFromProjectById.calledWith(@project_id) .should.equal true it "should set the root doc to main.tex", -> @@ -102,12 +102,12 @@ describe 'ProjectRootDocManager', -> "doc-id-2": "/main.tex" "doc-id-3": "/nested/chapter1a.tex" "doc-id-4": "/nested/chapter1b.tex" - @ProjectEntityHandler.getAllDocPathsFromProject = sinon.stub().callsArgWith(1, null, @docPaths) + @ProjectEntityHandler.getAllDocPathsFromProjectById = sinon.stub().callsArgWith(1, null, @docPaths) @ProjectEntityUpdateHandler.setRootDoc = sinon.stub().callsArgWith(2) @ProjectRootDocManager.setRootDocFromName @project_id, 'main.tex', done it "should check the docs of the project", -> - @ProjectEntityHandler.getAllDocPathsFromProject.calledWith(@project_id) + @ProjectEntityHandler.getAllDocPathsFromProjectById.calledWith(@project_id) .should.equal true it "should set the root doc to main.tex", -> @@ -121,12 +121,12 @@ describe 'ProjectRootDocManager', -> "doc-id-2": "/main.tex" "doc-id-3": "/nested/chapter1a.tex" "doc-id-4": "/nested/chapter1b.tex" - @ProjectEntityHandler.getAllDocPathsFromProject = sinon.stub().callsArgWith(1, null, @docPaths) + @ProjectEntityHandler.getAllDocPathsFromProjectById = sinon.stub().callsArgWith(1, null, @docPaths) @ProjectEntityUpdateHandler.setRootDoc = sinon.stub().callsArgWith(2) @ProjectRootDocManager.setRootDocFromName @project_id, 'chapter1a.tex', done it "should check the docs of the project", -> - @ProjectEntityHandler.getAllDocPathsFromProject.calledWith(@project_id) + @ProjectEntityHandler.getAllDocPathsFromProjectById.calledWith(@project_id) .should.equal true it "should set the root doc using the basename", -> @@ -140,7 +140,7 @@ describe 'ProjectRootDocManager', -> "doc-id-2": "/main.tex" "doc-id-3": "/nested/chapter1a.tex" "doc-id-4": "/nested/chapter1b.tex" - @ProjectEntityHandler.getAllDocPathsFromProject = sinon.stub().callsArgWith(1, null, @docPaths) + @ProjectEntityHandler.getAllDocPathsFromProjectById = sinon.stub().callsArgWith(1, null, @docPaths) @ProjectEntityUpdateHandler.setRootDoc = sinon.stub().callsArgWith(2) @ProjectRootDocManager.setRootDocFromName @project_id, "other.tex", done From 530a3b0d442a77a77e6c03f76f416edb97332fc5 Mon Sep 17 00:00:00 2001 From: Jessica Lawshe Date: Mon, 24 Sep 2018 09:59:22 -0500 Subject: [PATCH 11/44] Tab header styling li styling was being applied to nested lists in the tab, but this was only meant for the tab headers list --- .../web/public/stylesheets/components/tabs.less | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/services/web/public/stylesheets/components/tabs.less b/services/web/public/stylesheets/components/tabs.less index 71143cbefe..c93d6ad20a 100644 --- a/services/web/public/stylesheets/components/tabs.less +++ b/services/web/public/stylesheets/components/tabs.less @@ -6,8 +6,13 @@ margin-top: -@line-height-computed; //- adjusted for portal-name padding: @padding-lg 0 @padding-md; text-align: center; + } + .nav-tabs > li { + display: inline-block; + float: none; a { + border: 0; color: @link-color; &:hover { background-color: transparent!important; @@ -17,14 +22,6 @@ } } - li { - display: inline-block; - float: none; - a { - border: 0; - } - } - li.active > a { background-color: transparent!important; border: 0!important; @@ -39,6 +36,4 @@ background-color: transparent!important; border: none!important; } -} - - \ No newline at end of file +} \ No newline at end of file From 5c35cc959320bc25971b420f142de51e7fcbc96d Mon Sep 17 00:00:00 2001 From: Christopher Hoskin Date: Mon, 24 Sep 2018 16:33:54 +0100 Subject: [PATCH 12/44] Replace ShareLaTeX with Overleaf in name of multiple project download file (Closes: #963) --- .../coffee/Features/Downloads/ProjectDownloadsController.coffee | 2 +- .../coffee/Downloads/ProjectDownloadsControllerTests.coffee | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/services/web/app/coffee/Features/Downloads/ProjectDownloadsController.coffee b/services/web/app/coffee/Features/Downloads/ProjectDownloadsController.coffee index 2f4e2b7932..e73dc06eb2 100644 --- a/services/web/app/coffee/Features/Downloads/ProjectDownloadsController.coffee +++ b/services/web/app/coffee/Features/Downloads/ProjectDownloadsController.coffee @@ -32,7 +32,7 @@ module.exports = ProjectDownloadsController = return next(error) if error? res.setContentDisposition( 'attachment', - {filename: "ShareLaTeX Projects (#{project_ids.length} items).zip"} + {filename: "Overleaf Projects (#{project_ids.length} items).zip"} ) res.contentType('application/zip') stream.pipe(res) diff --git a/services/web/test/unit/coffee/Downloads/ProjectDownloadsControllerTests.coffee b/services/web/test/unit/coffee/Downloads/ProjectDownloadsControllerTests.coffee index 9e1b437bd0..dfa21508d8 100644 --- a/services/web/test/unit/coffee/Downloads/ProjectDownloadsControllerTests.coffee +++ b/services/web/test/unit/coffee/Downloads/ProjectDownloadsControllerTests.coffee @@ -112,7 +112,7 @@ describe "ProjectDownloadsController", -> @res.setContentDisposition .calledWith( 'attachment', - {filename: "ShareLaTeX Projects (2 items).zip"}) + {filename: "Overleaf Projects (2 items).zip"}) .should.equal true it "should record the action via Metrics", -> From 237810509adff8dd4b67bd164b031fca87a5d731 Mon Sep 17 00:00:00 2001 From: Alasdair Smith Date: Mon, 24 Sep 2018 17:06:11 +0100 Subject: [PATCH 13/44] If no project found for read token, redirect to v1 --- .../TokenAccess/TokenAccessController.coffee | 6 ++- .../TokenAccess/TokenAccessHandler.coffee | 14 +++++-- .../TokenAccessControllerTests.coffee | 18 +++++---- .../TokenAccessHandlerTests.coffee | 39 ++++++++++++++++++- 4 files changed, 63 insertions(+), 14 deletions(-) diff --git a/services/web/app/coffee/Features/TokenAccess/TokenAccessController.coffee b/services/web/app/coffee/Features/TokenAccess/TokenAccessController.coffee index 08aa4663f1..ada8c6cc10 100644 --- a/services/web/app/coffee/Features/TokenAccess/TokenAccessController.coffee +++ b/services/web/app/coffee/Features/TokenAccess/TokenAccessController.coffee @@ -77,11 +77,15 @@ module.exports = TokenAccessController = userId = AuthenticationController.getLoggedInUserId(req) token = req.params['read_only_token'] logger.log {userId, token}, "[TokenAccess] requesting read-only token access" - TokenAccessHandler.findProjectWithReadOnlyToken token, (err, project) -> + TokenAccessHandler.findProjectWithReadOnlyToken token, (err, project, projectExists) -> if err? logger.err {err, token, userId}, "[TokenAccess] error getting project by readOnly token" return next(err) + if !projectExists and settings.overleaf + logger.log {token, userId}, + "[TokenAccess] no project found for this token" + return res.redirect(302, settings.overleaf.host + '/read/' + token) if !project? logger.log {token, userId}, "[TokenAccess] no project found for readOnly token" diff --git a/services/web/app/coffee/Features/TokenAccess/TokenAccessHandler.coffee b/services/web/app/coffee/Features/TokenAccess/TokenAccessHandler.coffee index ed7f51f0d7..0f785abbc8 100644 --- a/services/web/app/coffee/Features/TokenAccess/TokenAccessHandler.coffee +++ b/services/web/app/coffee/Features/TokenAccess/TokenAccessHandler.coffee @@ -10,11 +10,17 @@ module.exports = TokenAccessHandler = ANONYMOUS_READ_AND_WRITE_ENABLED: Settings.allowAnonymousReadAndWriteSharing == true - findProjectWithReadOnlyToken: (token, callback=(err, project)->) -> + findProjectWithReadOnlyToken: (token, callback=(err, project, projectExists)->) -> Project.findOne { - 'tokens.readOnly': token, - 'publicAccesLevel': PublicAccessLevels.TOKEN_BASED - }, {_id: 1, publicAccesLevel: 1, owner_ref: 1}, callback + 'tokens.readOnly': token + }, {_id: 1, publicAccesLevel: 1, owner_ref: 1}, (err, project) -> + if err? + return callback(err) + if !project? + return callback(null, null, false) + if project.publicAccesLevel != PublicAccessLevels.TOKEN_BASED + return callback(null, null, true) + return callback(null, project, true) findProjectWithReadAndWriteToken: (token, callback=(err, project)->) -> Project.findOne { diff --git a/services/web/test/unit/coffee/TokenAccess/TokenAccessControllerTests.coffee b/services/web/test/unit/coffee/TokenAccess/TokenAccessControllerTests.coffee index 0bdb6d59e6..cb9e6afa39 100644 --- a/services/web/test/unit/coffee/TokenAccess/TokenAccessControllerTests.coffee +++ b/services/web/test/unit/coffee/TokenAccess/TokenAccessControllerTests.coffee @@ -405,7 +405,7 @@ describe "TokenAccessController", -> @next = sinon.stub() @req.params['read_only_token'] = @readOnlyToken @TokenAccessHandler.findProjectWithReadOnlyToken = sinon.stub() - .callsArgWith(1, null, @project) + .callsArgWith(1, null, @project, true) @TokenAccessHandler.addReadOnlyUserToProject = sinon.stub() .callsArgWith(2, null) @ProjectController.loadEditor = sinon.stub() @@ -441,7 +441,7 @@ describe "TokenAccessController", -> @req.params['read_only_token'] = @readOnlyToken @project.owner_ref = @userId @TokenAccessHandler.findProjectWithReadOnlyToken = sinon.stub() - .callsArgWith(1, null, @project) + .callsArgWith(1, null, @project, true) @TokenAccessHandler.addReadOnlyUserToProject = sinon.stub() .callsArgWith(2, null) @ProjectController.loadEditor = sinon.stub() @@ -513,7 +513,7 @@ describe "TokenAccessController", -> @next = sinon.stub() @req.params['read_and_write_token'] = '123abc' @TokenAccessHandler.findProjectWithReadOnlyToken = sinon.stub() - .callsArgWith(1, null, null) + .callsArgWith(1, null, null, true) @TokenAccessHandler.findProjectWithHigherAccess = sinon.stub() .callsArgWith(2, null, @project, false) @@ -626,7 +626,7 @@ describe "TokenAccessController", -> @next = sinon.stub() @req.params['read_only_token'] = @readOnlyToken @TokenAccessHandler.findProjectWithReadOnlyToken = sinon.stub() - .callsArgWith(1, null, @project) + .callsArgWith(1, null, @project, true) @TokenAccessHandler.addReadOnlyUserToProject = sinon.stub() .callsArgWith(2, new Error('woops')) @ProjectController.loadEditor = sinon.stub() @@ -670,7 +670,7 @@ describe "TokenAccessController", -> @next = sinon.stub() @req.params['read_only_token'] = @readOnlyToken @TokenAccessHandler.findProjectWithReadOnlyToken = sinon.stub() - .callsArgWith(1, null, @project) + .callsArgWith(1, null, @project, true) @TokenAccessHandler.addReadOnlyUserToProject = sinon.stub() .callsArgWith(2, null) @ProjectController.loadEditor = sinon.stub() @@ -748,6 +748,7 @@ describe "TokenAccessController", -> beforeEach -> @req = new MockRequest() @res = new MockResponse() + @res.redirect = sinon.stub() @next = sinon.stub() @req.params['read_only_token'] = @readOnlyToken @TokenAccessHandler.findProjectWithReadOnlyToken = sinon.stub() @@ -780,7 +781,10 @@ describe "TokenAccessController", -> done() it 'should call next with a not-found error', (done) -> - expect(@next.callCount).to.equal 1 - expect(@next.lastCall.args[0]).to.be.instanceof Error + expect(@res.redirect.callCount).to.equal 1 + expect(@res.redirect.calledWith( + 302, + "http://overleaf.test:5000/read/#{@readOnlyToken}" + )).to.equal true done() diff --git a/services/web/test/unit/coffee/TokenAccess/TokenAccessHandlerTests.coffee b/services/web/test/unit/coffee/TokenAccess/TokenAccessHandlerTests.coffee index 722a8496a2..8a2e66bb64 100644 --- a/services/web/test/unit/coffee/TokenAccess/TokenAccessHandlerTests.coffee +++ b/services/web/test/unit/coffee/TokenAccess/TokenAccessHandlerTests.coffee @@ -31,8 +31,7 @@ describe "TokenAccessHandler", -> @TokenAccessHandler.findProjectWithReadOnlyToken @token, (err, project) => expect(@Project.findOne.callCount).to.equal 1 expect(@Project.findOne.calledWith({ - 'tokens.readOnly': @token, - 'publicAccesLevel': 'tokenBased' + 'tokens.readOnly': @token })).to.equal true done() @@ -43,6 +42,11 @@ describe "TokenAccessHandler", -> expect(project).to.deep.equal @project done() + it 'should return projectExists flag as true', (done) -> + @TokenAccessHandler.findProjectWithReadOnlyToken @token, (err, project, projectExists) -> + expect(projectExists).to.equal true + done() + describe 'when Project.findOne produces an error', -> beforeEach -> @Project.findOne = sinon.stub().callsArgWith(2, new Error('woops')) @@ -54,6 +58,37 @@ describe "TokenAccessHandler", -> expect(err).to.be.instanceof Error done() + describe 'when project is not tokenBased', -> + beforeEach -> + @project.publicAccesLevel = 'private' + @Project.findOne = sinon.stub().callsArgWith(2, null, @project, true) + + it 'should not return a project', (done) -> + @TokenAccessHandler.findProjectWithReadOnlyToken @token, (err, project) -> + expect(err).to.not.exist + expect(project).to.not.exist + done() + + it 'should return projectExists flag as true', (done) -> + @TokenAccessHandler.findProjectWithReadOnlyToken @token, (err, project, projectExists) -> + expect(projectExists).to.equal true + done() + + describe 'when project does not exist', -> + beforeEach -> + @Project.findOne = sinon.stub().callsArgWith(2, null, null) + + it 'should not return a project', (done) -> + @TokenAccessHandler.findProjectWithReadOnlyToken @token, (err, project) -> + expect(err).to.not.exist + expect(project).to.not.exist + done() + + it 'should return projectExists flag as false', (done) -> + @TokenAccessHandler.findProjectWithReadOnlyToken @token, (err, project, projectExists) -> + expect(projectExists).to.equal false + done() + describe 'findProjectWithReadAndWriteToken', -> beforeEach -> @Project.findOne = sinon.stub().callsArgWith(2, null, @project) From 99dec02266ee69d56ea155ced9d748bd38129ecd Mon Sep 17 00:00:00 2001 From: Alasdair Smith Date: Mon, 24 Sep 2018 18:16:30 +0100 Subject: [PATCH 14/44] If no project found for read/write token, redirect to v1 --- .../TokenAccess/TokenAccessController.coffee | 6 ++- .../TokenAccess/TokenAccessHandler.coffee | 14 +++-- .../TokenAccessControllerTests.coffee | 52 ++++++------------- .../TokenAccessHandlerTests.coffee | 26 ++++++++-- 4 files changed, 53 insertions(+), 45 deletions(-) diff --git a/services/web/app/coffee/Features/TokenAccess/TokenAccessController.coffee b/services/web/app/coffee/Features/TokenAccess/TokenAccessController.coffee index ada8c6cc10..6c169b0bac 100644 --- a/services/web/app/coffee/Features/TokenAccess/TokenAccessController.coffee +++ b/services/web/app/coffee/Features/TokenAccess/TokenAccessController.coffee @@ -34,11 +34,15 @@ module.exports = TokenAccessController = userId = AuthenticationController.getLoggedInUserId(req) token = req.params['read_and_write_token'] logger.log {userId, token}, "[TokenAccess] requesting read-and-write token access" - TokenAccessHandler.findProjectWithReadAndWriteToken token, (err, project) -> + TokenAccessHandler.findProjectWithReadAndWriteToken token, (err, project, projectExists) -> if err? logger.err {err, token, userId}, "[TokenAccess] error getting project by readAndWrite token" return next(err) + if !projectExists and settings.overleaf + logger.log {token, userId}, + "[TokenAccess] no project found for this token" + return res.redirect(302, settings.overleaf.host + '/' + token) if !project? logger.log {token, userId}, "[TokenAccess] no token-based project found for readAndWrite token" diff --git a/services/web/app/coffee/Features/TokenAccess/TokenAccessHandler.coffee b/services/web/app/coffee/Features/TokenAccess/TokenAccessHandler.coffee index 0f785abbc8..41b7ed0ead 100644 --- a/services/web/app/coffee/Features/TokenAccess/TokenAccessHandler.coffee +++ b/services/web/app/coffee/Features/TokenAccess/TokenAccessHandler.coffee @@ -22,11 +22,17 @@ module.exports = TokenAccessHandler = return callback(null, null, true) return callback(null, project, true) - findProjectWithReadAndWriteToken: (token, callback=(err, project)->) -> + findProjectWithReadAndWriteToken: (token, callback=(err, project, projectExists)->) -> Project.findOne { - 'tokens.readAndWrite': token, - 'publicAccesLevel': PublicAccessLevels.TOKEN_BASED - }, {_id: 1, publicAccesLevel: 1, owner_ref: 1}, callback + 'tokens.readAndWrite': token + }, {_id: 1, publicAccesLevel: 1, owner_ref: 1}, (err, project) -> + if err? + return callback(err) + if !project? + return callback(null, null, false) + if project.publicAccesLevel != PublicAccessLevels.TOKEN_BASED + return callback(null, null, true) + return callback(null, project, true) findProjectWithHigherAccess: (token, userId, callback=(err, project, projectExists)->) -> Project.findOne { diff --git a/services/web/test/unit/coffee/TokenAccess/TokenAccessControllerTests.coffee b/services/web/test/unit/coffee/TokenAccess/TokenAccessControllerTests.coffee index cb9e6afa39..6a5f79151f 100644 --- a/services/web/test/unit/coffee/TokenAccess/TokenAccessControllerTests.coffee +++ b/services/web/test/unit/coffee/TokenAccess/TokenAccessControllerTests.coffee @@ -48,7 +48,7 @@ describe "TokenAccessController", -> @next = sinon.stub() @req.params['read_and_write_token'] = @readAndWriteToken @TokenAccessHandler.findProjectWithReadAndWriteToken = sinon.stub() - .callsArgWith(1, null, @project) + .callsArgWith(1, null, @project, true) @TokenAccessHandler.addReadAndWriteUserToProject = sinon.stub() .callsArgWith(2, null) @ProjectController.loadEditor = sinon.stub() @@ -85,7 +85,7 @@ describe "TokenAccessController", -> @req.params['read_and_write_token'] = @readAndWriteToken @project.owner_ref = @userId @TokenAccessHandler.findProjectWithReadAndWriteToken = sinon.stub() - .callsArgWith(1, null, @project) + .callsArgWith(1, null, @project, true) @TokenAccessHandler.addReadAndWriteUserToProject = sinon.stub() .callsArgWith(2, null) @ProjectController.loadEditor = sinon.stub() @@ -123,7 +123,7 @@ describe "TokenAccessController", -> @next = sinon.stub() @req.params['read_and_write_token'] = @readAndWriteToken @TokenAccessHandler.findProjectWithReadAndWriteToken = sinon.stub() - .callsArgWith(1, null, @project) + .callsArgWith(1, null, @project, true) @TokenAccessHandler.addReadAndWriteUserToProject = sinon.stub() .callsArgWith(2, null) @ProjectController.loadEditor = sinon.stub() @@ -159,7 +159,7 @@ describe "TokenAccessController", -> @next = sinon.stub() @req.params['read_and_write_token'] = @readAndWriteToken @TokenAccessHandler.findProjectWithReadAndWriteToken = sinon.stub() - .callsArgWith(1, null, @project) + .callsArgWith(1, null, @project, true) @TokenAccessHandler.addReadAndWriteUserToProject = sinon.stub() .callsArgWith(2, null) @ProjectController.loadEditor = sinon.stub() @@ -244,7 +244,7 @@ describe "TokenAccessController", -> @next = sinon.stub() @req.params['read_and_write_token'] = '123abc' @TokenAccessHandler.findProjectWithReadAndWriteToken = sinon.stub() - .callsArgWith(1, null, null) + .callsArgWith(1, null, null, false) @TokenAccessHandler.findProjectWithHigherAccess = sinon.stub() .callsArgWith(2, null, @project, false) @@ -252,8 +252,10 @@ describe "TokenAccessController", -> it 'should redirect to v1', (done) -> expect(@res.redirect.callCount).to.equal 1 - expect(@res.redirect.firstCall.args[0]) - .to.equal 'http://overleaf.test:5000/123abc' + expect(@res.redirect.calledWith( + 302, + 'http://overleaf.test:5000/123abc' + )).to.equal true done() describe 'when token access is off, but user has higher access anyway', -> @@ -264,7 +266,7 @@ describe "TokenAccessController", -> @next = sinon.stub() @req.params['read_and_write_token'] = @readAndWriteToken @TokenAccessHandler.findProjectWithReadAndWriteToken = sinon.stub() - .callsArgWith(1, null, null) + .callsArgWith(1, null, null, true) @TokenAccessHandler.findProjectWithHigherAccess = sinon.stub() .callsArgWith(2, null, @project, true) @@ -313,7 +315,7 @@ describe "TokenAccessController", -> @next = sinon.stub() @req.params['read_and_write_token'] = @readAndWriteToken @TokenAccessHandler.findProjectWithReadAndWriteToken = sinon.stub() - .callsArgWith(1, null, null) + .callsArgWith(1, null, null, true) @TokenAccessHandler.findProjectWithHigherAccess = sinon.stub() .callsArgWith(2, null, null, true) @@ -358,7 +360,7 @@ describe "TokenAccessController", -> @next = sinon.stub() @req.params['read_and_write_token'] = @readAndWriteToken @TokenAccessHandler.findProjectWithReadAndWriteToken = sinon.stub() - .callsArgWith(1, null, @project) + .callsArgWith(1, null, @project, true) @TokenAccessHandler.addReadAndWriteUserToProject = sinon.stub() .callsArgWith(2, new Error('woops')) @ProjectController.loadEditor = sinon.stub() @@ -500,31 +502,7 @@ describe "TokenAccessController", -> expect(@next.lastCall.args[0]).to.be.instanceof Error done() - ## describe 'when findProject does not find a project', -> - beforeEach -> - - describe 'when project does not exist', -> - beforeEach -> - @req = new MockRequest() - @req.url = '/123abc' - @res = new MockResponse() - @res.redirect = sinon.stub() - @next = sinon.stub() - @req.params['read_and_write_token'] = '123abc' - @TokenAccessHandler.findProjectWithReadOnlyToken = sinon.stub() - .callsArgWith(1, null, null, true) - @TokenAccessHandler.findProjectWithHigherAccess = - sinon.stub() - .callsArgWith(2, null, @project, false) - @TokenAccessController.readOnlyToken @req, @res, @next - - it 'should return a ProjectNotTokenAccessError', (done) -> - expect(@res.redirect.callCount).to.equal 1 - expect(@res.redirect.firstCall.args[0]) - .to.equal 'http://overleaf.test:5000/123abc' - done() - describe 'when token access is off, but user has higher access anyway', -> beforeEach -> @req = new MockRequest() @@ -533,7 +511,7 @@ describe "TokenAccessController", -> @next = sinon.stub() @req.params['read_and_write_token'] = @readAndWriteToken @TokenAccessHandler.findProjectWithReadAndWriteToken = sinon.stub() - .callsArgWith(1, null, null) + .callsArgWith(1, null, null, true) @TokenAccessHandler.findProjectWithHigherAccess = sinon.stub() .callsArgWith(2, null, @project, true) @@ -581,7 +559,7 @@ describe "TokenAccessController", -> @next = sinon.stub() @req.params['read_and_write_token'] = @readAndWriteToken @TokenAccessHandler.findProjectWithReadAndWriteToken = sinon.stub() - .callsArgWith(1, null, null) + .callsArgWith(1, null, null, true) @TokenAccessHandler.findProjectWithHigherAccess = sinon.stub() .callsArgWith(2, null, null, true) @@ -780,7 +758,7 @@ describe "TokenAccessController", -> .to.equal 0 done() - it 'should call next with a not-found error', (done) -> + it 'should redirect to v1', (done) -> expect(@res.redirect.callCount).to.equal 1 expect(@res.redirect.calledWith( 302, diff --git a/services/web/test/unit/coffee/TokenAccess/TokenAccessHandlerTests.coffee b/services/web/test/unit/coffee/TokenAccess/TokenAccessHandlerTests.coffee index 8a2e66bb64..ed6ba79877 100644 --- a/services/web/test/unit/coffee/TokenAccess/TokenAccessHandlerTests.coffee +++ b/services/web/test/unit/coffee/TokenAccess/TokenAccessHandlerTests.coffee @@ -58,7 +58,7 @@ describe "TokenAccessHandler", -> expect(err).to.be.instanceof Error done() - describe 'when project is not tokenBased', -> + describe 'when project does not have tokenBased access level', -> beforeEach -> @project.publicAccesLevel = 'private' @Project.findOne = sinon.stub().callsArgWith(2, null, @project, true) @@ -97,8 +97,7 @@ describe "TokenAccessHandler", -> @TokenAccessHandler.findProjectWithReadAndWriteToken @token, (err, project) => expect(@Project.findOne.callCount).to.equal 1 expect(@Project.findOne.calledWith({ - 'tokens.readAndWrite': @token, - 'publicAccesLevel': 'tokenBased' + 'tokens.readAndWrite': @token })).to.equal true done() @@ -109,6 +108,11 @@ describe "TokenAccessHandler", -> expect(project).to.deep.equal @project done() + it 'should return projectExists flag as true', (done) -> + @TokenAccessHandler.findProjectWithReadAndWriteToken @token, (err, project, projectExists) -> + expect(projectExists).to.equal true + done() + describe 'when Project.findOne produces an error', -> beforeEach -> @Project.findOne = sinon.stub().callsArgWith(2, new Error('woops')) @@ -120,6 +124,22 @@ describe "TokenAccessHandler", -> expect(err).to.be.instanceof Error done() + describe 'when project does not have tokenBased access level', -> + beforeEach -> + @project.publicAccesLevel = 'private' + @Project.findOne = sinon.stub().callsArgWith(2, null, @project, true) + + it 'should not return a project', (done) -> + @TokenAccessHandler.findProjectWithReadAndWriteToken @token, (err, project) -> + expect(err).to.not.exist + expect(project).to.not.exist + done() + + it 'should return projectExists flag as true', (done) -> + @TokenAccessHandler.findProjectWithReadAndWriteToken @token, (err, project, projectExists) -> + expect(projectExists).to.equal true + done() + describe 'findProjectWithHigherAccess', -> describe 'when user does have higher access', -> From d6350c963e35ffbc1c3d3735ce566edc3ef9102d Mon Sep 17 00:00:00 2001 From: Alasdair Smith Date: Mon, 24 Sep 2018 18:31:07 +0100 Subject: [PATCH 15/44] Remove projectExists flag from higher access check Now that find project by read and read/write token methods check whether the project exists, it is not neccessary to check whether the project exists in the higher access check. Therefore it has been removed --- .../TokenAccess/TokenAccessController.coffee | 7 +------ .../TokenAccess/TokenAccessHandler.coffee | 15 +++++++-------- .../TokenAccess/TokenAccessControllerTests.coffee | 10 +++++----- 3 files changed, 13 insertions(+), 19 deletions(-) diff --git a/services/web/app/coffee/Features/TokenAccess/TokenAccessController.coffee b/services/web/app/coffee/Features/TokenAccess/TokenAccessController.coffee index 6c169b0bac..d5d704703d 100644 --- a/services/web/app/coffee/Features/TokenAccess/TokenAccessController.coffee +++ b/services/web/app/coffee/Features/TokenAccess/TokenAccessController.coffee @@ -12,16 +12,11 @@ module.exports = TokenAccessController = return ProjectController.loadEditor(req, res, next) _tryHigherAccess: (token, userId, req, res, next) -> - TokenAccessHandler.findProjectWithHigherAccess token, userId, (err, project, projectExists) -> + TokenAccessHandler.findProjectWithHigherAccess token, userId, (err, project) -> if err? logger.err {err, token, userId}, "[TokenAccess] error finding project with higher access" return next(err) - if !projectExists and settings.overleaf - logger.log {token, userId}, - "[TokenAccess] no project found for this token" - # Project does not exist, but may be unimported - try it on v1 - return res.redirect(settings.overleaf.host + req.url) if !project? logger.log {token, userId}, "[TokenAccess] no project with higher access found for this user and token" diff --git a/services/web/app/coffee/Features/TokenAccess/TokenAccessHandler.coffee b/services/web/app/coffee/Features/TokenAccess/TokenAccessHandler.coffee index 41b7ed0ead..d2dedca76e 100644 --- a/services/web/app/coffee/Features/TokenAccess/TokenAccessHandler.coffee +++ b/services/web/app/coffee/Features/TokenAccess/TokenAccessHandler.coffee @@ -17,9 +17,9 @@ module.exports = TokenAccessHandler = if err? return callback(err) if !project? - return callback(null, null, false) + return callback(null, null, false) # Project doesn't exist, so we handle differently if project.publicAccesLevel != PublicAccessLevels.TOKEN_BASED - return callback(null, null, true) + return callback(null, null, true) # Project does exist, but it isn't token based return callback(null, project, true) findProjectWithReadAndWriteToken: (token, callback=(err, project, projectExists)->) -> @@ -29,12 +29,12 @@ module.exports = TokenAccessHandler = if err? return callback(err) if !project? - return callback(null, null, false) + return callback(null, null, false) # Project doesn't exist, so we handle differently if project.publicAccesLevel != PublicAccessLevels.TOKEN_BASED - return callback(null, null, true) + return callback(null, null, true) # Project does exist, but it isn't token based return callback(null, project, true) - findProjectWithHigherAccess: (token, userId, callback=(err, project, projectExists)->) -> + findProjectWithHigherAccess: (token, userId, callback=(err, project)->) -> Project.findOne { $or: [ {'tokens.readAndWrite': token}, @@ -44,15 +44,14 @@ module.exports = TokenAccessHandler = if err? return callback(err) if !project? - return callback(null, null, false) # Project doesn't exist, so we handle differently + return callback(null, null) projectId = project._id CollaboratorsHandler.isUserInvitedMemberOfProject userId, projectId, (err, isMember) -> if err? return callback(err) callback( null, - if isMember == true then project else null, - true # Project does exist, but user doesn't have access + if isMember == true then project else null ) addReadOnlyUserToProject: (userId, projectId, callback=(err)->) -> diff --git a/services/web/test/unit/coffee/TokenAccess/TokenAccessControllerTests.coffee b/services/web/test/unit/coffee/TokenAccess/TokenAccessControllerTests.coffee index 6a5f79151f..97312ab415 100644 --- a/services/web/test/unit/coffee/TokenAccess/TokenAccessControllerTests.coffee +++ b/services/web/test/unit/coffee/TokenAccess/TokenAccessControllerTests.coffee @@ -247,7 +247,7 @@ describe "TokenAccessController", -> .callsArgWith(1, null, null, false) @TokenAccessHandler.findProjectWithHigherAccess = sinon.stub() - .callsArgWith(2, null, @project, false) + .callsArgWith(2, null, @project) @TokenAccessController.readAndWriteToken @req, @res, @next it 'should redirect to v1', (done) -> @@ -269,7 +269,7 @@ describe "TokenAccessController", -> .callsArgWith(1, null, null, true) @TokenAccessHandler.findProjectWithHigherAccess = sinon.stub() - .callsArgWith(2, null, @project, true) + .callsArgWith(2, null, @project) @TokenAccessHandler.addReadAndWriteUserToProject = sinon.stub() .callsArgWith(2, null) @ProjectController.loadEditor = sinon.stub() @@ -318,7 +318,7 @@ describe "TokenAccessController", -> .callsArgWith(1, null, null, true) @TokenAccessHandler.findProjectWithHigherAccess = sinon.stub() - .callsArgWith(2, null, null, true) + .callsArgWith(2, null, null) @TokenAccessHandler.addReadAndWriteUserToProject = sinon.stub() .callsArgWith(2, null) @ProjectController.loadEditor = sinon.stub() @@ -514,7 +514,7 @@ describe "TokenAccessController", -> .callsArgWith(1, null, null, true) @TokenAccessHandler.findProjectWithHigherAccess = sinon.stub() - .callsArgWith(2, null, @project, true) + .callsArgWith(2, null, @project) @TokenAccessHandler.addReadAndWriteUserToProject = sinon.stub() .callsArgWith(2, null) @ProjectController.loadEditor = sinon.stub() @@ -562,7 +562,7 @@ describe "TokenAccessController", -> .callsArgWith(1, null, null, true) @TokenAccessHandler.findProjectWithHigherAccess = sinon.stub() - .callsArgWith(2, null, null, true) + .callsArgWith(2, null, null) @TokenAccessHandler.addReadOnlyUserToProject = sinon.stub() .callsArgWith(2, null) @ProjectController.loadEditor = sinon.stub() From d6126719bcfa84d4167a64dcf346bdc5b14775bc Mon Sep 17 00:00:00 2001 From: Jessica Lawshe Date: Mon, 24 Sep 2018 15:59:44 -0500 Subject: [PATCH 16/44] Alternate
color for content pages --- services/web/public/stylesheets/app/content_page.less | 3 +++ services/web/public/stylesheets/core/ol-variables.less | 1 + 2 files changed, 4 insertions(+) diff --git a/services/web/public/stylesheets/app/content_page.less b/services/web/public/stylesheets/app/content_page.less index 6e6374d944..2fb31b137f 100644 --- a/services/web/public/stylesheets/app/content_page.less +++ b/services/web/public/stylesheets/app/content_page.less @@ -6,4 +6,7 @@ a:not(.btn) { color: @link-color-alt; } + hr { + border-color: @hr-border-alt; + } } \ No newline at end of file diff --git a/services/web/public/stylesheets/core/ol-variables.less b/services/web/public/stylesheets/core/ol-variables.less index 6bf5284ad5..de3eaf9e3b 100644 --- a/services/web/public/stylesheets/core/ol-variables.less +++ b/services/web/public/stylesheets/core/ol-variables.less @@ -53,6 +53,7 @@ @link-active-color : @ol-dark-green; @link-hover-color : @ol-dark-blue; @hr-border : @ol-blue-gray-1; +@hr-border-alt : @gray-lighter; // Button colors and sizing @btn-border-width : 0; From 7fddf589163b98b82242a8cf0e873d688218d171 Mon Sep 17 00:00:00 2001 From: Jessica Lawshe Date: Mon, 24 Sep 2018 16:05:28 -0500 Subject: [PATCH 17/44] Green tab links --- services/web/public/stylesheets/components/tabs.less | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/web/public/stylesheets/components/tabs.less b/services/web/public/stylesheets/components/tabs.less index ff7b025fdc..c3e25ce546 100644 --- a/services/web/public/stylesheets/components/tabs.less +++ b/services/web/public/stylesheets/components/tabs.less @@ -13,7 +13,7 @@ float: none; a { border: 0; - color: @link-color; + color: @link-color-alt; &:hover { background-color: transparent!important; border: 0!important; From 670129049ff29b0eb1cf3b36f5b406eb76dd4354 Mon Sep 17 00:00:00 2001 From: Jessica Lawshe Date: Mon, 24 Sep 2018 16:12:13 -0500 Subject: [PATCH 18/44] Fix text wrapping on quotes --- services/web/public/stylesheets/app/content_page.less | 3 +++ 1 file changed, 3 insertions(+) diff --git a/services/web/public/stylesheets/app/content_page.less b/services/web/public/stylesheets/app/content_page.less index 2fb31b137f..bfef7e7fc8 100644 --- a/services/web/public/stylesheets/app/content_page.less +++ b/services/web/public/stylesheets/app/content_page.less @@ -9,4 +9,7 @@ hr { border-color: @hr-border-alt; } + .quote-by { + overflow: hidden; + } } \ No newline at end of file From f89e85231a91228a9fa127d241f59219f4b710c8 Mon Sep 17 00:00:00 2001 From: Ersun Warncke Date: Mon, 24 Sep 2018 18:03:28 -0400 Subject: [PATCH 19/44] check access for doc on read only token --- .../TokenAccess/TokenAccessController.coffee | 38 ++++++++++--------- .../web/app/coffee/Features/V1/V1Api.coffee | 26 +++++++++++++ .../coffee/helpers/MockV1Api.coffee | 3 ++ .../TokenAccessControllerTests.coffee | 17 +++++++++ 4 files changed, 67 insertions(+), 17 deletions(-) create mode 100644 services/web/app/coffee/Features/V1/V1Api.coffee diff --git a/services/web/app/coffee/Features/TokenAccess/TokenAccessController.coffee b/services/web/app/coffee/Features/TokenAccess/TokenAccessController.coffee index 08aa4663f1..49a3892855 100644 --- a/services/web/app/coffee/Features/TokenAccess/TokenAccessController.coffee +++ b/services/web/app/coffee/Features/TokenAccess/TokenAccessController.coffee @@ -4,6 +4,7 @@ TokenAccessHandler = require './TokenAccessHandler' Errors = require '../Errors/Errors' logger = require 'logger-sharelatex' settings = require 'settings-sharelatex' +V1Api = require "../V1/V1Api" module.exports = TokenAccessController = @@ -91,23 +92,26 @@ module.exports = TokenAccessController = return next(new Errors.NotFoundError()) TokenAccessController._tryHigherAccess(token, userId, req, res, next) else - if !userId? - logger.log {userId, projectId: project._id}, - "[TokenAccess] adding anonymous user to project with readOnly token" - TokenAccessHandler.grantSessionTokenAccess(req, project._id, token) - req._anonymousAccessToken = token - return TokenAccessController._loadEditor(project._id, req, res, next) - else - if project.owner_ref.toString() == userId + V1Api.request { url: "/api/v1/sharelatex/docs/#{token}/read" }, (err, respose, body) -> + return next err if err? + return res.redirect body.published_path if body.allow == false + if !userId? logger.log {userId, projectId: project._id}, - "[TokenAccess] user is already project owner" - return TokenAccessController._loadEditor(project._id, req, res, next) - logger.log {userId, projectId: project._id}, - "[TokenAccess] adding user to project with readOnly token" - TokenAccessHandler.addReadOnlyUserToProject userId, project._id, (err) -> - if err? - logger.err {err, token, userId, projectId: project._id}, - "[TokenAccess] error adding user to project with readAndWrite token" - return next(err) + "[TokenAccess] adding anonymous user to project with readOnly token" + TokenAccessHandler.grantSessionTokenAccess(req, project._id, token) + req._anonymousAccessToken = token return TokenAccessController._loadEditor(project._id, req, res, next) + else + if project.owner_ref.toString() == userId + logger.log {userId, projectId: project._id}, + "[TokenAccess] user is already project owner" + return TokenAccessController._loadEditor(project._id, req, res, next) + logger.log {userId, projectId: project._id}, + "[TokenAccess] adding user to project with readOnly token" + TokenAccessHandler.addReadOnlyUserToProject userId, project._id, (err) -> + if err? + logger.err {err, token, userId, projectId: project._id}, + "[TokenAccess] error adding user to project with readAndWrite token" + return next(err) + return TokenAccessController._loadEditor(project._id, req, res, next) diff --git a/services/web/app/coffee/Features/V1/V1Api.coffee b/services/web/app/coffee/Features/V1/V1Api.coffee new file mode 100644 index 0000000000..6e781ef147 --- /dev/null +++ b/services/web/app/coffee/Features/V1/V1Api.coffee @@ -0,0 +1,26 @@ +request = require 'request' +settings = require 'settings-sharelatex' + +# TODO: check what happens when these settings aren't defined +DEFAULT_V1_PARAMS = { + baseUrl: settings?.apis?.v1?.url + auth: + user: settings?.apis?.v1?.user + pass: settings?.apis?.v1?.pass + json: true, + timeout: 30 * 1000 +} + +request = request.defaults(DEFAULT_V1_PARAMS) + +module.exports = V1Api = + request: (options, callback) -> + return request(options) if !callback? + request options, (error, response, body) -> + return callback(error, response, body) if error? + if 200 <= response.statusCode < 300 or response.statusCode in (options.expectedStatusCodes or []) + callback null, response, body + else + error = new Error("overleaf v1 returned non-success code: #{response.statusCode}") + error.statusCode = response.statusCode + callback error diff --git a/services/web/test/acceptance/coffee/helpers/MockV1Api.coffee b/services/web/test/acceptance/coffee/helpers/MockV1Api.coffee index eeafa6a44b..5ecd11a991 100644 --- a/services/web/test/acceptance/coffee/helpers/MockV1Api.coffee +++ b/services/web/test/acceptance/coffee/helpers/MockV1Api.coffee @@ -81,5 +81,8 @@ module.exports = MockV1Api = .on "error", (error) -> console.error "error starting MockV1Api:", error.message process.exit(1) + + app.get '/api/v1/sharelatex/docs/:token/read', (req, res, next) => + res.json { allow: true } MockV1Api.run() diff --git a/services/web/test/unit/coffee/TokenAccess/TokenAccessControllerTests.coffee b/services/web/test/unit/coffee/TokenAccess/TokenAccessControllerTests.coffee index 0bdb6d59e6..0d65948225 100644 --- a/services/web/test/unit/coffee/TokenAccess/TokenAccessControllerTests.coffee +++ b/services/web/test/unit/coffee/TokenAccess/TokenAccessControllerTests.coffee @@ -34,6 +34,9 @@ describe "TokenAccessController", -> overleaf: host: 'http://overleaf.test:5000' } + '../V1/V1Api': @V1Api = { + request: sinon.stub().callsArgWith(1, null, {}, { allow: true }) + } @AuthenticationController.getLoggedInUserId = sinon.stub().returns(@userId.toString()) @@ -394,6 +397,20 @@ describe "TokenAccessController", -> describe 'readOnlyToken', -> beforeEach -> + describe 'when access not allowed by v1 api', -> + beforeEach -> + @req = new MockRequest() + @res = new MockResponse() + @res.redirect = sinon.stub() + @next = sinon.stub() + @TokenAccessHandler.findProjectWithReadOnlyToken = sinon.stub() + .callsArgWith(1, null, @project) + @V1Api.request = sinon.stub().callsArgWith(1, null, {}, { allow: false, published_path: 'doc-url'} ) + @TokenAccessController.readOnlyToken @req, @res, @next + + it 'should redirect to doc-url', -> + expect(@res.redirect.calledWith('doc-url')).to.equal true + describe 'with a user', -> beforeEach -> @AuthenticationController.getLoggedInUserId = sinon.stub().returns(@userId.toString()) From 19b97e953fa622ed663ebe52428cbe12b6f5c2f7 Mon Sep 17 00:00:00 2001 From: James Allen Date: Tue, 25 Sep 2018 08:29:34 +0100 Subject: [PATCH 20/44] Show register button on OL v2 --- services/web/app/coffee/infrastructure/Features.coffee | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/web/app/coffee/infrastructure/Features.coffee b/services/web/app/coffee/infrastructure/Features.coffee index 37f62ddec2..7d47876773 100644 --- a/services/web/app/coffee/infrastructure/Features.coffee +++ b/services/web/app/coffee/infrastructure/Features.coffee @@ -9,7 +9,7 @@ module.exports = Features = when 'homepage' return Settings.enableHomepage when 'registration' - return not Features.externalAuthenticationSystemUsed() + return not Features.externalAuthenticationSystemUsed() or Settings.overleaf? when 'github-sync' return Settings.enableGithubSync when 'v1-return-message' From 0d4143205d1728bd1101b572fffeebab911b2d53 Mon Sep 17 00:00:00 2001 From: Brian Gough Date: Tue, 25 Sep 2018 09:04:56 +0100 Subject: [PATCH 21/44] strip quotes from mainFile --- .../Project/ProjectRootDocManager.coffee | 6 +++++- .../Project/ProjectRootDocManagerTests.coffee | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/services/web/app/coffee/Features/Project/ProjectRootDocManager.coffee b/services/web/app/coffee/Features/Project/ProjectRootDocManager.coffee index b331c38aa6..973d2b2ed6 100644 --- a/services/web/app/coffee/Features/Project/ProjectRootDocManager.coffee +++ b/services/web/app/coffee/Features/Project/ProjectRootDocManager.coffee @@ -35,11 +35,15 @@ module.exports = ProjectRootDocManager = setRootDocFromName: (project_id, rootDocName, callback = (error) ->) -> ProjectEntityHandler.getAllDocPathsFromProjectById project_id, (error, docPaths) -> return callback(error) if error? + # strip off leading and trailing quotes from rootDocName + rootDocName = rootDocName.replace(/^\'|\'$/g,"") + # prepend a slash for the root folder if not present + rootDocName = "/#{rootDocName}" if rootDocName[0] isnt '/' # find the root doc from the filename root_doc_id = null for doc_id, path of docPaths # docpaths have a leading / so allow matching "folder/filename" and "/folder/filename" - if path == rootDocName or path == "/#{rootDocName}" + if path == rootDocName root_doc_id = doc_id # try a basename match if there was no match if !root_doc_id diff --git a/services/web/test/unit/coffee/Project/ProjectRootDocManagerTests.coffee b/services/web/test/unit/coffee/Project/ProjectRootDocManagerTests.coffee index d65b92043a..47c2973e9f 100644 --- a/services/web/test/unit/coffee/Project/ProjectRootDocManagerTests.coffee +++ b/services/web/test/unit/coffee/Project/ProjectRootDocManagerTests.coffee @@ -133,6 +133,25 @@ describe 'ProjectRootDocManager', -> @ProjectEntityUpdateHandler.setRootDoc.calledWith(@project_id, "doc-id-3") .should.equal true + describe "when there is a suitable root doc but the filename is in quotes", -> + beforeEach (done)-> + @docPaths = + "doc-id-1": "/chapter1.tex" + "doc-id-2": "/main.tex" + "doc-id-3": "/nested/chapter1a.tex" + "doc-id-4": "/nested/chapter1b.tex" + @ProjectEntityHandler.getAllDocPathsFromProjectById = sinon.stub().callsArgWith(1, null, @docPaths) + @ProjectEntityUpdateHandler.setRootDoc = sinon.stub().callsArgWith(2) + @ProjectRootDocManager.setRootDocFromName @project_id, "'main.tex'", done + + it "should check the docs of the project", -> + @ProjectEntityHandler.getAllDocPathsFromProjectById.calledWith(@project_id) + .should.equal true + + it "should set the root doc to main.tex", -> + @ProjectEntityUpdateHandler.setRootDoc.calledWith(@project_id, "doc-id-2") + .should.equal true + describe "when there is no suitable root doc", -> beforeEach (done)-> @docPaths = From ca895ae1b1484259a199b0e8fc55b2e99b07cf0c Mon Sep 17 00:00:00 2001 From: Alasdair Smith Date: Tue, 25 Sep 2018 09:37:22 +0100 Subject: [PATCH 22/44] Redirect to v1 via sign in link --- .../coffee/Features/TokenAccess/TokenAccessController.coffee | 2 +- services/web/test/acceptance/coffee/TokenAccessTests.coffee | 2 +- .../unit/coffee/TokenAccess/TokenAccessControllerTests.coffee | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/services/web/app/coffee/Features/TokenAccess/TokenAccessController.coffee b/services/web/app/coffee/Features/TokenAccess/TokenAccessController.coffee index d5d704703d..504a7fc24f 100644 --- a/services/web/app/coffee/Features/TokenAccess/TokenAccessController.coffee +++ b/services/web/app/coffee/Features/TokenAccess/TokenAccessController.coffee @@ -37,7 +37,7 @@ module.exports = TokenAccessController = if !projectExists and settings.overleaf logger.log {token, userId}, "[TokenAccess] no project found for this token" - return res.redirect(302, settings.overleaf.host + '/' + token) + return res.redirect(302, "/sign_in_to_v1?return_to=#{settings.overleaf.host}/#{token}") if !project? logger.log {token, userId}, "[TokenAccess] no token-based project found for readAndWrite token" diff --git a/services/web/test/acceptance/coffee/TokenAccessTests.coffee b/services/web/test/acceptance/coffee/TokenAccessTests.coffee index b0fbd7a0a1..a2e6a1ee7c 100644 --- a/services/web/test/acceptance/coffee/TokenAccessTests.coffee +++ b/services/web/test/acceptance/coffee/TokenAccessTests.coffee @@ -422,6 +422,6 @@ describe 'TokenAccess', -> try_read_and_write_token_access(@owner, unimportedV1Token, (response, body) => expect(response.statusCode).to.equal 302 expect(response.headers.location).to.equal( - 'http://overleaf.test:5000/123abc' + '/sign_in_to_v1?return_to=http://overleaf.test:5000/123abc' ) , done) diff --git a/services/web/test/unit/coffee/TokenAccess/TokenAccessControllerTests.coffee b/services/web/test/unit/coffee/TokenAccess/TokenAccessControllerTests.coffee index 97312ab415..a94a502396 100644 --- a/services/web/test/unit/coffee/TokenAccess/TokenAccessControllerTests.coffee +++ b/services/web/test/unit/coffee/TokenAccess/TokenAccessControllerTests.coffee @@ -254,7 +254,7 @@ describe "TokenAccessController", -> expect(@res.redirect.callCount).to.equal 1 expect(@res.redirect.calledWith( 302, - 'http://overleaf.test:5000/123abc' + '/sign_in_to_v1?return_to=http://overleaf.test:5000/123abc' )).to.equal true done() From da16e8d01f49752bb143333341f460bbbffd705a Mon Sep 17 00:00:00 2001 From: Alasdair Smith Date: Tue, 25 Sep 2018 09:43:39 +0100 Subject: [PATCH 23/44] Add acceptance test for unimported read only token --- .../test/acceptance/coffee/TokenAccessTests.coffee | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/services/web/test/acceptance/coffee/TokenAccessTests.coffee b/services/web/test/acceptance/coffee/TokenAccessTests.coffee index a2e6a1ee7c..8636012f23 100644 --- a/services/web/test/acceptance/coffee/TokenAccessTests.coffee +++ b/services/web/test/acceptance/coffee/TokenAccessTests.coffee @@ -417,7 +417,7 @@ describe 'TokenAccess', -> , done) describe 'unimported v1 project', -> - it 'should redirect to v1', (done) -> + it 'should redirect read and write token to v1', (done) -> unimportedV1Token = '123abc' try_read_and_write_token_access(@owner, unimportedV1Token, (response, body) => expect(response.statusCode).to.equal 302 @@ -425,3 +425,12 @@ describe 'TokenAccess', -> '/sign_in_to_v1?return_to=http://overleaf.test:5000/123abc' ) , done) + + it 'should redirect read only token to v1', (done) -> + unimportedV1Token = 'abcd' + try_read_only_token_access(@owner, unimportedV1Token, (response, body) => + expect(response.statusCode).to.equal 302 + expect(response.headers.location).to.equal( + 'http://overleaf.test:5000/read/abcd' + ) + , done) From 298ee2dbb494c733583a6b5831fc67c9f6c79c8d Mon Sep 17 00:00:00 2001 From: Alasdair Smith Date: Tue, 25 Sep 2018 10:06:24 +0100 Subject: [PATCH 24/44] Fix v1 return to path --- .../coffee/Features/TokenAccess/TokenAccessController.coffee | 2 +- services/web/test/acceptance/coffee/TokenAccessTests.coffee | 2 +- .../unit/coffee/TokenAccess/TokenAccessControllerTests.coffee | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/services/web/app/coffee/Features/TokenAccess/TokenAccessController.coffee b/services/web/app/coffee/Features/TokenAccess/TokenAccessController.coffee index 504a7fc24f..a0bd471630 100644 --- a/services/web/app/coffee/Features/TokenAccess/TokenAccessController.coffee +++ b/services/web/app/coffee/Features/TokenAccess/TokenAccessController.coffee @@ -37,7 +37,7 @@ module.exports = TokenAccessController = if !projectExists and settings.overleaf logger.log {token, userId}, "[TokenAccess] no project found for this token" - return res.redirect(302, "/sign_in_to_v1?return_to=#{settings.overleaf.host}/#{token}") + return res.redirect(302, "/sign_in_to_v1?return_to=/#{token}") if !project? logger.log {token, userId}, "[TokenAccess] no token-based project found for readAndWrite token" diff --git a/services/web/test/acceptance/coffee/TokenAccessTests.coffee b/services/web/test/acceptance/coffee/TokenAccessTests.coffee index 8636012f23..31be9523ba 100644 --- a/services/web/test/acceptance/coffee/TokenAccessTests.coffee +++ b/services/web/test/acceptance/coffee/TokenAccessTests.coffee @@ -422,7 +422,7 @@ describe 'TokenAccess', -> try_read_and_write_token_access(@owner, unimportedV1Token, (response, body) => expect(response.statusCode).to.equal 302 expect(response.headers.location).to.equal( - '/sign_in_to_v1?return_to=http://overleaf.test:5000/123abc' + '/sign_in_to_v1?return_to=/123abc' ) , done) diff --git a/services/web/test/unit/coffee/TokenAccess/TokenAccessControllerTests.coffee b/services/web/test/unit/coffee/TokenAccess/TokenAccessControllerTests.coffee index a94a502396..a0177a79a7 100644 --- a/services/web/test/unit/coffee/TokenAccess/TokenAccessControllerTests.coffee +++ b/services/web/test/unit/coffee/TokenAccess/TokenAccessControllerTests.coffee @@ -254,7 +254,7 @@ describe "TokenAccessController", -> expect(@res.redirect.callCount).to.equal 1 expect(@res.redirect.calledWith( 302, - '/sign_in_to_v1?return_to=http://overleaf.test:5000/123abc' + '/sign_in_to_v1?return_to=/123abc' )).to.equal true done() From b8baf1a6f4b56bcf907f85d9841327f1673d8dcd Mon Sep 17 00:00:00 2001 From: Paulo Reis Date: Wed, 19 Sep 2018 15:23:54 +0100 Subject: [PATCH 25/44] Hide front chat widget via code. --- services/web/app/views/project/list.pug | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/web/app/views/project/list.pug b/services/web/app/views/project/list.pug index 0fec00cb55..cee7d97efe 100644 --- a/services/web/app/views/project/list.pug +++ b/services/web/app/views/project/list.pug @@ -119,4 +119,4 @@ block content include ./list/modals - include ./list/front-chat + //- include ./list/front-chat From f0c0834b0fdb45b4d729c40032f30177bd4a389a Mon Sep 17 00:00:00 2001 From: Ersun Warncke Date: Tue, 25 Sep 2018 05:42:04 -0400 Subject: [PATCH 26/44] only do v1 access check when api config present --- .../TokenAccess/TokenAccessController.coffee | 5 +- .../TokenAccess/TokenAccessHandler.coffee | 8 +++ .../TokenAccessControllerTests.coffee | 3 +- .../TokenAccessHandlerTests.coffee | 49 ++++++++++++++++++- 4 files changed, 59 insertions(+), 6 deletions(-) diff --git a/services/web/app/coffee/Features/TokenAccess/TokenAccessController.coffee b/services/web/app/coffee/Features/TokenAccess/TokenAccessController.coffee index 49a3892855..f75b72d29e 100644 --- a/services/web/app/coffee/Features/TokenAccess/TokenAccessController.coffee +++ b/services/web/app/coffee/Features/TokenAccess/TokenAccessController.coffee @@ -4,7 +4,6 @@ TokenAccessHandler = require './TokenAccessHandler' Errors = require '../Errors/Errors' logger = require 'logger-sharelatex' settings = require 'settings-sharelatex' -V1Api = require "../V1/V1Api" module.exports = TokenAccessController = @@ -92,9 +91,9 @@ module.exports = TokenAccessController = return next(new Errors.NotFoundError()) TokenAccessController._tryHigherAccess(token, userId, req, res, next) else - V1Api.request { url: "/api/v1/sharelatex/docs/#{token}/read" }, (err, respose, body) -> + TokenAccessHandler.checkV1Access token, (err, allow_access, redirect_path) -> return next err if err? - return res.redirect body.published_path if body.allow == false + return res.redirect redirect_path unless allow_access if !userId? logger.log {userId, projectId: project._id}, "[TokenAccess] adding anonymous user to project with readOnly token" diff --git a/services/web/app/coffee/Features/TokenAccess/TokenAccessHandler.coffee b/services/web/app/coffee/Features/TokenAccess/TokenAccessHandler.coffee index ed7f51f0d7..b1778d3f12 100644 --- a/services/web/app/coffee/Features/TokenAccess/TokenAccessHandler.coffee +++ b/services/web/app/coffee/Features/TokenAccess/TokenAccessHandler.coffee @@ -4,6 +4,7 @@ PublicAccessLevels = require '../Authorization/PublicAccessLevels' PrivilegeLevels = require '../Authorization/PrivilegeLevels' ObjectId = require("mongojs").ObjectId Settings = require('settings-sharelatex') +V1Api = require "../V1/V1Api" module.exports = TokenAccessHandler = @@ -97,3 +98,10 @@ module.exports = TokenAccessHandler = project.tokens.readAndWrite = '' if privilegeLevel != PrivilegeLevels.READ_ONLY project.tokens.readOnly = '' + + checkV1Access: (token, callback=(err, allow, redirect)->) -> + return callback(null, true) unless Settings.apis?.v1? + V1Api.request { url: "/api/v1/sharelatex/docs/#{token}/read" }, (err, response, body) -> + return callback err if err? + callback null, false, body.published_path if body.allow == false + callback null, true diff --git a/services/web/test/unit/coffee/TokenAccess/TokenAccessControllerTests.coffee b/services/web/test/unit/coffee/TokenAccess/TokenAccessControllerTests.coffee index 0d65948225..18967534ac 100644 --- a/services/web/test/unit/coffee/TokenAccess/TokenAccessControllerTests.coffee +++ b/services/web/test/unit/coffee/TokenAccess/TokenAccessControllerTests.coffee @@ -396,6 +396,7 @@ describe "TokenAccessController", -> describe 'readOnlyToken', -> beforeEach -> + @TokenAccessHandler.checkV1Access = sinon.stub().callsArgWith(1, null, true) describe 'when access not allowed by v1 api', -> beforeEach -> @@ -405,7 +406,7 @@ describe "TokenAccessController", -> @next = sinon.stub() @TokenAccessHandler.findProjectWithReadOnlyToken = sinon.stub() .callsArgWith(1, null, @project) - @V1Api.request = sinon.stub().callsArgWith(1, null, {}, { allow: false, published_path: 'doc-url'} ) + @TokenAccessHandler.checkV1Access = sinon.stub().callsArgWith(1, null, false, 'doc-url') @TokenAccessController.readOnlyToken @req, @res, @next it 'should redirect to doc-url', -> diff --git a/services/web/test/unit/coffee/TokenAccess/TokenAccessHandlerTests.coffee b/services/web/test/unit/coffee/TokenAccess/TokenAccessHandlerTests.coffee index 722a8496a2..d934f31957 100644 --- a/services/web/test/unit/coffee/TokenAccess/TokenAccessHandlerTests.coffee +++ b/services/web/test/unit/coffee/TokenAccess/TokenAccessHandlerTests.coffee @@ -19,9 +19,11 @@ describe "TokenAccessHandler", -> @req = {} @TokenAccessHandler = SandboxedModule.require modulePath, requires: '../../models/Project': {Project: @Project = {}} - 'settings-sharelatex': {} + 'settings-sharelatex': @settings = {} '../Collaborators/CollaboratorsHandler': @CollaboratorsHandler = {} - + '../V1/V1Api': @V1Api = { + request: sinon.stub() + } describe 'findProjectWithReadOnlyToken', -> beforeEach -> @@ -434,3 +436,46 @@ describe "TokenAccessHandler", -> @TokenAccessHandler.protectTokens(@project, 'owner') expect(@project.tokens.readAndWrite).to.equal 'rw' expect(@project.tokens.readOnly).to.equal 'ro' + + describe 'checkV1Access', -> + beforeEach -> + @callback = sinon.stub() + + describe 'when v1 api not set', -> + beforeEach -> + @TokenAccessHandler.checkV1Access @token, @callback + + it 'should not check access and return true', -> + expect(@V1Api.request.called).to.equal false + expect(@callback.calledWith null, true).to.equal true + + describe 'when v1 api is set', -> + beforeEach -> + @settings.apis = { v1: 'v1' } + + describe 'when access allowed', -> + beforeEach -> + @V1Api.request = sinon.stub().callsArgWith(1, null, {}, { allow: true} ) + @TokenAccessHandler.checkV1Access @token, @callback + + it 'should check api', -> + expect(@V1Api.request.calledWith { url: "/api/v1/sharelatex/docs/#{@token}/read" }).to.equal true + + it 'should callback with true', -> + expect(@callback.calledWith null, true).to.equal true + + describe 'when access denied', -> + beforeEach -> + @V1Api.request = sinon.stub().callsArgWith(1, null, {}, { allow: false, published_path: 'doc-url'} ) + @TokenAccessHandler.checkV1Access @token, @callback + + it 'should callback with false and redirect', -> + expect(@callback.calledWith null, false, 'doc-url').to.equal true + + describe 'on error', -> + beforeEach -> + @V1Api.request = sinon.stub().callsArgWith(1, 'error') + @TokenAccessHandler.checkV1Access @token, @callback + + it 'should callback with error', -> + expect(@callback.calledWith 'error').to.equal true From eeed857dd96640239163d7fe5642111c1fd8a436 Mon Sep 17 00:00:00 2001 From: Ersun Warncke Date: Tue, 25 Sep 2018 06:45:27 -0400 Subject: [PATCH 27/44] change api path --- .../app/coffee/Features/TokenAccess/TokenAccessHandler.coffee | 2 +- services/web/test/acceptance/coffee/helpers/MockV1Api.coffee | 2 +- .../test/unit/coffee/TokenAccess/TokenAccessHandlerTests.coffee | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/services/web/app/coffee/Features/TokenAccess/TokenAccessHandler.coffee b/services/web/app/coffee/Features/TokenAccess/TokenAccessHandler.coffee index b1778d3f12..6a45280d42 100644 --- a/services/web/app/coffee/Features/TokenAccess/TokenAccessHandler.coffee +++ b/services/web/app/coffee/Features/TokenAccess/TokenAccessHandler.coffee @@ -101,7 +101,7 @@ module.exports = TokenAccessHandler = checkV1Access: (token, callback=(err, allow, redirect)->) -> return callback(null, true) unless Settings.apis?.v1? - V1Api.request { url: "/api/v1/sharelatex/docs/#{token}/read" }, (err, response, body) -> + V1Api.request { url: "/api/v1/sharelatex/docs/#{token}/is_published" }, (err, response, body) -> return callback err if err? callback null, false, body.published_path if body.allow == false callback null, true diff --git a/services/web/test/acceptance/coffee/helpers/MockV1Api.coffee b/services/web/test/acceptance/coffee/helpers/MockV1Api.coffee index 5ecd11a991..f9a7aed451 100644 --- a/services/web/test/acceptance/coffee/helpers/MockV1Api.coffee +++ b/services/web/test/acceptance/coffee/helpers/MockV1Api.coffee @@ -82,7 +82,7 @@ module.exports = MockV1Api = console.error "error starting MockV1Api:", error.message process.exit(1) - app.get '/api/v1/sharelatex/docs/:token/read', (req, res, next) => + app.get '/api/v1/sharelatex/docs/:token/is_published', (req, res, next) => res.json { allow: true } MockV1Api.run() diff --git a/services/web/test/unit/coffee/TokenAccess/TokenAccessHandlerTests.coffee b/services/web/test/unit/coffee/TokenAccess/TokenAccessHandlerTests.coffee index d934f31957..350dff0638 100644 --- a/services/web/test/unit/coffee/TokenAccess/TokenAccessHandlerTests.coffee +++ b/services/web/test/unit/coffee/TokenAccess/TokenAccessHandlerTests.coffee @@ -459,7 +459,7 @@ describe "TokenAccessHandler", -> @TokenAccessHandler.checkV1Access @token, @callback it 'should check api', -> - expect(@V1Api.request.calledWith { url: "/api/v1/sharelatex/docs/#{@token}/read" }).to.equal true + expect(@V1Api.request.calledWith { url: "/api/v1/sharelatex/docs/#{@token}/is_published" }).to.equal true it 'should callback with true', -> expect(@callback.calledWith null, true).to.equal true From a23f0a3d159f9c72d5830ef53e94b3f700d9037b Mon Sep 17 00:00:00 2001 From: Ersun Warncke Date: Tue, 25 Sep 2018 08:54:01 -0400 Subject: [PATCH 28/44] fix test failure from merge --- .../unit/coffee/TokenAccess/TokenAccessControllerTests.coffee | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/web/test/unit/coffee/TokenAccess/TokenAccessControllerTests.coffee b/services/web/test/unit/coffee/TokenAccess/TokenAccessControllerTests.coffee index 57f3a0afa4..aa2e8c4ede 100644 --- a/services/web/test/unit/coffee/TokenAccess/TokenAccessControllerTests.coffee +++ b/services/web/test/unit/coffee/TokenAccess/TokenAccessControllerTests.coffee @@ -407,7 +407,7 @@ describe "TokenAccessController", -> @res.redirect = sinon.stub() @next = sinon.stub() @TokenAccessHandler.findProjectWithReadOnlyToken = sinon.stub() - .callsArgWith(1, null, @project) + .callsArgWith(1, null, @project, true) @TokenAccessHandler.checkV1Access = sinon.stub().callsArgWith(1, null, false, 'doc-url') @TokenAccessController.readOnlyToken @req, @res, @next From 8ebfd7882db71d5d14aa6cc1d578e4f48c1b1821 Mon Sep 17 00:00:00 2001 From: James Allen Date: Wed, 26 Sep 2018 10:34:50 +0100 Subject: [PATCH 29/44] Add logos for homepage --- services/web/public/img/crests/agder.png | Bin 0 -> 423110 bytes services/web/public/img/crests/caltech.png | Bin 0 -> 6688 bytes services/web/public/img/crests/queensland.png | Bin 0 -> 23535 bytes services/web/public/img/crests/york.png | Bin 0 -> 21643 bytes 4 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 services/web/public/img/crests/agder.png create mode 100644 services/web/public/img/crests/caltech.png create mode 100644 services/web/public/img/crests/queensland.png create mode 100644 services/web/public/img/crests/york.png diff --git a/services/web/public/img/crests/agder.png b/services/web/public/img/crests/agder.png new file mode 100644 index 0000000000000000000000000000000000000000..f2102e7cf019c595218f88503337c72a9b179abd GIT binary patch literal 423110 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 Uy1VLwgQpE0HROds7yb4B0fltHOaK4? literal 0 HcmV?d00001 diff --git a/services/web/public/img/crests/caltech.png b/services/web/public/img/crests/caltech.png new file mode 100644 index 0000000000000000000000000000000000000000..3200b8ed223fe8589a5125fc665e156e5c76b8c1 GIT binary patch literal 6688 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 literal 0 HcmV?d00001 diff --git a/services/web/public/img/crests/york.png b/services/web/public/img/crests/york.png new file mode 100644 index 0000000000000000000000000000000000000000..0bd907081da15f333c37dc5ed9b3ab43d0ccb03e GIT binary patch literal 21643 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 literal 0 HcmV?d00001 From 9c0a888d117b4fd9c47aca1601892416a25ea6d1 Mon Sep 17 00:00:00 2001 From: Alasdair Smith Date: Wed, 26 Sep 2018 17:02:04 +0100 Subject: [PATCH 30/44] Add custom redirect for /docs We want to redirect POST and GET to different locations, but this is unsupported by RedirectManager. Therefore we redirect GETs with RedirectManager and POSTs with this custom route. --- services/web/app/coffee/router.coffee | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/services/web/app/coffee/router.coffee b/services/web/app/coffee/router.coffee index ac0889d0c4..8c22edae5d 100644 --- a/services/web/app/coffee/router.coffee +++ b/services/web/app/coffee/router.coffee @@ -330,6 +330,12 @@ module.exports = class Router AuthenticationController.httpAuth, CompileController.getFileFromClsiWithoutUser + # We want to redirect POST and GET to different locations, but this is + # unsupported by RedirectManager. Therefore we redirect GETs with + # RedirectManager and POSTs with this custom route + publicApiRouter.post '/docs', (req, res, next) -> + res.redirect(307, "#{Settings.apis.v1.url}/docs") + webRouter.get '/teams', (req, res, next) -> # Match v1 behaviour - if the user is signed in, show their teams list # Otherwise show some information about teams From f2fa83a218b42542f98e39e8f5f2924306acc570 Mon Sep 17 00:00:00 2001 From: Alasdair Smith Date: Wed, 26 Sep 2018 17:03:48 +0100 Subject: [PATCH 31/44] Fix /teams redirect using wrong setting --- services/web/app/coffee/router.coffee | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/web/app/coffee/router.coffee b/services/web/app/coffee/router.coffee index 8c22edae5d..1e30460193 100644 --- a/services/web/app/coffee/router.coffee +++ b/services/web/app/coffee/router.coffee @@ -342,7 +342,7 @@ module.exports = class Router if AuthenticationController.isUserLoggedIn(req) res.redirect('/user/subscription') else - res.redirect("#{settings.v1Api.host}/teams") + res.redirect("#{settings.apis.v1.url}/teams") webRouter.get '/chrome', (req, res, next) -> # Match v1 behaviour - this is used for a Chrome web app From c2ecccfa02d5fffbc6f5f943b5948c20528662ad Mon Sep 17 00:00:00 2001 From: Alasdair Smith Date: Wed, 26 Sep 2018 17:35:51 +0100 Subject: [PATCH 32/44] Use correct setting --- services/web/app/coffee/router.coffee | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/web/app/coffee/router.coffee b/services/web/app/coffee/router.coffee index 1e30460193..2c7e7ec8b2 100644 --- a/services/web/app/coffee/router.coffee +++ b/services/web/app/coffee/router.coffee @@ -334,7 +334,7 @@ module.exports = class Router # unsupported by RedirectManager. Therefore we redirect GETs with # RedirectManager and POSTs with this custom route publicApiRouter.post '/docs', (req, res, next) -> - res.redirect(307, "#{Settings.apis.v1.url}/docs") + res.redirect(307, "#{Settings.overleaf.host}/docs") webRouter.get '/teams', (req, res, next) -> # Match v1 behaviour - if the user is signed in, show their teams list @@ -342,7 +342,7 @@ module.exports = class Router if AuthenticationController.isUserLoggedIn(req) res.redirect('/user/subscription') else - res.redirect("#{settings.apis.v1.url}/teams") + res.redirect("#{settings.overleaf.host}/teams") webRouter.get '/chrome', (req, res, next) -> # Match v1 behaviour - this is used for a Chrome web app From aaac1fabfda8a13e5bc74d8a00fb52d5e95634d9 Mon Sep 17 00:00:00 2001 From: Jessica Lawshe Date: Wed, 26 Sep 2018 12:45:50 -0500 Subject: [PATCH 33/44] Set width for iframes on blog posts --- services/web/public/stylesheets/app/blog.less | 3 +++ 1 file changed, 3 insertions(+) diff --git a/services/web/public/stylesheets/app/blog.less b/services/web/public/stylesheets/app/blog.less index 1e474e54d5..190e2562db 100644 --- a/services/web/public/stylesheets/app/blog.less +++ b/services/web/public/stylesheets/app/blog.less @@ -14,6 +14,9 @@ } .blog { + iframe { + width: 100%; + } > .page-header { h1 { margin: 0; From 0cb563816d5e8d96c285ee8066b2a2f0d82d1918 Mon Sep 17 00:00:00 2001 From: James Allen Date: Thu, 27 Sep 2018 10:56:14 +0100 Subject: [PATCH 34/44] Don't enable legacy blog in v2 --- services/web/app/coffee/router.coffee | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/services/web/app/coffee/router.coffee b/services/web/app/coffee/router.coffee index 2c7e7ec8b2..578ff0ceb7 100644 --- a/services/web/app/coffee/router.coffee +++ b/services/web/app/coffee/router.coffee @@ -90,8 +90,9 @@ module.exports = class Router if Settings.enableSubscriptions webRouter.get '/user/bonus', AuthenticationController.requireLogin(), ReferalController.bonus - webRouter.get '/blog', BlogController.getIndexPage - webRouter.get '/blog/*', BlogController.getPage + if !Settings.overleaf? + webRouter.get '/blog', BlogController.getIndexPage + webRouter.get '/blog/*', BlogController.getPage webRouter.get '/user/activate', UserPagesController.activateAccountPage AuthenticationController.addEndpointToLoginWhitelist '/user/activate' From 4f2c91a59ac7953b83c0a0b392d06b8bbcbb8f48 Mon Sep 17 00:00:00 2001 From: Alasdair Smith Date: Thu, 27 Sep 2018 12:19:16 +0100 Subject: [PATCH 35/44] Add new redirect option to auth with v1, which will urlencode the query string This is necessary for the GET /docs endpoint, which can be used to send urls as part of query parameters. If these are not encoded before redirecting, they can become corrupted. --- .../coffee/infrastructure/RedirectManager.coffee | 14 ++++++++++++-- .../acceptance/coffee/RedirectUrlsTests.coffee | 11 ++++++++++- .../test/acceptance/config/settings.test.coffee | 4 ++++ 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/services/web/app/coffee/infrastructure/RedirectManager.coffee b/services/web/app/coffee/infrastructure/RedirectManager.coffee index fe64f31c05..0777941244 100644 --- a/services/web/app/coffee/infrastructure/RedirectManager.coffee +++ b/services/web/app/coffee/infrastructure/RedirectManager.coffee @@ -1,5 +1,7 @@ settings = require("settings-sharelatex") logger = require("logger-sharelatex") +URL = require('url') +querystring = require('querystring') module.exports = RedirectManager = apply: (webRouter) -> @@ -15,12 +17,20 @@ module.exports = RedirectManager = else if req.method == "POST" code = 307 + if typeof target.url == "function" url = target.url(req.params) if !url return next() else url = target.url + + # Special handling for redirecting to v1, to ensure that query params + # are encoded + if target.authWithV1 + url = "/sign_in_to_v1?" + querystring.stringify(return_to: url + getQueryString(req)) + return res.redirect code, url + if target.baseUrl? url = "#{target.baseUrl}#{url}" res.redirect code, url + getQueryString(req) @@ -29,5 +39,5 @@ module.exports = RedirectManager = # have differences between Express and Rails, so safer to just pass the raw # string getQueryString = (req) -> - qs = req.url.match(/\?.*$/) - if qs? then qs[0] else "" + {search} = URL.parse(req.url) + if search then search else "" diff --git a/services/web/test/acceptance/coffee/RedirectUrlsTests.coffee b/services/web/test/acceptance/coffee/RedirectUrlsTests.coffee index 7cd9ecfd22..71a6902c81 100644 --- a/services/web/test/acceptance/coffee/RedirectUrlsTests.coffee +++ b/services/web/test/acceptance/coffee/RedirectUrlsTests.coffee @@ -31,4 +31,13 @@ describe "RedirectUrls", -> assertRedirect 'get', '/redirect/get_and_post', 302, '/destination/get_and_post', done it 'redirects with query params', (done) -> - assertRedirect 'get', '/redirect/qs?foo=bar&baz[]=qux1&baz[]=qux2', 302, '/destination/qs?foo=bar&baz[]=qux1&baz[]=qux2', done \ No newline at end of file + assertRedirect 'get', '/redirect/qs?foo=bar&baz[]=qux1&baz[]=qux2', 302, '/destination/qs?foo=bar&baz[]=qux1&baz[]=qux2', done + + it 'redirects to /sign_in_to_v1 with authWithV1 setting', (done) -> + assertRedirect( + 'get', + '/docs?zip_uri=http%3A%2F%2Foverleaf.test%2Ffoo%3Fbar%3Dbaz%26qux%3Dthing&bar=baz', + 302, + '/sign_in_to_v1?return_to=%2Fdocs%3Fzip_uri%3Dhttp%253A%252F%252Foverleaf.test%252Ffoo%253Fbar%253Dbaz%2526qux%253Dthing%26bar%3Dbaz', + done + ) \ No newline at end of file diff --git a/services/web/test/acceptance/config/settings.test.coffee b/services/web/test/acceptance/config/settings.test.coffee index 0890823ab7..893af4dde3 100644 --- a/services/web/test/acceptance/config/settings.test.coffee +++ b/services/web/test/acceptance/config/settings.test.coffee @@ -128,3 +128,7 @@ module.exports = url: (params) -> "/destination/#{params.id}/params" }, '/redirect/qs': '/destination/qs' + '/docs': { + authWithV1: true + url: '/docs' + } From 69421cb7b7ad995f1cf20b2dc15d5be494f9fa7d Mon Sep 17 00:00:00 2001 From: Jessica Lawshe Date: Thu, 27 Sep 2018 11:37:14 -0500 Subject: [PATCH 36/44] Add hover color for content page links --- services/web/public/stylesheets/app/content_page.less | 3 +++ services/web/public/stylesheets/core/ol-variables.less | 1 + 2 files changed, 4 insertions(+) diff --git a/services/web/public/stylesheets/app/content_page.less b/services/web/public/stylesheets/app/content_page.less index bfef7e7fc8..1b462ff996 100644 --- a/services/web/public/stylesheets/app/content_page.less +++ b/services/web/public/stylesheets/app/content_page.less @@ -5,6 +5,9 @@ .content-page { a:not(.btn) { color: @link-color-alt; + &:hover { + color: @link-hover-color-alt; + } } hr { border-color: @hr-border-alt; diff --git a/services/web/public/stylesheets/core/ol-variables.less b/services/web/public/stylesheets/core/ol-variables.less index de3eaf9e3b..fcef0e67f4 100644 --- a/services/web/public/stylesheets/core/ol-variables.less +++ b/services/web/public/stylesheets/core/ol-variables.less @@ -52,6 +52,7 @@ @link-color-alt : @ol-green; @link-active-color : @ol-dark-green; @link-hover-color : @ol-dark-blue; +@link-hover-color-alt : @ol-dark-green; @hr-border : @ol-blue-gray-1; @hr-border-alt : @gray-lighter; From 23c9c719af032475503b061594dda020f1c16f4f Mon Sep 17 00:00:00 2001 From: Jessica Lawshe Date: Thu, 27 Sep 2018 11:50:38 -0500 Subject: [PATCH 37/44] Update hover color for tabs --- services/web/public/stylesheets/components/tabs.less | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/web/public/stylesheets/components/tabs.less b/services/web/public/stylesheets/components/tabs.less index c3e25ce546..0a942c1e59 100644 --- a/services/web/public/stylesheets/components/tabs.less +++ b/services/web/public/stylesheets/components/tabs.less @@ -17,7 +17,7 @@ &:hover { background-color: transparent!important; border: 0!important; - color: @link-hover-color!important; + color: @link-hover-color-alt; } } } From 0ff6ef0748830741505e5b4e1d3a982bbede59f8 Mon Sep 17 00:00:00 2001 From: Jessica Lawshe Date: Thu, 27 Sep 2018 11:51:33 -0500 Subject: [PATCH 38/44] Ensure Wiki search link color property set on hover --- services/web/public/stylesheets/app/contact-us.less | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/web/public/stylesheets/app/contact-us.less b/services/web/public/stylesheets/app/contact-us.less index 37577f60e0..caa8cad5f6 100644 --- a/services/web/public/stylesheets/app/contact-us.less +++ b/services/web/public/stylesheets/app/contact-us.less @@ -43,7 +43,7 @@ &:hover, &:focus { text-decoration: none; - color: @dropdown-link-hover-color; + color: @dropdown-link-hover-color!important; background-color: @dropdown-link-hover-bg; .fa { From 6b80d3563d4e65830a335e0f559bea6c0879a446 Mon Sep 17 00:00:00 2001 From: Brian Gough Date: Fri, 14 Sep 2018 11:08:03 +0100 Subject: [PATCH 39/44] add support for creating unique project names --- .../Project/ProjectDetailsHandler.coffee | 30 +++++++++++++ .../Project/ProjectDetailsHandlerTests.coffee | 42 +++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/services/web/app/coffee/Features/Project/ProjectDetailsHandler.coffee b/services/web/app/coffee/Features/Project/ProjectDetailsHandler.coffee index 9767d148b9..3525bcddbc 100644 --- a/services/web/app/coffee/Features/Project/ProjectDetailsHandler.coffee +++ b/services/web/app/coffee/Features/Project/ProjectDetailsHandler.coffee @@ -67,6 +67,36 @@ module.exports = ProjectDetailsHandler = else return callback() + _addSuffixToProjectName: (name, suffix = '') -> + # append the suffix and truncate the project title if needed + truncatedLength = ProjectDetailsHandler.MAX_PROJECT_NAME_LENGTH - suffix.length + return name.substr(0, truncatedLength) + suffix + + # FIXME: we should put a lock around this to make it completely safe, but we would need to do that at + # the point of project creation, rather than just checking the name at the start of the import. + # If we later move this check into ProjectCreationHandler we can ensure all new projects are created + # with a unique name. But that requires thinking through how we would handle incoming projects from + # dropbox for example. + ensureProjectNameIsUnique: (user_id, name, suffixes = [], callback = (error, name, changed)->) -> + ProjectGetter.findAllUsersProjects user_id, {name: 1}, (error, allUsersProjects) -> + return callback(error) if error? + {owned, readAndWrite, readOnly, tokenReadAndWrite, tokenReadOnly} = allUsersProjects + # create a set of all project names + allProjectNames = new Set() + for projectName in owned.concat(readAndWrite, readOnly, tokenReadAndWrite, tokenReadOnly) + allProjectNames.add(projectName) + isUnique = (x) -> !allProjectNames.has(x) + # check if the supplied name is already unique + if isUnique(name) + return callback(null, name, false) + # the name already exists, try adding the user-supplied suffixes to generate a unique name + for suffix in suffixes + candidateName = ProjectDetailsHandler._addSuffixToProjectName(name, suffix) + if isUnique(candidateName) + return callback(null, candidateName, true) + # we couldn't make the name unique, something is wrong + return callback new Errors.InvalidNameError("Project name could not be made unique") + setPublicAccessLevel : (project_id, newAccessLevel, callback = ->)-> logger.log project_id: project_id, level: newAccessLevel, "set public access level" # DEPRECATED: `READ_ONLY` and `READ_AND_WRITE` are still valid in, but should no longer diff --git a/services/web/test/unit/coffee/Project/ProjectDetailsHandlerTests.coffee b/services/web/test/unit/coffee/Project/ProjectDetailsHandlerTests.coffee index 3b47c48420..7fadf0b12c 100644 --- a/services/web/test/unit/coffee/Project/ProjectDetailsHandlerTests.coffee +++ b/services/web/test/unit/coffee/Project/ProjectDetailsHandlerTests.coffee @@ -155,6 +155,48 @@ describe 'ProjectDetailsHandler', -> expect(error).to.not.exist done() + describe "ensureProjectNameIsUnique", -> + beforeEach -> + @result = { + owned: ["name", "name1", "name11"] + readAndWrite: ["name2", "name22"] + readOnly: ["name3", "name33"] + tokenReadAndWrite: ["name4", "name44"] + tokenReadOnly: ["name5", "name55", "x".repeat(15)] + } + @ProjectGetter.findAllUsersProjects = sinon.stub().callsArgWith(2, null, @result) + + it "should leave a unique name unchanged", (done) -> + @handler.ensureProjectNameIsUnique @user_id, "unique-name", ["-test-suffix"], (error, name, changed) -> + expect(name).to.equal "unique-name" + expect(changed).to.equal false + done() + + it "should append a suffix to an existing name", (done) -> + @handler.ensureProjectNameIsUnique @user_id, "name1", ["-test-suffix"], (error, name, changed) -> + expect(name).to.equal "name1-test-suffix" + expect(changed).to.equal true + done() + + it "should fallback to a second suffix when needed", (done) -> + @handler.ensureProjectNameIsUnique @user_id, "name1", ["1", "-test-suffix"], (error, name, changed) -> + expect(name).to.equal "name1-test-suffix" + expect(changed).to.equal true + done() + + it "should truncate the name when append a suffix if the result is too long", (done) -> + @handler.MAX_PROJECT_NAME_LENGTH = 20 + @handler.ensureProjectNameIsUnique @user_id, "x".repeat(15), ["-test-suffix"], (error, name, changed) -> + expect(name).to.equal "x".repeat(8) + "-test-suffix" + expect(changed).to.equal true + done() + + it "should return an error if the name cannot be made unique", (done) -> + @handler.ensureProjectNameIsUnique @user_id, "name", ["1", "5", "55"], (error, name, changed) -> + expect(error).to.eql new Errors.InvalidNameError("Project name could not be made unique") + done() + + describe "setPublicAccessLevel", -> beforeEach -> @ProjectModel.update.callsArgWith(2) From 8f8694ad94fa971607552d95a8eb855dc00b4cc0 Mon Sep 17 00:00:00 2001 From: Brian Gough Date: Thu, 27 Sep 2018 16:41:45 +0100 Subject: [PATCH 40/44] iterate over owned projects in a more robust way --- .../coffee/Features/Project/ProjectDetailsHandler.coffee | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/services/web/app/coffee/Features/Project/ProjectDetailsHandler.coffee b/services/web/app/coffee/Features/Project/ProjectDetailsHandler.coffee index 3525bcddbc..4946d8126e 100644 --- a/services/web/app/coffee/Features/Project/ProjectDetailsHandler.coffee +++ b/services/web/app/coffee/Features/Project/ProjectDetailsHandler.coffee @@ -78,12 +78,14 @@ module.exports = ProjectDetailsHandler = # with a unique name. But that requires thinking through how we would handle incoming projects from # dropbox for example. ensureProjectNameIsUnique: (user_id, name, suffixes = [], callback = (error, name, changed)->) -> - ProjectGetter.findAllUsersProjects user_id, {name: 1}, (error, allUsersProjects) -> + ProjectGetter.findAllUsersProjects user_id, {name: 1}, (error, allUsersProjectNames) -> return callback(error) if error? - {owned, readAndWrite, readOnly, tokenReadAndWrite, tokenReadOnly} = allUsersProjects + # allUsersProjectNames is returned as a hash {owned: [name1, name2, ...], readOnly: [....]} + # collect all of the names and flatten them into a single array + projectNameList = _.flatten(_.values(allUsersProjectNames)) # create a set of all project names allProjectNames = new Set() - for projectName in owned.concat(readAndWrite, readOnly, tokenReadAndWrite, tokenReadOnly) + for projectName in projectNameList allProjectNames.add(projectName) isUnique = (x) -> !allProjectNames.has(x) # check if the supplied name is already unique From 1f6abd4e6941543b235a0d80f59a6860da688d47 Mon Sep 17 00:00:00 2001 From: Brian Gough Date: Tue, 25 Sep 2018 11:15:32 +0100 Subject: [PATCH 41/44] fix invalid project names when opening templates --- .../Features/Project/ProjectDetailsHandler.coffee | 10 ++++++++++ .../Features/Templates/TemplatesController.coffee | 5 ++++- .../Project/ProjectDetailsHandlerTests.coffee | 14 ++++++++++++++ .../Templates/TemplatesControllerTests.coffee | 2 ++ 4 files changed, 30 insertions(+), 1 deletion(-) diff --git a/services/web/app/coffee/Features/Project/ProjectDetailsHandler.coffee b/services/web/app/coffee/Features/Project/ProjectDetailsHandler.coffee index 4946d8126e..4f3f9b6b1e 100644 --- a/services/web/app/coffee/Features/Project/ProjectDetailsHandler.coffee +++ b/services/web/app/coffee/Features/Project/ProjectDetailsHandler.coffee @@ -98,6 +98,16 @@ module.exports = ProjectDetailsHandler = return callback(null, candidateName, true) # we couldn't make the name unique, something is wrong return callback new Errors.InvalidNameError("Project name could not be made unique") + + fixProjectName: (name) -> + if name == "" + name = "Untitled" + if name.indexOf('/') > -1 + # v2 does not allow / in a project name + name = name.replace(/\//g, '-') + if name.length > @MAX_PROJECT_NAME_LENGTH + name = name.substr(0, @MAX_PROJECT_NAME_LENGTH) + return name setPublicAccessLevel : (project_id, newAccessLevel, callback = ->)-> logger.log project_id: project_id, level: newAccessLevel, "set public access level" diff --git a/services/web/app/coffee/Features/Templates/TemplatesController.coffee b/services/web/app/coffee/Features/Templates/TemplatesController.coffee index 724abd2766..742ee4a7d1 100644 --- a/services/web/app/coffee/Features/Templates/TemplatesController.coffee +++ b/services/web/app/coffee/Features/Templates/TemplatesController.coffee @@ -3,6 +3,7 @@ Project = require('../../../js/models/Project').Project ProjectUploadManager = require('../../../js/Features/Uploads/ProjectUploadManager') ProjectOptionsHandler = require('../../../js/Features/Project/ProjectOptionsHandler') ProjectRootDocManager = require('../../../js/Features/Project/ProjectRootDocManager') +ProjectDetailsHandler = require('../../../js/Features/Project/ProjectDetailsHandler') AuthenticationController = require('../../../js/Features/Authentication/AuthenticationController') settings = require('settings-sharelatex') fs = require('fs') @@ -55,6 +56,8 @@ module.exports = TemplatesController = ) createFromZip: (zipReq, options, req, res)-> + # remove any invalid characters from template name + projectName = ProjectDetailsHandler.fixProjectName(options.templateName) dumpPath = "#{settings.path.dumpFolder}/#{uuid.v4()}" writeStream = fs.createWriteStream(dumpPath) @@ -62,7 +65,7 @@ module.exports = TemplatesController = logger.error err: error, "error getting zip from template API" zipReq.pipe(writeStream) writeStream.on 'close', -> - ProjectUploadManager.createProjectFromZipArchive options.currentUserId, options.templateName, dumpPath, (err, project)-> + ProjectUploadManager.createProjectFromZipArchive options.currentUserId, projectName, dumpPath, (err, project)-> if err? logger.err err:err, zipReq:zipReq, "problem building project from zip" return res.sendStatus 500 diff --git a/services/web/test/unit/coffee/Project/ProjectDetailsHandlerTests.coffee b/services/web/test/unit/coffee/Project/ProjectDetailsHandlerTests.coffee index 7fadf0b12c..0dd7d11ac8 100644 --- a/services/web/test/unit/coffee/Project/ProjectDetailsHandlerTests.coffee +++ b/services/web/test/unit/coffee/Project/ProjectDetailsHandlerTests.coffee @@ -196,6 +196,20 @@ describe 'ProjectDetailsHandler', -> expect(error).to.eql new Errors.InvalidNameError("Project name could not be made unique") done() + describe "fixProjectName", -> + + it "should change empty names to Untitled", () -> + expect(@handler.fixProjectName "").to.equal "Untitled" + + it "should replace / with -", () -> + expect(@handler.fixProjectName "foo/bar").to.equal "foo-bar" + + it "should truncate long names", () -> + expect(@handler.fixProjectName new Array(1000).join("a")).to.equal "a".repeat(150) + + it "should accept normal names", () -> + expect(@handler.fixProjectName "foobar").to.equal "foobar" + describe "setPublicAccessLevel", -> beforeEach -> diff --git a/services/web/test/unit/coffee/Templates/TemplatesControllerTests.coffee b/services/web/test/unit/coffee/Templates/TemplatesControllerTests.coffee index 776e177244..a08789ede9 100644 --- a/services/web/test/unit/coffee/Templates/TemplatesControllerTests.coffee +++ b/services/web/test/unit/coffee/Templates/TemplatesControllerTests.coffee @@ -32,12 +32,14 @@ describe 'TemplatesController', -> } @ProjectDetailsHandler = getProjectDescription:sinon.stub() + fixProjectName: sinon.stub().returns(@templateName) @Project = update: sinon.stub().callsArgWith(3, null) @controller = SandboxedModule.require modulePath, requires: '../../../js/Features/Uploads/ProjectUploadManager':@ProjectUploadManager '../../../js/Features/Project/ProjectOptionsHandler':@ProjectOptionsHandler '../../../js/Features/Project/ProjectRootDocManager':@ProjectRootDocManager + '../../../js/Features/Project/ProjectDetailsHandler':@ProjectDetailsHandler '../../../js/Features/Authentication/AuthenticationController': @AuthenticationController = {getLoggedInUserId: sinon.stub()} './TemplatesPublisher':@TemplatesPublisher "logger-sharelatex": From 435fe11115c137247bdc08ec6a36bb46beb8c88f Mon Sep 17 00:00:00 2001 From: Alasdair Smith Date: Thu, 27 Sep 2018 17:38:35 +0100 Subject: [PATCH 42/44] Check if v1 project was exported if not found This prevents a redirect loop for projects which were exported but then deleted on v2. v2 would not find the project, redirect to v1, which would find that it was exported and redirect back to v2. --- .../TokenAccess/TokenAccessController.coffee | 10 ++++-- .../TokenAccess/TokenAccessHandler.coffee | 6 ++++ .../coffee/helpers/MockV1Api.coffee | 3 ++ .../TokenAccessControllerTests.coffee | 34 +++++++++++++------ 4 files changed, 39 insertions(+), 14 deletions(-) diff --git a/services/web/app/coffee/Features/TokenAccess/TokenAccessController.coffee b/services/web/app/coffee/Features/TokenAccess/TokenAccessController.coffee index e335b19181..c6123cecf6 100644 --- a/services/web/app/coffee/Features/TokenAccess/TokenAccessController.coffee +++ b/services/web/app/coffee/Features/TokenAccess/TokenAccessController.coffee @@ -1,6 +1,7 @@ ProjectController = require "../Project/ProjectController" AuthenticationController = require '../Authentication/AuthenticationController' TokenAccessHandler = require './TokenAccessHandler' +V1Api = require '../V1/V1Api' Errors = require '../Errors/Errors' logger = require 'logger-sharelatex' settings = require 'settings-sharelatex' @@ -36,9 +37,12 @@ module.exports = TokenAccessController = return next(err) if !projectExists and settings.overleaf logger.log {token, userId}, - "[TokenAccess] no project found for this token" - return res.redirect(302, "/sign_in_to_v1?return_to=/#{token}") - if !project? + "[TokenAccess] no project found for this token" + TokenAccessHandler.checkV1ProjectExported token, (err, exported) -> + return next err if err? + return next(new Errors.NotFoundError()) if exported + return res.redirect(302, "/sign_in_to_v1?return_to=/#{token}") + else if !project? logger.log {token, userId}, "[TokenAccess] no token-based project found for readAndWrite token" if !userId? diff --git a/services/web/app/coffee/Features/TokenAccess/TokenAccessHandler.coffee b/services/web/app/coffee/Features/TokenAccess/TokenAccessHandler.coffee index 8d9825da2c..100786f776 100644 --- a/services/web/app/coffee/Features/TokenAccess/TokenAccessHandler.coffee +++ b/services/web/app/coffee/Features/TokenAccess/TokenAccessHandler.coffee @@ -116,3 +116,9 @@ module.exports = TokenAccessHandler = return callback err if err? callback null, false, body.published_path if body.allow == false callback null, true + + checkV1ProjectExported: (token, callback = (err, exists) ->) -> + return callback(null, false) unless Settings.apis?.v1? + V1Api.request { url: "/api/v1/sharelatex/docs/#{token}/exported_to_v2" }, (err, response, body) -> + return callback err if err? + callback null, body.exported diff --git a/services/web/test/acceptance/coffee/helpers/MockV1Api.coffee b/services/web/test/acceptance/coffee/helpers/MockV1Api.coffee index f9a7aed451..fcaf7a80df 100644 --- a/services/web/test/acceptance/coffee/helpers/MockV1Api.coffee +++ b/services/web/test/acceptance/coffee/helpers/MockV1Api.coffee @@ -85,4 +85,7 @@ module.exports = MockV1Api = app.get '/api/v1/sharelatex/docs/:token/is_published', (req, res, next) => res.json { allow: true } + app.get '/api/v1/sharelatex/docs/:token/exported_to_v2', (req, res, next) => + res.json { exported: false } + MockV1Api.run() diff --git a/services/web/test/unit/coffee/TokenAccess/TokenAccessControllerTests.coffee b/services/web/test/unit/coffee/TokenAccess/TokenAccessControllerTests.coffee index aa2e8c4ede..09123b5939 100644 --- a/services/web/test/unit/coffee/TokenAccess/TokenAccessControllerTests.coffee +++ b/services/web/test/unit/coffee/TokenAccess/TokenAccessControllerTests.coffee @@ -248,18 +248,30 @@ describe "TokenAccessController", -> @req.params['read_and_write_token'] = '123abc' @TokenAccessHandler.findProjectWithReadAndWriteToken = sinon.stub() .callsArgWith(1, null, null, false) - @TokenAccessHandler.findProjectWithHigherAccess = - sinon.stub() - .callsArgWith(2, null, @project) - @TokenAccessController.readAndWriteToken @req, @res, @next - it 'should redirect to v1', (done) -> - expect(@res.redirect.callCount).to.equal 1 - expect(@res.redirect.calledWith( - 302, - '/sign_in_to_v1?return_to=/123abc' - )).to.equal true - done() + describe 'when project was not exported from v1', -> + beforeEach -> + @TokenAccessHandler.checkV1ProjectExported = sinon.stub() + .callsArgWith(1, null, false) + @TokenAccessController.readAndWriteToken @req, @res, @next + + it 'should redirect to v1', (done) -> + expect(@res.redirect.callCount).to.equal 1 + expect(@res.redirect.calledWith( + 302, + '/sign_in_to_v1?return_to=/123abc' + )).to.equal true + done() + + describe 'when project was exported from v1', -> + beforeEach -> + @TokenAccessHandler.checkV1ProjectExported = sinon.stub() + .callsArgWith(1, null, false) + @TokenAccessController.readAndWriteToken @req, @res, @next + + it 'should call next with a not-found error', (done) -> + expect(@next.callCount).to.equal 0 + done() describe 'when token access is off, but user has higher access anyway', -> beforeEach -> From 1330c8da733f67e66e7befc2975e53db3cc0cb3b Mon Sep 17 00:00:00 2001 From: Alasdair Smith Date: Fri, 28 Sep 2018 11:31:07 +0100 Subject: [PATCH 43/44] Also check if v1 project exported if not found for read-only tokens --- .../TokenAccess/TokenAccessController.coffee | 9 ++- .../acceptance/coffee/TokenAccessTests.coffee | 2 +- .../TokenAccessControllerTests.coffee | 60 ++++++++++++++++--- 3 files changed, 60 insertions(+), 11 deletions(-) diff --git a/services/web/app/coffee/Features/TokenAccess/TokenAccessController.coffee b/services/web/app/coffee/Features/TokenAccess/TokenAccessController.coffee index c6123cecf6..d58b28cb30 100644 --- a/services/web/app/coffee/Features/TokenAccess/TokenAccessController.coffee +++ b/services/web/app/coffee/Features/TokenAccess/TokenAccessController.coffee @@ -87,9 +87,12 @@ module.exports = TokenAccessController = return next(err) if !projectExists and settings.overleaf logger.log {token, userId}, - "[TokenAccess] no project found for this token" - return res.redirect(302, settings.overleaf.host + '/read/' + token) - if !project? + "[TokenAccess] no project found for this token" + TokenAccessHandler.checkV1ProjectExported token, (err, exported) -> + return next err if err? + return next(new Errors.NotFoundError()) if exported + return res.redirect(302, "/sign_in_to_v1?return_to=/read/#{token}") + else if !project? logger.log {token, userId}, "[TokenAccess] no project found for readOnly token" if !userId? diff --git a/services/web/test/acceptance/coffee/TokenAccessTests.coffee b/services/web/test/acceptance/coffee/TokenAccessTests.coffee index 31be9523ba..832281f6a3 100644 --- a/services/web/test/acceptance/coffee/TokenAccessTests.coffee +++ b/services/web/test/acceptance/coffee/TokenAccessTests.coffee @@ -431,6 +431,6 @@ describe 'TokenAccess', -> try_read_only_token_access(@owner, unimportedV1Token, (response, body) => expect(response.statusCode).to.equal 302 expect(response.headers.location).to.equal( - 'http://overleaf.test:5000/read/abcd' + '/sign_in_to_v1?return_to=/read/abcd' ) , done) diff --git a/services/web/test/unit/coffee/TokenAccess/TokenAccessControllerTests.coffee b/services/web/test/unit/coffee/TokenAccess/TokenAccessControllerTests.coffee index 09123b5939..6d9728536d 100644 --- a/services/web/test/unit/coffee/TokenAccess/TokenAccessControllerTests.coffee +++ b/services/web/test/unit/coffee/TokenAccess/TokenAccessControllerTests.coffee @@ -533,6 +533,44 @@ describe "TokenAccessController", -> done() describe 'when findProject does not find a project', -> + describe 'when project does not exist', -> + beforeEach -> + @req = new MockRequest() + @res = new MockResponse() + @res.redirect = sinon.stub() + @next = sinon.stub() + @req.params['read_only_token'] = 'abcd' + @TokenAccessHandler.findProjectWithReadOnlyToken = sinon.stub() + .callsArgWith(1, null, null, false) + @TokenAccessHandler.checkV1ProjectExported = sinon.stub() + .callsArgWith(1, null, false) + @TokenAccessController.readOnlyToken @req, @res, @next + + it 'should redirect to v1', (done) -> + expect(@res.redirect.callCount).to.equal 1 + expect(@res.redirect.calledWith( + 302, + '/sign_in_to_v1?return_to=/read/abcd' + )).to.equal true + done() + + describe 'when project was exported from v1', -> + beforeEach -> + @req = new MockRequest() + @res = new MockResponse() + @res.redirect = sinon.stub() + @next = sinon.stub() + @req.params['read_only_token'] = 'abcd' + @TokenAccessHandler.findProjectWithReadOnlyToken = sinon.stub() + .callsArgWith(1, null, null, false) + @TokenAccessHandler.checkV1ProjectExported = sinon.stub() + .callsArgWith(1, null, true) + @TokenAccessController.readOnlyToken @req, @res, @next + + it 'should call next with a not-found error', (done) -> + expect(@next.callCount).to.equal 1 + done() + describe 'when token access is off, but user has higher access anyway', -> beforeEach -> @req = new MockRequest() @@ -761,6 +799,8 @@ describe "TokenAccessController", -> @req.params['read_only_token'] = @readOnlyToken @TokenAccessHandler.findProjectWithReadOnlyToken = sinon.stub() .callsArgWith(1, null, null) + @TokenAccessHandler.checkV1ProjectExported = sinon.stub() + .callsArgWith(1, null, false) @TokenAccessHandler.addReadOnlyUserToProject = sinon.stub() .callsArgWith(2, null) @ProjectController.loadEditor = sinon.stub() @@ -788,11 +828,17 @@ describe "TokenAccessController", -> .to.equal 0 done() - it 'should redirect to v1', (done) -> - expect(@res.redirect.callCount).to.equal 1 - expect(@res.redirect.calledWith( - 302, - "http://overleaf.test:5000/read/#{@readOnlyToken}" - )).to.equal true - done() + describe 'when project was exported to v2', -> + beforeEach -> + @TokenAccessHandler.checkV1ProjectExported = sinon.stub() + .callsArgWith(1, null, true) + @TokenAccessController.readOnlyToken @req, @res, @next + + it 'should redirect to v1', (done) -> + expect(@res.redirect.callCount).to.equal 1 + expect(@res.redirect.calledWith( + 302, + "/sign_in_to_v1?return_to=/read/#{@readOnlyToken}" + )).to.equal true + done() From 062f26dda3b8f5700c41609d8e5b5da4cd402eea Mon Sep 17 00:00:00 2001 From: Alasdair Smith Date: Fri, 28 Sep 2018 14:11:38 +0100 Subject: [PATCH 44/44] Remove POST /docs custom handler, now handled by redirects Implementing a system for signing into v1 via v2 using POSTs so the unauthenticated route is no longer necessary --- .../web/app/coffee/infrastructure/RedirectManager.coffee | 2 +- services/web/app/coffee/router.coffee | 6 ------ 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/services/web/app/coffee/infrastructure/RedirectManager.coffee b/services/web/app/coffee/infrastructure/RedirectManager.coffee index 0777941244..d4a9dd7c9a 100644 --- a/services/web/app/coffee/infrastructure/RedirectManager.coffee +++ b/services/web/app/coffee/infrastructure/RedirectManager.coffee @@ -15,7 +15,7 @@ module.exports = RedirectManager = if typeof target is 'string' url = target else - if req.method == "POST" + if req.method != "GET" code = 307 if typeof target.url == "function" diff --git a/services/web/app/coffee/router.coffee b/services/web/app/coffee/router.coffee index 578ff0ceb7..954356c4a0 100644 --- a/services/web/app/coffee/router.coffee +++ b/services/web/app/coffee/router.coffee @@ -331,12 +331,6 @@ module.exports = class Router AuthenticationController.httpAuth, CompileController.getFileFromClsiWithoutUser - # We want to redirect POST and GET to different locations, but this is - # unsupported by RedirectManager. Therefore we redirect GETs with - # RedirectManager and POSTs with this custom route - publicApiRouter.post '/docs', (req, res, next) -> - res.redirect(307, "#{Settings.overleaf.host}/docs") - webRouter.get '/teams', (req, res, next) -> # Match v1 behaviour - if the user is signed in, show their teams list # Otherwise show some information about teams