ProjectDuplicator.js 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312
  1. const { callbackify } = require('util')
  2. const Path = require('path')
  3. const logger = require('@overleaf/logger')
  4. const OError = require('@overleaf/o-error')
  5. const { promiseMapWithLimit } = require('@overleaf/promise-utils')
  6. const { Doc } = require('../../models/Doc')
  7. const { File } = require('../../models/File')
  8. const DocstoreManager = require('../Docstore/DocstoreManager')
  9. const DocumentUpdaterHandler = require('../DocumentUpdater/DocumentUpdaterHandler')
  10. const FileStoreHandler = require('../FileStore/FileStoreHandler')
  11. const HistoryManager = require('../History/HistoryManager')
  12. const ProjectCreationHandler = require('./ProjectCreationHandler')
  13. const ProjectDeleter = require('./ProjectDeleter')
  14. const ProjectEntityMongoUpdateHandler = require('./ProjectEntityMongoUpdateHandler')
  15. const ProjectEntityUpdateHandler = require('./ProjectEntityUpdateHandler')
  16. const ProjectGetter = require('./ProjectGetter')
  17. const ProjectLocator = require('./ProjectLocator')
  18. const ProjectOptionsHandler = require('./ProjectOptionsHandler')
  19. const SafePath = require('./SafePath')
  20. const TpdsProjectFlusher = require('../ThirdPartyDataStore/TpdsProjectFlusher')
  21. const _ = require('lodash')
  22. const TagsHandler = require('../Tags/TagsHandler')
  23. const Features = require('../../infrastructure/Features')
  24. const ClsiCacheManager = require('../Compile/ClsiCacheManager')
  25. module.exports = {
  26. duplicate: callbackify(duplicate),
  27. promises: {
  28. duplicate,
  29. },
  30. }
  31. async function duplicate(owner, originalProjectId, newProjectName, tags = []) {
  32. await DocumentUpdaterHandler.promises.flushProjectToMongo(originalProjectId)
  33. const originalProject = await ProjectGetter.promises.getProject(
  34. originalProjectId,
  35. {
  36. compiler: true,
  37. imageName: true,
  38. rootFolder: true,
  39. rootDoc_id: true,
  40. fromV1TemplateId: true,
  41. fromV1TemplateVersionId: true,
  42. overleaf: true,
  43. }
  44. )
  45. const { path: rootDocPath } = await ProjectLocator.promises.findRootDoc({
  46. project_id: originalProjectId,
  47. })
  48. const originalEntries = _getFolderEntries(originalProject.rootFolder[0])
  49. // Pass template ID as analytics segmentation if duplicating project from a template
  50. const segmentation = _.pick(originalProject, [
  51. 'fromV1TemplateId',
  52. 'fromV1TemplateVersionId',
  53. ])
  54. segmentation.duplicatedFromProject = originalProjectId
  55. // count the number of tags before and after, for analytics
  56. segmentation['original-tags'] =
  57. await TagsHandler.promises.countTagsForProject(
  58. owner._id,
  59. originalProject._id
  60. )
  61. segmentation['updated-tags'] = tags.length
  62. // remove any leading or trailing spaces
  63. newProjectName = newProjectName.trim()
  64. // Now create the new project, cleaning it up on failure if necessary
  65. const newProject = await ProjectCreationHandler.promises.createBlankProject(
  66. owner._id,
  67. newProjectName,
  68. { segmentation }
  69. )
  70. let prepareClsiCacheInBackground = Promise.resolve()
  71. if (originalProject.imageName === newProject.imageName) {
  72. // Populate the clsi-cache unless the TeXLive release has changed.
  73. prepareClsiCacheInBackground = ClsiCacheManager.prepareClsiCache(
  74. newProject._id,
  75. owner._id,
  76. { sourceProjectId: originalProjectId }
  77. ).catch(err => {
  78. logger.warn(
  79. { err, originalProjectId, projectId: newProject._id },
  80. 'failed to prepare clsi-cache for cloned project'
  81. )
  82. })
  83. }
  84. try {
  85. await ProjectOptionsHandler.promises.setCompiler(
  86. newProject._id,
  87. originalProject.compiler
  88. )
  89. const [docEntries, fileEntries] = await Promise.all([
  90. _copyDocs(originalEntries.docEntries, originalProject, newProject),
  91. _copyFiles(originalEntries.fileEntries, originalProject, newProject),
  92. ])
  93. const projectVersion =
  94. await ProjectEntityMongoUpdateHandler.promises.createNewFolderStructure(
  95. newProject._id,
  96. docEntries,
  97. fileEntries
  98. )
  99. // Silently ignore the rootDoc in case it's not valid per the new limits.
  100. if (
  101. rootDocPath &&
  102. ProjectEntityUpdateHandler.isPathValidForRootDoc(rootDocPath.fileSystem)
  103. ) {
  104. await _setRootDoc(newProject._id, rootDocPath.fileSystem)
  105. }
  106. await _notifyDocumentUpdater(newProject, owner._id, {
  107. newFiles: fileEntries,
  108. newDocs: docEntries,
  109. newProject: { version: projectVersion },
  110. })
  111. await TpdsProjectFlusher.promises.flushProjectToTpds(newProject._id)
  112. if (tags?.length > 0) {
  113. await TagsHandler.promises.addProjectToTags(
  114. owner._id,
  115. tags.map(tag => tag.id),
  116. newProject._id
  117. )
  118. }
  119. } catch (err) {
  120. // Clean up broken clone on error.
  121. // Make sure we delete the new failed project, not the original one!
  122. await ProjectDeleter.promises.deleteProject(newProject._id)
  123. throw OError.tag(err, 'error cloning project, broken clone deleted', {
  124. originalProjectId,
  125. newProjectName,
  126. newProjectId: newProject._id,
  127. })
  128. }
  129. try {
  130. await prepareClsiCacheInBackground
  131. } catch {}
  132. return newProject
  133. }
  134. function _getFolderEntries(folder, folderPath = '/') {
  135. const docEntries = []
  136. const fileEntries = []
  137. const docs = folder.docs || []
  138. const files = folder.fileRefs || []
  139. const subfolders = folder.folders || []
  140. for (const doc of docs) {
  141. if (doc == null || doc._id == null) {
  142. continue
  143. }
  144. const path = Path.join(folderPath, doc.name)
  145. docEntries.push({ doc, path })
  146. }
  147. for (const file of files) {
  148. if (file == null || file._id == null) {
  149. continue
  150. }
  151. const path = Path.join(folderPath, file.name)
  152. fileEntries.push({ file, path })
  153. }
  154. for (const subfolder of subfolders) {
  155. if (subfolder == null || subfolder._id == null) {
  156. continue
  157. }
  158. const subfolderPath = Path.join(folderPath, subfolder.name)
  159. const subfolderEntries = _getFolderEntries(subfolder, subfolderPath)
  160. for (const docEntry of subfolderEntries.docEntries) {
  161. docEntries.push(docEntry)
  162. }
  163. for (const fileEntry of subfolderEntries.fileEntries) {
  164. fileEntries.push(fileEntry)
  165. }
  166. }
  167. return { docEntries, fileEntries }
  168. }
  169. async function _copyDocs(sourceEntries, sourceProject, targetProject) {
  170. const docLinesById = await _getDocLinesForProject(sourceProject._id)
  171. const targetEntries = []
  172. for (const sourceEntry of sourceEntries) {
  173. const sourceDoc = sourceEntry.doc
  174. const path = sourceEntry.path
  175. const doc = new Doc({ name: sourceDoc.name })
  176. const docLines = docLinesById.get(sourceDoc._id.toString())
  177. await DocstoreManager.promises.updateDoc(
  178. targetProject._id.toString(),
  179. doc._id.toString(),
  180. docLines,
  181. 0,
  182. {}
  183. )
  184. targetEntries.push({ doc, path, docLines: docLines.join('\n') })
  185. }
  186. return targetEntries
  187. }
  188. async function _getDocLinesForProject(projectId) {
  189. const docs = await DocstoreManager.promises.getAllDocs(projectId)
  190. const docLinesById = new Map(docs.map(doc => [doc._id, doc.lines]))
  191. return docLinesById
  192. }
  193. async function _copyFiles(sourceEntries, sourceProject, targetProject) {
  194. const sourceHistoryId = sourceProject.overleaf?.history?.id
  195. const targetHistoryId = targetProject.overleaf?.history?.id
  196. if (!sourceHistoryId) {
  197. throw new OError('missing history id', { sourceProject })
  198. }
  199. if (!targetHistoryId) {
  200. throw new OError('missing history id', { targetProject })
  201. }
  202. const targetEntries = await promiseMapWithLimit(
  203. 5,
  204. sourceEntries,
  205. async sourceEntry => {
  206. const sourceFile = sourceEntry.file
  207. const path = sourceEntry.path
  208. const file = new File({ name: SafePath.clean(sourceFile.name) })
  209. if (sourceFile.linkedFileData != null) {
  210. file.linkedFileData = sourceFile.linkedFileData
  211. file.created = sourceFile.created
  212. }
  213. if (sourceFile.hash != null) {
  214. file.hash = sourceFile.hash
  215. }
  216. let createdBlob = false
  217. const usingFilestore = Features.hasFeature('filestore')
  218. if (file.hash != null && Features.hasFeature('project-history-blobs')) {
  219. try {
  220. await HistoryManager.promises.copyBlob(
  221. sourceHistoryId,
  222. targetHistoryId,
  223. file.hash
  224. )
  225. createdBlob = true
  226. if (!usingFilestore) {
  227. return { createdBlob, file, path, url: null }
  228. }
  229. } catch (err) {
  230. if (!usingFilestore) {
  231. throw OError.tag(err, 'unexpected error copying blob', {
  232. sourceProjectId: sourceProject._id,
  233. targetProjectId: targetProject._id,
  234. sourceFile,
  235. sourceHistoryId,
  236. })
  237. } else {
  238. logger.error(
  239. {
  240. err,
  241. sourceProjectId: sourceProject._id,
  242. targetProjectId: targetProject._id,
  243. sourceFile,
  244. sourceHistoryId,
  245. },
  246. 'unexpected error copying blob'
  247. )
  248. }
  249. }
  250. }
  251. if (createdBlob && Features.hasFeature('project-history-blobs')) {
  252. return { createdBlob, file, path, url: null }
  253. }
  254. if (!usingFilestore) {
  255. // Note: This is also checked in app.mjs
  256. throw new OError(
  257. 'bad config: need to enable either filestore or project-history-blobs'
  258. )
  259. }
  260. const url = await FileStoreHandler.promises.copyFile(
  261. sourceProject._id,
  262. sourceFile._id,
  263. targetProject._id,
  264. file._id
  265. )
  266. return { createdBlob, file, path, url }
  267. }
  268. )
  269. return targetEntries
  270. }
  271. async function _setRootDoc(projectId, path) {
  272. const { element: rootDoc } = await ProjectLocator.promises.findElementByPath({
  273. project_id: projectId,
  274. path,
  275. exactCaseMatch: true,
  276. })
  277. await ProjectEntityUpdateHandler.promises.setRootDoc(projectId, rootDoc._id)
  278. }
  279. async function _notifyDocumentUpdater(project, userId, changes) {
  280. const projectHistoryId =
  281. project.overleaf && project.overleaf.history && project.overleaf.history.id
  282. await DocumentUpdaterHandler.promises.updateProjectStructure(
  283. project._id,
  284. projectHistoryId,
  285. userId,
  286. changes,
  287. null
  288. )
  289. }