Merge pull request #2168 from overleaf/pr-restrict-main-file-options

Restrict main file options based on extension.

GitOrigin-RevId: f7d7a61c0454621dd8bc6ab5edce8a89721018ea
This commit is contained in:
Jessica Lawshe 2019-10-03 09:10:00 -05:00 committed by sharelatex
parent 6737637b39
commit ea0270dbdd
13 changed files with 253 additions and 65 deletions

View file

@ -278,6 +278,51 @@ const ProjectEntityHandler = {
return DocstoreManager.getDoc(project_id, doc_id, options, callback)
},
getDocPathByProjectIdAndDocId(project_id, doc_id, callback) {
logger.log({ project_id, doc_id }, 'getting path for doc and project')
return ProjectGetter.getProjectWithoutDocLines(project_id, function(
err,
project
) {
if (err != null) {
return callback(err)
}
if (project == null) {
return callback(new Errors.NotFoundError('no project'))
}
function recursivelyFindDocInFolder(basePath, doc_id, folder) {
let docInCurrentFolder = Array.from(folder.docs || []).find(
currentDoc => currentDoc._id.toString() === doc_id.toString()
)
if (docInCurrentFolder != null) {
return path.join(basePath, docInCurrentFolder.name)
} else {
let docPath, childFolder
for (childFolder of Array.from(folder.folders || [])) {
docPath = recursivelyFindDocInFolder(
path.join(basePath, childFolder.name),
doc_id,
childFolder
)
if (docPath != null) {
return docPath
}
}
return null
}
}
const docPath = recursivelyFindDocInFolder(
'/',
doc_id,
project.rootFolder[0]
)
if (docPath == null) {
return callback(new Errors.NotFoundError('no doc'))
}
return callback(null, docPath)
})
},
_getAllFolders(project_id, callback) {
logger.log({ project_id }, 'getting all folders for project')
return ProjectGetter.getProjectWithoutDocLines(project_id, function(

View file

@ -20,6 +20,7 @@ let ProjectEntityUpdateHandler, self
const _ = require('lodash')
const async = require('async')
const logger = require('logger-sharelatex')
const Settings = require('settings-sharelatex')
const path = require('path')
const { Doc } = require('../../models/Doc')
const DocstoreManager = require('../Docstore/DocstoreManager')
@ -39,6 +40,12 @@ const TpdsUpdateSender = require('../ThirdPartyDataStore/TpdsUpdateSender')
const LOCK_NAMESPACE = 'sequentialProjectStructureUpdateLock'
const validRootDocExtensions = Settings.validRootDocExtensions
const validRootDocRegExp = new RegExp(
`^\\.(${validRootDocExtensions.join('|')})$`,
'i'
)
const wrapWithLock = function(methodWithoutLock) {
// This lock is used to make sure that the project structure updates are made
// sequentially. In particular the updates must be made in mongo and sent to
@ -348,11 +355,33 @@ module.exports = ProjectEntityUpdateHandler = self = {
callback = function(error) {}
}
logger.log({ project_id, rootDocId: newRootDocID }, 'setting root doc')
return Project.update(
{ _id: project_id },
{ rootDoc_id: newRootDocID },
{},
callback
if (project_id == null || newRootDocID == null) {
return callback(
new Errors.InvalidError('missing arguments (project or doc)')
)
}
ProjectEntityHandler.getDocPathByProjectIdAndDocId(
project_id,
newRootDocID,
function(err, docPath) {
if (err != null) {
return callback(err)
}
if (ProjectEntityUpdateHandler.isPathValidForRootDoc(docPath)) {
return Project.update(
{ _id: project_id },
{ rootDoc_id: newRootDocID },
{},
callback
)
} else {
return callback(
new Errors.UnsupportedFileTypeError(
'invalid file extension for root doc'
)
)
}
}
)
},
@ -1401,6 +1430,11 @@ module.exports = ProjectEntityUpdateHandler = self = {
)
),
isPathValidForRootDoc(docPath) {
let docExtension = path.extname(docPath)
return validRootDocRegExp.test(docExtension)
},
_cleanUpEntity(
project,
newProject,

View file

@ -40,7 +40,7 @@ module.exports = ProjectRootDocManager = {
(doc, path) =>
function(cb) {
if (
/\.R?tex$/.test(Path.extname(path)) &&
ProjectEntityUpdateHandler.isPathValidForRootDoc(path) &&
DocumentHelper.contentHasDocumentclass(doc.lines)
) {
return cb(doc._id)
@ -232,14 +232,11 @@ module.exports = ProjectRootDocManager = {
if (rootDocValid) {
return callback()
} else {
return ProjectEntityUpdateHandler.setRootDoc(
project_id,
null,
() =>
ProjectRootDocManager.setRootDocAutomatically(
project_id,
callback
)
return ProjectEntityUpdateHandler.unsetRootDoc(project_id, () =>
ProjectRootDocManager.setRootDocAutomatically(
project_id,
callback
)
)
}
}

View file

@ -533,7 +533,8 @@ module.exports = function(app, webRouter, privateApiRouter, publicApiRouter) {
recaptchaSiteKeyV3:
Settings.recaptcha != null ? Settings.recaptcha.siteKeyV3 : undefined,
recaptchaDisabled:
Settings.recaptcha != null ? Settings.recaptcha.disabled : undefined
Settings.recaptcha != null ? Settings.recaptcha.disabled : undefined,
validRootDocExtensions: Settings.validRootDocExtensions
}
return next()
})

View file

@ -90,7 +90,7 @@ aside#left-menu.full-size(
select(
name="rootDoc_id",
ng-model="project.rootDoc_id",
ng-options="doc.doc.id as doc.path for doc in docs"
ng-options="doc.doc.id as doc.path for doc in getValidMainDocs()"
)
.form-controls

View file

@ -556,6 +556,8 @@ module.exports = settings =
compileBodySizeLimitMb: process.env['COMPILE_BODY_SIZE_LIMIT_MB'] or 5
validRootDocExtensions: ['tex', 'Rtex', 'ltx']
# allowedImageNames: [
# {imageName: 'texlive-full:2017.1', imageDesc: 'TeXLive 2017'}
# {imageName: 'wl_texlive:2018.1', imageDesc: 'Legacy OL TeXLive 2015'}

View file

@ -59,7 +59,8 @@ define([
'services/wait-for',
'filters/formatDate',
'main/event',
'main/account-upgrade'
'main/account-upgrade',
'main/exposed-settings'
], function(
App,
FileTreeManager,

View file

@ -13,7 +13,19 @@
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
define(['base'], App =>
App.controller('SettingsController', function($scope, settings, ide, _) {
App.controller('SettingsController', function(
$scope,
ExposedSettings,
settings,
ide,
_
) {
const validRootDocExtensions = ExposedSettings.validRootDocExtensions
const validRootDocRegExp = new RegExp(
`\\.(${validRootDocExtensions.join('|')})$`,
'i'
)
$scope.overallThemesList = window.overallThemes
$scope.ui = { loadingStyleSheet: false }
@ -63,6 +75,21 @@ define(['base'], App =>
return $scope.settings.fontSize.toString()
}
$scope.getValidMainDocs = () => {
let filteredDocs = []
if ($scope.docs) {
// Filter the existing docs (editable files) by accepted file extensions.
// It's possible that an existing project has an invalid file selected as the main one.
// To gracefully handle that case, make sure we also show the current main file (ignoring extension).
filteredDocs = $scope.docs.filter(
doc =>
validRootDocRegExp.test(doc.doc.name) ||
$scope.project.rootDoc_id === doc.doc.id
)
}
return filteredDocs
}
$scope.$watch('settings.editorTheme', (editorTheme, oldEditorTheme) => {
if (editorTheme !== oldEditorTheme) {
return settings.saveSettings({ editorTheme })
@ -176,7 +203,9 @@ define(['base'], App =>
}
// otherwise only save changes, null values are allowed
if (rootDoc_id !== oldRootDoc_id) {
return settings.saveProjectSettings({ rootDocId: rootDoc_id })
settings.saveProjectSettings({ rootDocId: rootDoc_id }).catch(() => {
$scope.project.rootDoc_id = oldRootDoc_id
})
}
})

View file

@ -30,6 +30,7 @@ define([
'main/subscription/team-invite-controller',
'main/contact-us',
'main/learn',
'main/exposed-settings',
'main/affiliations/components/affiliationForm',
'main/affiliations/controllers/UserAffiliationsController',
'main/affiliations/factories/UserAffiliationsDataService',

View file

@ -0,0 +1,3 @@
define(['base'], function(App) {
App.constant('ExposedSettings', window.ExposedSettings)
})

View file

@ -57,7 +57,6 @@ describe('ProjectEntityHandler', function() {
this.DocumentUpdaterHandler = {
updateProjectStructure: sinon.stub().yields()
}
this.callback = sinon.stub()
return (this.ProjectEntityHandler = SandboxedModule.require(modulePath, {
@ -76,6 +75,7 @@ describe('ProjectEntityHandler', function() {
'../../models/Project': {
Project: this.ProjectModel
},
'../Errors/Errors': Errors,
'./ProjectLocator': this.ProjectLocator,
'./ProjectGetter': (this.ProjectGetter = {}),
'../ThirdPartyDataStore/TpdsUpdateSender': this.TpdsUpdateSender
@ -121,9 +121,9 @@ describe('ProjectEntityHandler', function() {
]
}
]
return (this.ProjectGetter.getProjectWithoutDocLines = sinon
this.ProjectGetter.getProjectWithoutDocLines = sinon
.stub()
.yields(null, this.project))
.yields(null, this.project)
})
describe('getAllDocs', function() {
@ -143,17 +143,17 @@ describe('ProjectEntityHandler', function() {
this.DocstoreManager.getAllDocs = sinon
.stub()
.callsArgWith(1, null, this.docs)
return this.ProjectEntityHandler.getAllDocs(project_id, this.callback)
this.ProjectEntityHandler.getAllDocs(project_id, this.callback)
})
it('should get the doc lines and rev from the docstore', function() {
return this.DocstoreManager.getAllDocs
this.DocstoreManager.getAllDocs
.calledWith(project_id)
.should.equal(true)
})
it('should call the callback with the docs with the lines and rev included', function() {
return this.callback
this.callback
.calledWith(null, {
'/doc1': {
_id: this.doc1._id,
@ -175,11 +175,11 @@ describe('ProjectEntityHandler', function() {
describe('getAllFiles', function() {
beforeEach(function() {
this.callback = sinon.stub()
return this.ProjectEntityHandler.getAllFiles(project_id, this.callback)
this.ProjectEntityHandler.getAllFiles(project_id, this.callback)
})
it('should call the callback with the files', function() {
return this.callback
this.callback
.calledWith(null, {
'/file1': this.file1,
'/folder1/file2': this.file2
@ -203,7 +203,7 @@ describe('ProjectEntityHandler', function() {
}
]
this.callback = sinon.stub()
return this.ProjectEntityHandler.getAllDocPathsFromProject(
this.ProjectEntityHandler.getAllDocPathsFromProject(
this.project,
this.callback
)
@ -213,27 +213,71 @@ describe('ProjectEntityHandler', function() {
this.expected = {}
this.expected[this.doc1._id] = `/${this.doc1.name}`
this.expected[this.doc2._id] = `/folder1/${this.doc2.name}`
return this.callback.calledWith(null, this.expected).should.equal(true)
this.callback.calledWith(null, this.expected).should.equal(true)
})
})
describe('getDocPathByProjectIdAndDocId', function() {
beforeEach(function() {
this.callback = sinon.stub()
})
it('should call the callback with the path for an existing doc id at the root level', function() {
this.ProjectEntityHandler.getDocPathByProjectIdAndDocId(
project_id,
this.doc1._id,
this.callback
)
this.callback.calledWith(null, `/${this.doc1.name}`).should.equal(true)
})
it('should call the callback with the path for an existing doc id nested within a folder', function() {
this.ProjectEntityHandler.getDocPathByProjectIdAndDocId(
project_id,
this.doc2._id,
this.callback
)
this.callback
.calledWith(null, `/folder1/${this.doc2.name}`)
.should.equal(true)
})
it('should call the callback with a NotFoundError for a non-existing doc', function() {
this.ProjectEntityHandler.getDocPathByProjectIdAndDocId(
project_id,
'non-existing-id',
this.callback
)
expect(this.callback.firstCall.args[0]).to.be.an.instanceof(
Errors.NotFoundError
)
})
it('should call the callback with a NotFoundError for an existing file', function() {
this.ProjectEntityHandler.getDocPathByProjectIdAndDocId(
project_id,
this.file1._id,
this.callback
)
expect(this.callback.firstCall.args[0]).to.be.an.instanceof(
Errors.NotFoundError
)
})
})
describe('_getAllFolders', function() {
beforeEach(function() {
this.callback = sinon.stub()
return this.ProjectEntityHandler._getAllFolders(
project_id,
this.callback
)
this.ProjectEntityHandler._getAllFolders(project_id, this.callback)
})
it('should get the project without the docs lines', function() {
return this.ProjectGetter.getProjectWithoutDocLines
this.ProjectGetter.getProjectWithoutDocLines
.calledWith(project_id)
.should.equal(true)
})
it('should call the callback with the folders', function() {
return this.callback
this.callback
.calledWith(null, {
'/': this.project.rootFolder[0],
'/folder1': this.folder1
@ -245,14 +289,14 @@ describe('ProjectEntityHandler', function() {
describe('_getAllFoldersFromProject', function() {
beforeEach(function() {
this.callback = sinon.stub()
return this.ProjectEntityHandler._getAllFoldersFromProject(
this.ProjectEntityHandler._getAllFoldersFromProject(
this.project,
this.callback
)
})
it('should call the callback with the folders', function() {
return this.callback
this.callback
.calledWith(null, {
'/': this.project.rootFolder[0],
'/folder1': this.folder1
@ -286,32 +330,30 @@ describe('ProjectEntityHandler', function() {
this.ProjectGetter.getProject = sinon.stub().yields(null, this.project)
return this.ProjectEntityHandler.flushProjectToThirdPartyDataStore(
this.ProjectEntityHandler.flushProjectToThirdPartyDataStore(
project_id,
() => done()
)
})
it('should flush the project from the doc updater', function() {
return this.DocumentUpdaterHandler.flushProjectToMongo
this.DocumentUpdaterHandler.flushProjectToMongo
.calledWith(project_id)
.should.equal(true)
})
it('should look up the project in mongo', function() {
return this.ProjectGetter.getProject
.calledWith(project_id)
.should.equal(true)
this.ProjectGetter.getProject.calledWith(project_id).should.equal(true)
})
it('should get all the docs in the project', function() {
return this.ProjectEntityHandler.getAllDocs
this.ProjectEntityHandler.getAllDocs
.calledWith(project_id)
.should.equal(true)
})
it('should get all the files in the project', function() {
return this.ProjectEntityHandler.getAllFiles
this.ProjectEntityHandler.getAllFiles
.calledWith(project_id)
.should.equal(true)
})
@ -369,17 +411,17 @@ describe('ProjectEntityHandler', function() {
this.DocstoreManager.getDoc = sinon
.stub()
.callsArgWith(3, null, this.lines, this.rev, this.version, this.ranges)
return this.ProjectEntityHandler.getDoc(project_id, doc_id, this.callback)
this.ProjectEntityHandler.getDoc(project_id, doc_id, this.callback)
})
it('should call the docstore', function() {
return this.DocstoreManager.getDoc
this.DocstoreManager.getDoc
.calledWith(project_id, doc_id)
.should.equal(true)
})
it('should call the callback with the lines, version and rev', function() {
return this.callback
this.callback
.calledWith(null, this.lines, this.rev, this.version, this.ranges)
.should.equal(true)
})

View file

@ -493,14 +493,50 @@ describe('ProjectEntityUpdateHandler', function() {
})
describe('setRootDoc', function() {
it('should call Project.update', function() {
const rootDoc_id = 'root-doc-id-123123'
beforeEach(function() {
this.rootDoc_id = 'root-doc-id-123123'
this.callback = sinon.stub()
})
it('should call Project.update when the doc exists and has a valid extension', function() {
this.ProjectModel.update = sinon.stub()
this.ProjectEntityUpdateHandler.setRootDoc(project_id, rootDoc_id)
this.ProjectEntityHandler.getDocPathByProjectIdAndDocId = sinon
.stub()
.yields(null, `/main.tex`)
this.ProjectEntityUpdateHandler.setRootDoc(project_id, this.rootDoc_id)
return this.ProjectModel.update
.calledWith({ _id: project_id }, { rootDoc_id })
.calledWith({ _id: project_id }, { rootDoc_id: this.rootDoc_id })
.should.equal(true)
})
it("should not call Project.update when the doc doesn't exist", function() {
this.ProjectModel.update = sinon.stub()
this.ProjectEntityHandler.getDocPathByProjectIdAndDocId = sinon
.stub()
.yields(Errors.NotFoundError)
this.ProjectEntityUpdateHandler.setRootDoc(project_id, this.rootDoc_id)
return this.ProjectModel.update
.calledWith({ _id: project_id }, { rootDoc_id: this.rootDoc_id })
.should.equal(false)
})
it('should call the callback with an UnsupportedFileTypeError when the doc has an unaccepted file extension', function() {
this.ProjectModel.update = sinon.stub()
this.ProjectEntityHandler.getDocPathByProjectIdAndDocId = sinon
.stub()
.yields(null, `/foo/bar.baz`)
this.ProjectEntityUpdateHandler.setRootDoc(
project_id,
this.rootDoc_id,
this.callback
)
return expect(this.callback.firstCall.args[0]).to.be.an.instanceof(
Errors.UnsupportedFileTypeError
)
})
})
describe('unsetRootDoc', function() {

View file

@ -55,6 +55,12 @@ describe('ProjectRootDocManager', function() {
})
describe('setRootDocAutomatically', function() {
beforeEach(function() {
this.ProjectEntityUpdateHandler.setRootDoc = sinon.stub().callsArgWith(2)
this.ProjectEntityUpdateHandler.isPathValidForRootDoc = sinon
.stub()
.returns(true)
})
describe('when there is a suitable root doc', function() {
beforeEach(function(done) {
this.docs = {
@ -84,14 +90,10 @@ describe('ProjectRootDocManager', function() {
lines: ['Hello world']
}
}
this.ProjectEntityHandler.getAllDocs = sinon
.stub()
.callsArgWith(1, null, this.docs)
this.ProjectEntityUpdateHandler.setRootDoc = sinon
.stub()
.callsArgWith(2)
return this.ProjectRootDocManager.setRootDocAutomatically(
this.ProjectRootDocManager.setRootDocAutomatically(
this.project_id,
done
)
@ -125,9 +127,6 @@ describe('ProjectRootDocManager', function() {
this.ProjectEntityHandler.getAllDocs = sinon
.stub()
.callsArgWith(1, null, this.docs)
this.ProjectEntityUpdateHandler.setRootDoc = sinon
.stub()
.callsArgWith(2)
return this.ProjectRootDocManager.setRootDocAutomatically(
this.project_id,
this.callback
@ -156,9 +155,6 @@ describe('ProjectRootDocManager', function() {
this.ProjectEntityHandler.getAllDocs = sinon
.stub()
.callsArgWith(1, null, this.docs)
this.ProjectEntityUpdateHandler.setRootDoc = sinon
.stub()
.callsArgWith(2)
return this.ProjectRootDocManager.setRootDocAutomatically(
this.project_id,
done
@ -580,6 +576,7 @@ describe('ProjectRootDocManager', function() {
.stub()
.callsArgWith(2, null, this.project)
this.ProjectEntityUpdateHandler.setRootDoc = sinon.stub().yields()
this.ProjectEntityUpdateHandler.unsetRootDoc = sinon.stub().yields()
this.ProjectEntityHandler.getAllDocPathsFromProjectById = sinon
.stub()
.callsArgWith(1, null, this.docPaths)
@ -630,9 +627,9 @@ describe('ProjectRootDocManager', function() {
.should.equal(true)
})
it('should null the rootDoc_id field', function() {
return this.ProjectEntityUpdateHandler.setRootDoc
.calledWith(this.project_id, null)
it('should unset the root doc', function() {
return this.ProjectEntityUpdateHandler.unsetRootDoc
.calledWith(this.project_id)
.should.equal(true)
})