ProjectEntityMongoUpdateHandler.mjs 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753
  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.mjs'
  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 = await ProjectGetter.promises.getProjectWithoutLock(
  261. projectId,
  262. { rootFolder: 1 }
  263. )
  264. if (path === '/') {
  265. return { newFolders: [], folder: project.rootFolder[0] }
  266. }
  267. const newFolders = []
  268. let builtUpPath = ''
  269. let lastFolder = null
  270. for (const folderName of folders) {
  271. builtUpPath += `/${folderName}`
  272. try {
  273. const { element: foundFolder, folder: parentFolder } =
  274. await ProjectLocator.promises.findElementByPath({
  275. project,
  276. path: builtUpPath,
  277. exactCaseMatch: options.exactCaseMatch,
  278. })
  279. lastFolder = foundFolder
  280. lastFolder.parentFolder_id = parentFolder._id
  281. } catch (err) {
  282. // Folder couldn't be found. Create it.
  283. const parentFolderId = lastFolder && lastFolder._id
  284. const { folder: newFolder, parentFolderId: newParentFolderId } =
  285. await addFolder(projectId, parentFolderId, folderName, userId)
  286. newFolder.parentFolder_id = newParentFolderId
  287. lastFolder = newFolder
  288. newFolders.push(newFolder)
  289. }
  290. }
  291. return { folder: lastFolder, newFolders }
  292. }
  293. async function moveEntity(
  294. projectId,
  295. entityId,
  296. destFolderId,
  297. entityType,
  298. userId
  299. ) {
  300. const project = await ProjectGetter.promises.getProjectWithoutLock(
  301. projectId,
  302. { rootFolder: true, name: true, overleaf: true }
  303. )
  304. const { element: entity, path: entityPath } =
  305. await ProjectLocator.promises.findElement({
  306. project,
  307. element_id: entityId,
  308. type: entityType,
  309. })
  310. // Prevent top-level docs/files with reserved names (to match v1 behaviour)
  311. if (_blockedFilename(entityPath, entityType)) {
  312. throw new Errors.InvalidNameError('blocked element name')
  313. }
  314. await _checkValidMove(project, entityType, entity, entityPath, destFolderId)
  315. const { docs: oldDocs, files: oldFiles } =
  316. ProjectEntityHandler.getAllEntitiesFromProject(project)
  317. // For safety, insert the entity in the destination
  318. // location first, and then remove the original. If
  319. // there is an error the entity may appear twice. This
  320. // will cause some breakage but is better than being
  321. // lost, which is what happens if this is done in the
  322. // opposite order.
  323. const { result } = await _putElement(
  324. project,
  325. destFolderId,
  326. entity,
  327. entityType,
  328. userId
  329. )
  330. // Note: putElement always pushes onto the end of an
  331. // array so it will never change an existing mongo
  332. // path. Therefore it is safe to remove an element
  333. // from the project with an existing path after
  334. // calling putElement. But we must be sure that we
  335. // have not moved a folder subfolder of itself (which
  336. // is done by _checkValidMove above) because that
  337. // would lead to it being deleted.
  338. const newProject = await _removeElementFromMongoArray(
  339. projectId,
  340. entityPath.mongo,
  341. entityId,
  342. userId
  343. )
  344. const { docs: newDocs, files: newFiles } =
  345. ProjectEntityHandler.getAllEntitiesFromProject(newProject)
  346. const startPath = entityPath.fileSystem
  347. const endPath = result.path.fileSystem
  348. const changes = {
  349. oldDocs,
  350. newDocs,
  351. oldFiles,
  352. newFiles,
  353. newProject,
  354. }
  355. // check that no files have been lost (or duplicated)
  356. if (
  357. oldFiles.length !== newFiles.length ||
  358. oldDocs.length !== newDocs.length
  359. ) {
  360. logger.warn(
  361. {
  362. projectId,
  363. oldDocs: oldDocs.length,
  364. newDocs: newDocs.length,
  365. oldFiles: oldFiles.length,
  366. newFiles: newFiles.length,
  367. origProject: project,
  368. newProject,
  369. },
  370. "project corrupted moving files - shouldn't happen"
  371. )
  372. throw new Error('unexpected change in project structure')
  373. }
  374. return { project, startPath, endPath, rev: entity.rev, changes }
  375. }
  376. async function deleteEntity(projectId, entityId, entityType, userId) {
  377. const project = await ProjectGetter.promises.getProjectWithoutLock(
  378. projectId,
  379. { name: true, rootFolder: true, overleaf: true, rootDoc_id: true }
  380. )
  381. if (
  382. entityType === 'folder' &&
  383. project.rootFolder.some(
  384. rootFolder => rootFolder._id.toString() === entityId.toString()
  385. )
  386. ) {
  387. throw new Errors.NonDeletableEntityError('cannot delete root folder')
  388. }
  389. const deleteRootDoc =
  390. project.rootDoc_id &&
  391. entityId &&
  392. project.rootDoc_id.toString() === entityId.toString()
  393. const { element: entity, path } = await ProjectLocator.promises.findElement({
  394. project,
  395. element_id: entityId,
  396. type: entityType,
  397. })
  398. const newProject = await _removeElementFromMongoArray(
  399. projectId,
  400. path.mongo,
  401. entityId,
  402. userId,
  403. deleteRootDoc
  404. )
  405. return { entity, path, projectBeforeDeletion: project, newProject }
  406. }
  407. async function renameEntity(projectId, entityId, entityType, newName, userId) {
  408. const project = await ProjectGetter.promises.getProjectWithoutLock(
  409. projectId,
  410. { rootFolder: true, name: true, overleaf: true }
  411. )
  412. const {
  413. element: entity,
  414. path: entPath,
  415. folder: parentFolder,
  416. } = await ProjectLocator.promises.findElement({
  417. project,
  418. element_id: entityId,
  419. type: entityType,
  420. })
  421. const startPath = entPath.fileSystem
  422. const endPath = path.join(path.dirname(entPath.fileSystem), newName)
  423. // Prevent top-level docs/files with reserved names (to match v1 behaviour)
  424. if (_blockedFilename({ fileSystem: endPath }, entityType)) {
  425. throw new Errors.InvalidNameError('blocked element name')
  426. }
  427. // check if the new name already exists in the current folder
  428. _checkValidElementName(parentFolder, newName)
  429. const { docs: oldDocs, files: oldFiles } =
  430. ProjectEntityHandler.getAllEntitiesFromProject(project)
  431. // we need to increment the project version number for any structure change
  432. const newProject = await Project.findOneAndUpdate(
  433. { _id: projectId, [entPath.mongo]: { $exists: true } },
  434. {
  435. $set: {
  436. [`${entPath.mongo}.name`]: newName,
  437. lastUpdated: new Date(),
  438. lastUpdatedBy: userId,
  439. },
  440. $inc: { version: 1 },
  441. },
  442. { new: true }
  443. ).exec()
  444. if (newProject == null) {
  445. throw new OError('Project not found or path not found in filetree', {
  446. projectId,
  447. path: entPath,
  448. })
  449. }
  450. const { docs: newDocs, files: newFiles } =
  451. ProjectEntityHandler.getAllEntitiesFromProject(newProject)
  452. return {
  453. project,
  454. startPath,
  455. endPath,
  456. rev: entity.rev,
  457. changes: { oldDocs, newDocs, oldFiles, newFiles, newProject },
  458. }
  459. }
  460. async function _removeElementFromMongoArray(
  461. modelId,
  462. path,
  463. elementId,
  464. userId,
  465. deleteRootDoc = false
  466. ) {
  467. const nonArrayPath = path.slice(0, path.lastIndexOf('.'))
  468. const options = { new: true }
  469. const query = { _id: modelId }
  470. const update = {
  471. $pull: { [nonArrayPath]: { _id: elementId } },
  472. $inc: { version: 1 },
  473. $set: { lastUpdated: new Date(), lastUpdatedBy: userId },
  474. }
  475. if (deleteRootDoc) {
  476. update.$unset = { rootDoc_id: 1 }
  477. }
  478. return Project.findOneAndUpdate(query, update, options).exec()
  479. }
  480. function _countElements(project) {
  481. function countFolder(folder) {
  482. if (folder == null) {
  483. return 0
  484. }
  485. let total = 0
  486. if (folder.folders) {
  487. total += folder.folders.length
  488. for (const subfolder of iterablePaths(folder, 'folders')) {
  489. total += countFolder(subfolder)
  490. }
  491. }
  492. if (folder.docs) {
  493. total += folder.docs.length
  494. }
  495. if (folder.fileRefs) {
  496. total += folder.fileRefs.length
  497. }
  498. return total
  499. }
  500. return countFolder(project.rootFolder[0])
  501. }
  502. async function _putElement(project, folderId, element, type, userId) {
  503. if (element == null || element._id == null) {
  504. logger.warn(
  505. { projectId: project._id, folderId, element, type },
  506. 'failed trying to insert element as it was null'
  507. )
  508. throw new Error('no element passed to be inserted')
  509. }
  510. const pathSegment = _getMongoPathSegmentFromType(type)
  511. // original check path.resolve("/", element.name) isnt "/#{element.name}" or element.name.match("/")
  512. // check if name is allowed
  513. if (!SafePath.isCleanFilename(element.name)) {
  514. logger.warn(
  515. { projectId: project._id, folderId, element, type },
  516. 'failed trying to insert element as name was invalid'
  517. )
  518. throw new Errors.InvalidNameError('invalid element name')
  519. }
  520. if (folderId == null) {
  521. folderId = project.rootFolder[0]._id
  522. }
  523. if (_countElements(project) > Settings.maxEntitiesPerProject) {
  524. logger.warn(
  525. { projectId: project._id },
  526. 'project too big, stopping insertions'
  527. )
  528. await CooldownManager.putProjectOnCooldown(project._id)
  529. throw new Error('project_has_too_many_files')
  530. }
  531. const { element: folder, path } = await ProjectLocator.promises.findElement({
  532. project,
  533. element_id: folderId,
  534. type: 'folder',
  535. })
  536. const newPath = {
  537. fileSystem: `${path.fileSystem}/${element.name}`,
  538. mongo: path.mongo,
  539. }
  540. // check if the path would be too long
  541. if (!SafePath.isAllowedLength(newPath.fileSystem)) {
  542. throw new Errors.InvalidNameError('path too long')
  543. }
  544. // Prevent top-level docs/files with reserved names (to match v1 behaviour)
  545. if (_blockedFilename(newPath, type)) {
  546. throw new Errors.InvalidNameError('blocked element name')
  547. }
  548. _checkValidElementName(folder, element.name)
  549. element._id = new ObjectId(element._id.toString())
  550. const mongoPath = `${path.mongo}.${pathSegment}`
  551. const newProject = await Project.findOneAndUpdate(
  552. { _id: project._id, [path.mongo]: { $exists: true } },
  553. {
  554. $push: { [mongoPath]: element },
  555. $inc: { version: 1 },
  556. $set: { lastUpdated: new Date(), lastUpdatedBy: userId },
  557. },
  558. { new: true }
  559. ).exec()
  560. if (newProject == null) {
  561. throw new OError('Project not found or path not found in filetree', {
  562. projectId: project._id,
  563. path,
  564. })
  565. }
  566. return { result: { path: newPath }, project: newProject }
  567. }
  568. function _blockedFilename(entityPath, entityType) {
  569. // check if name would be blocked in v1
  570. // javascript reserved names are forbidden for docs and files
  571. // at the top-level (but folders with reserved names are allowed).
  572. const isFolder = entityType === 'folder'
  573. const dir = path.dirname(entityPath.fileSystem)
  574. const file = path.basename(entityPath.fileSystem)
  575. const isTopLevel = dir === '/'
  576. if (isTopLevel && !isFolder && SafePath.isBlockedFilename(file)) {
  577. return true
  578. } else {
  579. return false
  580. }
  581. }
  582. function _getMongoPathSegmentFromType(type) {
  583. const pathSegment = ENTITY_TYPE_TO_MONGO_PATH_SEGMENT[type]
  584. if (pathSegment == null) {
  585. throw new Error(`Unknown entity type: ${type}`)
  586. }
  587. return pathSegment
  588. }
  589. /**
  590. * Check if the name is already taken by a doc, file or folder. If so, return an
  591. * error "file already exists".
  592. */
  593. function _checkValidElementName(folder, name) {
  594. if (folder == null) {
  595. return
  596. }
  597. const elements = []
  598. .concat(folder.docs || [])
  599. .concat(folder.fileRefs || [])
  600. .concat(folder.folders || [])
  601. for (const element of elements) {
  602. if (element.name === name) {
  603. throw new Errors.DuplicateNameError('file already exists')
  604. }
  605. }
  606. }
  607. function _confirmFolder(project, folderId) {
  608. if (folderId == null) {
  609. return project.rootFolder[0]._id
  610. } else {
  611. return folderId
  612. }
  613. }
  614. function _checkValidFolderPath(folderPath, destinationFolderPath) {
  615. if (!folderPath.endsWith('/')) {
  616. folderPath += '/'
  617. }
  618. if (!destinationFolderPath.endsWith('/')) {
  619. destinationFolderPath += '/'
  620. }
  621. if (destinationFolderPath === folderPath) {
  622. throw new Errors.InvalidNameError('destination folder is the same as me')
  623. }
  624. if (destinationFolderPath.startsWith(folderPath)) {
  625. throw new Errors.InvalidNameError(
  626. 'destination folder is a child folder of me'
  627. )
  628. }
  629. }
  630. async function _checkValidMove(
  631. project,
  632. entityType,
  633. entity,
  634. entityPath,
  635. destFolderId
  636. ) {
  637. const { element: destEntity, path: destFolderPath } =
  638. await ProjectLocator.promises.findElement({
  639. project,
  640. element_id: destFolderId,
  641. type: 'folder',
  642. })
  643. // check if there is already a doc/file/folder with the same name
  644. // in the destination folder
  645. _checkValidElementName(destEntity, entity.name)
  646. // check if the folder being moved is a parent of the destination folder
  647. if (/folder/.test(entityType)) {
  648. _checkValidFolderPath(entityPath.fileSystem, destFolderPath.fileSystem)
  649. }
  650. }
  651. /**
  652. * Create an initial file tree out of a list of doc and file entries
  653. *
  654. * Each entry specifies a path to the doc or file. Folders are automatically
  655. * created.
  656. *
  657. * @param {ObjectId} projectId - id of the project
  658. * @param {DocEntry[]} docEntries - list of docs to add
  659. * @param {FileEntry[]} fileEntries - list of files to add
  660. * @return {Promise<string>} the project version after the operation
  661. */
  662. async function createNewFolderStructure(projectId, docEntries, fileEntries) {
  663. try {
  664. const rootFolder = FolderStructureBuilder.buildFolderStructure(
  665. docEntries,
  666. fileEntries
  667. )
  668. const project = await Project.findOneAndUpdate(
  669. {
  670. _id: projectId,
  671. 'rootFolder.0.folders.0': { $exists: false },
  672. 'rootFolder.0.docs.0': { $exists: false },
  673. 'rootFolder.0.files.0': { $exists: false },
  674. },
  675. {
  676. $set: {
  677. rootFolder: [rootFolder],
  678. // NOTE: Do not set lastUpdated/lastUpdatedBy here. They are both set when creating the initial record.
  679. // The newly created clsi-cache record uses the lastUpdated timestamp of the initial record. Updating the lastUpdated timestamp here invalidates the cache record.
  680. },
  681. $inc: { version: 1 },
  682. },
  683. {
  684. new: true,
  685. lean: true,
  686. fields: { version: 1 },
  687. }
  688. ).exec()
  689. if (project == null) {
  690. throw new OError('project not found or folder structure already exists', {
  691. projectId,
  692. })
  693. }
  694. return project.version
  695. } catch (err) {
  696. throw OError.tag(err, 'failed to create folder structure', { projectId })
  697. }
  698. }
  699. /**
  700. * Given a Mongo path to an entity, return the Mongo path to the parent folder
  701. */
  702. function _getParentMongoPath(mongoPath) {
  703. const segments = mongoPath.split('.')
  704. if (segments.length <= 2) {
  705. throw new Error('Root folder has no parents')
  706. }
  707. return segments.slice(0, -2).join('.')
  708. }