ProjectUploadManager.mjs 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297
  1. import Path from 'node:path'
  2. import fs from 'node:fs'
  3. import { callbackify } from 'node:util'
  4. import ArchiveManager from './ArchiveManager.mjs'
  5. import { Doc } from '../../models/Doc.mjs'
  6. import DocstoreManager from '../Docstore/DocstoreManager.mjs'
  7. import DocumentHelper from '../Documents/DocumentHelper.mjs'
  8. import DocumentUpdaterHandler from '../DocumentUpdater/DocumentUpdaterHandler.mjs'
  9. import FileStoreHandler from '../FileStore/FileStoreHandler.mjs'
  10. import FileSystemImportManager from './FileSystemImportManager.mjs'
  11. import ProjectCreationHandler from '../Project/ProjectCreationHandler.mjs'
  12. import ProjectEntityMongoUpdateHandler from '../Project/ProjectEntityMongoUpdateHandler.mjs'
  13. import ProjectRootDocManager from '../Project/ProjectRootDocManager.mjs'
  14. import ProjectDetailsHandler from '../Project/ProjectDetailsHandler.mjs'
  15. import ProjectDeleter from '../Project/ProjectDeleter.mjs'
  16. import { DeletedProjectReasons } from '../Project/DeletedProjectReasons.mjs'
  17. import TpdsProjectFlusher from '../ThirdPartyDataStore/TpdsProjectFlusher.mjs'
  18. import logger from '@overleaf/logger'
  19. import OError from '@overleaf/o-error'
  20. export default {
  21. createProjectFromZipArchive: callbackify(createProjectFromZipArchive),
  22. createProjectFromZipArchiveWithName: callbackify(
  23. createProjectFromZipArchiveWithName
  24. ),
  25. promises: {
  26. createProjectFromZipArchive,
  27. createProjectFromZipArchiveWithName,
  28. },
  29. }
  30. async function createProjectFromZipArchive(ownerId, defaultName, zipPath) {
  31. const contentsPath = await _extractZip(zipPath)
  32. try {
  33. const { path, content } =
  34. await ProjectRootDocManager.promises.findRootDocFileFromDirectory(
  35. contentsPath
  36. )
  37. const projectName =
  38. DocumentHelper.getTitleFromTexContent(content || '') || defaultName
  39. const uniqueName = await _generateUniqueName(ownerId, projectName)
  40. const project = await ProjectCreationHandler.promises.createBlankProject(
  41. ownerId,
  42. uniqueName
  43. )
  44. try {
  45. await _initializeProjectWithZipContents(ownerId, project, contentsPath)
  46. if (path) {
  47. await ProjectRootDocManager.promises.setRootDocFromName(
  48. project._id,
  49. path
  50. )
  51. }
  52. } catch (err) {
  53. // no need to wait for the cleanup here
  54. ProjectDeleter.promises
  55. .deleteProject(project._id, {
  56. deletedReason: DeletedProjectReasons.ZIP_IMPORT_FAILURE,
  57. })
  58. .catch(err =>
  59. logger.error(
  60. { err, projectId: project._id },
  61. 'there was an error cleaning up project after importing a zip failed'
  62. )
  63. )
  64. throw err
  65. }
  66. return project
  67. } finally {
  68. await fs.promises.rm(contentsPath, { recursive: true, force: true })
  69. }
  70. }
  71. async function createProjectFromZipArchiveWithName(
  72. ownerId,
  73. proposedName,
  74. zipPath,
  75. attributes = {}
  76. ) {
  77. const contentsPath = await _extractZip(zipPath)
  78. try {
  79. const uniqueName = await _generateUniqueName(ownerId, proposedName)
  80. const project = await ProjectCreationHandler.promises.createBlankProject(
  81. ownerId,
  82. uniqueName,
  83. attributes
  84. )
  85. try {
  86. const { fileEntries, docEntries } =
  87. await _initializeProjectWithZipContents(ownerId, project, contentsPath)
  88. const rootDocId =
  89. await ProjectRootDocManager.promises.setRootDocAutomatically(
  90. project._id
  91. )
  92. if (rootDocId) project.rootDoc_id = rootDocId
  93. return { fileEntries, docEntries, project }
  94. } catch (err) {
  95. // no need to wait for the cleanup here
  96. ProjectDeleter.promises
  97. .deleteProject(project._id, {
  98. deletedReason: DeletedProjectReasons.ZIP_IMPORT_FAILURE,
  99. })
  100. .catch(err =>
  101. logger.error(
  102. { err, projectId: project._id },
  103. 'there was an error cleaning up project after importing a zip failed'
  104. )
  105. )
  106. throw err
  107. }
  108. } finally {
  109. await fs.promises.rm(contentsPath, { recursive: true, force: true })
  110. }
  111. }
  112. async function _extractZip(zipPath) {
  113. const destination = Path.join(
  114. Path.dirname(zipPath),
  115. `${Path.basename(zipPath, '.zip')}-${Date.now()}`
  116. )
  117. try {
  118. await ArchiveManager.promises.extractZipArchive(zipPath, destination)
  119. } catch (error) {
  120. logger.debug({ zipPath, error }, 'error extracting from zip archive')
  121. await fs.promises.rm(destination, { recursive: true, force: true })
  122. throw error
  123. }
  124. return destination
  125. }
  126. async function _generateUniqueName(ownerId, originalName) {
  127. const fixedName = ProjectDetailsHandler.fixProjectName(originalName)
  128. const uniqueName = await ProjectDetailsHandler.promises.generateUniqueName(
  129. ownerId,
  130. fixedName
  131. )
  132. return uniqueName
  133. }
  134. async function _initializeProjectWithZipContents(
  135. ownerId,
  136. project,
  137. contentsPath
  138. ) {
  139. const topLevelDir =
  140. await ArchiveManager.promises.findTopLevelDirectory(contentsPath)
  141. const importEntries =
  142. await FileSystemImportManager.promises.importDir(topLevelDir)
  143. const { fileEntries, docEntries } = await _createEntriesFromImports(
  144. project,
  145. importEntries
  146. )
  147. const projectVersion =
  148. await ProjectEntityMongoUpdateHandler.promises.createNewFolderStructure(
  149. project._id,
  150. docEntries,
  151. fileEntries
  152. )
  153. await _notifyDocumentUpdater(project, ownerId, {
  154. newFiles: fileEntries,
  155. newDocs: docEntries,
  156. newProject: { version: projectVersion },
  157. })
  158. await TpdsProjectFlusher.promises.flushProjectToTpds(project._id)
  159. return { fileEntries, docEntries }
  160. }
  161. /**
  162. * Create project docs and files using concurrent workers.
  163. * Returns the created entries grouped by type, in the correct order.
  164. */
  165. async function _createEntriesFromImports(project, importEntries) {
  166. const createdEntries = new Array(importEntries.length)
  167. let nextIndex = 0
  168. let firstError
  169. async function worker() {
  170. while (firstError == null) {
  171. const currentIndex = nextIndex
  172. nextIndex++
  173. if (currentIndex >= importEntries.length) {
  174. return
  175. }
  176. try {
  177. createdEntries[currentIndex] = await _createEntryFromImport(
  178. project,
  179. importEntries[currentIndex]
  180. )
  181. } catch (error) {
  182. firstError = firstError ?? error
  183. return
  184. }
  185. }
  186. }
  187. await Promise.allSettled(
  188. Array.from({ length: Math.min(5, importEntries.length) }, () => worker())
  189. )
  190. if (firstError != null) {
  191. throw firstError
  192. }
  193. const fileEntries = []
  194. const docEntries = []
  195. for (const createdEntry of createdEntries) {
  196. switch (createdEntry.type) {
  197. case 'doc': {
  198. docEntries.push(createdEntry.entry)
  199. break
  200. }
  201. case 'file': {
  202. fileEntries.push(createdEntry.entry)
  203. break
  204. }
  205. }
  206. }
  207. return { fileEntries, docEntries }
  208. }
  209. async function _createEntryFromImport(project, importEntry) {
  210. switch (importEntry.type) {
  211. case 'doc': {
  212. return {
  213. type: 'doc',
  214. entry: await _createDoc(
  215. project,
  216. importEntry.projectPath,
  217. importEntry.lines
  218. ),
  219. }
  220. }
  221. case 'file': {
  222. return {
  223. type: 'file',
  224. entry: await _createFile(
  225. project,
  226. importEntry.projectPath,
  227. importEntry.fsPath
  228. ),
  229. }
  230. }
  231. default: {
  232. throw new Error(`Invalid import type: ${importEntry.type}`)
  233. }
  234. }
  235. }
  236. async function _createDoc(project, projectPath, docLines) {
  237. const projectId = project._id
  238. const docName = Path.basename(projectPath)
  239. const doc = new Doc({ name: docName })
  240. await DocstoreManager.promises.updateDoc(
  241. projectId.toString(),
  242. doc._id.toString(),
  243. docLines,
  244. 0,
  245. {}
  246. )
  247. return { doc, path: projectPath, docLines: docLines.join('\n') }
  248. }
  249. async function _createFile(project, projectPath, fsPath) {
  250. const projectId = project._id
  251. const historyId = project.overleaf?.history?.id
  252. if (!historyId) {
  253. throw new OError('missing history id')
  254. }
  255. const fileName = Path.basename(projectPath)
  256. const { createdBlob, fileRef } =
  257. await FileStoreHandler.promises.uploadFileFromDiskWithHistoryId(
  258. projectId,
  259. historyId,
  260. { name: fileName },
  261. fsPath
  262. )
  263. return { createdBlob, file: fileRef, path: projectPath }
  264. }
  265. async function _notifyDocumentUpdater(project, userId, changes) {
  266. const projectHistoryId =
  267. project.overleaf && project.overleaf.history && project.overleaf.history.id
  268. await DocumentUpdaterHandler.promises.updateProjectStructure(
  269. project._id,
  270. projectHistoryId,
  271. userId,
  272. changes,
  273. null
  274. )
  275. }