ConversionController.js 6.3 KB

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