ConversionController.js 7.5 KB

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