ConversionController.js 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257
  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 { z } from '@overleaf/validation-tools'
  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 Errors.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. const PDFToJPEGQuerySchema = z.object({
  76. mode: z.enum(['preview', 'thumbnail']),
  77. })
  78. async function convertPDFToJPEG(req, res) {
  79. const { path } = req.file
  80. if (!Settings.enablePdfConversions) {
  81. await fs.unlink(path).catch(() => {})
  82. return res.sendStatus(404)
  83. }
  84. const parsed = PDFToJPEGQuerySchema.safeParse(req.query)
  85. if (!parsed.success) {
  86. await fs.unlink(path).catch(() => {})
  87. return res.sendStatus(400)
  88. }
  89. const { mode } = parsed.data
  90. logger.debug({ path, mode }, 'received pdf for conversion to jpeg')
  91. const conversionId = crypto.randomUUID()
  92. let jpegPath
  93. try {
  94. jpegPath = await ConversionManager.promises.convertPDFToJPEGWithLock(
  95. conversionId,
  96. path,
  97. mode
  98. )
  99. } finally {
  100. await fs.unlink(path).catch(() => {})
  101. }
  102. try {
  103. const jpegStat = await fs.stat(jpegPath)
  104. res.setHeader('Content-Length', jpegStat.size)
  105. res.attachment('output.jpg')
  106. res.setHeader('X-Content-Type-Options', 'nosniff')
  107. const readStream = fsSync.createReadStream(jpegPath)
  108. await pipeline(readStream, res)
  109. } finally {
  110. await fs
  111. .rm(Path.dirname(jpegPath), { recursive: true, force: true })
  112. .catch(() => {})
  113. }
  114. }
  115. async function convertProjectToDocument(req, res) {
  116. if (!Settings.enablePandocConversions) {
  117. return res.sendStatus(404)
  118. }
  119. const { user_id: userId, project_id: projectId } = req.params
  120. const type = req.query.type
  121. if (!Object.hasOwn(CONVERSION_CONFIGS, type)) {
  122. return res.sendStatus(400)
  123. }
  124. const config = CONVERSION_CONFIGS[type]
  125. const request = await RequestParser.promises.parse(req.body)
  126. request.project_id = projectId
  127. request.user_id = userId
  128. request.metricsOpts = {}
  129. const responseFormat = req.query.responseFormat === 'json' ? 'json' : 'stream'
  130. const conversionId = crypto.randomUUID()
  131. const conversionDir = Path.join(Settings.path.compilesDir, conversionId)
  132. const conversionCacheDir = Path.join(Settings.path.clsiCacheDir, conversionId)
  133. const projectCacheDir = Path.join(Settings.path.clsiCacheDir, projectId)
  134. const cleanupDirs = [conversionCacheDir, conversionDir]
  135. logger.debug(
  136. {
  137. projectId,
  138. userId,
  139. rootResourcePath: request.rootResourcePath,
  140. type,
  141. },
  142. 'syncing resources for project-to-document conversion'
  143. )
  144. Metrics.inc('convert_project_to_document', 1, {
  145. compileFromHistory: request.isCompileFromHistory,
  146. method: type,
  147. })
  148. try {
  149. if (await fs.mkdir(projectCacheDir, { recursive: true })) {
  150. // Newly created. Cleanup behind us.
  151. cleanupDirs.push(projectCacheDir)
  152. }
  153. if (request.isCompileFromHistory) {
  154. await fs.mkdir(conversionDir)
  155. try {
  156. await HistoryResourceWriter.syncResourcesToDisk(
  157. projectId,
  158. userId,
  159. request,
  160. conversionDir,
  161. {}
  162. )
  163. } catch (err) {
  164. if (err instanceof Errors.MissingUpdatesError) {
  165. return res.status(409).json({
  166. baseHistoryVersion: err.info.baseHistoryVersion,
  167. })
  168. }
  169. throw err
  170. }
  171. } else {
  172. await ResourceWriter.promises.syncResourcesToDisk(request, conversionDir)
  173. }
  174. const documentPath =
  175. await ConversionManager.promises.convertLaTeXToDocumentInDirWithLock(
  176. conversionId,
  177. conversionDir,
  178. request.rootResourcePath,
  179. type
  180. )
  181. const outputName = `output.${config.extension}`
  182. if (responseFormat === 'json') {
  183. // TODO: drop the streaming branch once web is migrated to the two-step flow
  184. const buildId = await OutputCacheManager.promises.generateBuildId()
  185. const buildDir = Path.join(
  186. Settings.path.outputDir,
  187. conversionId,
  188. OutputCacheManager.CACHE_SUBDIR,
  189. buildId
  190. )
  191. try {
  192. await fs.mkdir(buildDir, { recursive: true })
  193. await fs.copyFile(documentPath, Path.join(buildDir, outputName))
  194. res.json({ conversionId, buildId, file: outputName })
  195. } finally {
  196. ConversionOutputCleaner.scheduleCleanup(conversionId)
  197. }
  198. } else {
  199. const documentStat = await fs.stat(documentPath)
  200. res.setHeader('Content-Length', documentStat.size)
  201. res.attachment(outputName)
  202. res.setHeader('X-Content-Type-Options', 'nosniff')
  203. const readStream = fsSync.createReadStream(documentPath)
  204. await pipeline(readStream, res)
  205. }
  206. } catch (err) {
  207. if (err instanceof Errors.ConversionError) {
  208. if (err.isUserFacing) {
  209. return res.status(422).json({
  210. error: err.stderr,
  211. exitCode: err.exitCode,
  212. })
  213. } else {
  214. logger.warn(
  215. { err, type, stderr: err.stderr },
  216. 'Conversion failed with non-user-facing error'
  217. )
  218. return res.status(422).json({})
  219. }
  220. } else {
  221. throw err
  222. }
  223. } finally {
  224. for (const dir of cleanupDirs) {
  225. try {
  226. await fs.rm(dir, { recursive: true, force: true })
  227. } catch (err) {
  228. logger.warn({ err, dir }, 'cleanup failed')
  229. }
  230. }
  231. }
  232. }
  233. export default {
  234. convertDocumentToLaTeX: expressify(convertDocumentToLaTeX),
  235. convertProjectToDocument: expressify(convertProjectToDocument),
  236. convertPDFToJPEG: expressify(convertPDFToJPEG),
  237. }