ConversionController.js 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210
  1. import crypto from 'node:crypto'
  2. import logger from '@overleaf/logger'
  3. import { expressify } from '@overleaf/promise-utils'
  4. import fs from 'node:fs/promises'
  5. import fsSync from 'node:fs'
  6. import Metrics from '@overleaf/metrics'
  7. import * as HistoryResourceWriter from './HistoryResourceWriter.js'
  8. import Errors from './Errors.js'
  9. import ConversionManager from './ConversionManager.js'
  10. import ConversionOutputCleaner from './ConversionOutputCleaner.js'
  11. import OutputCacheManager from './OutputCacheManager.js'
  12. import ResourceWriter from './ResourceWriter.js'
  13. import RequestParser from './RequestParser.js'
  14. import { pipeline } from 'node:stream/promises'
  15. import Settings from '@overleaf/settings'
  16. import Path from 'node:path'
  17. const CONVERSION_CONFIGS = {
  18. docx: { extension: 'docx' },
  19. markdown: { extension: 'zip' },
  20. }
  21. async function convertDocumentToLaTeX(req, res) {
  22. const { path } = req.file
  23. const conversionType = req.query.type
  24. if (!Settings.enablePandocConversions) {
  25. await fs.unlink(path).catch(() => {})
  26. return res.sendStatus(404)
  27. }
  28. if (!conversionType || !['docx', 'markdown'].includes(conversionType)) {
  29. await fs.unlink(path).catch(() => {})
  30. return res.sendStatus(400)
  31. }
  32. logger.debug({ path, conversionType }, 'received file for conversion')
  33. const conversionId = crypto.randomUUID()
  34. let zipPath
  35. try {
  36. zipPath = await ConversionManager.promises.convertToLaTeXWithLock(
  37. conversionId,
  38. path,
  39. conversionType
  40. )
  41. } catch (err) {
  42. if (err instanceof Errors.ConversionError) {
  43. if (err.isUserFacing) {
  44. return res.status(422).json({
  45. error: err.stderr,
  46. exitCode: err.exitCode,
  47. })
  48. } else {
  49. logger.warn(
  50. { err, conversionType, stderr: err.stderr },
  51. 'Conversion failed with non-user-facing error'
  52. )
  53. return res.status(422).json({})
  54. }
  55. } else {
  56. throw err
  57. }
  58. } finally {
  59. await fs.unlink(path).catch(() => {})
  60. }
  61. try {
  62. const zipStat = await fs.stat(zipPath)
  63. res.setHeader('Content-Length', zipStat.size)
  64. res.attachment('conversion.zip')
  65. res.setHeader('X-Content-Type-Options', 'nosniff')
  66. const readStream = fsSync.createReadStream(zipPath)
  67. await pipeline(readStream, res)
  68. } finally {
  69. await fs
  70. .rm(Path.dirname(zipPath), { recursive: true, force: true })
  71. .catch(() => {})
  72. }
  73. }
  74. async function convertProjectToDocument(req, res) {
  75. if (!Settings.enablePandocConversions) {
  76. return res.sendStatus(404)
  77. }
  78. const { user_id: userId, project_id: projectId } = req.params
  79. const type = req.query.type
  80. if (!Object.hasOwn(CONVERSION_CONFIGS, type)) {
  81. return res.sendStatus(400)
  82. }
  83. const config = CONVERSION_CONFIGS[type]
  84. const request = await RequestParser.promises.parse(req.body)
  85. request.project_id = projectId
  86. request.user_id = userId
  87. request.metricsOpts = {}
  88. const responseFormat = req.query.responseFormat === 'json' ? 'json' : 'stream'
  89. const conversionId = crypto.randomUUID()
  90. const conversionDir = Path.join(Settings.path.compilesDir, conversionId)
  91. const conversionCacheDir = Path.join(Settings.path.clsiCacheDir, conversionId)
  92. const projectCacheDir = Path.join(Settings.path.clsiCacheDir, projectId)
  93. const cleanupDirs = [conversionCacheDir, conversionDir]
  94. logger.debug(
  95. {
  96. projectId,
  97. userId,
  98. rootResourcePath: request.rootResourcePath,
  99. type,
  100. },
  101. 'syncing resources for project-to-document conversion'
  102. )
  103. Metrics.inc('convert_project_to_document', 1, {
  104. compileFromHistory: request.isCompileFromHistory,
  105. method: type,
  106. })
  107. try {
  108. if (await fs.mkdir(projectCacheDir, { recursive: true })) {
  109. // Newly created. Cleanup behind us.
  110. cleanupDirs.push(projectCacheDir)
  111. }
  112. if (request.isCompileFromHistory) {
  113. await fs.mkdir(conversionDir)
  114. try {
  115. await HistoryResourceWriter.syncResourcesToDisk(
  116. projectId,
  117. userId,
  118. request,
  119. conversionDir,
  120. {}
  121. )
  122. } catch (err) {
  123. if (err instanceof Errors.MissingUpdatesError) {
  124. return res.status(409).json({
  125. baseHistoryVersion: err.info.baseHistoryVersion,
  126. })
  127. }
  128. throw err
  129. }
  130. } else {
  131. await ResourceWriter.promises.syncResourcesToDisk(request, conversionDir)
  132. }
  133. const documentPath =
  134. await ConversionManager.promises.convertLaTeXToDocumentInDirWithLock(
  135. conversionId,
  136. conversionDir,
  137. request.rootResourcePath,
  138. type
  139. )
  140. const outputName = `output.${config.extension}`
  141. if (responseFormat === 'json') {
  142. // TODO: drop the streaming branch once web is migrated to the two-step flow
  143. const buildId = await OutputCacheManager.promises.generateBuildId()
  144. const buildDir = Path.join(
  145. Settings.path.outputDir,
  146. conversionId,
  147. OutputCacheManager.CACHE_SUBDIR,
  148. buildId
  149. )
  150. try {
  151. await fs.mkdir(buildDir, { recursive: true })
  152. await fs.copyFile(documentPath, Path.join(buildDir, outputName))
  153. res.json({ conversionId, buildId, file: outputName })
  154. } finally {
  155. ConversionOutputCleaner.scheduleCleanup(conversionId)
  156. }
  157. } else {
  158. const documentStat = await fs.stat(documentPath)
  159. res.setHeader('Content-Length', documentStat.size)
  160. res.attachment(outputName)
  161. res.setHeader('X-Content-Type-Options', 'nosniff')
  162. const readStream = fsSync.createReadStream(documentPath)
  163. await pipeline(readStream, res)
  164. }
  165. } catch (err) {
  166. if (err instanceof Errors.ConversionError) {
  167. if (err.isUserFacing) {
  168. return res.status(422).json({
  169. error: err.stderr,
  170. exitCode: err.exitCode,
  171. })
  172. } else {
  173. logger.warn(
  174. { err, type, stderr: err.stderr },
  175. 'Conversion failed with non-user-facing error'
  176. )
  177. return res.status(422).json({})
  178. }
  179. } else {
  180. throw err
  181. }
  182. } finally {
  183. for (const dir of cleanupDirs) {
  184. try {
  185. await fs.rm(dir, { recursive: true, force: true })
  186. } catch (err) {
  187. logger.warn({ err, dir }, 'cleanup failed')
  188. }
  189. }
  190. }
  191. }
  192. export default {
  193. convertDocumentToLaTeX: expressify(convertDocumentToLaTeX),
  194. convertProjectToDocument: expressify(convertProjectToDocument),
  195. }