overleaf/services/web/app/src/Features/Project/ProjectEntityMongoUpdateHandler.js

889 lines
30 KiB
JavaScript
Raw Normal View History

/* eslint-disable
camelcase,
handle-callback-err,
max-len,
one-var,
standard/no-callback-literal,
*/
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS101: Remove unnecessary use of Array.from
* DS102: Remove unnecessary code created because of implicit returns
* DS103: Rewrite code to no longer use __guard__
* DS201: Simplify complex destructure assignments
* DS207: Consider shorter variations of null checks
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
let ProjectEntityMongoUpdateHandler, self
const _ = require('underscore')
const async = require('async')
const logger = require('logger-sharelatex')
const path = require('path')
const settings = require('settings-sharelatex')
const CooldownManager = require('../Cooldown/CooldownManager')
const Errors = require('../Errors/Errors')
const { Folder } = require('../../models/Folder')
const LockManager = require('../../infrastructure/LockManager')
const { Project } = require('../../models/Project')
const ProjectEntityHandler = require('./ProjectEntityHandler')
const ProjectGetter = require('./ProjectGetter')
const ProjectLocator = require('./ProjectLocator')
const SafePath = require('./SafePath')
const LOCK_NAMESPACE = 'mongoTransaction'
const wrapWithLock = function(methodWithoutLock) {
// This lock is used whenever we read or write to an existing project's
// structure. Some operations to project structure cannot be done atomically
// in mongo, this lock is used to prevent reading the structure between two
// parts of a staged update.
const methodWithLock = function(project_id, ...rest) {
const adjustedLength = Math.max(rest.length, 1),
args = rest.slice(0, adjustedLength - 1),
callback = rest[adjustedLength - 1]
return LockManager.runWithLock(
LOCK_NAMESPACE,
project_id,
cb => methodWithoutLock(project_id, ...Array.from(args), cb),
callback
)
}
methodWithLock.withoutLock = methodWithoutLock
return methodWithLock
}
module.exports = ProjectEntityMongoUpdateHandler = self = {
LOCK_NAMESPACE,
addDoc: wrapWithLock(function(project_id, folder_id, doc, callback) {
if (callback == null) {
callback = function(err, result) {}
}
return ProjectGetter.getProjectWithoutLock(
project_id,
{ rootFolder: true, name: true, overleaf: true },
function(err, project) {
if (err != null) {
logger.err({ project_id, err }, 'error getting project for add doc')
return callback(err)
}
logger.log(
{ project_id, folder_id, doc_name: doc.name },
'adding doc to project with project'
)
return self._confirmFolder(project, folder_id, folder_id => {
return self._putElement(project, folder_id, doc, 'doc', callback)
})
}
)
}),
addFile: wrapWithLock(function(project_id, folder_id, fileRef, callback) {
if (callback == null) {
callback = function(error, result, project) {}
}
return ProjectGetter.getProjectWithoutLock(
project_id,
{ rootFolder: true, name: true, overleaf: true },
function(err, project) {
if (err != null) {
logger.err({ project_id, err }, 'error getting project for add file')
return callback(err)
}
logger.log(
{ project_id: project._id, folder_id, file_name: fileRef.name },
'adding file'
)
return self._confirmFolder(project, folder_id, folder_id =>
self._putElement(project, folder_id, fileRef, 'file', callback)
)
}
)
}),
replaceFileWithNew: wrapWithLock(
(project_id, file_id, newFileRef, callback) =>
ProjectGetter.getProjectWithoutLock(
project_id,
{ rootFolder: true, name: true, overleaf: true },
function(err, project) {
if (err != null) {
return callback(err)
}
return ProjectLocator.findElement(
{ project, element_id: file_id, type: 'file' },
(err, fileRef, path) => {
if (err != null) {
return callback(err)
}
return ProjectEntityMongoUpdateHandler._insertDeletedFileReference(
project_id,
fileRef,
function(err) {
if (err != null) {
return callback(err)
}
const conditions = { _id: project._id }
const inc = {}
// increment the project structure version as we are adding a new file here
inc['version'] = 1
const set = {}
set[`${path.mongo}._id`] = newFileRef._id
set[`${path.mongo}.created`] = new Date()
set[`${path.mongo}.linkedFileData`] =
newFileRef.linkedFileData
inc[`${path.mongo}.rev`] = 1
set[`${path.mongo}.hash`] = newFileRef.hash
const update = {
$inc: inc,
$set: set
}
// Note: Mongoose uses new:true to return the modified document
// https://mongoosejs.com/docs/api.html#model_Model.findOneAndUpdate
// but Mongo uses returnNewDocument:true instead
// https://docs.mongodb.com/manual/reference/method/db.collection.findOneAndUpdate/
// We are using Mongoose here, but if we ever switch to a direct mongo call
// the next line will need to be updated.
return Project.findOneAndUpdate(
conditions,
update,
{ new: true },
function(err, newProject) {
if (err != null) {
return callback(err)
}
return callback(null, fileRef, project, path, newProject)
}
)
}
)
}
)
}
)
),
mkdirp: wrapWithLock(function(project_id, path, options, callback) {
// defaults to case insensitive paths, use options {exactCaseMatch:true}
// to make matching case-sensitive
let folders = path.split('/')
folders = _.select(folders, folder => folder.length !== 0)
return ProjectGetter.getProjectWithOnlyFolders(
project_id,
(err, project) => {
if (path === '/') {
logger.log(
{ project_id: project._id },
'mkdir is only trying to make path of / so sending back root folder'
)
return callback(null, [], project.rootFolder[0])
}
logger.log({ project_id: project._id, path, folders }, 'running mkdirp')
let builtUpPath = ''
const procesFolder = (previousFolders, folderName, callback) => {
let parentFolder_id
previousFolders = previousFolders || []
const parentFolder = previousFolders[previousFolders.length - 1]
if (parentFolder != null) {
parentFolder_id = parentFolder._id
}
builtUpPath = `${builtUpPath}/${folderName}`
return ProjectLocator.findElementByPath(
{
project,
path: builtUpPath,
exactCaseMatch:
options != null ? options.exactCaseMatch : undefined
},
(err, foundFolder) => {
if (foundFolder == null) {
logger.log(
{ path, project_id: project._id, folderName },
'making folder from mkdirp'
)
return self.addFolder.withoutLock(
project_id,
parentFolder_id,
folderName,
function(err, newFolder, parentFolder_id) {
if (err != null) {
return callback(err)
}
newFolder.parentFolder_id = parentFolder_id
previousFolders.push(newFolder)
return callback(null, previousFolders)
}
)
} else {
foundFolder.filterOut = true
previousFolders.push(foundFolder)
return callback(null, previousFolders)
}
}
)
}
return async.reduce(folders, [], procesFolder, function(err, folders) {
if (err != null) {
return callback(err)
}
const lastFolder = folders[folders.length - 1]
folders = _.select(folders, folder => !folder.filterOut)
return callback(null, folders, lastFolder)
})
}
)
}),
moveEntity: wrapWithLock(function(
project_id,
entity_id,
destFolderId,
entityType,
callback
) {
if (callback == null) {
callback = function(error) {}
}
return ProjectGetter.getProjectWithoutLock(
project_id,
{ rootFolder: true, name: true, overleaf: true },
function(err, project) {
if (err != null) {
return callback(err)
}
return ProjectLocator.findElement(
{ project, element_id: entity_id, type: entityType },
function(err, entity, entityPath) {
if (err != null) {
return callback(err)
}
// Prevent top-level docs/files with reserved names (to match v1 behaviour)
if (self._blockedFilename(entityPath, entityType)) {
return callback(
new Errors.InvalidNameError('blocked element name')
)
}
return self._checkValidMove(
project,
entityType,
entity,
entityPath,
destFolderId,
function(error) {
if (error != null) {
return callback(error)
}
return ProjectEntityHandler.getAllEntitiesFromProject(
project,
function(error, oldDocs, oldFiles) {
if (error != null) {
return callback(error)
}
// For safety, insert the entity in the destination
// location first, and then remove the original. If
// there is an error the entity may appear twice. This
// will cause some breakage but is better than being
// lost, which is what happens if this is done in the
// opposite order.
return self._putElement(
project,
destFolderId,
entity,
entityType,
function(err, result) {
if (err != null) {
return callback(err)
}
// Note: putElement always pushes onto the end of an
// array so it will never change an existing mongo
// path. Therefore it is safe to remove an element
// from the project with an existing path after
// calling putElement. But we must be sure that we
// have not moved a folder subfolder of itself (which
// is done by _checkValidMove above) because that
// would lead to it being deleted.
return self._removeElementFromMongoArray(
Project,
project_id,
entityPath.mongo,
entity_id,
function(err, newProject) {
if (err != null) {
return callback(err)
}
return ProjectEntityHandler.getAllEntitiesFromProject(
newProject,
function(err, newDocs, newFiles) {
if (err != null) {
return callback(err)
}
const startPath = entityPath.fileSystem
const endPath = result.path.fileSystem
const changes = {
oldDocs,
newDocs,
oldFiles,
newFiles,
newProject
}
// check that no files have been lost (or duplicated)
if (
oldFiles.length !== newFiles.length ||
oldDocs.length !== newDocs.length
) {
logger.err(
{
project_id,
oldDocs: oldDocs.length,
newDocs: newDocs.length,
oldFiles: oldFiles.length,
newFiles: newFiles.length,
origProject: project,
newProject
},
"project corrupted moving files - shouldn't happen"
)
return callback(
new Error(
'unexpected change in project structure'
)
)
}
return callback(
null,
project,
startPath,
endPath,
entity.rev,
changes,
callback
)
}
)
}
)
}
)
}
)
}
)
}
)
}
)
}),
deleteEntity: wrapWithLock((project_id, entity_id, entityType, callback) =>
ProjectGetter.getProjectWithoutLock(
project_id,
{ name: true, rootFolder: true, overleaf: true },
function(error, project) {
if (error != null) {
return callback(error)
}
return ProjectLocator.findElement(
{ project, element_id: entity_id, type: entityType },
function(error, entity, path) {
if (error != null) {
return callback(error)
}
return self._removeElementFromMongoArray(
Project,
project_id,
path.mongo,
entity_id,
function(error, newProject) {
if (error != null) {
return callback(error)
}
return callback(null, entity, path, project, newProject)
}
)
}
)
}
)
),
renameEntity: wrapWithLock(
(project_id, entity_id, entityType, newName, callback) =>
ProjectGetter.getProjectWithoutLock(
project_id,
{ rootFolder: true, name: true, overleaf: true },
(error, project) => {
if (error != null) {
return callback(error)
}
return ProjectEntityHandler.getAllEntitiesFromProject(
project,
(error, oldDocs, oldFiles) => {
if (error != null) {
return callback(error)
}
return ProjectLocator.findElement(
{ project, element_id: entity_id, type: entityType },
(error, entity, entPath, parentFolder) => {
if (error != null) {
return callback(error)
}
const endPath = path.join(
path.dirname(entPath.fileSystem),
newName
)
// Prevent top-level docs/files with reserved names (to match v1 behaviour)
if (
self._blockedFilename({ fileSystem: endPath }, entityType)
) {
return callback(
new Errors.InvalidNameError('blocked element name')
)
}
// check if the new name already exists in the current folder
return self._checkValidElementName(
parentFolder,
newName,
error => {
if (error != null) {
return callback(error)
}
const conditions = { _id: project_id }
const update = { $set: {}, $inc: {} }
const namePath = entPath.mongo + '.name'
update['$set'][namePath] = newName
// we need to increment the project version number for any structure change
update['$inc']['version'] = 1
return Project.findOneAndUpdate(
conditions,
update,
{ new: true },
function(error, newProject) {
if (error != null) {
return callback(error)
}
return ProjectEntityHandler.getAllEntitiesFromProject(
newProject,
(error, newDocs, newFiles) => {
if (error != null) {
return callback(error)
}
const startPath = entPath.fileSystem
const changes = {
oldDocs,
newDocs,
oldFiles,
newFiles,
newProject
}
return callback(
null,
project,
startPath,
endPath,
entity.rev,
changes,
callback
)
}
)
}
)
}
)
}
)
}
)
}
)
),
addFolder: wrapWithLock((project_id, parentFolder_id, folderName, callback) =>
ProjectGetter.getProjectWithoutLock(
project_id,
{ rootFolder: true, name: true, overleaf: true },
function(err, project) {
if (err != null) {
logger.err(
{ project_id, err },
'error getting project for add folder'
)
return callback(err)
}
return self._confirmFolder(
project,
parentFolder_id,
parentFolder_id => {
const folder = new Folder({ name: folderName })
logger.log(
{ project: project._id, parentFolder_id, folderName },
'adding new folder'
)
return self._putElement(
project,
parentFolder_id,
folder,
'folder',
err => {
if (err != null) {
logger.err(
{ err, project_id: project._id },
'error adding folder to project'
)
return callback(err)
}
return callback(null, folder, parentFolder_id)
}
)
}
)
}
)
),
_removeElementFromMongoArray(model, model_id, path, element_id, callback) {
if (callback == null) {
callback = function(err, project) {}
}
const conditions = { _id: model_id }
const pullUpdate = { $pull: {}, $inc: {} }
const nonArrayPath = path.slice(0, path.lastIndexOf('.'))
// remove specific element from array by id
pullUpdate['$pull'][nonArrayPath] = { _id: element_id }
// we need to increment the project version number for any structure change
pullUpdate['$inc']['version'] = 1
return model.findOneAndUpdate(
conditions,
pullUpdate,
{ new: true },
callback
)
},
_countElements(project) {
var countFolder = function(folder) {
let total = 0
for (let subfolder of Array.from(
(folder != null ? folder.folders : undefined) || []
)) {
total += countFolder(subfolder)
}
if (
__guard__(folder != null ? folder.folders : undefined, x => x.length) !=
null
) {
total += folder.folders.length
}
if (
__guard__(folder != null ? folder.docs : undefined, x1 => x1.length) !=
null
) {
total += folder.docs.length
}
if (
__guard__(
folder != null ? folder.fileRefs : undefined,
x2 => x2.length
) != null
) {
total += folder.fileRefs.length
}
return total
}
return countFolder(project.rootFolder[0])
},
_putElement(project, folder_id, element, type, callback) {
let e
if (callback == null) {
callback = function(err, path, project) {}
}
const sanitizeTypeOfElement = function(elementType) {
const lastChar = elementType.slice(-1)
if (lastChar !== 's') {
elementType += 's'
}
if (elementType === 'files') {
elementType = 'fileRefs'
}
return elementType
}
if (element == null || element._id == null) {
e = new Error('no element passed to be inserted')
logger.err(
{ project_id: project._id, folder_id, element, type },
'failed trying to insert element as it was null'
)
return callback(e)
}
type = sanitizeTypeOfElement(type)
// original check path.resolve("/", element.name) isnt "/#{element.name}" or element.name.match("/")
// check if name is allowed
if (!SafePath.isCleanFilename(element.name)) {
e = new Errors.InvalidNameError('invalid element name')
logger.err(
{ project_id: project._id, folder_id, element, type },
'failed trying to insert element as name was invalid'
)
return callback(e)
}
if (folder_id == null) {
folder_id = project.rootFolder[0]._id
}
if (self._countElements(project) > settings.maxEntitiesPerProject) {
logger.warn(
{ project_id: project._id },
'project too big, stopping insertions'
)
CooldownManager.putProjectOnCooldown(project._id)
return callback('project_has_to_many_files')
}
return ProjectLocator.findElement(
{ project, element_id: folder_id, type: 'folders' },
(err, folder, path) => {
if (err != null) {
logger.err(
{ err, project_id: project._id, folder_id, type, element },
'error finding folder for _putElement'
)
return callback(err)
}
const newPath = {
fileSystem: `${path.fileSystem}/${element.name}`,
mongo: path.mongo
}
// check if the path would be too long
if (!SafePath.isAllowedLength(newPath.fileSystem)) {
return callback(new Errors.InvalidNameError('path too long'))
}
// Prevent top-level docs/files with reserved names (to match v1 behaviour)
if (self._blockedFilename(newPath, type)) {
return callback(new Errors.InvalidNameError('blocked element name'))
}
return self._checkValidElementName(folder, element.name, err => {
if (err != null) {
return callback(err)
}
const id = element._id + ''
element._id = require('mongoose').Types.ObjectId(id)
const conditions = { _id: project._id }
const mongopath = `${path.mongo}.${type}`
const update = { $push: {}, $inc: {} }
update['$push'][mongopath] = element
// we need to increment the project version number for any structure change
update['$inc']['version'] = 1 // increment project version number
logger.log(
{
project_id: project._id,
element_id: element._id,
fileType: type,
folder_id,
mongopath
},
'adding element to project'
)
// We are using Mongoose here, but if we ever switch to a direct mongo call
// the next line will need to be updated to {returnNewDocument:true}
return Project.findOneAndUpdate(
conditions,
update,
{ new: true },
function(err, newProject) {
if (err != null) {
logger.err(
{ err, project_id: project._id },
'error saving in putElement project'
)
return callback(err)
}
return callback(err, { path: newPath }, newProject)
}
)
})
}
)
},
_blockedFilename(entityPath, entityType) {
// check if name would be blocked in v1
// javascript reserved names are forbidden for docs and files
// at the top-level (but folders with reserved names are allowed).
const isFolder = ['folder', 'folders'].includes(entityType)
const [dir, file] = Array.from([
path.dirname(entityPath.fileSystem),
path.basename(entityPath.fileSystem)
])
const isTopLevel = dir === '/'
if (isTopLevel && !isFolder && SafePath.isBlockedFilename(file)) {
return true
} else {
return false
}
},
_checkValidElementName(folder, name, callback) {
// check if the name is already taken by a doc, file or
// folder. If so, return an error "file already exists".
if (callback == null) {
callback = function(err) {}
}
const err = new Errors.InvalidNameError('file already exists')
for (let doc of Array.from(
(folder != null ? folder.docs : undefined) || []
)) {
if (doc.name === name) {
return callback(err)
}
}
for (let file of Array.from(
(folder != null ? folder.fileRefs : undefined) || []
)) {
if (file.name === name) {
return callback(err)
}
}
for (folder of Array.from(
(folder != null ? folder.folders : undefined) || []
)) {
if (folder.name === name) {
return callback(err)
}
}
return callback()
},
_confirmFolder(project, folder_id, callback) {
logger.log(
{ folder_id, project_id: project._id },
'confirming folder in project'
)
if (folder_id + '' === 'undefined') {
return callback(project.rootFolder[0]._id)
} else if (folder_id !== null) {
return callback(folder_id)
} else {
return callback(project.rootFolder[0]._id)
}
},
_checkValidMove(
project,
entityType,
entity,
entityPath,
destFolderId,
callback
) {
if (callback == null) {
callback = function(error) {}
}
return ProjectLocator.findElement(
{ project, element_id: destFolderId, type: 'folder' },
function(err, destEntity, destFolderPath) {
if (err != null) {
return callback(err)
}
// check if there is already a doc/file/folder with the same name
// in the destination folder
return self._checkValidElementName(destEntity, entity.name, function(
err
) {
if (err != null) {
return callback(err)
}
if (/folder/.test(entityType)) {
logger.log(
{
destFolderPath: destFolderPath.fileSystem,
folderPath: entityPath.fileSystem
},
'checking folder is not moving into child folder'
)
const isNestedFolder =
destFolderPath.fileSystem.slice(
0,
entityPath.fileSystem.length
) === entityPath.fileSystem
if (isNestedFolder) {
return callback(
new Errors.InvalidNameError(
'destination folder is a child folder of me'
)
)
}
}
return callback()
})
}
)
},
_insertDeletedDocReference(project_id, doc, callback) {
if (callback == null) {
callback = function(error) {}
}
return Project.update(
{
_id: project_id
},
{
$push: {
deletedDocs: {
_id: doc._id,
name: doc.name,
deletedAt: new Date()
}
}
},
{},
callback
)
},
_insertDeletedFileReference(project_id, fileRef, callback) {
if (callback == null) {
callback = function(error) {}
}
return Project.update(
{
_id: project_id
},
{
$push: {
deletedFiles: {
_id: fileRef._id,
name: fileRef.name,
linkedFileData: fileRef.linkedFileData,
hash: fileRef.hash,
deletedAt: new Date()
}
}
},
{},
callback
)
}
}
function __guard__(value, transform) {
return typeof value !== 'undefined' && value !== null
? transform(value)
: undefined
}