ProjectEntityMongoUpdateHandler.mjs 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751
  1. import { callbackify } from 'node:util'
  2. import { callbackifyMultiResult } from '@overleaf/promise-utils'
  3. import logger from '@overleaf/logger'
  4. import path from 'node:path'
  5. import mongodb from 'mongodb-legacy'
  6. import Settings from '@overleaf/settings'
  7. import OError from '@overleaf/o-error'
  8. import CooldownManager from '../Cooldown/CooldownManager.mjs'
  9. import Errors from '../Errors/Errors.js'
  10. import { Folder } from '../../models/Folder.mjs'
  11. import LockManager from '../../infrastructure/LockManager.js'
  12. import { Project } from '../../models/Project.mjs'
  13. import ProjectEntityHandler from './ProjectEntityHandler.mjs'
  14. import ProjectGetter from './ProjectGetter.mjs'
  15. import ProjectLocator from './ProjectLocator.mjs'
  16. import FolderStructureBuilder from './FolderStructureBuilder.mjs'
  17. import SafePath from './SafePath.mjs'
  18. import { iterablePaths } from './IterablePath.mjs'
  19. const { ObjectId } = mongodb
  20. const LOCK_NAMESPACE = 'mongoTransaction'
  21. const ENTITY_TYPE_TO_MONGO_PATH_SEGMENT = {
  22. doc: 'docs',
  23. docs: 'docs',
  24. file: 'fileRefs',
  25. files: 'fileRefs',
  26. fileRefs: 'fileRefs',
  27. folder: 'folders',
  28. folders: 'folders',
  29. }
  30. export default {
  31. LOCK_NAMESPACE,
  32. addDoc: callbackifyMultiResult(wrapWithLock(addDoc), ['result', 'project']),
  33. addFile: callbackifyMultiResult(wrapWithLock(addFile), ['result', 'project']),
  34. addFolder: callbackifyMultiResult(wrapWithLock(addFolder), [
  35. 'folder',
  36. 'parentFolderId',
  37. ]),
  38. replaceFileWithNew: callbackifyMultiResult(wrapWithLock(replaceFileWithNew), [
  39. 'oldFileRef',
  40. 'project',
  41. 'path',
  42. 'newProject',
  43. 'newFileRef',
  44. ]),
  45. replaceDocWithFile: callbackify(replaceDocWithFile),
  46. replaceFileWithDoc: callbackify(replaceFileWithDoc),
  47. mkdirp: callbackifyMultiResult(wrapWithLock(mkdirp), [
  48. 'newFolders',
  49. 'folder',
  50. 'parentFolder',
  51. ]),
  52. moveEntity: callbackifyMultiResult(wrapWithLock(moveEntity), [
  53. 'project',
  54. 'startPath',
  55. 'endPath',
  56. 'rev',
  57. 'changes',
  58. ]),
  59. deleteEntity: callbackifyMultiResult(wrapWithLock(deleteEntity), [
  60. 'entity',
  61. 'path',
  62. 'projectBeforeDeletion',
  63. 'newProject',
  64. ]),
  65. renameEntity: callbackifyMultiResult(wrapWithLock(renameEntity), [
  66. 'project',
  67. 'startPath',
  68. 'endPath',
  69. 'rev',
  70. 'changes',
  71. ]),
  72. createNewFolderStructure: callbackify(wrapWithLock(createNewFolderStructure)),
  73. _putElement: callbackifyMultiResult(_putElement, ['result', 'project']),
  74. _confirmFolder,
  75. promises: {
  76. addDoc: wrapWithLock(addDoc),
  77. addFile: wrapWithLock(addFile),
  78. addFolder: wrapWithLock(addFolder),
  79. replaceFileWithNew: wrapWithLock(replaceFileWithNew),
  80. replaceDocWithFile: wrapWithLock(replaceDocWithFile),
  81. replaceFileWithDoc: wrapWithLock(replaceFileWithDoc),
  82. mkdirp: wrapWithLock(mkdirp),
  83. moveEntity: wrapWithLock(moveEntity),
  84. deleteEntity: wrapWithLock(deleteEntity),
  85. renameEntity: wrapWithLock(renameEntity),
  86. createNewFolderStructure: wrapWithLock(createNewFolderStructure),
  87. _putElement,
  88. },
  89. }
  90. function wrapWithLock(methodWithoutLock) {
  91. // This lock is used whenever we read or write to an existing project's
  92. // structure. Some operations to project structure cannot be done atomically
  93. // in mongo, this lock is used to prevent reading the structure between two
  94. // parts of a staged update.
  95. async function methodWithLock(projectId, ...rest) {
  96. return LockManager.promises.runWithLock(LOCK_NAMESPACE, projectId, () =>
  97. methodWithoutLock(projectId, ...rest)
  98. )
  99. }
  100. return methodWithLock
  101. }
  102. async function addDoc(projectId, folderId, doc, userId) {
  103. const project = await ProjectGetter.promises.getProjectWithoutLock(
  104. projectId,
  105. {
  106. rootFolder: true,
  107. name: true,
  108. overleaf: true,
  109. }
  110. )
  111. folderId = _confirmFolder(project, folderId)
  112. const { result, project: newProject } = await _putElement(
  113. project,
  114. folderId,
  115. doc,
  116. 'doc',
  117. userId
  118. )
  119. return { result, project: newProject }
  120. }
  121. async function addFile(projectId, folderId, fileRef, userId) {
  122. const project = await ProjectGetter.promises.getProjectWithoutLock(
  123. projectId,
  124. { rootFolder: true, name: true, overleaf: true }
  125. )
  126. folderId = _confirmFolder(project, folderId)
  127. const { result, project: newProject } = await _putElement(
  128. project,
  129. folderId,
  130. fileRef,
  131. 'file',
  132. userId
  133. )
  134. return { result, project: newProject }
  135. }
  136. async function addFolder(projectId, parentFolderId, folderName, userId) {
  137. const project = await ProjectGetter.promises.getProjectWithoutLock(
  138. projectId,
  139. { rootFolder: true, name: true, overleaf: true }
  140. )
  141. parentFolderId = _confirmFolder(project, parentFolderId)
  142. const folder = new Folder({ name: folderName })
  143. await _putElement(project, parentFolderId, folder, 'folder', userId)
  144. return { folder, parentFolderId }
  145. }
  146. async function replaceFileWithNew(projectId, fileId, newFileRef, userId) {
  147. const project = await ProjectGetter.promises.getProjectWithoutLock(
  148. projectId,
  149. { rootFolder: true, name: true, overleaf: true }
  150. )
  151. const { element: fileRef, path } = await ProjectLocator.promises.findElement({
  152. project,
  153. element_id: fileId,
  154. type: 'file',
  155. })
  156. const newProject = await Project.findOneAndUpdate(
  157. { _id: project._id, [path.mongo]: { $exists: true } },
  158. {
  159. $set: {
  160. [`${path.mongo}._id`]: newFileRef._id,
  161. [`${path.mongo}.created`]: new Date(),
  162. [`${path.mongo}.linkedFileData`]: newFileRef.linkedFileData,
  163. [`${path.mongo}.hash`]: newFileRef.hash,
  164. lastUpdated: new Date(),
  165. lastUpdatedBy: userId,
  166. },
  167. $inc: {
  168. version: 1,
  169. [`${path.mongo}.rev`]: 1,
  170. },
  171. },
  172. // Note: Mongoose uses new:true to return the modified document
  173. // https://mongoosejs.com/docs/api.html#model_Model.findOneAndUpdate
  174. // but Mongo uses returnNewDocument:true instead
  175. // https://docs.mongodb.com/manual/reference/method/db.collection.findOneAndUpdate/
  176. // We are using Mongoose here, but if we ever switch to a direct mongo call
  177. // the next line will need to be updated.
  178. { new: true }
  179. ).exec()
  180. if (newProject == null) {
  181. throw new OError('Project not found or path not found in filetree', {
  182. projectId,
  183. path,
  184. })
  185. }
  186. // Refresh newFileRef with the version returned from the database
  187. newFileRef = ProjectLocator.findElementByMongoPath(newProject, path.mongo)
  188. return { oldFileRef: fileRef, project, path, newProject, newFileRef }
  189. }
  190. async function replaceDocWithFile(projectId, docId, fileRef, userId) {
  191. const project = await ProjectGetter.promises.getProjectWithoutLock(
  192. projectId,
  193. { rootFolder: true, name: true, overleaf: true }
  194. )
  195. const { path } = await ProjectLocator.promises.findElement({
  196. project,
  197. element_id: docId,
  198. type: 'doc',
  199. })
  200. const folderMongoPath = _getParentMongoPath(path.mongo)
  201. const newProject = await Project.findOneAndUpdate(
  202. { _id: project._id, [folderMongoPath]: { $exists: true } },
  203. {
  204. $pull: {
  205. [`${folderMongoPath}.docs`]: { _id: docId },
  206. },
  207. $push: {
  208. [`${folderMongoPath}.fileRefs`]: fileRef,
  209. },
  210. $inc: { version: 1 },
  211. $set: { lastUpdated: new Date(), lastUpdatedBy: userId },
  212. },
  213. { new: true }
  214. ).exec()
  215. if (newProject == null) {
  216. throw new OError('Project not found or path not found in filetree', {
  217. projectId,
  218. path,
  219. })
  220. }
  221. return newProject
  222. }
  223. async function replaceFileWithDoc(projectId, fileId, newDoc, userId) {
  224. const project = await ProjectGetter.promises.getProjectWithoutLock(
  225. projectId,
  226. { rootFolder: true, name: true, overleaf: true }
  227. )
  228. const { path } = await ProjectLocator.promises.findElement({
  229. project,
  230. element_id: fileId,
  231. type: 'file',
  232. })
  233. const folderMongoPath = _getParentMongoPath(path.mongo)
  234. const newProject = await Project.findOneAndUpdate(
  235. { _id: project._id, [folderMongoPath]: { $exists: true } },
  236. {
  237. $pull: {
  238. [`${folderMongoPath}.fileRefs`]: { _id: fileId },
  239. },
  240. $push: {
  241. [`${folderMongoPath}.docs`]: newDoc,
  242. },
  243. $inc: { version: 1 },
  244. $set: { lastUpdated: new Date(), lastUpdatedBy: userId },
  245. },
  246. { new: true }
  247. ).exec()
  248. if (newProject == null) {
  249. throw new OError('Project not found or path not found in filetree', {
  250. projectId,
  251. path,
  252. })
  253. }
  254. return newProject
  255. }
  256. async function mkdirp(projectId, path, userId, options = {}) {
  257. // defaults to case insensitive paths, use options {exactCaseMatch:true}
  258. // to make matching case-sensitive
  259. const folders = path.split('/').filter(folder => folder.length !== 0)
  260. const project =
  261. await ProjectGetter.promises.getProjectWithOnlyFolders(projectId)
  262. if (path === '/') {
  263. return { newFolders: [], folder: project.rootFolder[0] }
  264. }
  265. const newFolders = []
  266. let builtUpPath = ''
  267. let lastFolder = null
  268. for (const folderName of folders) {
  269. builtUpPath += `/${folderName}`
  270. try {
  271. const { element: foundFolder, folder: parentFolder } =
  272. await ProjectLocator.promises.findElementByPath({
  273. project,
  274. path: builtUpPath,
  275. exactCaseMatch: options.exactCaseMatch,
  276. })
  277. lastFolder = foundFolder
  278. lastFolder.parentFolder_id = parentFolder._id
  279. } catch (err) {
  280. // Folder couldn't be found. Create it.
  281. const parentFolderId = lastFolder && lastFolder._id
  282. const { folder: newFolder, parentFolderId: newParentFolderId } =
  283. await addFolder(projectId, parentFolderId, folderName, userId)
  284. newFolder.parentFolder_id = newParentFolderId
  285. lastFolder = newFolder
  286. newFolders.push(newFolder)
  287. }
  288. }
  289. return { folder: lastFolder, newFolders }
  290. }
  291. async function moveEntity(
  292. projectId,
  293. entityId,
  294. destFolderId,
  295. entityType,
  296. userId
  297. ) {
  298. const project = await ProjectGetter.promises.getProjectWithoutLock(
  299. projectId,
  300. { rootFolder: true, name: true, overleaf: true }
  301. )
  302. const { element: entity, path: entityPath } =
  303. await ProjectLocator.promises.findElement({
  304. project,
  305. element_id: entityId,
  306. type: entityType,
  307. })
  308. // Prevent top-level docs/files with reserved names (to match v1 behaviour)
  309. if (_blockedFilename(entityPath, entityType)) {
  310. throw new Errors.InvalidNameError('blocked element name')
  311. }
  312. await _checkValidMove(project, entityType, entity, entityPath, destFolderId)
  313. const { docs: oldDocs, files: oldFiles } =
  314. ProjectEntityHandler.getAllEntitiesFromProject(project)
  315. // For safety, insert the entity in the destination
  316. // location first, and then remove the original. If
  317. // there is an error the entity may appear twice. This
  318. // will cause some breakage but is better than being
  319. // lost, which is what happens if this is done in the
  320. // opposite order.
  321. const { result } = await _putElement(
  322. project,
  323. destFolderId,
  324. entity,
  325. entityType,
  326. userId
  327. )
  328. // Note: putElement always pushes onto the end of an
  329. // array so it will never change an existing mongo
  330. // path. Therefore it is safe to remove an element
  331. // from the project with an existing path after
  332. // calling putElement. But we must be sure that we
  333. // have not moved a folder subfolder of itself (which
  334. // is done by _checkValidMove above) because that
  335. // would lead to it being deleted.
  336. const newProject = await _removeElementFromMongoArray(
  337. projectId,
  338. entityPath.mongo,
  339. entityId,
  340. userId
  341. )
  342. const { docs: newDocs, files: newFiles } =
  343. ProjectEntityHandler.getAllEntitiesFromProject(newProject)
  344. const startPath = entityPath.fileSystem
  345. const endPath = result.path.fileSystem
  346. const changes = {
  347. oldDocs,
  348. newDocs,
  349. oldFiles,
  350. newFiles,
  351. newProject,
  352. }
  353. // check that no files have been lost (or duplicated)
  354. if (
  355. oldFiles.length !== newFiles.length ||
  356. oldDocs.length !== newDocs.length
  357. ) {
  358. logger.warn(
  359. {
  360. projectId,
  361. oldDocs: oldDocs.length,
  362. newDocs: newDocs.length,
  363. oldFiles: oldFiles.length,
  364. newFiles: newFiles.length,
  365. origProject: project,
  366. newProject,
  367. },
  368. "project corrupted moving files - shouldn't happen"
  369. )
  370. throw new Error('unexpected change in project structure')
  371. }
  372. return { project, startPath, endPath, rev: entity.rev, changes }
  373. }
  374. async function deleteEntity(projectId, entityId, entityType, userId) {
  375. const project = await ProjectGetter.promises.getProjectWithoutLock(
  376. projectId,
  377. { name: true, rootFolder: true, overleaf: true, rootDoc_id: true }
  378. )
  379. if (
  380. entityType === 'folder' &&
  381. project.rootFolder.some(
  382. rootFolder => rootFolder._id.toString() === entityId.toString()
  383. )
  384. ) {
  385. throw new Errors.NonDeletableEntityError('cannot delete root folder')
  386. }
  387. const deleteRootDoc =
  388. project.rootDoc_id &&
  389. entityId &&
  390. project.rootDoc_id.toString() === entityId.toString()
  391. const { element: entity, path } = await ProjectLocator.promises.findElement({
  392. project,
  393. element_id: entityId,
  394. type: entityType,
  395. })
  396. const newProject = await _removeElementFromMongoArray(
  397. projectId,
  398. path.mongo,
  399. entityId,
  400. userId,
  401. deleteRootDoc
  402. )
  403. return { entity, path, projectBeforeDeletion: project, newProject }
  404. }
  405. async function renameEntity(projectId, entityId, entityType, newName, userId) {
  406. const project = await ProjectGetter.promises.getProjectWithoutLock(
  407. projectId,
  408. { rootFolder: true, name: true, overleaf: true }
  409. )
  410. const {
  411. element: entity,
  412. path: entPath,
  413. folder: parentFolder,
  414. } = await ProjectLocator.promises.findElement({
  415. project,
  416. element_id: entityId,
  417. type: entityType,
  418. })
  419. const startPath = entPath.fileSystem
  420. const endPath = path.join(path.dirname(entPath.fileSystem), newName)
  421. // Prevent top-level docs/files with reserved names (to match v1 behaviour)
  422. if (_blockedFilename({ fileSystem: endPath }, entityType)) {
  423. throw new Errors.InvalidNameError('blocked element name')
  424. }
  425. // check if the new name already exists in the current folder
  426. _checkValidElementName(parentFolder, newName)
  427. const { docs: oldDocs, files: oldFiles } =
  428. ProjectEntityHandler.getAllEntitiesFromProject(project)
  429. // we need to increment the project version number for any structure change
  430. const newProject = await Project.findOneAndUpdate(
  431. { _id: projectId, [entPath.mongo]: { $exists: true } },
  432. {
  433. $set: {
  434. [`${entPath.mongo}.name`]: newName,
  435. lastUpdated: new Date(),
  436. lastUpdatedBy: userId,
  437. },
  438. $inc: { version: 1 },
  439. },
  440. { new: true }
  441. ).exec()
  442. if (newProject == null) {
  443. throw new OError('Project not found or path not found in filetree', {
  444. projectId,
  445. path: entPath,
  446. })
  447. }
  448. const { docs: newDocs, files: newFiles } =
  449. ProjectEntityHandler.getAllEntitiesFromProject(newProject)
  450. return {
  451. project,
  452. startPath,
  453. endPath,
  454. rev: entity.rev,
  455. changes: { oldDocs, newDocs, oldFiles, newFiles, newProject },
  456. }
  457. }
  458. async function _removeElementFromMongoArray(
  459. modelId,
  460. path,
  461. elementId,
  462. userId,
  463. deleteRootDoc = false
  464. ) {
  465. const nonArrayPath = path.slice(0, path.lastIndexOf('.'))
  466. const options = { new: true }
  467. const query = { _id: modelId }
  468. const update = {
  469. $pull: { [nonArrayPath]: { _id: elementId } },
  470. $inc: { version: 1 },
  471. $set: { lastUpdated: new Date(), lastUpdatedBy: userId },
  472. }
  473. if (deleteRootDoc) {
  474. update.$unset = { rootDoc_id: 1 }
  475. }
  476. return Project.findOneAndUpdate(query, update, options).exec()
  477. }
  478. function _countElements(project) {
  479. function countFolder(folder) {
  480. if (folder == null) {
  481. return 0
  482. }
  483. let total = 0
  484. if (folder.folders) {
  485. total += folder.folders.length
  486. for (const subfolder of iterablePaths(folder, 'folders')) {
  487. total += countFolder(subfolder)
  488. }
  489. }
  490. if (folder.docs) {
  491. total += folder.docs.length
  492. }
  493. if (folder.fileRefs) {
  494. total += folder.fileRefs.length
  495. }
  496. return total
  497. }
  498. return countFolder(project.rootFolder[0])
  499. }
  500. async function _putElement(project, folderId, element, type, userId) {
  501. if (element == null || element._id == null) {
  502. logger.warn(
  503. { projectId: project._id, folderId, element, type },
  504. 'failed trying to insert element as it was null'
  505. )
  506. throw new Error('no element passed to be inserted')
  507. }
  508. const pathSegment = _getMongoPathSegmentFromType(type)
  509. // original check path.resolve("/", element.name) isnt "/#{element.name}" or element.name.match("/")
  510. // check if name is allowed
  511. if (!SafePath.isCleanFilename(element.name)) {
  512. logger.warn(
  513. { projectId: project._id, folderId, element, type },
  514. 'failed trying to insert element as name was invalid'
  515. )
  516. throw new Errors.InvalidNameError('invalid element name')
  517. }
  518. if (folderId == null) {
  519. folderId = project.rootFolder[0]._id
  520. }
  521. if (_countElements(project) > Settings.maxEntitiesPerProject) {
  522. logger.warn(
  523. { projectId: project._id },
  524. 'project too big, stopping insertions'
  525. )
  526. CooldownManager.putProjectOnCooldown(project._id)
  527. throw new Error('project_has_too_many_files')
  528. }
  529. const { element: folder, path } = await ProjectLocator.promises.findElement({
  530. project,
  531. element_id: folderId,
  532. type: 'folder',
  533. })
  534. const newPath = {
  535. fileSystem: `${path.fileSystem}/${element.name}`,
  536. mongo: path.mongo,
  537. }
  538. // check if the path would be too long
  539. if (!SafePath.isAllowedLength(newPath.fileSystem)) {
  540. throw new Errors.InvalidNameError('path too long')
  541. }
  542. // Prevent top-level docs/files with reserved names (to match v1 behaviour)
  543. if (_blockedFilename(newPath, type)) {
  544. throw new Errors.InvalidNameError('blocked element name')
  545. }
  546. _checkValidElementName(folder, element.name)
  547. element._id = new ObjectId(element._id.toString())
  548. const mongoPath = `${path.mongo}.${pathSegment}`
  549. const newProject = await Project.findOneAndUpdate(
  550. { _id: project._id, [path.mongo]: { $exists: true } },
  551. {
  552. $push: { [mongoPath]: element },
  553. $inc: { version: 1 },
  554. $set: { lastUpdated: new Date(), lastUpdatedBy: userId },
  555. },
  556. { new: true }
  557. ).exec()
  558. if (newProject == null) {
  559. throw new OError('Project not found or path not found in filetree', {
  560. projectId: project._id,
  561. path,
  562. })
  563. }
  564. return { result: { path: newPath }, project: newProject }
  565. }
  566. function _blockedFilename(entityPath, entityType) {
  567. // check if name would be blocked in v1
  568. // javascript reserved names are forbidden for docs and files
  569. // at the top-level (but folders with reserved names are allowed).
  570. const isFolder = entityType === 'folder'
  571. const dir = path.dirname(entityPath.fileSystem)
  572. const file = path.basename(entityPath.fileSystem)
  573. const isTopLevel = dir === '/'
  574. if (isTopLevel && !isFolder && SafePath.isBlockedFilename(file)) {
  575. return true
  576. } else {
  577. return false
  578. }
  579. }
  580. function _getMongoPathSegmentFromType(type) {
  581. const pathSegment = ENTITY_TYPE_TO_MONGO_PATH_SEGMENT[type]
  582. if (pathSegment == null) {
  583. throw new Error(`Unknown entity type: ${type}`)
  584. }
  585. return pathSegment
  586. }
  587. /**
  588. * Check if the name is already taken by a doc, file or folder. If so, return an
  589. * error "file already exists".
  590. */
  591. function _checkValidElementName(folder, name) {
  592. if (folder == null) {
  593. return
  594. }
  595. const elements = []
  596. .concat(folder.docs || [])
  597. .concat(folder.fileRefs || [])
  598. .concat(folder.folders || [])
  599. for (const element of elements) {
  600. if (element.name === name) {
  601. throw new Errors.DuplicateNameError('file already exists')
  602. }
  603. }
  604. }
  605. function _confirmFolder(project, folderId) {
  606. if (folderId == null) {
  607. return project.rootFolder[0]._id
  608. } else {
  609. return folderId
  610. }
  611. }
  612. function _checkValidFolderPath(folderPath, destinationFolderPath) {
  613. if (!folderPath.endsWith('/')) {
  614. folderPath += '/'
  615. }
  616. if (!destinationFolderPath.endsWith('/')) {
  617. destinationFolderPath += '/'
  618. }
  619. if (destinationFolderPath === folderPath) {
  620. throw new Errors.InvalidNameError('destination folder is the same as me')
  621. }
  622. if (destinationFolderPath.startsWith(folderPath)) {
  623. throw new Errors.InvalidNameError(
  624. 'destination folder is a child folder of me'
  625. )
  626. }
  627. }
  628. async function _checkValidMove(
  629. project,
  630. entityType,
  631. entity,
  632. entityPath,
  633. destFolderId
  634. ) {
  635. const { element: destEntity, path: destFolderPath } =
  636. await ProjectLocator.promises.findElement({
  637. project,
  638. element_id: destFolderId,
  639. type: 'folder',
  640. })
  641. // check if there is already a doc/file/folder with the same name
  642. // in the destination folder
  643. _checkValidElementName(destEntity, entity.name)
  644. // check if the folder being moved is a parent of the destination folder
  645. if (/folder/.test(entityType)) {
  646. _checkValidFolderPath(entityPath.fileSystem, destFolderPath.fileSystem)
  647. }
  648. }
  649. /**
  650. * Create an initial file tree out of a list of doc and file entries
  651. *
  652. * Each entry specifies a path to the doc or file. Folders are automatically
  653. * created.
  654. *
  655. * @param {ObjectId} projectId - id of the project
  656. * @param {DocEntry[]} docEntries - list of docs to add
  657. * @param {FileEntry[]} fileEntries - list of files to add
  658. * @return {Promise<string>} the project version after the operation
  659. */
  660. async function createNewFolderStructure(projectId, docEntries, fileEntries) {
  661. try {
  662. const rootFolder = FolderStructureBuilder.buildFolderStructure(
  663. docEntries,
  664. fileEntries
  665. )
  666. const project = await Project.findOneAndUpdate(
  667. {
  668. _id: projectId,
  669. 'rootFolder.0.folders.0': { $exists: false },
  670. 'rootFolder.0.docs.0': { $exists: false },
  671. 'rootFolder.0.files.0': { $exists: false },
  672. },
  673. {
  674. $set: {
  675. rootFolder: [rootFolder],
  676. // NOTE: Do not set lastUpdated/lastUpdatedBy here. They are both set when creating the initial record.
  677. // The newly created clsi-cache record uses the lastUpdated timestamp of the initial record. Updating the lastUpdated timestamp here invalidates the cache record.
  678. },
  679. $inc: { version: 1 },
  680. },
  681. {
  682. new: true,
  683. lean: true,
  684. fields: { version: 1 },
  685. }
  686. ).exec()
  687. if (project == null) {
  688. throw new OError('project not found or folder structure already exists', {
  689. projectId,
  690. })
  691. }
  692. return project.version
  693. } catch (err) {
  694. throw OError.tag(err, 'failed to create folder structure', { projectId })
  695. }
  696. }
  697. /**
  698. * Given a Mongo path to an entity, return the Mongo path to the parent folder
  699. */
  700. function _getParentMongoPath(mongoPath) {
  701. const segments = mongoPath.split('.')
  702. if (segments.length <= 2) {
  703. throw new Error('Root folder has no parents')
  704. }
  705. return segments.slice(0, -2).join('.')
  706. }