ProjectEntityUpdateHandler.mjs 44 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697
  1. import _ from 'lodash'
  2. import OError from '@overleaf/o-error'
  3. import logger from '@overleaf/logger'
  4. import Settings from '@overleaf/settings'
  5. import Path from 'node:path'
  6. import fs from 'node:fs'
  7. import { Doc } from '../../models/Doc.mjs'
  8. import DocstoreManager from '../Docstore/DocstoreManager.mjs'
  9. import DocumentUpdaterHandler from '../../Features/DocumentUpdater/DocumentUpdaterHandler.mjs'
  10. import Errors from '../Errors/Errors.js'
  11. import FileStoreHandler from '../FileStore/FileStoreHandler.mjs'
  12. import LockManager from '../../infrastructure/LockManager.js'
  13. import { Project } from '../../models/Project.mjs'
  14. import ProjectEntityHandler from './ProjectEntityHandler.mjs'
  15. import ProjectGetter from './ProjectGetter.mjs'
  16. import ProjectLocator from './ProjectLocator.mjs'
  17. import ProjectOptionsHandler from './ProjectOptionsHandler.mjs'
  18. import ProjectUpdateHandler from './ProjectUpdateHandler.mjs'
  19. import ProjectEntityMongoUpdateHandler from './ProjectEntityMongoUpdateHandler.mjs'
  20. import SafePath from './SafePath.mjs'
  21. import TpdsUpdateSender from '../ThirdPartyDataStore/TpdsUpdateSender.mjs'
  22. import FileWriter from '../../infrastructure/FileWriter.js'
  23. import EditorRealTimeController from '../Editor/EditorRealTimeController.mjs'
  24. import { callbackifyMultiResult, callbackify } from '@overleaf/promise-utils'
  25. import { iterablePaths } from './IterablePath.mjs'
  26. const LOCK_NAMESPACE = 'sequentialProjectStructureUpdateLock'
  27. const VALID_ROOT_DOC_EXTENSIONS = Settings.validRootDocExtensions
  28. const VALID_ROOT_DOC_REGEXP = new RegExp(
  29. `^\\.(${VALID_ROOT_DOC_EXTENSIONS.join('|')})$`,
  30. 'i'
  31. )
  32. function wrapWithLock(methodWithoutLock, lockManager = LockManager) {
  33. // This lock is used to make sure that the project structure updates are made
  34. // sequentially. In particular the updates must be made in mongo and sent to
  35. // the doc-updater in the same order.
  36. if (typeof methodWithoutLock === 'function') {
  37. const methodWithLock = async (projectId, ...rest) => {
  38. return lockManager.promises.runWithLock(LOCK_NAMESPACE, projectId, () =>
  39. methodWithoutLock(projectId, ...rest)
  40. )
  41. }
  42. methodWithLock.withoutLock = methodWithoutLock
  43. return methodWithLock
  44. } else {
  45. // handle case with separate setup and locked stages
  46. const mainTask = methodWithoutLock.withLock
  47. const methodWithLock = async (projectId, ...rest) => {
  48. const arg = await methodWithoutLock.beforeLock(projectId, ...rest)
  49. return lockManager.promises.runWithLock(LOCK_NAMESPACE, projectId, () =>
  50. mainTask(arg)
  51. )
  52. }
  53. methodWithLock.withoutLock = async (...args) => {
  54. return await mainTask(await methodWithoutLock.beforeLock(...args))
  55. }
  56. methodWithLock.beforeLock = methodWithoutLock.beforeLock
  57. methodWithLock.mainTask = methodWithoutLock.withLock
  58. return methodWithLock
  59. }
  60. }
  61. async function getDocContext(projectId, docId) {
  62. let project
  63. try {
  64. project = await ProjectGetter.promises.getProject(projectId, {
  65. name: true,
  66. rootFolder: true,
  67. })
  68. } catch (err) {
  69. throw OError.tag(err, 'error fetching project', {
  70. projectId,
  71. })
  72. }
  73. if (!project) {
  74. throw new Errors.NotFoundError('project not found')
  75. }
  76. try {
  77. const { path, folder } = await ProjectLocator.promises.findElement({
  78. project,
  79. element_id: docId,
  80. type: 'docs',
  81. })
  82. return {
  83. projectName: project.name,
  84. isDeletedDoc: false,
  85. path: path.fileSystem,
  86. folder,
  87. }
  88. } catch (err) {
  89. if (err instanceof Errors.NotFoundError) {
  90. // (Soft-)Deleted docs are removed from the file-tree (rootFolder).
  91. // docstore can tell whether it exists and is (soft)-deleted.
  92. let isDeletedDoc
  93. try {
  94. isDeletedDoc = await DocstoreManager.promises.isDocDeleted(
  95. projectId,
  96. docId
  97. )
  98. if (!isDeletedDoc) {
  99. // NOTE: This can happen while we delete a doc:
  100. // 1. web will update the projects entry
  101. // 2. web triggers flushes to tpds/doc-updater
  102. // 3. web triggers (soft)-delete in docstore
  103. // Specifically when an update comes in after 1
  104. // and before 3 completes.
  105. logger.debug(
  106. { projectId, docId },
  107. 'updating doc that is in process of getting soft-deleted'
  108. )
  109. }
  110. return {
  111. projectName: project.name,
  112. isDeletedDoc: true,
  113. path: null,
  114. folder: null,
  115. }
  116. } catch (error) {
  117. if (error instanceof Errors.NotFoundError) {
  118. logger.warn(
  119. { projectId, docId },
  120. 'doc not found while updating doc lines'
  121. )
  122. throw error
  123. }
  124. throw OError.tag(
  125. error,
  126. 'error checking deletion status with docstore',
  127. {
  128. projectId,
  129. docId,
  130. }
  131. )
  132. }
  133. } else {
  134. throw OError.tag(err, 'error finding doc in rootFolder', {
  135. docId,
  136. projectId,
  137. })
  138. }
  139. }
  140. }
  141. async function updateDocLines(
  142. projectId,
  143. docId,
  144. lines,
  145. version,
  146. ranges,
  147. lastUpdatedAt,
  148. lastUpdatedBy
  149. ) {
  150. let ctx
  151. try {
  152. ctx = await getDocContext(projectId, docId)
  153. } catch (error) {
  154. if (error instanceof Errors.NotFoundError) {
  155. // Do not allow an update to a doc which has never exist on this project
  156. logger.warn(
  157. { docId, projectId },
  158. 'project or doc not found while updating doc lines'
  159. )
  160. }
  161. throw error
  162. }
  163. const { projectName, isDeletedDoc, path, folder } = ctx
  164. logger.debug({ projectId, docId }, 'telling docstore manager to update doc')
  165. let modified, rev
  166. try {
  167. ;({ modified, rev } = await DocstoreManager.promises.updateDoc(
  168. projectId,
  169. docId,
  170. lines,
  171. version,
  172. ranges
  173. ))
  174. } catch (err) {
  175. throw OError.tag(err, 'error sending doc to docstore', { docId, projectId })
  176. }
  177. // path will only be present if the doc is not deleted
  178. if (!modified || isDeletedDoc) {
  179. return { rev }
  180. }
  181. // Don't need to block for marking as updated
  182. ProjectUpdateHandler.promises
  183. .markAsUpdated(projectId, lastUpdatedAt, lastUpdatedBy)
  184. .catch(error => {
  185. logger.error({ error }, 'failed to mark project as updated')
  186. })
  187. await TpdsUpdateSender.promises.addDoc({
  188. projectId,
  189. path,
  190. docId,
  191. projectName,
  192. rev,
  193. folderId: folder?._id,
  194. })
  195. return { rev, modified }
  196. }
  197. async function setRootDoc(projectId, newRootDocID) {
  198. logger.debug({ projectId, rootDocId: newRootDocID }, 'setting root doc')
  199. if (projectId == null || newRootDocID == null) {
  200. throw new Errors.InvalidError('missing arguments (project or doc)')
  201. }
  202. const docPath =
  203. await ProjectEntityHandler.promises.getDocPathByProjectIdAndDocId(
  204. projectId,
  205. newRootDocID
  206. )
  207. if (ProjectEntityUpdateHandler.isPathValidForRootDoc(docPath)) {
  208. await Project.updateOne(
  209. { _id: projectId },
  210. { rootDoc_id: newRootDocID }
  211. ).exec()
  212. } else {
  213. throw new Errors.UnsupportedFileTypeError(
  214. 'invalid file extension for root doc'
  215. )
  216. }
  217. }
  218. async function unsetRootDoc(projectId) {
  219. logger.debug({ projectId }, 'removing root doc')
  220. await Project.updateOne(
  221. { _id: projectId },
  222. { $unset: { rootDoc_id: true } }
  223. ).exec()
  224. }
  225. async function addDoc(projectId, folderId, docName, docLines, userId, source) {
  226. return await ProjectEntityUpdateHandler.promises.addDocWithRanges(
  227. projectId,
  228. folderId,
  229. docName,
  230. docLines,
  231. {},
  232. userId,
  233. source
  234. )
  235. }
  236. const addDocWithRanges = wrapWithLock({
  237. async beforeLock(
  238. projectId,
  239. folderId,
  240. docName,
  241. docLines,
  242. ranges,
  243. userId,
  244. source
  245. ) {
  246. if (!SafePath.isCleanFilename(docName)) {
  247. throw new Errors.InvalidNameError('invalid element name')
  248. }
  249. // Put doc in docstore first, so that if it errors, we don't have a doc_id in the project
  250. // which hasn't been created in docstore.
  251. const doc = new Doc({ name: docName })
  252. const { rev } = await DocstoreManager.promises.updateDoc(
  253. projectId.toString(),
  254. doc._id.toString(),
  255. docLines,
  256. 0,
  257. ranges
  258. )
  259. doc.rev = rev
  260. return {
  261. projectId,
  262. folderId,
  263. doc,
  264. docName,
  265. docLines,
  266. ranges,
  267. userId,
  268. source,
  269. }
  270. },
  271. async withLock({
  272. projectId,
  273. folderId,
  274. doc,
  275. docName,
  276. docLines,
  277. ranges,
  278. userId,
  279. source,
  280. }) {
  281. const { result, project } =
  282. await ProjectEntityUpdateHandler._addDocAndSendToTpds(
  283. projectId,
  284. folderId,
  285. doc,
  286. userId
  287. )
  288. const docPath = result?.path?.fileSystem
  289. const projectHistoryId = project?.overleaf?.history?.id
  290. const newDocs = [
  291. {
  292. doc,
  293. path: docPath,
  294. docLines: docLines.join('\n'),
  295. ranges,
  296. },
  297. ]
  298. await DocumentUpdaterHandler.promises.updateProjectStructure(
  299. projectId,
  300. projectHistoryId,
  301. userId,
  302. { newDocs, newProject: project },
  303. source
  304. )
  305. return { doc, folderId: folderId || project.rootFolder[0]._id }
  306. },
  307. })
  308. const addFile = wrapWithLock({
  309. async beforeLock(
  310. projectId,
  311. folderId,
  312. fileName,
  313. fsPath,
  314. linkedFileData,
  315. userId,
  316. source
  317. ) {
  318. if (!SafePath.isCleanFilename(fileName)) {
  319. throw new Errors.InvalidNameError('invalid element name')
  320. }
  321. const { fileRef, createdBlob } =
  322. await ProjectEntityUpdateHandler._uploadFile(
  323. projectId,
  324. folderId,
  325. fileName,
  326. fsPath,
  327. linkedFileData
  328. )
  329. return {
  330. projectId,
  331. folderId,
  332. userId,
  333. fileRef,
  334. createdBlob,
  335. source,
  336. }
  337. },
  338. async withLock({
  339. projectId,
  340. folderId,
  341. userId,
  342. fileRef,
  343. createdBlob,
  344. source,
  345. }) {
  346. const { result, project } =
  347. await ProjectEntityUpdateHandler._addFileAndSendToTpds(
  348. projectId,
  349. folderId,
  350. fileRef,
  351. userId
  352. )
  353. const projectHistoryId = project.overleaf?.history?.id
  354. const newFiles = [
  355. {
  356. createdBlob,
  357. file: fileRef,
  358. path: result && result.path && result.path.fileSystem,
  359. },
  360. ]
  361. await DocumentUpdaterHandler.promises.updateProjectStructure(
  362. projectId,
  363. projectHistoryId,
  364. userId,
  365. { newFiles, newProject: project },
  366. source
  367. )
  368. return { fileRef, folderId, createdBlob }
  369. },
  370. })
  371. const upsertDoc = wrapWithLock(
  372. async function (projectId, folderId, docName, docLines, source, userId) {
  373. if (!SafePath.isCleanFilename(docName)) {
  374. throw new Errors.InvalidNameError('invalid element name')
  375. }
  376. let element, folderPath
  377. try {
  378. ;({ element, path: folderPath } =
  379. await ProjectLocator.promises.findElement({
  380. project_id: projectId,
  381. element_id: folderId,
  382. type: 'folder',
  383. }))
  384. } catch (error) {
  385. if (error instanceof Errors.NotFoundError) {
  386. throw new Error('folder_not_found')
  387. }
  388. throw error
  389. }
  390. if (element == null) {
  391. throw new Error("Couldn't find folder")
  392. }
  393. const existingDoc = element.docs.find(({ name }) => name === docName)
  394. const existingFile = element.fileRefs.find(({ name }) => name === docName)
  395. if (existingFile) {
  396. const doc = new Doc({ name: docName })
  397. const filePath = `${folderPath.fileSystem}/${existingFile.name}`
  398. const { rev } = await DocstoreManager.promises.updateDoc(
  399. projectId.toString(),
  400. doc._id.toString(),
  401. docLines,
  402. 0,
  403. {}
  404. )
  405. doc.rev = rev
  406. const project =
  407. await ProjectEntityMongoUpdateHandler.promises.replaceFileWithDoc(
  408. projectId,
  409. existingFile._id,
  410. doc,
  411. userId
  412. )
  413. await TpdsUpdateSender.promises.addDoc({
  414. projectId,
  415. docId: doc._id,
  416. path: filePath,
  417. projectName: project.name,
  418. rev: existingFile.rev + 1,
  419. folderId,
  420. })
  421. const projectHistoryId =
  422. project.overleaf &&
  423. project.overleaf.history &&
  424. project.overleaf.history.id
  425. const newDocs = [
  426. {
  427. doc,
  428. path: filePath,
  429. docLines: docLines.join('\n'),
  430. },
  431. ]
  432. const oldFiles = [
  433. {
  434. file: existingFile,
  435. path: filePath,
  436. },
  437. ]
  438. await DocumentUpdaterHandler.promises.updateProjectStructure(
  439. projectId,
  440. projectHistoryId,
  441. userId,
  442. { oldFiles, newDocs, newProject: project },
  443. source
  444. )
  445. EditorRealTimeController.emitToRoom(
  446. projectId,
  447. 'removeEntity',
  448. existingFile._id,
  449. 'convertFileToDoc'
  450. )
  451. return { doc, isNew: true }
  452. } else if (existingDoc) {
  453. const result = await DocumentUpdaterHandler.promises.setDocument(
  454. projectId,
  455. existingDoc._id,
  456. userId,
  457. docLines,
  458. source
  459. )
  460. logger.debug(
  461. { projectId, docId: existingDoc._id },
  462. 'notifying users that the document has been updated'
  463. )
  464. // there is no need to flush the doc to mongo at this point as docupdater
  465. // flushes it as part of setDoc.
  466. //
  467. // combine rev from response with existing doc metadata
  468. return {
  469. doc: { ...existingDoc, ...result },
  470. isNew: existingDoc == null,
  471. }
  472. } else {
  473. const { doc } =
  474. await ProjectEntityUpdateHandler.promises.addDocWithRanges.withoutLock(
  475. projectId,
  476. folderId,
  477. docName,
  478. docLines,
  479. {},
  480. userId,
  481. source
  482. )
  483. return { doc, isNew: existingDoc == null }
  484. }
  485. }
  486. )
  487. const appendToDoc = wrapWithLock(
  488. async (projectId, docId, lines, source, userId) => {
  489. const { element } = await ProjectLocator.promises.findElement({
  490. project_id: projectId,
  491. element_id: docId,
  492. type: 'doc',
  493. })
  494. return await DocumentUpdaterHandler.promises.appendToDocument(
  495. projectId,
  496. element._id,
  497. userId,
  498. lines,
  499. source
  500. )
  501. }
  502. )
  503. const upsertFile = wrapWithLock({
  504. async beforeLock(
  505. projectId,
  506. folderId,
  507. fileName,
  508. fsPath,
  509. linkedFileData,
  510. userId,
  511. source
  512. ) {
  513. if (!SafePath.isCleanFilename(fileName)) {
  514. throw new Errors.InvalidNameError('invalid element name')
  515. }
  516. // create a new file
  517. const fileArgs = {
  518. name: fileName,
  519. linkedFileData,
  520. }
  521. const { fileRef, createdBlob } =
  522. await FileStoreHandler.promises.uploadFileFromDisk(
  523. projectId,
  524. fileArgs,
  525. fsPath
  526. )
  527. return {
  528. projectId,
  529. folderId,
  530. fileName,
  531. fsPath,
  532. linkedFileData,
  533. userId,
  534. fileRef,
  535. createdBlob,
  536. source,
  537. }
  538. },
  539. async withLock({
  540. projectId,
  541. folderId,
  542. fileName,
  543. userId,
  544. fileRef,
  545. createdBlob,
  546. source,
  547. }) {
  548. let element
  549. try {
  550. ;({ element } = await ProjectLocator.promises.findElement({
  551. project_id: projectId,
  552. element_id: folderId,
  553. type: 'folder',
  554. }))
  555. } catch (error) {
  556. if (error instanceof Errors.NotFoundError) {
  557. throw new Error('folder_not_found')
  558. }
  559. throw error
  560. }
  561. if (element == null) {
  562. throw new Error("Couldn't find folder")
  563. }
  564. const existingFile = element.fileRefs.find(({ name }) => name === fileName)
  565. const existingDoc = element.docs.find(({ name }) => name === fileName)
  566. if (existingDoc) {
  567. let path
  568. try {
  569. ;({ path } = await ProjectLocator.promises.findElement({
  570. project_id: projectId,
  571. element_id: existingDoc._id,
  572. type: 'doc',
  573. }))
  574. } catch (err) {
  575. throw new Error("couldn't find existing file")
  576. }
  577. const project =
  578. await ProjectEntityMongoUpdateHandler.promises.replaceDocWithFile(
  579. projectId,
  580. existingDoc._id,
  581. fileRef,
  582. userId
  583. )
  584. const projectHistoryId = project.overleaf?.history?.id
  585. await TpdsUpdateSender.promises.addFile({
  586. projectId: project._id,
  587. historyId: projectHistoryId,
  588. fileId: fileRef._id,
  589. hash: fileRef.hash,
  590. path: path.fileSystem,
  591. rev: fileRef.rev,
  592. projectName: project.name,
  593. folderId,
  594. })
  595. await DocumentUpdaterHandler.promises.updateProjectStructure(
  596. projectId,
  597. projectHistoryId,
  598. userId,
  599. {
  600. oldDocs: [{ doc: existingDoc, path: path.fileSystem }],
  601. newFiles: [
  602. {
  603. createdBlob,
  604. file: fileRef,
  605. path: path.fileSystem,
  606. },
  607. ],
  608. newProject: project,
  609. },
  610. source
  611. )
  612. EditorRealTimeController.emitToRoom(
  613. projectId,
  614. 'removeEntity',
  615. existingDoc._id,
  616. 'convertDocToFile'
  617. )
  618. return { fileRef, isNew: true, oldFileRef: existingFile }
  619. } else if (existingFile) {
  620. await ProjectEntityUpdateHandler._replaceFile(
  621. projectId,
  622. existingFile._id,
  623. userId,
  624. fileRef,
  625. folderId,
  626. source,
  627. createdBlob
  628. )
  629. return { fileRef, isNew: false, oldFileRef: existingFile }
  630. } else {
  631. // this calls directly into the addFile main task (without the beforeLock part)
  632. await ProjectEntityUpdateHandler.promises.addFile.mainTask({
  633. projectId,
  634. folderId,
  635. userId,
  636. fileRef,
  637. createdBlob,
  638. source,
  639. })
  640. return {
  641. fileRef,
  642. isNew: existingFile == null,
  643. oldFileRef: existingFile,
  644. }
  645. }
  646. },
  647. })
  648. const upsertDocWithPath = wrapWithLock(
  649. async function (projectId, elementPath, docLines, source, userId) {
  650. if (!SafePath.isCleanPath(elementPath)) {
  651. throw new Errors.InvalidNameError('invalid element name')
  652. }
  653. const docName = Path.basename(elementPath)
  654. const folderPath = Path.dirname(elementPath)
  655. const { newFolders, folder } =
  656. await ProjectEntityUpdateHandler.promises.mkdirp.withoutLock(
  657. projectId,
  658. folderPath,
  659. userId
  660. )
  661. const { isNew, doc } =
  662. await ProjectEntityUpdateHandler.promises.upsertDoc.withoutLock(
  663. projectId,
  664. folder._id,
  665. docName,
  666. docLines,
  667. source,
  668. userId
  669. )
  670. return { doc, isNew, newFolders, folder }
  671. }
  672. )
  673. const upsertFileWithPath = wrapWithLock({
  674. async beforeLock(
  675. projectId,
  676. elementPath,
  677. fsPath,
  678. linkedFileData,
  679. userId,
  680. source
  681. ) {
  682. if (!SafePath.isCleanPath(elementPath)) {
  683. throw new Errors.InvalidNameError('invalid element name')
  684. }
  685. const fileName = Path.basename(elementPath)
  686. const folderPath = Path.dirname(elementPath)
  687. // create a new file
  688. const fileArgs = {
  689. name: fileName,
  690. linkedFileData,
  691. }
  692. const { fileRef, createdBlob } =
  693. await FileStoreHandler.promises.uploadFileFromDisk(
  694. projectId,
  695. fileArgs,
  696. fsPath
  697. )
  698. return {
  699. projectId,
  700. folderPath,
  701. fileName,
  702. fsPath,
  703. linkedFileData,
  704. userId,
  705. fileRef,
  706. createdBlob,
  707. source,
  708. }
  709. },
  710. async withLock({
  711. projectId,
  712. folderPath,
  713. fileName,
  714. fsPath,
  715. linkedFileData,
  716. userId,
  717. fileRef,
  718. createdBlob,
  719. source,
  720. }) {
  721. const { newFolders, folder } =
  722. await ProjectEntityUpdateHandler.promises.mkdirp.withoutLock(
  723. projectId,
  724. folderPath,
  725. userId
  726. )
  727. // this calls directly into the upsertFile main task (without the beforeLock part)
  728. const {
  729. fileRef: newFileRef,
  730. isNew,
  731. oldFileRef,
  732. } = await ProjectEntityUpdateHandler.promises.upsertFile.mainTask({
  733. projectId,
  734. folderId: folder._id,
  735. fileName,
  736. fsPath,
  737. linkedFileData,
  738. userId,
  739. fileRef,
  740. createdBlob,
  741. source,
  742. })
  743. return {
  744. fileRef: newFileRef,
  745. isNew,
  746. oldFileRef,
  747. newFolders,
  748. folder,
  749. }
  750. },
  751. })
  752. const deleteEntity = wrapWithLock(
  753. async function (projectId, entityId, entityType, userId, source, callback) {
  754. logger.debug({ entityId, entityType, projectId }, 'deleting project entity')
  755. if (entityType == null) {
  756. logger.warn({ err: 'No entityType set', projectId, entityId })
  757. throw new Error('No entityType set')
  758. }
  759. entityType = entityType.toLowerCase()
  760. // Flush the entire project to avoid leaving partially deleted docs in redis.
  761. await DocumentUpdaterHandler.promises.flushProjectToMongo(projectId)
  762. const { entity, path, projectBeforeDeletion, newProject } =
  763. await ProjectEntityMongoUpdateHandler.promises.deleteEntity(
  764. projectId,
  765. entityId,
  766. entityType,
  767. userId
  768. )
  769. const subtreeListing = await ProjectEntityUpdateHandler._cleanUpEntity(
  770. projectBeforeDeletion,
  771. newProject,
  772. entity,
  773. entityType,
  774. path.fileSystem,
  775. userId,
  776. source
  777. )
  778. const subtreeEntityIds = subtreeListing.map(entry =>
  779. entry.entity._id.toString()
  780. )
  781. await TpdsUpdateSender.promises.deleteEntity({
  782. projectId,
  783. path: path.fileSystem,
  784. projectName: projectBeforeDeletion.name,
  785. entityId,
  786. entityType,
  787. subtreeEntityIds,
  788. })
  789. return entityId
  790. }
  791. )
  792. const deleteEntityWithPath = wrapWithLock(
  793. async (projectId, path, userId, source) => {
  794. const { element, type } = await ProjectLocator.promises.findElementByPath({
  795. project_id: projectId,
  796. path,
  797. exactCaseMatch: true,
  798. })
  799. if (element == null) {
  800. throw new Errors.NotFoundError('project not found')
  801. }
  802. return await ProjectEntityUpdateHandler.promises.deleteEntity.withoutLock(
  803. projectId,
  804. element._id,
  805. type,
  806. userId,
  807. source
  808. )
  809. }
  810. )
  811. const mkdirp = wrapWithLock(async function (projectId, path, userId) {
  812. for (const folder of path.split('/')) {
  813. if (folder.length > 0 && !SafePath.isCleanFilename(folder)) {
  814. throw new Errors.InvalidNameError('invalid element name')
  815. }
  816. }
  817. return await ProjectEntityMongoUpdateHandler.promises.mkdirp(
  818. projectId,
  819. path,
  820. userId,
  821. { exactCaseMatch: false }
  822. )
  823. })
  824. const mkdirpWithExactCase = wrapWithLock(
  825. async function (projectId, path, userId) {
  826. for (const folder of path.split('/')) {
  827. if (folder.length > 0 && !SafePath.isCleanFilename(folder)) {
  828. throw new Errors.InvalidNameError('invalid element name')
  829. }
  830. }
  831. return await ProjectEntityMongoUpdateHandler.promises.mkdirp(
  832. projectId,
  833. path,
  834. userId,
  835. { exactCaseMatch: true }
  836. )
  837. }
  838. )
  839. const addFolder = wrapWithLock(
  840. async function (projectId, parentFolderId, folderName, userId) {
  841. if (!SafePath.isCleanFilename(folderName)) {
  842. throw new Errors.InvalidNameError('invalid element name')
  843. }
  844. return await ProjectEntityMongoUpdateHandler.promises.addFolder(
  845. projectId,
  846. parentFolderId,
  847. folderName,
  848. userId
  849. )
  850. }
  851. )
  852. const moveEntity = wrapWithLock(
  853. async function (
  854. projectId,
  855. entityId,
  856. destFolderId,
  857. entityType,
  858. userId,
  859. source
  860. ) {
  861. logger.debug(
  862. { entityType, entityId, projectId, destFolderId },
  863. 'moving entity'
  864. )
  865. if (entityType == null) {
  866. logger.warn({ err: 'No entityType set', projectId, entityId })
  867. throw new Error('No entityType set')
  868. }
  869. entityType = entityType.toLowerCase()
  870. await DocumentUpdaterHandler.promises.flushProjectToMongo(projectId)
  871. const { project, startPath, endPath, rev, changes } =
  872. await ProjectEntityMongoUpdateHandler.promises.moveEntity(
  873. projectId,
  874. entityId,
  875. destFolderId,
  876. entityType,
  877. userId
  878. )
  879. const projectHistoryId = project.overleaf?.history?.id
  880. try {
  881. await TpdsUpdateSender.promises.moveEntity({
  882. projectId,
  883. projectName: project.name,
  884. startPath,
  885. endPath,
  886. rev,
  887. entityId,
  888. entityType,
  889. folderId: destFolderId,
  890. })
  891. } catch (err) {
  892. logger.error({ err }, 'error sending tpds update')
  893. }
  894. return await DocumentUpdaterHandler.promises.updateProjectStructure(
  895. projectId,
  896. projectHistoryId,
  897. userId,
  898. changes,
  899. source
  900. )
  901. }
  902. )
  903. const renameEntity = wrapWithLock(
  904. async function (projectId, entityId, entityType, newName, userId, source) {
  905. if (!newName || typeof newName !== 'string') {
  906. const err = new OError('invalid newName value', {
  907. value: newName,
  908. type: typeof newName,
  909. projectId,
  910. entityId,
  911. entityType,
  912. userId,
  913. source,
  914. })
  915. logger.error({ err }, 'Invalid newName passed to renameEntity')
  916. throw err
  917. }
  918. if (!SafePath.isCleanFilename(newName)) {
  919. throw new Errors.InvalidNameError('invalid element name')
  920. }
  921. logger.debug({ entityId, projectId }, `renaming ${entityType}`)
  922. if (entityType == null) {
  923. logger.warn({ err: 'No entityType set', projectId, entityId })
  924. throw new Error('No entityType set')
  925. }
  926. entityType = entityType.toLowerCase()
  927. await DocumentUpdaterHandler.promises.flushProjectToMongo(projectId)
  928. const { project, startPath, endPath, rev, changes } =
  929. await ProjectEntityMongoUpdateHandler.promises.renameEntity(
  930. projectId,
  931. entityId,
  932. entityType,
  933. newName,
  934. userId
  935. )
  936. const projectHistoryId = project.overleaf?.history?.id
  937. try {
  938. await TpdsUpdateSender.promises.moveEntity({
  939. projectId,
  940. projectName: project.name,
  941. startPath,
  942. endPath,
  943. rev,
  944. entityId,
  945. entityType,
  946. folderId: null, // this means the folder has not changed
  947. })
  948. } catch (err) {
  949. logger.error({ err }, 'error sending tpds update')
  950. }
  951. return await DocumentUpdaterHandler.promises.updateProjectStructure(
  952. projectId,
  953. projectHistoryId,
  954. userId,
  955. changes,
  956. source
  957. )
  958. }
  959. )
  960. // This doesn't directly update project structure, but we need to take the lock
  961. // to prevent anything else being queued before the resync update
  962. const resyncProjectHistory = wrapWithLock(
  963. async (projectId, opts) => {
  964. const project = await ProjectGetter.promises.getProject(projectId, {
  965. rootFolder: true,
  966. overleaf: true,
  967. })
  968. const projectHistoryId = project.overleaf?.history?.id
  969. if (projectHistoryId == null) {
  970. throw new Errors.ProjectHistoryDisabledError(
  971. `project history not enabled for ${projectId}`
  972. )
  973. }
  974. const { docs, files, folders } =
  975. ProjectEntityHandler.getAllEntitiesFromProject(project)
  976. // _checkFileTree() must be passed the folders before docs and
  977. // files
  978. await ProjectEntityUpdateHandler._checkFiletree(
  979. projectId,
  980. projectHistoryId,
  981. [...folders, ...docs, ...files]
  982. )
  983. await DocumentUpdaterHandler.promises.resyncProjectHistory(
  984. projectId,
  985. projectHistoryId,
  986. docs,
  987. files,
  988. opts
  989. )
  990. if (opts.historyRangesMigration) {
  991. return await ProjectOptionsHandler.promises.setHistoryRangesSupport(
  992. projectId,
  993. opts.historyRangesMigration === 'forwards'
  994. )
  995. }
  996. },
  997. LockManager.withTimeout(6 * 60) // use an extended lock for the resync operations
  998. )
  999. const convertDocToFile = wrapWithLock({
  1000. async beforeLock(projectId, docId, userId, source) {
  1001. await DocumentUpdaterHandler.promises.flushDocToMongo(projectId, docId)
  1002. const { element: doc, path } = await ProjectLocator.promises.findElement({
  1003. project_id: projectId,
  1004. element_id: docId,
  1005. type: 'doc',
  1006. })
  1007. const docPath = path.fileSystem
  1008. const { lines, rev, ranges } = await DocstoreManager.promises.getDoc(
  1009. projectId,
  1010. docId
  1011. )
  1012. if (!_.isEmpty(ranges)) {
  1013. throw new Errors.DocHasRangesError({})
  1014. }
  1015. await DocumentUpdaterHandler.promises.deleteDoc(projectId, docId, false)
  1016. const fsPath = await FileWriter.promises.writeLinesToDisk(projectId, lines)
  1017. const { fileRef, createdBlob } =
  1018. await FileStoreHandler.promises.uploadFileFromDisk(
  1019. projectId,
  1020. { name: doc.name, rev: rev + 1 },
  1021. fsPath
  1022. )
  1023. try {
  1024. await fs.promises.unlink(fsPath)
  1025. } catch (err) {
  1026. logger.warn({ err, path: fsPath }, 'failed to clean up temporary file')
  1027. }
  1028. return {
  1029. projectId,
  1030. doc,
  1031. path: docPath,
  1032. fileRef,
  1033. userId,
  1034. source,
  1035. createdBlob,
  1036. }
  1037. },
  1038. async withLock({
  1039. projectId,
  1040. doc,
  1041. path,
  1042. fileRef,
  1043. userId,
  1044. source,
  1045. createdBlob,
  1046. }) {
  1047. const project =
  1048. await ProjectEntityMongoUpdateHandler.promises.replaceDocWithFile(
  1049. projectId,
  1050. doc._id,
  1051. fileRef,
  1052. userId
  1053. )
  1054. const projectHistoryId = project.overleaf?.history?.id
  1055. await DocumentUpdaterHandler.promises.updateProjectStructure(
  1056. projectId,
  1057. projectHistoryId,
  1058. userId,
  1059. {
  1060. oldDocs: [{ doc, path }],
  1061. newFiles: [{ file: fileRef, path, createdBlob }],
  1062. newProject: project,
  1063. },
  1064. source
  1065. )
  1066. const { folder } = await ProjectLocator.promises.findElement({
  1067. project_id: projectId,
  1068. element_id: fileRef._id,
  1069. type: 'file',
  1070. })
  1071. EditorRealTimeController.emitToRoom(
  1072. projectId,
  1073. 'removeEntity',
  1074. doc._id,
  1075. 'convertDocToFile'
  1076. )
  1077. EditorRealTimeController.emitToRoom(
  1078. projectId,
  1079. 'reciveNewFile',
  1080. folder._id,
  1081. fileRef,
  1082. 'convertDocToFile',
  1083. null,
  1084. userId
  1085. )
  1086. return fileRef
  1087. },
  1088. })
  1089. async function setMainBibliographyDoc(projectId, newBibliographyDocId) {
  1090. logger.debug(
  1091. { projectId, mainBibliographyDocId: newBibliographyDocId },
  1092. 'setting main bibliography doc'
  1093. )
  1094. if (projectId == null || newBibliographyDocId == null) {
  1095. throw new Errors.InvalidError('missing arguments (project or doc)')
  1096. }
  1097. const docPath =
  1098. await ProjectEntityHandler.promises.getDocPathByProjectIdAndDocId(
  1099. projectId,
  1100. newBibliographyDocId
  1101. )
  1102. if (ProjectEntityUpdateHandler.isPathValidForMainBibliographyDoc(docPath)) {
  1103. await Project.updateOne(
  1104. { _id: projectId },
  1105. { mainBibliographyDoc_id: newBibliographyDocId }
  1106. ).exec()
  1107. } else {
  1108. throw new Errors.UnsupportedFileTypeError(
  1109. 'invalid file extension for main bibliography doc'
  1110. )
  1111. }
  1112. }
  1113. const ProjectEntityUpdateHandler = {
  1114. LOCK_NAMESPACE,
  1115. addDoc: callbackifyMultiResult(addDoc, ['doc', 'folderId']),
  1116. addDocWithRanges: callbackifyMultiResult(addDocWithRanges, [
  1117. 'doc',
  1118. 'folderId',
  1119. ]),
  1120. addFile: callbackifyMultiResult(addFile, [
  1121. 'fileRef',
  1122. 'folderId',
  1123. 'createdBlob',
  1124. ]),
  1125. addFolder: callbackifyMultiResult(addFolder, ['folder', 'parentFolderId']),
  1126. convertDocToFile: callbackify(convertDocToFile),
  1127. deleteEntity: callbackify(deleteEntity),
  1128. deleteEntityWithPath: callbackify(deleteEntityWithPath),
  1129. mkdirp: callbackifyMultiResult(mkdirp, [
  1130. 'newFolders',
  1131. 'folder',
  1132. 'parentFolder',
  1133. ]),
  1134. mkdirpWithExactCase: callbackifyMultiResult(mkdirpWithExactCase, [
  1135. 'newFolders',
  1136. 'folder',
  1137. 'parentFolder',
  1138. ]),
  1139. moveEntity: callbackify(moveEntity),
  1140. renameEntity: callbackify(renameEntity),
  1141. resyncProjectHistory: callbackify(resyncProjectHistory),
  1142. setRootDoc: callbackify(setRootDoc),
  1143. unsetRootDoc: callbackify(unsetRootDoc),
  1144. setMainBibliographyDoc: callbackify(setMainBibliographyDoc),
  1145. updateDocLines: callbackify(updateDocLines),
  1146. upsertDoc: callbackifyMultiResult(upsertDoc, ['doc', 'isNew']),
  1147. appendToDoc: callbackify(appendToDoc),
  1148. upsertDocWithPath: callbackifyMultiResult(upsertDocWithPath, [
  1149. 'doc',
  1150. 'isNew',
  1151. 'newFolders',
  1152. 'folder',
  1153. ]),
  1154. upsertFile: callbackifyMultiResult(upsertFile, [
  1155. 'fileRef',
  1156. 'isNew',
  1157. 'oldFileRef',
  1158. ]),
  1159. upsertFileWithPath: callbackifyMultiResult(upsertFileWithPath, [
  1160. 'fileRef',
  1161. 'isNew',
  1162. 'oldFileRef',
  1163. 'newFolders',
  1164. 'folder',
  1165. ]),
  1166. promises: {
  1167. addDoc,
  1168. addDocWithRanges,
  1169. addFile,
  1170. addFolder,
  1171. convertDocToFile,
  1172. deleteEntity,
  1173. deleteEntityWithPath,
  1174. mkdirp,
  1175. mkdirpWithExactCase,
  1176. moveEntity,
  1177. renameEntity,
  1178. resyncProjectHistory,
  1179. setRootDoc,
  1180. unsetRootDoc,
  1181. updateDocLines,
  1182. upsertDoc,
  1183. upsertDocWithPath,
  1184. upsertFile,
  1185. upsertFileWithPath,
  1186. appendToDocWithPath: appendToDoc,
  1187. setMainBibliographyDoc,
  1188. },
  1189. async _addDocAndSendToTpds(projectId, folderId, doc, userId) {
  1190. let result, project
  1191. try {
  1192. ;({ result, project } =
  1193. await ProjectEntityMongoUpdateHandler.promises.addDoc(
  1194. projectId,
  1195. folderId,
  1196. doc,
  1197. userId
  1198. ))
  1199. } catch (err) {
  1200. throw OError.tag(err, 'error adding file with project', {
  1201. projectId,
  1202. folderId,
  1203. doc_name: doc != null ? doc.name : undefined,
  1204. doc_id: doc != null ? doc._id : undefined,
  1205. })
  1206. }
  1207. await TpdsUpdateSender.promises.addDoc({
  1208. projectId,
  1209. docId: doc != null ? doc._id : undefined,
  1210. path: result?.path?.fileSystem,
  1211. projectName: project.name,
  1212. rev: 0,
  1213. folderId,
  1214. })
  1215. return { result, project }
  1216. },
  1217. async _uploadFile(projectId, folderId, fileName, fsPath, linkedFileData) {
  1218. if (!SafePath.isCleanFilename(fileName)) {
  1219. throw new Errors.InvalidNameError('invalid element name')
  1220. }
  1221. const fileArgs = {
  1222. name: fileName,
  1223. linkedFileData,
  1224. }
  1225. try {
  1226. return await FileStoreHandler.promises.uploadFileFromDisk(
  1227. projectId,
  1228. fileArgs,
  1229. fsPath
  1230. )
  1231. } catch (err) {
  1232. throw OError.tag(err, 'error uploading image to s3', {
  1233. projectId,
  1234. folderId,
  1235. file_name: fileName,
  1236. })
  1237. }
  1238. },
  1239. async _addFileAndSendToTpds(projectId, folderId, fileRef, userId) {
  1240. let result, project
  1241. try {
  1242. ;({ result, project } =
  1243. await ProjectEntityMongoUpdateHandler.promises.addFile(
  1244. projectId,
  1245. folderId,
  1246. fileRef,
  1247. userId
  1248. ))
  1249. } catch (err) {
  1250. throw OError.tag(err, 'error adding file with project', {
  1251. projectId,
  1252. folderId,
  1253. file_name: fileRef.name,
  1254. fileRef,
  1255. })
  1256. }
  1257. const historyId = project?.overleaf?.history?.id
  1258. if (!historyId) {
  1259. throw new OError('project does not have a history id', { projectId })
  1260. }
  1261. await TpdsUpdateSender.promises.addFile({
  1262. projectId,
  1263. historyId,
  1264. fileId: fileRef._id,
  1265. hash: fileRef.hash,
  1266. path: result?.path?.fileSystem,
  1267. projectName: project.name,
  1268. rev: fileRef.rev,
  1269. folderId,
  1270. })
  1271. return { result, project }
  1272. },
  1273. async _replaceFile(
  1274. projectId,
  1275. fileId,
  1276. userId,
  1277. newFileRef,
  1278. folderId,
  1279. source,
  1280. createdBlob
  1281. ) {
  1282. const {
  1283. oldFileRef,
  1284. project,
  1285. path,
  1286. newProject,
  1287. newFileRef: updatedFileRef,
  1288. } = await ProjectEntityMongoUpdateHandler.promises.replaceFileWithNew(
  1289. projectId,
  1290. fileId,
  1291. newFileRef,
  1292. userId
  1293. )
  1294. const oldFiles = [
  1295. {
  1296. file: oldFileRef,
  1297. path: path.fileSystem,
  1298. },
  1299. ]
  1300. const newFiles = [
  1301. {
  1302. file: updatedFileRef,
  1303. createdBlob,
  1304. path: path.fileSystem,
  1305. },
  1306. ]
  1307. const projectHistoryId = project.overleaf?.history?.id
  1308. await TpdsUpdateSender.promises.addFile({
  1309. projectId: project._id,
  1310. historyId: projectHistoryId,
  1311. fileId: updatedFileRef._id,
  1312. hash: updatedFileRef.hash,
  1313. path: path.fileSystem,
  1314. rev: updatedFileRef.rev,
  1315. projectName: project.name,
  1316. folderId,
  1317. })
  1318. await DocumentUpdaterHandler.promises.updateProjectStructure(
  1319. projectId,
  1320. projectHistoryId,
  1321. userId,
  1322. { oldFiles, newFiles, newProject },
  1323. source
  1324. )
  1325. return updatedFileRef
  1326. },
  1327. async _checkFiletree(projectId, projectHistoryId, entities) {
  1328. const adjustPathsAfterFolderRename = (oldPath, newPath) => {
  1329. oldPath = oldPath + '/'
  1330. newPath = newPath + '/'
  1331. for (const entity of entities) {
  1332. if (entity.path.startsWith(oldPath)) {
  1333. entity.path = newPath + entity.path.slice(oldPath.length)
  1334. }
  1335. }
  1336. }
  1337. // Data structures for recording pending renames
  1338. const renames = []
  1339. const paths = new Set()
  1340. for (const entity of entities) {
  1341. const originalName = entity.folder
  1342. ? entity.folder.name
  1343. : entity.doc
  1344. ? entity.doc.name
  1345. : entity.file.name
  1346. let newPath = entity.path
  1347. let newName = originalName
  1348. // Clean the filename if necessary
  1349. if (newName === '') {
  1350. newName = 'untitled'
  1351. } else {
  1352. newName = SafePath.clean(newName)
  1353. }
  1354. if (newName !== originalName) {
  1355. newPath = Path.join(
  1356. newPath.slice(0, newPath.length - originalName.length),
  1357. newName
  1358. )
  1359. }
  1360. // Check if we've seen that path already
  1361. if (paths.has(newPath)) {
  1362. newPath = ProjectEntityUpdateHandler.findNextAvailablePath(
  1363. paths,
  1364. newPath
  1365. )
  1366. newName = newPath.split('/').pop()
  1367. }
  1368. // If we've changed the filename, schedule a rename
  1369. if (newName !== originalName) {
  1370. renames.push({ entity, newName, newPath })
  1371. if (entity.folder) {
  1372. // Here, we rely on entities being processed in the right order.
  1373. // Parent folders need to be processed before their children. This is
  1374. // the case only because getAllEntitiesFromProject() returns folders
  1375. // in that order and resyncProjectHistory() calls us with the folders
  1376. // first.
  1377. adjustPathsAfterFolderRename(entity.path, newPath)
  1378. }
  1379. }
  1380. // Remember that we've seen this path
  1381. paths.add(newPath)
  1382. }
  1383. if (renames.length === 0) {
  1384. return
  1385. }
  1386. logger.warn(
  1387. {
  1388. projectId,
  1389. renames: renames.map(rename => ({
  1390. oldPath: rename.entity.path,
  1391. newPath: rename.newPath,
  1392. })),
  1393. },
  1394. 'found conflicts or bad filenames in filetree'
  1395. )
  1396. // Avoid conflicts by processing renames in the reverse order. If we have
  1397. // the following starting situation:
  1398. //
  1399. // somefile.tex
  1400. // somefile.tex
  1401. // somefile.tex (1)
  1402. //
  1403. // somefile.tex would be processed first, and then somefile.tex (1),
  1404. // yielding the following renames:
  1405. //
  1406. // somefile.tex -> somefile.tex (1)
  1407. // somefile.tex (1) -> somefile.tex (2)
  1408. //
  1409. // When the first rename was decided, we didn't know that somefile.tex (1)
  1410. // existed, so that created a conflict. By processing renames in the
  1411. // reverse order, we start with the files that had the most extensive
  1412. // information about existing files.
  1413. renames.reverse()
  1414. for (const rename of renames) {
  1415. // rename the duplicate files
  1416. const entity = rename.entity
  1417. const entityId = entity.folder
  1418. ? entity.folder._id
  1419. : entity.doc
  1420. ? entity.doc._id
  1421. : entity.file._id
  1422. const entityType = entity.folder ? 'folder' : entity.doc ? 'doc' : 'file'
  1423. const { changes } =
  1424. await ProjectEntityMongoUpdateHandler.promises.renameEntity(
  1425. projectId,
  1426. entityId,
  1427. entityType,
  1428. rename.newName,
  1429. null // unset lastUpdatedBy
  1430. )
  1431. // update the renamed entity for the resync
  1432. entity.path = rename.newPath
  1433. if (entityType === 'folder') {
  1434. entity.folder.name = rename.newName
  1435. } else if (entityType === 'doc') {
  1436. entity.doc.name = rename.newName
  1437. } else {
  1438. entity.file.name = rename.newName
  1439. }
  1440. await DocumentUpdaterHandler.promises.updateProjectStructure(
  1441. projectId,
  1442. projectHistoryId,
  1443. null,
  1444. changes,
  1445. 'automatic-fix'
  1446. )
  1447. }
  1448. },
  1449. findNextAvailablePath(allPaths, candidatePath) {
  1450. const incrementReplacer = (match, p1) => {
  1451. return ' (' + (parseInt(p1, 10) + 1) + ')'
  1452. }
  1453. // if the filename was invalid we should normalise it here too. Currently
  1454. // this only handles renames in the same folder, so we will be out of luck
  1455. // if it is the folder name which in invalid. We could handle folder
  1456. // renames by returning the folders list from getAllEntitiesFromProject
  1457. do {
  1458. // does the filename look like "foo (1)" if so, increment the number in parentheses
  1459. if (/ \(\d+\)$/.test(candidatePath)) {
  1460. candidatePath = candidatePath.replace(/ \((\d+)\)$/, incrementReplacer)
  1461. } else {
  1462. // otherwise, add a ' (1)' suffix to the name
  1463. candidatePath = candidatePath + ' (1)'
  1464. }
  1465. } while (allPaths.has(candidatePath)) // keep going until the name is unique
  1466. // add the new name to the set
  1467. allPaths.add(candidatePath)
  1468. return candidatePath
  1469. },
  1470. isPathValidForRootDoc(docPath) {
  1471. const docExtension = Path.extname(docPath)
  1472. return VALID_ROOT_DOC_REGEXP.test(docExtension)
  1473. },
  1474. isPathValidForMainBibliographyDoc(docPath) {
  1475. const docExtension = Path.extname(docPath).toLowerCase()
  1476. return docExtension === '.bib'
  1477. },
  1478. async _cleanUpEntity(
  1479. project,
  1480. newProject,
  1481. entity,
  1482. entityType,
  1483. path,
  1484. userId,
  1485. source
  1486. ) {
  1487. const subtreeListing = _listSubtree(entity, entityType, path)
  1488. await ProjectEntityUpdateHandler._updateProjectStructureWithDeletedEntity(
  1489. project,
  1490. newProject,
  1491. subtreeListing,
  1492. userId,
  1493. source
  1494. )
  1495. for (const entry of subtreeListing) {
  1496. if (entry.type === 'doc') {
  1497. await ProjectEntityUpdateHandler._cleanUpDoc(
  1498. project,
  1499. entry.entity,
  1500. entry.path,
  1501. userId
  1502. )
  1503. }
  1504. }
  1505. return subtreeListing
  1506. },
  1507. async _updateProjectStructureWithDeletedEntity(
  1508. project,
  1509. newProject,
  1510. subtreeListing,
  1511. userId,
  1512. source
  1513. ) {
  1514. const changes = { oldDocs: [], oldFiles: [] }
  1515. for (const entry of subtreeListing) {
  1516. if (entry.type === 'doc') {
  1517. changes.oldDocs.push({ doc: entry.entity, path: entry.path })
  1518. } else if (entry.type === 'file') {
  1519. changes.oldFiles.push({ file: entry.entity, path: entry.path })
  1520. }
  1521. }
  1522. // now send the project structure changes to the docupdater
  1523. changes.newProject = newProject
  1524. const projectId = project._id.toString()
  1525. const projectHistoryId =
  1526. project.overleaf &&
  1527. project.overleaf.history &&
  1528. project.overleaf.history.id
  1529. return await DocumentUpdaterHandler.promises.updateProjectStructure(
  1530. projectId,
  1531. projectHistoryId,
  1532. userId,
  1533. changes,
  1534. source
  1535. )
  1536. },
  1537. async _cleanUpDoc(project, doc) {
  1538. const projectId = project._id.toString()
  1539. const docId = doc._id.toString()
  1540. if (project.rootDoc_id != null && project.rootDoc_id.toString() === docId) {
  1541. await ProjectEntityUpdateHandler.promises.unsetRootDoc(projectId)
  1542. }
  1543. const { name } = doc
  1544. const deletedAt = new Date()
  1545. await DocstoreManager.promises.deleteDoc(projectId, docId, name, deletedAt)
  1546. return await DocumentUpdaterHandler.promises.deleteDoc(projectId, docId)
  1547. },
  1548. }
  1549. /**
  1550. * List all descendants of an entity along with their type and path. Include
  1551. * the top-level entity as well.
  1552. */
  1553. function _listSubtree(entity, entityType, entityPath) {
  1554. if (entityType.indexOf('file') !== -1) {
  1555. return [{ type: 'file', entity, path: entityPath }]
  1556. } else if (entityType.indexOf('doc') !== -1) {
  1557. return [{ type: 'doc', entity, path: entityPath }]
  1558. } else if (entityType.indexOf('folder') !== -1) {
  1559. const listing = []
  1560. const _recurseFolder = (folder, folderPath) => {
  1561. listing.push({ type: 'folder', entity: folder, path: folderPath })
  1562. for (const doc of iterablePaths(folder, 'docs')) {
  1563. listing.push({
  1564. type: 'doc',
  1565. entity: doc,
  1566. path: Path.join(folderPath, doc.name),
  1567. })
  1568. }
  1569. for (const file of iterablePaths(folder, 'fileRefs')) {
  1570. listing.push({
  1571. type: 'file',
  1572. entity: file,
  1573. path: Path.join(folderPath, file.name),
  1574. })
  1575. }
  1576. for (const childFolder of iterablePaths(folder, 'folders')) {
  1577. _recurseFolder(childFolder, Path.join(folderPath, childFolder.name))
  1578. }
  1579. }
  1580. _recurseFolder(entity, entityPath)
  1581. return listing
  1582. } else {
  1583. // This shouldn't happen, but if it does, fail silently.
  1584. return []
  1585. }
  1586. }
  1587. export default ProjectEntityUpdateHandler