Files
overleaf-cep/services/web/app/src/Features/Project/ProjectEntityUpdateHandler.mjs
T
Jakob Ackermann 292230b1db [web] inline trivial mongo queries when creating project from template (#30601)
GitOrigin-RevId: 7aefee892d491b1ec191d07bf1871317b897dec8
2026-01-13 09:06:47 +00:00

1699 lines
44 KiB
JavaScript

import _ from 'lodash'
import OError from '@overleaf/o-error'
import logger from '@overleaf/logger'
import Settings from '@overleaf/settings'
import Path from 'node:path'
import fs from 'node:fs'
import { Doc } from '../../models/Doc.mjs'
import DocstoreManager from '../Docstore/DocstoreManager.mjs'
import DocumentUpdaterHandler from '../../Features/DocumentUpdater/DocumentUpdaterHandler.mjs'
import Errors from '../Errors/Errors.js'
import FileStoreHandler from '../FileStore/FileStoreHandler.mjs'
import LockManager from '../../infrastructure/LockManager.mjs'
import { Project } from '../../models/Project.mjs'
import ProjectEntityHandler from './ProjectEntityHandler.mjs'
import ProjectGetter from './ProjectGetter.mjs'
import ProjectLocator from './ProjectLocator.mjs'
import ProjectOptionsHandler from './ProjectOptionsHandler.mjs'
import ProjectUpdateHandler from './ProjectUpdateHandler.mjs'
import ProjectEntityMongoUpdateHandler from './ProjectEntityMongoUpdateHandler.mjs'
import SafePath from './SafePath.mjs'
import TpdsUpdateSender from '../ThirdPartyDataStore/TpdsUpdateSender.mjs'
import FileWriter from '../../infrastructure/FileWriter.mjs'
import EditorRealTimeController from '../Editor/EditorRealTimeController.mjs'
import { callbackifyMultiResult, callbackify } from '@overleaf/promise-utils'
import { iterablePaths } from './IterablePath.mjs'
const LOCK_NAMESPACE = 'sequentialProjectStructureUpdateLock'
const VALID_ROOT_DOC_EXTENSIONS = Settings.validRootDocExtensions
const VALID_ROOT_DOC_REGEXP = new RegExp(
`^\\.(${VALID_ROOT_DOC_EXTENSIONS.join('|')})$`,
'i'
)
function wrapWithLock(methodWithoutLock, lockManager = LockManager) {
// 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
// the doc-updater in the same order.
if (typeof methodWithoutLock === 'function') {
const methodWithLock = async (projectId, ...rest) => {
return lockManager.promises.runWithLock(LOCK_NAMESPACE, projectId, () =>
methodWithoutLock(projectId, ...rest)
)
}
methodWithLock.withoutLock = methodWithoutLock
return methodWithLock
} else {
// handle case with separate setup and locked stages
const mainTask = methodWithoutLock.withLock
const methodWithLock = async (projectId, ...rest) => {
const arg = await methodWithoutLock.beforeLock(projectId, ...rest)
return lockManager.promises.runWithLock(LOCK_NAMESPACE, projectId, () =>
mainTask(arg)
)
}
methodWithLock.withoutLock = async (...args) => {
return await mainTask(await methodWithoutLock.beforeLock(...args))
}
methodWithLock.beforeLock = methodWithoutLock.beforeLock
methodWithLock.mainTask = methodWithoutLock.withLock
return methodWithLock
}
}
async function getDocContext(projectId, docId) {
let project
try {
project = await ProjectGetter.promises.getProject(projectId, {
name: true,
rootFolder: true,
})
} catch (err) {
throw OError.tag(err, 'error fetching project', {
projectId,
})
}
if (!project) {
throw new Errors.NotFoundError('project not found')
}
try {
const { path, folder } = await ProjectLocator.promises.findElement({
project,
element_id: docId,
type: 'docs',
})
return {
projectName: project.name,
isDeletedDoc: false,
path: path.fileSystem,
folder,
}
} catch (err) {
if (err instanceof Errors.NotFoundError) {
// (Soft-)Deleted docs are removed from the file-tree (rootFolder).
// docstore can tell whether it exists and is (soft)-deleted.
let isDeletedDoc
try {
isDeletedDoc = await DocstoreManager.promises.isDocDeleted(
projectId,
docId
)
if (!isDeletedDoc) {
// NOTE: This can happen while we delete a doc:
// 1. web will update the projects entry
// 2. web triggers flushes to tpds/doc-updater
// 3. web triggers (soft)-delete in docstore
// Specifically when an update comes in after 1
// and before 3 completes.
logger.debug(
{ projectId, docId },
'updating doc that is in process of getting soft-deleted'
)
}
return {
projectName: project.name,
isDeletedDoc: true,
path: null,
folder: null,
}
} catch (error) {
if (error instanceof Errors.NotFoundError) {
logger.warn(
{ projectId, docId },
'doc not found while updating doc lines'
)
throw error
}
throw OError.tag(
error,
'error checking deletion status with docstore',
{
projectId,
docId,
}
)
}
} else {
throw OError.tag(err, 'error finding doc in rootFolder', {
docId,
projectId,
})
}
}
}
async function updateDocLines(
projectId,
docId,
lines,
version,
ranges,
lastUpdatedAt,
lastUpdatedBy
) {
let ctx
try {
ctx = await getDocContext(projectId, docId)
} catch (error) {
if (error instanceof Errors.NotFoundError) {
// Do not allow an update to a doc which has never exist on this project
logger.warn(
{ docId, projectId },
'project or doc not found while updating doc lines'
)
}
throw error
}
const { projectName, isDeletedDoc, path, folder } = ctx
logger.debug({ projectId, docId }, 'telling docstore manager to update doc')
let modified, rev
try {
;({ modified, rev } = await DocstoreManager.promises.updateDoc(
projectId,
docId,
lines,
version,
ranges
))
} catch (err) {
throw OError.tag(err, 'error sending doc to docstore', { docId, projectId })
}
// path will only be present if the doc is not deleted
if (!modified || isDeletedDoc) {
return { rev }
}
// Don't need to block for marking as updated
ProjectUpdateHandler.promises
.markAsUpdated(projectId, lastUpdatedAt, lastUpdatedBy)
.catch(error => {
logger.error({ error }, 'failed to mark project as updated')
})
await TpdsUpdateSender.promises.addDoc({
projectId,
path,
docId,
projectName,
rev,
folderId: folder?._id,
})
return { rev, modified }
}
async function setRootDoc(projectId, newRootDocID) {
logger.debug({ projectId, rootDocId: newRootDocID }, 'setting root doc')
if (projectId == null || newRootDocID == null) {
throw new Errors.InvalidError('missing arguments (project or doc)')
}
const docPath =
await ProjectEntityHandler.promises.getDocPathByProjectIdAndDocId(
projectId,
newRootDocID
)
if (ProjectEntityUpdateHandler.isPathValidForRootDoc(docPath)) {
await Project.updateOne(
{ _id: projectId },
{ rootDoc_id: newRootDocID }
).exec()
} else {
throw new Errors.UnsupportedFileTypeError(
'invalid file extension for root doc'
)
}
return newRootDocID
}
async function unsetRootDoc(projectId) {
logger.debug({ projectId }, 'removing root doc')
await Project.updateOne(
{ _id: projectId },
{ $unset: { rootDoc_id: true } }
).exec()
}
async function addDoc(projectId, folderId, docName, docLines, userId, source) {
return await ProjectEntityUpdateHandler.promises.addDocWithRanges(
projectId,
folderId,
docName,
docLines,
{},
userId,
source
)
}
const addDocWithRanges = wrapWithLock({
async beforeLock(
projectId,
folderId,
docName,
docLines,
ranges,
userId,
source
) {
if (!SafePath.isCleanFilename(docName)) {
throw new Errors.InvalidNameError('invalid element name')
}
// Put doc in docstore first, so that if it errors, we don't have a doc_id in the project
// which hasn't been created in docstore.
const doc = new Doc({ name: docName })
const { rev } = await DocstoreManager.promises.updateDoc(
projectId.toString(),
doc._id.toString(),
docLines,
0,
ranges
)
doc.rev = rev
return {
projectId,
folderId,
doc,
docName,
docLines,
ranges,
userId,
source,
}
},
async withLock({
projectId,
folderId,
doc,
docName,
docLines,
ranges,
userId,
source,
}) {
const { result, project } =
await ProjectEntityUpdateHandler._addDocAndSendToTpds(
projectId,
folderId,
doc,
userId
)
const docPath = result?.path?.fileSystem
const projectHistoryId = project?.overleaf?.history?.id
const newDocs = [
{
doc,
path: docPath,
docLines: docLines.join('\n'),
ranges,
},
]
await DocumentUpdaterHandler.promises.updateProjectStructure(
projectId,
projectHistoryId,
userId,
{ newDocs, newProject: project },
source
)
return { doc, folderId: folderId || project.rootFolder[0]._id }
},
})
const addFile = wrapWithLock({
async beforeLock(
projectId,
folderId,
fileName,
fsPath,
linkedFileData,
userId,
source
) {
if (!SafePath.isCleanFilename(fileName)) {
throw new Errors.InvalidNameError('invalid element name')
}
const { fileRef, createdBlob } =
await ProjectEntityUpdateHandler._uploadFile(
projectId,
folderId,
fileName,
fsPath,
linkedFileData
)
return {
projectId,
folderId,
userId,
fileRef,
createdBlob,
source,
}
},
async withLock({
projectId,
folderId,
userId,
fileRef,
createdBlob,
source,
}) {
const { result, project } =
await ProjectEntityUpdateHandler._addFileAndSendToTpds(
projectId,
folderId,
fileRef,
userId
)
const projectHistoryId = project.overleaf?.history?.id
const newFiles = [
{
createdBlob,
file: fileRef,
path: result && result.path && result.path.fileSystem,
},
]
await DocumentUpdaterHandler.promises.updateProjectStructure(
projectId,
projectHistoryId,
userId,
{ newFiles, newProject: project },
source
)
return { fileRef, folderId, createdBlob }
},
})
const upsertDoc = wrapWithLock(
async function (projectId, folderId, docName, docLines, source, userId) {
if (!SafePath.isCleanFilename(docName)) {
throw new Errors.InvalidNameError('invalid element name')
}
let element, folderPath
try {
;({ element, path: folderPath } =
await ProjectLocator.promises.findElement({
project_id: projectId,
element_id: folderId,
type: 'folder',
}))
} catch (error) {
if (error instanceof Errors.NotFoundError) {
throw new Error('folder_not_found')
}
throw error
}
if (element == null) {
throw new Error("Couldn't find folder")
}
const existingDoc = element.docs.find(({ name }) => name === docName)
const existingFile = element.fileRefs.find(({ name }) => name === docName)
if (existingFile) {
const doc = new Doc({ name: docName })
const filePath = `${folderPath.fileSystem}/${existingFile.name}`
const { rev } = await DocstoreManager.promises.updateDoc(
projectId.toString(),
doc._id.toString(),
docLines,
0,
{}
)
doc.rev = rev
const project =
await ProjectEntityMongoUpdateHandler.promises.replaceFileWithDoc(
projectId,
existingFile._id,
doc,
userId
)
await TpdsUpdateSender.promises.addDoc({
projectId,
docId: doc._id,
path: filePath,
projectName: project.name,
rev: existingFile.rev + 1,
folderId,
})
const projectHistoryId =
project.overleaf &&
project.overleaf.history &&
project.overleaf.history.id
const newDocs = [
{
doc,
path: filePath,
docLines: docLines.join('\n'),
},
]
const oldFiles = [
{
file: existingFile,
path: filePath,
},
]
await DocumentUpdaterHandler.promises.updateProjectStructure(
projectId,
projectHistoryId,
userId,
{ oldFiles, newDocs, newProject: project },
source
)
EditorRealTimeController.emitToRoom(
projectId,
'removeEntity',
existingFile._id,
'convertFileToDoc'
)
return { doc, isNew: true }
} else if (existingDoc) {
const result = await DocumentUpdaterHandler.promises.setDocument(
projectId,
existingDoc._id,
userId,
docLines,
source
)
logger.debug(
{ projectId, docId: existingDoc._id },
'notifying users that the document has been updated'
)
// there is no need to flush the doc to mongo at this point as docupdater
// flushes it as part of setDoc.
//
// combine rev from response with existing doc metadata
return {
doc: { ...existingDoc, ...result },
isNew: existingDoc == null,
}
} else {
const { doc } =
await ProjectEntityUpdateHandler.promises.addDocWithRanges.withoutLock(
projectId,
folderId,
docName,
docLines,
{},
userId,
source
)
return { doc, isNew: existingDoc == null }
}
}
)
const appendToDoc = wrapWithLock(
async (projectId, docId, lines, source, userId) => {
const { element } = await ProjectLocator.promises.findElement({
project_id: projectId,
element_id: docId,
type: 'doc',
})
return await DocumentUpdaterHandler.promises.appendToDocument(
projectId,
element._id,
userId,
lines,
source
)
}
)
const upsertFile = wrapWithLock({
async beforeLock(
projectId,
folderId,
fileName,
fsPath,
linkedFileData,
userId,
source
) {
if (!SafePath.isCleanFilename(fileName)) {
throw new Errors.InvalidNameError('invalid element name')
}
// create a new file
const fileArgs = {
name: fileName,
linkedFileData,
}
const { fileRef, createdBlob } =
await FileStoreHandler.promises.uploadFileFromDisk(
projectId,
fileArgs,
fsPath
)
return {
projectId,
folderId,
fileName,
fsPath,
linkedFileData,
userId,
fileRef,
createdBlob,
source,
}
},
async withLock({
projectId,
folderId,
fileName,
userId,
fileRef,
createdBlob,
source,
}) {
let element
try {
;({ element } = await ProjectLocator.promises.findElement({
project_id: projectId,
element_id: folderId,
type: 'folder',
}))
} catch (error) {
if (error instanceof Errors.NotFoundError) {
throw new Error('folder_not_found')
}
throw error
}
if (element == null) {
throw new Error("Couldn't find folder")
}
const existingFile = element.fileRefs.find(({ name }) => name === fileName)
const existingDoc = element.docs.find(({ name }) => name === fileName)
if (existingDoc) {
let path
try {
;({ path } = await ProjectLocator.promises.findElement({
project_id: projectId,
element_id: existingDoc._id,
type: 'doc',
}))
} catch (err) {
throw new Error("couldn't find existing file")
}
const project =
await ProjectEntityMongoUpdateHandler.promises.replaceDocWithFile(
projectId,
existingDoc._id,
fileRef,
userId
)
const projectHistoryId = project.overleaf?.history?.id
await TpdsUpdateSender.promises.addFile({
projectId: project._id,
historyId: projectHistoryId,
fileId: fileRef._id,
hash: fileRef.hash,
path: path.fileSystem,
rev: fileRef.rev,
projectName: project.name,
folderId,
})
await DocumentUpdaterHandler.promises.updateProjectStructure(
projectId,
projectHistoryId,
userId,
{
oldDocs: [{ doc: existingDoc, path: path.fileSystem }],
newFiles: [
{
createdBlob,
file: fileRef,
path: path.fileSystem,
},
],
newProject: project,
},
source
)
EditorRealTimeController.emitToRoom(
projectId,
'removeEntity',
existingDoc._id,
'convertDocToFile'
)
return { fileRef, isNew: true, oldFileRef: existingFile }
} else if (existingFile) {
await ProjectEntityUpdateHandler._replaceFile(
projectId,
existingFile._id,
userId,
fileRef,
folderId,
source,
createdBlob
)
return { fileRef, isNew: false, oldFileRef: existingFile }
} else {
// this calls directly into the addFile main task (without the beforeLock part)
await ProjectEntityUpdateHandler.promises.addFile.mainTask({
projectId,
folderId,
userId,
fileRef,
createdBlob,
source,
})
return {
fileRef,
isNew: existingFile == null,
oldFileRef: existingFile,
}
}
},
})
const upsertDocWithPath = wrapWithLock(
async function (projectId, elementPath, docLines, source, userId) {
if (!SafePath.isCleanPath(elementPath)) {
throw new Errors.InvalidNameError('invalid element name')
}
const docName = Path.basename(elementPath)
const folderPath = Path.dirname(elementPath)
const { newFolders, folder } =
await ProjectEntityUpdateHandler.promises.mkdirp.withoutLock(
projectId,
folderPath,
userId
)
const { isNew, doc } =
await ProjectEntityUpdateHandler.promises.upsertDoc.withoutLock(
projectId,
folder._id,
docName,
docLines,
source,
userId
)
return { doc, isNew, newFolders, folder }
}
)
const upsertFileWithPath = wrapWithLock({
async beforeLock(
projectId,
elementPath,
fsPath,
linkedFileData,
userId,
source
) {
if (!SafePath.isCleanPath(elementPath)) {
throw new Errors.InvalidNameError('invalid element name')
}
const fileName = Path.basename(elementPath)
const folderPath = Path.dirname(elementPath)
// create a new file
const fileArgs = {
name: fileName,
linkedFileData,
}
const { fileRef, createdBlob } =
await FileStoreHandler.promises.uploadFileFromDisk(
projectId,
fileArgs,
fsPath
)
return {
projectId,
folderPath,
fileName,
fsPath,
linkedFileData,
userId,
fileRef,
createdBlob,
source,
}
},
async withLock({
projectId,
folderPath,
fileName,
fsPath,
linkedFileData,
userId,
fileRef,
createdBlob,
source,
}) {
const { newFolders, folder } =
await ProjectEntityUpdateHandler.promises.mkdirp.withoutLock(
projectId,
folderPath,
userId
)
// this calls directly into the upsertFile main task (without the beforeLock part)
const {
fileRef: newFileRef,
isNew,
oldFileRef,
} = await ProjectEntityUpdateHandler.promises.upsertFile.mainTask({
projectId,
folderId: folder._id,
fileName,
fsPath,
linkedFileData,
userId,
fileRef,
createdBlob,
source,
})
return {
fileRef: newFileRef,
isNew,
oldFileRef,
newFolders,
folder,
}
},
})
const deleteEntity = wrapWithLock(
async function (projectId, entityId, entityType, userId, source, callback) {
logger.debug({ entityId, entityType, projectId }, 'deleting project entity')
if (entityType == null) {
logger.warn({ err: 'No entityType set', projectId, entityId })
throw new Error('No entityType set')
}
entityType = entityType.toLowerCase()
// Flush the entire project to avoid leaving partially deleted docs in redis.
await DocumentUpdaterHandler.promises.flushProjectToMongo(projectId)
const { entity, path, projectBeforeDeletion, newProject } =
await ProjectEntityMongoUpdateHandler.promises.deleteEntity(
projectId,
entityId,
entityType,
userId
)
const subtreeListing = await ProjectEntityUpdateHandler._cleanUpEntity(
projectBeforeDeletion,
newProject,
entity,
entityType,
path.fileSystem,
userId,
source
)
const subtreeEntityIds = subtreeListing.map(entry =>
entry.entity._id.toString()
)
await TpdsUpdateSender.promises.deleteEntity({
projectId,
path: path.fileSystem,
projectName: projectBeforeDeletion.name,
entityId,
entityType,
subtreeEntityIds,
})
return entityId
}
)
const deleteEntityWithPath = wrapWithLock(
async (projectId, path, userId, source) => {
const { element, type } = await ProjectLocator.promises.findElementByPath({
project_id: projectId,
path,
exactCaseMatch: true,
})
if (element == null) {
throw new Errors.NotFoundError('project not found')
}
return await ProjectEntityUpdateHandler.promises.deleteEntity.withoutLock(
projectId,
element._id,
type,
userId,
source
)
}
)
const mkdirp = wrapWithLock(async function (projectId, path, userId) {
for (const folder of path.split('/')) {
if (folder.length > 0 && !SafePath.isCleanFilename(folder)) {
throw new Errors.InvalidNameError('invalid element name')
}
}
return await ProjectEntityMongoUpdateHandler.promises.mkdirp(
projectId,
path,
userId,
{ exactCaseMatch: false }
)
})
const mkdirpWithExactCase = wrapWithLock(
async function (projectId, path, userId) {
for (const folder of path.split('/')) {
if (folder.length > 0 && !SafePath.isCleanFilename(folder)) {
throw new Errors.InvalidNameError('invalid element name')
}
}
return await ProjectEntityMongoUpdateHandler.promises.mkdirp(
projectId,
path,
userId,
{ exactCaseMatch: true }
)
}
)
const addFolder = wrapWithLock(
async function (projectId, parentFolderId, folderName, userId) {
if (!SafePath.isCleanFilename(folderName)) {
throw new Errors.InvalidNameError('invalid element name')
}
return await ProjectEntityMongoUpdateHandler.promises.addFolder(
projectId,
parentFolderId,
folderName,
userId
)
}
)
const moveEntity = wrapWithLock(
async function (
projectId,
entityId,
destFolderId,
entityType,
userId,
source
) {
logger.debug(
{ entityType, entityId, projectId, destFolderId },
'moving entity'
)
if (entityType == null) {
logger.warn({ err: 'No entityType set', projectId, entityId })
throw new Error('No entityType set')
}
entityType = entityType.toLowerCase()
await DocumentUpdaterHandler.promises.flushProjectToMongo(projectId)
const { project, startPath, endPath, rev, changes } =
await ProjectEntityMongoUpdateHandler.promises.moveEntity(
projectId,
entityId,
destFolderId,
entityType,
userId
)
const projectHistoryId = project.overleaf?.history?.id
try {
await TpdsUpdateSender.promises.moveEntity({
projectId,
projectName: project.name,
startPath,
endPath,
rev,
entityId,
entityType,
folderId: destFolderId,
})
} catch (err) {
logger.error({ err }, 'error sending tpds update')
}
return await DocumentUpdaterHandler.promises.updateProjectStructure(
projectId,
projectHistoryId,
userId,
changes,
source
)
}
)
const renameEntity = wrapWithLock(
async function (projectId, entityId, entityType, newName, userId, source) {
if (!newName || typeof newName !== 'string') {
const err = new OError('invalid newName value', {
value: newName,
type: typeof newName,
projectId,
entityId,
entityType,
userId,
source,
})
logger.error({ err }, 'Invalid newName passed to renameEntity')
throw err
}
if (!SafePath.isCleanFilename(newName)) {
throw new Errors.InvalidNameError('invalid element name')
}
logger.debug({ entityId, projectId }, `renaming ${entityType}`)
if (entityType == null) {
logger.warn({ err: 'No entityType set', projectId, entityId })
throw new Error('No entityType set')
}
entityType = entityType.toLowerCase()
await DocumentUpdaterHandler.promises.flushProjectToMongo(projectId)
const { project, startPath, endPath, rev, changes } =
await ProjectEntityMongoUpdateHandler.promises.renameEntity(
projectId,
entityId,
entityType,
newName,
userId
)
const projectHistoryId = project.overleaf?.history?.id
try {
await TpdsUpdateSender.promises.moveEntity({
projectId,
projectName: project.name,
startPath,
endPath,
rev,
entityId,
entityType,
folderId: null, // this means the folder has not changed
})
} catch (err) {
logger.error({ err }, 'error sending tpds update')
}
return await DocumentUpdaterHandler.promises.updateProjectStructure(
projectId,
projectHistoryId,
userId,
changes,
source
)
}
)
// This doesn't directly update project structure, but we need to take the lock
// to prevent anything else being queued before the resync update
const resyncProjectHistory = wrapWithLock(
async (projectId, opts) => {
const project = await ProjectGetter.promises.getProject(projectId, {
rootFolder: true,
overleaf: true,
})
const projectHistoryId = project.overleaf?.history?.id
if (projectHistoryId == null) {
throw new Errors.ProjectHistoryDisabledError(
`project history not enabled for ${projectId}`
)
}
const { docs, files, folders } =
ProjectEntityHandler.getAllEntitiesFromProject(project)
// _checkFileTree() must be passed the folders before docs and
// files
await ProjectEntityUpdateHandler._checkFiletree(
projectId,
projectHistoryId,
[...folders, ...docs, ...files]
)
await DocumentUpdaterHandler.promises.resyncProjectHistory(
projectId,
projectHistoryId,
docs,
files,
opts
)
if (opts.historyRangesMigration) {
return await ProjectOptionsHandler.promises.setHistoryRangesSupport(
projectId,
opts.historyRangesMigration === 'forwards'
)
}
},
LockManager.withTimeout(6 * 60) // use an extended lock for the resync operations
)
const convertDocToFile = wrapWithLock({
async beforeLock(projectId, docId, userId, source) {
await DocumentUpdaterHandler.promises.flushDocToMongo(projectId, docId)
const { element: doc, path } = await ProjectLocator.promises.findElement({
project_id: projectId,
element_id: docId,
type: 'doc',
})
const docPath = path.fileSystem
const { lines, rev, ranges } = await DocstoreManager.promises.getDoc(
projectId,
docId
)
if (!_.isEmpty(ranges)) {
throw new Errors.DocHasRangesError({})
}
await DocumentUpdaterHandler.promises.deleteDoc(projectId, docId, false)
const fsPath = await FileWriter.promises.writeLinesToDisk(projectId, lines)
const { fileRef, createdBlob } =
await FileStoreHandler.promises.uploadFileFromDisk(
projectId,
{ name: doc.name, rev: rev + 1 },
fsPath
)
try {
await fs.promises.unlink(fsPath)
} catch (err) {
logger.warn({ err, path: fsPath }, 'failed to clean up temporary file')
}
return {
projectId,
doc,
path: docPath,
fileRef,
userId,
source,
createdBlob,
}
},
async withLock({
projectId,
doc,
path,
fileRef,
userId,
source,
createdBlob,
}) {
const project =
await ProjectEntityMongoUpdateHandler.promises.replaceDocWithFile(
projectId,
doc._id,
fileRef,
userId
)
const projectHistoryId = project.overleaf?.history?.id
await DocumentUpdaterHandler.promises.updateProjectStructure(
projectId,
projectHistoryId,
userId,
{
oldDocs: [{ doc, path }],
newFiles: [{ file: fileRef, path, createdBlob }],
newProject: project,
},
source
)
const { folder } = await ProjectLocator.promises.findElement({
project_id: projectId,
element_id: fileRef._id,
type: 'file',
})
EditorRealTimeController.emitToRoom(
projectId,
'removeEntity',
doc._id,
'convertDocToFile'
)
EditorRealTimeController.emitToRoom(
projectId,
'reciveNewFile',
folder._id,
fileRef,
'convertDocToFile',
null,
userId
)
return fileRef
},
})
async function setMainBibliographyDoc(projectId, newBibliographyDocId) {
logger.debug(
{ projectId, mainBibliographyDocId: newBibliographyDocId },
'setting main bibliography doc'
)
if (projectId == null || newBibliographyDocId == null) {
throw new Errors.InvalidError('missing arguments (project or doc)')
}
const docPath =
await ProjectEntityHandler.promises.getDocPathByProjectIdAndDocId(
projectId,
newBibliographyDocId
)
if (ProjectEntityUpdateHandler.isPathValidForMainBibliographyDoc(docPath)) {
await Project.updateOne(
{ _id: projectId },
{ mainBibliographyDoc_id: newBibliographyDocId }
).exec()
} else {
throw new Errors.UnsupportedFileTypeError(
'invalid file extension for main bibliography doc'
)
}
}
const ProjectEntityUpdateHandler = {
LOCK_NAMESPACE,
addDoc: callbackifyMultiResult(addDoc, ['doc', 'folderId']),
addDocWithRanges: callbackifyMultiResult(addDocWithRanges, [
'doc',
'folderId',
]),
addFile: callbackifyMultiResult(addFile, [
'fileRef',
'folderId',
'createdBlob',
]),
addFolder: callbackifyMultiResult(addFolder, ['folder', 'parentFolderId']),
convertDocToFile: callbackify(convertDocToFile),
deleteEntity: callbackify(deleteEntity),
deleteEntityWithPath: callbackify(deleteEntityWithPath),
mkdirp: callbackifyMultiResult(mkdirp, [
'newFolders',
'folder',
'parentFolder',
]),
mkdirpWithExactCase: callbackifyMultiResult(mkdirpWithExactCase, [
'newFolders',
'folder',
'parentFolder',
]),
moveEntity: callbackify(moveEntity),
renameEntity: callbackify(renameEntity),
resyncProjectHistory: callbackify(resyncProjectHistory),
setRootDoc: callbackify(setRootDoc),
unsetRootDoc: callbackify(unsetRootDoc),
setMainBibliographyDoc: callbackify(setMainBibliographyDoc),
updateDocLines: callbackify(updateDocLines),
upsertDoc: callbackifyMultiResult(upsertDoc, ['doc', 'isNew']),
appendToDoc: callbackify(appendToDoc),
upsertDocWithPath: callbackifyMultiResult(upsertDocWithPath, [
'doc',
'isNew',
'newFolders',
'folder',
]),
upsertFile: callbackifyMultiResult(upsertFile, [
'fileRef',
'isNew',
'oldFileRef',
]),
upsertFileWithPath: callbackifyMultiResult(upsertFileWithPath, [
'fileRef',
'isNew',
'oldFileRef',
'newFolders',
'folder',
]),
promises: {
addDoc,
addDocWithRanges,
addFile,
addFolder,
convertDocToFile,
deleteEntity,
deleteEntityWithPath,
mkdirp,
mkdirpWithExactCase,
moveEntity,
renameEntity,
resyncProjectHistory,
setRootDoc,
unsetRootDoc,
updateDocLines,
upsertDoc,
upsertDocWithPath,
upsertFile,
upsertFileWithPath,
appendToDocWithPath: appendToDoc,
setMainBibliographyDoc,
},
async _addDocAndSendToTpds(projectId, folderId, doc, userId) {
let result, project
try {
;({ result, project } =
await ProjectEntityMongoUpdateHandler.promises.addDoc(
projectId,
folderId,
doc,
userId
))
} catch (err) {
throw OError.tag(err, 'error adding file with project', {
projectId,
folderId,
doc_name: doc != null ? doc.name : undefined,
doc_id: doc != null ? doc._id : undefined,
})
}
await TpdsUpdateSender.promises.addDoc({
projectId,
docId: doc != null ? doc._id : undefined,
path: result?.path?.fileSystem,
projectName: project.name,
rev: 0,
folderId,
})
return { result, project }
},
async _uploadFile(projectId, folderId, fileName, fsPath, linkedFileData) {
if (!SafePath.isCleanFilename(fileName)) {
throw new Errors.InvalidNameError('invalid element name')
}
const fileArgs = {
name: fileName,
linkedFileData,
}
try {
return await FileStoreHandler.promises.uploadFileFromDisk(
projectId,
fileArgs,
fsPath
)
} catch (err) {
throw OError.tag(err, 'error uploading image to s3', {
projectId,
folderId,
file_name: fileName,
})
}
},
async _addFileAndSendToTpds(projectId, folderId, fileRef, userId) {
let result, project
try {
;({ result, project } =
await ProjectEntityMongoUpdateHandler.promises.addFile(
projectId,
folderId,
fileRef,
userId
))
} catch (err) {
throw OError.tag(err, 'error adding file with project', {
projectId,
folderId,
file_name: fileRef.name,
fileRef,
})
}
const historyId = project?.overleaf?.history?.id
if (!historyId) {
throw new OError('project does not have a history id', { projectId })
}
await TpdsUpdateSender.promises.addFile({
projectId,
historyId,
fileId: fileRef._id,
hash: fileRef.hash,
path: result?.path?.fileSystem,
projectName: project.name,
rev: fileRef.rev,
folderId,
})
return { result, project }
},
async _replaceFile(
projectId,
fileId,
userId,
newFileRef,
folderId,
source,
createdBlob
) {
const {
oldFileRef,
project,
path,
newProject,
newFileRef: updatedFileRef,
} = await ProjectEntityMongoUpdateHandler.promises.replaceFileWithNew(
projectId,
fileId,
newFileRef,
userId
)
const oldFiles = [
{
file: oldFileRef,
path: path.fileSystem,
},
]
const newFiles = [
{
file: updatedFileRef,
createdBlob,
path: path.fileSystem,
},
]
const projectHistoryId = project.overleaf?.history?.id
await TpdsUpdateSender.promises.addFile({
projectId: project._id,
historyId: projectHistoryId,
fileId: updatedFileRef._id,
hash: updatedFileRef.hash,
path: path.fileSystem,
rev: updatedFileRef.rev,
projectName: project.name,
folderId,
})
await DocumentUpdaterHandler.promises.updateProjectStructure(
projectId,
projectHistoryId,
userId,
{ oldFiles, newFiles, newProject },
source
)
return updatedFileRef
},
async _checkFiletree(projectId, projectHistoryId, entities) {
const adjustPathsAfterFolderRename = (oldPath, newPath) => {
oldPath = oldPath + '/'
newPath = newPath + '/'
for (const entity of entities) {
if (entity.path.startsWith(oldPath)) {
entity.path = newPath + entity.path.slice(oldPath.length)
}
}
}
// Data structures for recording pending renames
const renames = []
const paths = new Set()
for (const entity of entities) {
const originalName = entity.folder
? entity.folder.name
: entity.doc
? entity.doc.name
: entity.file.name
let newPath = entity.path
let newName = originalName
// Clean the filename if necessary
if (newName === '') {
newName = 'untitled'
} else {
newName = SafePath.clean(newName)
}
if (newName !== originalName) {
newPath = Path.join(
newPath.slice(0, newPath.length - originalName.length),
newName
)
}
// Check if we've seen that path already
if (paths.has(newPath)) {
newPath = ProjectEntityUpdateHandler.findNextAvailablePath(
paths,
newPath
)
newName = newPath.split('/').pop()
}
// If we've changed the filename, schedule a rename
if (newName !== originalName) {
renames.push({ entity, newName, newPath })
if (entity.folder) {
// Here, we rely on entities being processed in the right order.
// Parent folders need to be processed before their children. This is
// the case only because getAllEntitiesFromProject() returns folders
// in that order and resyncProjectHistory() calls us with the folders
// first.
adjustPathsAfterFolderRename(entity.path, newPath)
}
}
// Remember that we've seen this path
paths.add(newPath)
}
if (renames.length === 0) {
return
}
logger.warn(
{
projectId,
renames: renames.map(rename => ({
oldPath: rename.entity.path,
newPath: rename.newPath,
})),
},
'found conflicts or bad filenames in filetree'
)
// Avoid conflicts by processing renames in the reverse order. If we have
// the following starting situation:
//
// somefile.tex
// somefile.tex
// somefile.tex (1)
//
// somefile.tex would be processed first, and then somefile.tex (1),
// yielding the following renames:
//
// somefile.tex -> somefile.tex (1)
// somefile.tex (1) -> somefile.tex (2)
//
// When the first rename was decided, we didn't know that somefile.tex (1)
// existed, so that created a conflict. By processing renames in the
// reverse order, we start with the files that had the most extensive
// information about existing files.
renames.reverse()
for (const rename of renames) {
// rename the duplicate files
const entity = rename.entity
const entityId = entity.folder
? entity.folder._id
: entity.doc
? entity.doc._id
: entity.file._id
const entityType = entity.folder ? 'folder' : entity.doc ? 'doc' : 'file'
const { changes } =
await ProjectEntityMongoUpdateHandler.promises.renameEntity(
projectId,
entityId,
entityType,
rename.newName,
null // unset lastUpdatedBy
)
// update the renamed entity for the resync
entity.path = rename.newPath
if (entityType === 'folder') {
entity.folder.name = rename.newName
} else if (entityType === 'doc') {
entity.doc.name = rename.newName
} else {
entity.file.name = rename.newName
}
await DocumentUpdaterHandler.promises.updateProjectStructure(
projectId,
projectHistoryId,
null,
changes,
'automatic-fix'
)
}
},
findNextAvailablePath(allPaths, candidatePath) {
const incrementReplacer = (match, p1) => {
return ' (' + (parseInt(p1, 10) + 1) + ')'
}
// if the filename was invalid we should normalise it here too. Currently
// this only handles renames in the same folder, so we will be out of luck
// if it is the folder name which in invalid. We could handle folder
// renames by returning the folders list from getAllEntitiesFromProject
do {
// does the filename look like "foo (1)" if so, increment the number in parentheses
if (/ \(\d+\)$/.test(candidatePath)) {
candidatePath = candidatePath.replace(/ \((\d+)\)$/, incrementReplacer)
} else {
// otherwise, add a ' (1)' suffix to the name
candidatePath = candidatePath + ' (1)'
}
} while (allPaths.has(candidatePath)) // keep going until the name is unique
// add the new name to the set
allPaths.add(candidatePath)
return candidatePath
},
isPathValidForRootDoc(docPath) {
const docExtension = Path.extname(docPath)
return VALID_ROOT_DOC_REGEXP.test(docExtension)
},
isPathValidForMainBibliographyDoc(docPath) {
const docExtension = Path.extname(docPath).toLowerCase()
return docExtension === '.bib'
},
async _cleanUpEntity(
project,
newProject,
entity,
entityType,
path,
userId,
source
) {
const subtreeListing = _listSubtree(entity, entityType, path)
await ProjectEntityUpdateHandler._updateProjectStructureWithDeletedEntity(
project,
newProject,
subtreeListing,
userId,
source
)
for (const entry of subtreeListing) {
if (entry.type === 'doc') {
await ProjectEntityUpdateHandler._cleanUpDoc(
project,
entry.entity,
entry.path,
userId
)
}
}
return subtreeListing
},
async _updateProjectStructureWithDeletedEntity(
project,
newProject,
subtreeListing,
userId,
source
) {
const changes = { oldDocs: [], oldFiles: [] }
for (const entry of subtreeListing) {
if (entry.type === 'doc') {
changes.oldDocs.push({ doc: entry.entity, path: entry.path })
} else if (entry.type === 'file') {
changes.oldFiles.push({ file: entry.entity, path: entry.path })
}
}
// now send the project structure changes to the docupdater
changes.newProject = newProject
const projectId = project._id.toString()
const projectHistoryId =
project.overleaf &&
project.overleaf.history &&
project.overleaf.history.id
return await DocumentUpdaterHandler.promises.updateProjectStructure(
projectId,
projectHistoryId,
userId,
changes,
source
)
},
async _cleanUpDoc(project, doc) {
const projectId = project._id.toString()
const docId = doc._id.toString()
if (project.rootDoc_id != null && project.rootDoc_id.toString() === docId) {
await ProjectEntityUpdateHandler.promises.unsetRootDoc(projectId)
}
const { name } = doc
const deletedAt = new Date()
await DocstoreManager.promises.deleteDoc(projectId, docId, name, deletedAt)
return await DocumentUpdaterHandler.promises.deleteDoc(projectId, docId)
},
}
/**
* List all descendants of an entity along with their type and path. Include
* the top-level entity as well.
*/
function _listSubtree(entity, entityType, entityPath) {
if (entityType.indexOf('file') !== -1) {
return [{ type: 'file', entity, path: entityPath }]
} else if (entityType.indexOf('doc') !== -1) {
return [{ type: 'doc', entity, path: entityPath }]
} else if (entityType.indexOf('folder') !== -1) {
const listing = []
const _recurseFolder = (folder, folderPath) => {
listing.push({ type: 'folder', entity: folder, path: folderPath })
for (const doc of iterablePaths(folder, 'docs')) {
listing.push({
type: 'doc',
entity: doc,
path: Path.join(folderPath, doc.name),
})
}
for (const file of iterablePaths(folder, 'fileRefs')) {
listing.push({
type: 'file',
entity: file,
path: Path.join(folderPath, file.name),
})
}
for (const childFolder of iterablePaths(folder, 'folders')) {
_recurseFolder(childFolder, Path.join(folderPath, childFolder.name))
}
}
_recurseFolder(entity, entityPath)
return listing
} else {
// This shouldn't happen, but if it does, fail silently.
return []
}
}
export default ProjectEntityUpdateHandler