Merge pull request #33341 from overleaf/mj-two-step-export-web

[web] Add two-step pandoc conversion download

GitOrigin-RevId: 093f435a497a7583d2b4d23558415cc442f84553
This commit is contained in:
Mathias Jakobsen
2026-05-12 08:06:13 +00:00
committed by Copybot
parent 64d706f114
commit 62d92b70dd
12 changed files with 554 additions and 82 deletions
@@ -444,7 +444,7 @@ async function _makeRequest(
timer.done()
let newClsiServerId
if (CLSI_COOKIES_ENABLED) {
newClsiServerId = _getClsiServerIdFromResponse(response)
newClsiServerId = getClsiServerIdFromResponse(response)
await ClsiCookieManager.promises.setServerId(
projectId,
userId,
@@ -603,7 +603,7 @@ async function _makeNewBackendRequest(
timer.done()
let newClsiServerId
if (CLSI_COOKIES_ENABLED) {
newClsiServerId = _getClsiServerIdFromResponse(response)
newClsiServerId = getClsiServerIdFromResponse(response)
await NewBackendCloudClsiCookieManager.promises.setServerId(
projectId,
userId,
@@ -1268,7 +1268,7 @@ async function syncTeX(
}
}
function _getClsiServerIdFromResponse(response) {
function getClsiServerIdFromResponse(response) {
const setCookieHeaders = response.headers.raw()['set-cookie'] ?? []
for (const header of setCookieHeaders) {
const cookie = Cookie.parse(header)
@@ -1307,6 +1307,8 @@ export default {
getOutputFileStream: callbackify(getOutputFileStream),
wordCount: callbackify(wordCount),
syncTeX: callbackify(syncTeX),
getClsiServerIdFromResponse,
CLSI_COOKIES_ENABLED,
promises: {
sendRequest,
sendExternalRequest,
@@ -1,4 +1,5 @@
import Metrics from '@overleaf/metrics'
import Settings from '@overleaf/settings'
import ProjectGetter from '../Project/ProjectGetter.mjs'
import ProjectZipStreamManager from './ProjectZipStreamManager.mjs'
import DocumentUpdaterHandler from '../DocumentUpdater/DocumentUpdaterHandler.mjs'
@@ -6,56 +7,130 @@ import { prepareZipAttachment } from '../../infrastructure/Response.mjs'
import SessionManager from '../Authentication/SessionManager.mjs'
import ProjectAuditLogHandler from '../Project/ProjectAuditLogHandler.mjs'
import DocumentConversionManager from '../Uploads/DocumentConversionManager.mjs'
import Validation from '../../infrastructure/Validation.mjs'
import { expressify } from '@overleaf/promise-utils'
import { pipeline } from 'node:stream/promises'
const { z, zz, parseReq } = Validation
const SUPPORTED_CONVERSION_TYPES = new Map([
['docx', 'docx'],
['markdown', 'zip'],
])
const exportProjectConversionSchema = z.object({
params: z.object({
Project_id: zz.objectId(),
type: z.enum([...SUPPORTED_CONVERSION_TYPES.keys()]),
}),
query: z.object({
responseFormat: z.enum(['json', 'stream']).optional().default('stream'),
}),
})
const downloadPreparedProjectExportSchema = z.object({
params: z.object({
Project_id: zz.objectId(),
buildId: zz.buildId(),
conversionId: z.uuid(),
file: zz.filepath(),
type: z.enum([...SUPPORTED_CONVERSION_TYPES.keys()]),
}),
query: z.object({
clsiserverid: zz.clsiServerId().optional(),
}),
})
// Keep in sync with the logic for PDF files in CompileController
function getSafeProjectName(project) {
return project.name.replace(/[^\p{L}\p{Nd}]/gu, '_')
}
async function exportProjectConversion(req, res) {
const type = req.params.type
async function _streamConvertedDocumentToResponse(
res,
{ projectId, type, conversionId, buildId, clsiServerId, file }
) {
const extension = SUPPORTED_CONVERSION_TYPES.get(type)
if (!extension) {
return res.sendStatus(400)
}
const userId = SessionManager.getLoggedInUserId(req.session)
const projectId = req.params.Project_id
Metrics.inc('document-exports', 1, { type })
const project = await ProjectGetter.promises.getProject(projectId, {
name: true,
})
const safeFileName = getSafeProjectName(project)
const { stream, contentLength } =
await DocumentConversionManager.promises.streamConvertedProjectDocument({
conversionId,
buildId,
clsiServerId,
file,
})
res.setHeader('Content-Length', contentLength)
res.attachment(`${safeFileName}.${extension}`)
res.setHeader('X-Content-Type-Options', 'nosniff')
res.setHeader('X-Accel-Buffering', 'no')
await pipeline(stream, res)
}
async function exportProjectConversion(req, res) {
const { params, query } = parseReq(req, exportProjectConversionSchema)
const { Project_id: projectId, type } = params
const { responseFormat } = query
const userId = SessionManager.getLoggedInUserId(req.session)
Metrics.inc('document-exports', 1, { type })
const { conversionId, buildId, clsiServerId, file } =
await DocumentConversionManager.promises.convertProjectToDocument(
projectId,
userId,
type
)
res.setHeader('Content-Length', contentLength)
res.attachment(`${safeFileName}.${extension}`)
res.setHeader('X-Content-Type-Options', 'nosniff')
res.setHeader('X-Accel-Buffering', 'no')
ProjectAuditLogHandler.addEntryInBackground(
projectId,
`project-exported-${type}`,
userId,
req.ip
)
await pipeline(stream, res)
if (responseFormat === 'json') {
const downloadUrl = new URL(
`/project/${projectId}/download/conversion/${conversionId}/${type}/build/${buildId}/output/${file}`,
Settings.siteUrl
)
if (clsiServerId) {
downloadUrl.searchParams.set('clsiserverid', clsiServerId)
}
return res.json({
downloadUrl: downloadUrl.pathname + downloadUrl.search,
})
}
await _streamConvertedDocumentToResponse(res, {
projectId,
type,
conversionId,
buildId,
clsiServerId,
file,
})
}
async function downloadPreparedProjectExport(req, res) {
const { params, query } = parseReq(req, downloadPreparedProjectExportSchema)
const { Project_id: projectId, conversionId, buildId, file, type } = params
const { clsiserverid: clsiServerId } = query
await _streamConvertedDocumentToResponse(res, {
projectId,
type,
conversionId,
buildId,
clsiServerId,
file,
})
}
export default {
exportProjectConversion: expressify(exportProjectConversion),
downloadPreparedProjectExport: expressify(downloadPreparedProjectExport),
downloadProject(req, res, next) {
const userId = SessionManager.getLoggedInUserId(req.session)
@@ -1,11 +1,15 @@
import Settings from '@overleaf/settings'
import CompileManager from '../Compile/CompileManager.mjs'
import ClsiManager from '../Compile/ClsiManager.mjs'
import { getOutputFileURL } from '../Compile/ClsiURLHelpers.mjs'
import fs from 'node:fs'
import fsPromises from 'node:fs/promises'
import logger from '@overleaf/logger'
import Path from 'node:path'
import { fetchStreamWithResponse } from '@overleaf/fetch-utils'
import {
fetchJsonWithResponse,
fetchStreamWithResponse,
} from '@overleaf/fetch-utils'
import { pipeline } from 'node:stream/promises'
import OError from '@overleaf/o-error'
import FormData from 'form-data'
@@ -85,6 +89,7 @@ async function convertProjectToDocument(projectId, userId, type) {
const clsiUrl = new URL(Settings.apis.clsi.url)
clsiUrl.pathname = `/project/${projectId}/user/${userId}/download/project-to-document`
clsiUrl.searchParams.set('type', type)
clsiUrl.searchParams.set('responseFormat', 'json')
clsiUrl.searchParams.set('compileBackendClass', limits.compileBackendClass)
clsiUrl.searchParams.set('compileGroup', limits.compileGroup)
@@ -93,11 +98,33 @@ async function convertProjectToDocument(projectId, userId, type) {
'sending project to CLSI for document conversion'
)
const { stream, response } = await fetchStreamWithResponse(clsiUrl, {
const { json, response } = await fetchJsonWithResponse(clsiUrl, {
method: 'POST',
json: clsiRequest,
})
const { conversionId, buildId, file } = json
const clsiServerId = ClsiManager.CLSI_COOKIES_ENABLED
? ClsiManager.getClsiServerIdFromResponse(response)
: undefined
return { conversionId, buildId, clsiServerId, file }
}
async function streamConvertedProjectDocument({
conversionId,
buildId,
clsiServerId,
file,
}) {
const downloadUrl = getOutputFileURL(
conversionId,
null,
buildId,
file,
clsiServerId ?? undefined
)
const { stream, response } = await fetchStreamWithResponse(downloadUrl)
const contentLength = parseInt(response.headers.get('Content-Length'), 10)
return { stream, contentLength }
@@ -107,5 +134,6 @@ export default {
promises: {
convertDocumentToLaTeXZipArchive,
convertProjectToDocument,
streamConvertedProjectDocument,
},
}
+13
View File
@@ -197,6 +197,10 @@ const rateLimiters = {
points: 5,
duration: 60,
}),
documentExportDownload: new RateLimiter('document-export-download', {
points: 30,
duration: 60,
}),
}
async function initialize(webRouter, privateApiRouter, publicApiRouter) {
@@ -759,6 +763,15 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) {
AuthorizationMiddleware.ensureUserCanReadProject,
ProjectDownloadsController.exportProjectConversion
)
webRouter.get(
'/project/:Project_id/download/conversion/:conversionId/:type/build/:buildId/output/:file(.*)',
AuthenticationController.requireLogin(),
RateLimiterMiddleware.rateLimit(rateLimiters.documentExportDownload, {
params: ['Project_id'],
}),
AuthorizationMiddleware.ensureUserCanReadProject,
ProjectDownloadsController.downloadPreparedProjectExport
)
}
webRouter.get(