| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733 |
- const { callbackify } = require('util')
- const { callbackifyMultiResult } = require('@overleaf/promise-utils')
- const logger = require('@overleaf/logger')
- const path = require('path')
- const { ObjectId } = require('mongodb')
- const Settings = require('@overleaf/settings')
- const OError = require('@overleaf/o-error')
- 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 FolderStructureBuilder = require('./FolderStructureBuilder')
- const SafePath = require('./SafePath')
- const { DeletedFile } = require('../../models/DeletedFile')
- const { iterablePaths } = require('./IterablePath')
- const LOCK_NAMESPACE = 'mongoTransaction'
- const ENTITY_TYPE_TO_MONGO_PATH_SEGMENT = {
- doc: 'docs',
- docs: 'docs',
- file: 'fileRefs',
- files: 'fileRefs',
- fileRefs: 'fileRefs',
- folder: 'folders',
- folders: 'folders',
- }
- module.exports = {
- LOCK_NAMESPACE,
- addDoc: callbackifyMultiResult(wrapWithLock(addDoc), ['result', 'project']),
- addFile: callbackifyMultiResult(wrapWithLock(addFile), ['result', 'project']),
- addFolder: callbackifyMultiResult(wrapWithLock(addFolder), [
- 'folder',
- 'parentFolderId',
- ]),
- replaceFileWithNew: callbackifyMultiResult(wrapWithLock(replaceFileWithNew), [
- 'oldFileRef',
- 'project',
- 'path',
- 'newProject',
- 'newFileRef',
- ]),
- replaceDocWithFile: callbackify(replaceDocWithFile),
- replaceFileWithDoc: callbackify(replaceFileWithDoc),
- mkdirp: callbackifyMultiResult(wrapWithLock(mkdirp), [
- 'newFolders',
- 'folder',
- 'parentFolder',
- ]),
- moveEntity: callbackifyMultiResult(wrapWithLock(moveEntity), [
- 'project',
- 'startPath',
- 'endPath',
- 'rev',
- 'changes',
- ]),
- deleteEntity: callbackifyMultiResult(wrapWithLock(deleteEntity), [
- 'entity',
- 'path',
- 'projectBeforeDeletion',
- 'newProject',
- ]),
- renameEntity: callbackifyMultiResult(wrapWithLock(renameEntity), [
- 'project',
- 'startPath',
- 'endPath',
- 'rev',
- 'changes',
- ]),
- createNewFolderStructure: callbackify(wrapWithLock(createNewFolderStructure)),
- _insertDeletedFileReference: callbackify(_insertDeletedFileReference),
- _putElement: callbackifyMultiResult(_putElement, ['result', 'project']),
- _confirmFolder,
- promises: {
- addDoc: wrapWithLock(addDoc),
- addFile: wrapWithLock(addFile),
- addFolder: wrapWithLock(addFolder),
- replaceFileWithNew: wrapWithLock(replaceFileWithNew),
- replaceDocWithFile: wrapWithLock(replaceDocWithFile),
- replaceFileWithDoc: wrapWithLock(replaceFileWithDoc),
- mkdirp: wrapWithLock(mkdirp),
- moveEntity: wrapWithLock(moveEntity),
- deleteEntity: wrapWithLock(deleteEntity),
- renameEntity: wrapWithLock(renameEntity),
- createNewFolderStructure: wrapWithLock(createNewFolderStructure),
- _insertDeletedFileReference,
- _putElement,
- },
- }
- function wrapWithLock(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.
- async function methodWithLock(projectId, ...rest) {
- return LockManager.promises.runWithLock(LOCK_NAMESPACE, projectId, () =>
- methodWithoutLock(projectId, ...rest)
- )
- }
- return methodWithLock
- }
- async function addDoc(projectId, folderId, doc) {
- const project = await ProjectGetter.promises.getProjectWithoutLock(
- projectId,
- {
- rootFolder: true,
- name: true,
- overleaf: true,
- }
- )
- folderId = _confirmFolder(project, folderId)
- const { result, project: newProject } = await _putElement(
- project,
- folderId,
- doc,
- 'doc'
- )
- return { result, project: newProject }
- }
- async function addFile(projectId, folderId, fileRef) {
- const project = await ProjectGetter.promises.getProjectWithoutLock(
- projectId,
- { rootFolder: true, name: true, overleaf: true }
- )
- folderId = _confirmFolder(project, folderId)
- const { result, project: newProject } = await _putElement(
- project,
- folderId,
- fileRef,
- 'file'
- )
- return { result, project: newProject }
- }
- async function addFolder(projectId, parentFolderId, folderName) {
- const project = await ProjectGetter.promises.getProjectWithoutLock(
- projectId,
- { rootFolder: true, name: true, overleaf: true }
- )
- parentFolderId = _confirmFolder(project, parentFolderId)
- const folder = new Folder({ name: folderName })
- await _putElement(project, parentFolderId, folder, 'folder')
- return { folder, parentFolderId }
- }
- async function replaceFileWithNew(projectId, fileId, newFileRef) {
- const project = await ProjectGetter.promises.getProjectWithoutLock(
- projectId,
- { rootFolder: true, name: true, overleaf: true }
- )
- const { element: fileRef, path } = await ProjectLocator.promises.findElement({
- project,
- element_id: fileId,
- type: 'file',
- })
- await _insertDeletedFileReference(projectId, fileRef)
- const newProject = await Project.findOneAndUpdate(
- { _id: project._id, [path.mongo]: { $exists: true } },
- {
- $set: {
- [`${path.mongo}._id`]: newFileRef._id,
- [`${path.mongo}.created`]: new Date(),
- [`${path.mongo}.linkedFileData`]: newFileRef.linkedFileData,
- [`${path.mongo}.hash`]: newFileRef.hash,
- },
- $inc: {
- version: 1,
- [`${path.mongo}.rev`]: 1,
- },
- },
- // 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.
- { new: true }
- ).exec()
- if (newProject == null) {
- throw new OError('Project not found or path not found in filetree', {
- projectId,
- path,
- })
- }
- // Refresh newFileRef with the version returned from the database
- newFileRef = ProjectLocator.findElementByMongoPath(newProject, path.mongo)
- return { oldFileRef: fileRef, project, path, newProject, newFileRef }
- }
- async function replaceDocWithFile(projectId, docId, fileRef) {
- const project = await ProjectGetter.promises.getProjectWithoutLock(
- projectId,
- { rootFolder: true, name: true, overleaf: true }
- )
- const { path } = await ProjectLocator.promises.findElement({
- project,
- element_id: docId,
- type: 'doc',
- })
- const folderMongoPath = _getParentMongoPath(path.mongo)
- const newProject = await Project.findOneAndUpdate(
- { _id: project._id, [folderMongoPath]: { $exists: true } },
- {
- $pull: {
- [`${folderMongoPath}.docs`]: { _id: docId },
- },
- $push: {
- [`${folderMongoPath}.fileRefs`]: fileRef,
- },
- $inc: { version: 1 },
- },
- { new: true }
- ).exec()
- if (newProject == null) {
- throw new OError('Project not found or path not found in filetree', {
- projectId,
- path,
- })
- }
- return newProject
- }
- async function replaceFileWithDoc(projectId, fileId, newDoc) {
- const project = await ProjectGetter.promises.getProjectWithoutLock(
- projectId,
- { rootFolder: true, name: true, overleaf: true }
- )
- const { path } = await ProjectLocator.promises.findElement({
- project,
- element_id: fileId,
- type: 'file',
- })
- const folderMongoPath = _getParentMongoPath(path.mongo)
- const newProject = await Project.findOneAndUpdate(
- { _id: project._id, [folderMongoPath]: { $exists: true } },
- {
- $pull: {
- [`${folderMongoPath}.fileRefs`]: { _id: fileId },
- },
- $push: {
- [`${folderMongoPath}.docs`]: newDoc,
- },
- $inc: { version: 1 },
- },
- { new: true }
- ).exec()
- if (newProject == null) {
- throw new OError('Project not found or path not found in filetree', {
- projectId,
- path,
- })
- }
- return newProject
- }
- async function mkdirp(projectId, path, options = {}) {
- // defaults to case insensitive paths, use options {exactCaseMatch:true}
- // to make matching case-sensitive
- const folders = path.split('/').filter(folder => folder.length !== 0)
- const project = await ProjectGetter.promises.getProjectWithOnlyFolders(
- projectId
- )
- if (path === '/') {
- return { newFolders: [], folder: project.rootFolder[0] }
- }
- const newFolders = []
- let builtUpPath = ''
- let lastFolder = null
- for (const folderName of folders) {
- builtUpPath += `/${folderName}`
- try {
- const { element: foundFolder, folder: parentFolder } =
- await ProjectLocator.promises.findElementByPath({
- project,
- path: builtUpPath,
- exactCaseMatch: options.exactCaseMatch,
- })
- lastFolder = foundFolder
- lastFolder.parentFolder_id = parentFolder._id
- } catch (err) {
- // Folder couldn't be found. Create it.
- const parentFolderId = lastFolder && lastFolder._id
- const { folder: newFolder, parentFolderId: newParentFolderId } =
- await addFolder(projectId, parentFolderId, folderName)
- newFolder.parentFolder_id = newParentFolderId
- lastFolder = newFolder
- newFolders.push(newFolder)
- }
- }
- return { folder: lastFolder, newFolders }
- }
- async function moveEntity(projectId, entityId, destFolderId, entityType) {
- const project = await ProjectGetter.promises.getProjectWithoutLock(
- projectId,
- { rootFolder: true, name: true, overleaf: true }
- )
- const { element: entity, path: entityPath } =
- await ProjectLocator.promises.findElement({
- project,
- element_id: entityId,
- type: entityType,
- })
- // Prevent top-level docs/files with reserved names (to match v1 behaviour)
- if (_blockedFilename(entityPath, entityType)) {
- throw new Errors.InvalidNameError('blocked element name')
- }
- await _checkValidMove(project, entityType, entity, entityPath, destFolderId)
- const { docs: oldDocs, files: oldFiles } =
- ProjectEntityHandler.getAllEntitiesFromProject(project)
- // 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.
- const { result } = await _putElement(
- project,
- destFolderId,
- entity,
- entityType
- )
- // 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.
- const newProject = await _removeElementFromMongoArray(
- Project,
- projectId,
- entityPath.mongo,
- entityId
- )
- const { docs: newDocs, files: newFiles } =
- ProjectEntityHandler.getAllEntitiesFromProject(newProject)
- 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.warn(
- {
- projectId,
- oldDocs: oldDocs.length,
- newDocs: newDocs.length,
- oldFiles: oldFiles.length,
- newFiles: newFiles.length,
- origProject: project,
- newProject,
- },
- "project corrupted moving files - shouldn't happen"
- )
- throw new Error('unexpected change in project structure')
- }
- return { project, startPath, endPath, rev: entity.rev, changes }
- }
- async function deleteEntity(projectId, entityId, entityType, callback) {
- const project = await ProjectGetter.promises.getProjectWithoutLock(
- projectId,
- { name: true, rootFolder: true, overleaf: true, rootDoc_id: true }
- )
- const deleteRootDoc =
- project.rootDoc_id &&
- entityId &&
- project.rootDoc_id.toString() === entityId.toString()
- const { element: entity, path } = await ProjectLocator.promises.findElement({
- project,
- element_id: entityId,
- type: entityType,
- })
- const newProject = await _removeElementFromMongoArray(
- Project,
- projectId,
- path.mongo,
- entityId,
- deleteRootDoc
- )
- return { entity, path, projectBeforeDeletion: project, newProject }
- }
- async function renameEntity(
- projectId,
- entityId,
- entityType,
- newName,
- callback
- ) {
- const project = await ProjectGetter.promises.getProjectWithoutLock(
- projectId,
- { rootFolder: true, name: true, overleaf: true }
- )
- const {
- element: entity,
- path: entPath,
- folder: parentFolder,
- } = await ProjectLocator.promises.findElement({
- project,
- element_id: entityId,
- type: entityType,
- })
- const startPath = entPath.fileSystem
- const endPath = path.join(path.dirname(entPath.fileSystem), newName)
- // Prevent top-level docs/files with reserved names (to match v1 behaviour)
- if (_blockedFilename({ fileSystem: endPath }, entityType)) {
- throw new Errors.InvalidNameError('blocked element name')
- }
- // check if the new name already exists in the current folder
- _checkValidElementName(parentFolder, newName)
- const { docs: oldDocs, files: oldFiles } =
- ProjectEntityHandler.getAllEntitiesFromProject(project)
- // we need to increment the project version number for any structure change
- const newProject = await Project.findOneAndUpdate(
- { _id: projectId, [entPath.mongo]: { $exists: true } },
- { $set: { [`${entPath.mongo}.name`]: newName }, $inc: { version: 1 } },
- { new: true }
- ).exec()
- if (newProject == null) {
- throw new OError('Project not found or path not found in filetree', {
- projectId,
- path: entPath,
- })
- }
- const { docs: newDocs, files: newFiles } =
- ProjectEntityHandler.getAllEntitiesFromProject(newProject)
- return {
- project,
- startPath,
- endPath,
- rev: entity.rev,
- changes: { oldDocs, newDocs, oldFiles, newFiles, newProject },
- }
- }
- async function _insertDeletedFileReference(projectId, fileRef) {
- await DeletedFile.create({
- projectId,
- _id: fileRef._id,
- name: fileRef.name,
- linkedFileData: fileRef.linkedFileData,
- hash: fileRef.hash,
- deletedAt: new Date(),
- })
- }
- async function _removeElementFromMongoArray(
- model,
- modelId,
- path,
- elementId,
- deleteRootDoc = false
- ) {
- const nonArrayPath = path.slice(0, path.lastIndexOf('.'))
- const options = { new: true }
- const query = { _id: modelId }
- const update = {
- $pull: { [nonArrayPath]: { _id: elementId } },
- $inc: { version: 1 },
- }
- if (deleteRootDoc) {
- update.$unset = { rootDoc_id: 1 }
- }
- return model.findOneAndUpdate(query, update, options).exec()
- }
- function _countElements(project) {
- function countFolder(folder) {
- if (folder == null) {
- return 0
- }
- let total = 0
- if (folder.folders) {
- total += folder.folders.length
- for (const subfolder of iterablePaths(folder, 'folders')) {
- total += countFolder(subfolder)
- }
- }
- if (folder.docs) {
- total += folder.docs.length
- }
- if (folder.fileRefs) {
- total += folder.fileRefs.length
- }
- return total
- }
- return countFolder(project.rootFolder[0])
- }
- async function _putElement(project, folderId, element, type) {
- if (element == null || element._id == null) {
- logger.warn(
- { projectId: project._id, folderId, element, type },
- 'failed trying to insert element as it was null'
- )
- throw new Error('no element passed to be inserted')
- }
- const pathSegment = _getMongoPathSegmentFromType(type)
- // original check path.resolve("/", element.name) isnt "/#{element.name}" or element.name.match("/")
- // check if name is allowed
- if (!SafePath.isCleanFilename(element.name)) {
- logger.warn(
- { projectId: project._id, folderId, element, type },
- 'failed trying to insert element as name was invalid'
- )
- throw new Errors.InvalidNameError('invalid element name')
- }
- if (folderId == null) {
- folderId = project.rootFolder[0]._id
- }
- if (_countElements(project) > Settings.maxEntitiesPerProject) {
- logger.warn(
- { projectId: project._id },
- 'project too big, stopping insertions'
- )
- CooldownManager.putProjectOnCooldown(project._id)
- throw new Error('project_has_too_many_files')
- }
- const { element: folder, path } = await ProjectLocator.promises.findElement({
- project,
- element_id: folderId,
- type: 'folder',
- })
- const newPath = {
- fileSystem: `${path.fileSystem}/${element.name}`,
- mongo: path.mongo,
- }
- // check if the path would be too long
- if (!SafePath.isAllowedLength(newPath.fileSystem)) {
- throw new Errors.InvalidNameError('path too long')
- }
- // Prevent top-level docs/files with reserved names (to match v1 behaviour)
- if (_blockedFilename(newPath, type)) {
- throw new Errors.InvalidNameError('blocked element name')
- }
- _checkValidElementName(folder, element.name)
- element._id = new ObjectId(element._id.toString())
- const mongoPath = `${path.mongo}.${pathSegment}`
- const newProject = await Project.findOneAndUpdate(
- { _id: project._id, [path.mongo]: { $exists: true } },
- { $push: { [mongoPath]: element }, $inc: { version: 1 } },
- { new: true }
- ).exec()
- if (newProject == null) {
- throw new OError('Project not found or path not found in filetree', {
- projectId: project._id,
- path,
- })
- }
- return { result: { path: newPath }, project: newProject }
- }
- function _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 = entityType === 'folder'
- const dir = path.dirname(entityPath.fileSystem)
- const file = path.basename(entityPath.fileSystem)
- const isTopLevel = dir === '/'
- if (isTopLevel && !isFolder && SafePath.isBlockedFilename(file)) {
- return true
- } else {
- return false
- }
- }
- function _getMongoPathSegmentFromType(type) {
- const pathSegment = ENTITY_TYPE_TO_MONGO_PATH_SEGMENT[type]
- if (pathSegment == null) {
- throw new Error(`Unknown entity type: ${type}`)
- }
- return pathSegment
- }
- /**
- * Check if the name is already taken by a doc, file or folder. If so, return an
- * error "file already exists".
- */
- function _checkValidElementName(folder, name) {
- if (folder == null) {
- return
- }
- const elements = []
- .concat(folder.docs || [])
- .concat(folder.fileRefs || [])
- .concat(folder.folders || [])
- for (const element of elements) {
- if (element.name === name) {
- throw new Errors.InvalidNameError('file already exists')
- }
- }
- }
- function _confirmFolder(project, folderId) {
- if (folderId == null) {
- return project.rootFolder[0]._id
- } else {
- return folderId
- }
- }
- function _checkValidFolderPath(folderPath, destinationFolderPath) {
- if (!folderPath.endsWith('/')) {
- folderPath += '/'
- }
- if (!destinationFolderPath.endsWith('/')) {
- destinationFolderPath += '/'
- }
- if (destinationFolderPath === folderPath) {
- throw new Errors.InvalidNameError('destination folder is the same as me')
- }
- if (destinationFolderPath.startsWith(folderPath)) {
- throw new Errors.InvalidNameError(
- 'destination folder is a child folder of me'
- )
- }
- }
- async function _checkValidMove(
- project,
- entityType,
- entity,
- entityPath,
- destFolderId
- ) {
- const { element: destEntity, path: destFolderPath } =
- await ProjectLocator.promises.findElement({
- project,
- element_id: destFolderId,
- type: 'folder',
- })
- // check if there is already a doc/file/folder with the same name
- // in the destination folder
- _checkValidElementName(destEntity, entity.name)
- // check if the folder being moved is a parent of the destination folder
- if (/folder/.test(entityType)) {
- _checkValidFolderPath(entityPath.fileSystem, destFolderPath.fileSystem)
- }
- }
- /**
- * Create an initial file tree out of a list of doc and file entries
- *
- * Each entry specifies a path to the doc or file. Folders are automatically
- * created.
- *
- * @param {ObjectId} projectId - id of the project
- * @param {DocEntry[]} docEntries - list of docs to add
- * @param {FileEntry[]} fileEntries - list of files to add
- * @return {Promise<string>} the project version after the operation
- */
- async function createNewFolderStructure(projectId, docEntries, fileEntries) {
- try {
- const rootFolder = FolderStructureBuilder.buildFolderStructure(
- docEntries,
- fileEntries
- )
- const project = await Project.findOneAndUpdate(
- {
- _id: projectId,
- 'rootFolder.0.folders.0': { $exists: false },
- 'rootFolder.0.docs.0': { $exists: false },
- 'rootFolder.0.files.0': { $exists: false },
- },
- {
- $set: { rootFolder: [rootFolder] },
- $inc: { version: 1 },
- },
- {
- new: true,
- lean: true,
- fields: { version: 1 },
- }
- ).exec()
- if (project == null) {
- throw new OError('project not found or folder structure already exists', {
- projectId,
- })
- }
- return project.version
- } catch (err) {
- throw OError.tag(err, 'failed to create folder structure', { projectId })
- }
- }
- /**
- * Given a Mongo path to an entity, return the Mongo path to the parent folder
- */
- function _getParentMongoPath(mongoPath) {
- const segments = mongoPath.split('.')
- if (segments.length <= 2) {
- throw new Error('Root folder has no parents')
- }
- return segments.slice(0, -2).join('.')
- }
|