[WEB + CLSI] Download as docx file feature (#32851)

* using CLSI logic for fetching the project contents and skip the .zip export

* Use unique conversion directory for project-to-docx export to avoid corrupting the shared compile
  directory when a compile runs concurrently

* Remove X-Accel-Buffering header — not needed as CLSI does not run behind nginx

* moving log before sending the data

* Return CLSI stream directly instead of buffering to disk on web

  Previously convertProjectToDocx wrote the CLSI response to a temp file
  on disk, then the controller read it back to stream to the client.
  Now the stream is returned directly and piped to the response,
  avoiding unnecessary disk I/O on the web server.

* Use href redirect for docx export instead of fetching blob into memory

* making functions and files more generic so they can be used in future for other documents exports as well

* adding export-docx split test

* adding unit tests

* adding cypress E2E test

* format:fix

* renaming the route to download from convert

* adding new icon for export docx button

* format:fix

* remove unused showExportDocumentErrorToast export and adding guard against invalid Content-Length header from CLSI

* format:fix

* refactor(clsi): move promisify(parse) into RequestParser

* refactor: generic conversion endpoint with type as route
  param

* refactor: use type→extension map for validated conversion types

* refactor(clsi): remove --standalone flag and fix rejection test

* fixing the href in cypress test

* renaming function

* adding type to Metrics.inc

* fix: rename exportProjectDocument, add WithLock wrapper and metrics type label

* format:fix

* fix: hide docx export from anonymous users and add WithLock wrapper

* format fix

* remove redundant Content-Length validation from DocumentConversionManager

* format:fix

* removing trailing icon

GitOrigin-RevId: e9764fefac2c4b625d23be9e942ea4a8b283c70d
This commit is contained in:
Davinder Singh
2026-04-24 08:06:10 +00:00
committed by Copybot
parent b6ec7945f4
commit be5a7b56c8
20 changed files with 772 additions and 41 deletions
@@ -13,12 +13,29 @@ describe('ConversionController', function () {
ctx.conversionDir = '/path/to/conversion/result'
ctx.zipPath = '/path/to/conversion/result/output.zip'
ctx.zipStat = { size: 1234 }
ctx.documentPath = '/compiles/output-uuid/output-uuid.docx'
ctx.documentStat = { size: 5678 }
ctx.Settings = {
enablePandocConversions: true,
path: { compilesDir: '/compiles' },
}
ctx.parsedRequest = { rootResourcePath: 'main.tex' }
ctx.ConversionManager = {
promises: {
convertDocxToLaTeXWithLock: sinon.stub().resolves(ctx.zipPath),
convertLaTeXToDocumentInDirWithLock: sinon
.stub()
.resolves(ctx.documentPath),
},
}
ctx.ResourceWriter = {
promises: {
syncResourcesToDisk: sinon.stub().resolves(),
},
}
ctx.RequestParser = {
promises: {
parse: sinon.stub().resolves(ctx.parsedRequest),
},
}
@@ -54,6 +71,14 @@ describe('ConversionController', function () {
default: ctx.ConversionManager,
}))
vi.doMock('../../../app/js/ResourceWriter', () => ({
default: ctx.ResourceWriter,
}))
vi.doMock('../../../app/js/RequestParser', () => ({
default: ctx.RequestParser,
}))
ctx.res = new PassThrough()
ctx.res.attachment = sinon.stub()
ctx.res.setHeader = sinon.stub()
@@ -155,4 +180,156 @@ describe('ConversionController', function () {
})
})
})
describe('convertProjectToDocument', function () {
beforeEach(function (ctx) {
ctx.req = {
body: {},
params: { project_id: 'test-project-id', user_id: 'test-user-id' },
query: { type: 'docx' },
}
ctx.fs.stat.resolves(ctx.documentStat)
})
describe('when conversions are disabled', function () {
beforeEach(async function (ctx) {
ctx.Settings.enablePandocConversions = false
ctx.res.sendStatus = sinon.stub()
await ctx.ConversionController.convertProjectToDocument(
ctx.req,
ctx.res,
sinon.stub()
)
})
it('should return 404', function (ctx) {
sinon.assert.calledWith(ctx.res.sendStatus, 404)
})
it('should not sync resources or call the conversion manager', function (ctx) {
sinon.assert.notCalled(ctx.ResourceWriter.promises.syncResourcesToDisk)
sinon.assert.notCalled(
ctx.ConversionManager.promises.convertLaTeXToDocumentInDirWithLock
)
})
})
describe('when an unsupported type is requested', function () {
beforeEach(async function (ctx) {
ctx.req.query = { type: 'unsupported' }
ctx.res.sendStatus = sinon.stub()
await ctx.ConversionController.convertProjectToDocument(
ctx.req,
ctx.res,
sinon.stub()
)
})
it('should return 400', function (ctx) {
sinon.assert.calledWith(ctx.res.sendStatus, 400)
})
it('should not sync resources or call the conversion manager', function (ctx) {
sinon.assert.notCalled(ctx.ResourceWriter.promises.syncResourcesToDisk)
sinon.assert.notCalled(
ctx.ConversionManager.promises.convertLaTeXToDocumentInDirWithLock
)
})
})
const uuidDirPattern =
/^\/compiles\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
describe('successfully', function () {
beforeEach(async function (ctx) {
await ctx.ConversionController.convertProjectToDocument(
ctx.req,
ctx.res,
sinon.stub()
)
})
it('should sync resources to a unique conversion directory', function (ctx) {
sinon.assert.calledWith(
ctx.ResourceWriter.promises.syncResourcesToDisk,
sinon.match({ rootResourcePath: 'main.tex' }),
sinon.match(uuidDirPattern)
)
})
it('should call convertLaTeXToDocumentInDirWithLock with docx type and extension', function (ctx) {
sinon.assert.calledWith(
ctx.ConversionManager.promises.convertLaTeXToDocumentInDirWithLock,
sinon.match(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
),
sinon.match(uuidDirPattern),
'main.tex',
'docx',
'docx'
)
})
it('should set the Content-Length header from the document stat', function (ctx) {
sinon.assert.calledWith(
ctx.res.setHeader,
'Content-Length',
ctx.documentStat.size
)
})
it('should set the attachment filename', function (ctx) {
sinon.assert.calledWith(ctx.res.attachment, 'output.docx')
})
it('should set X-Content-Type-Options header', function (ctx) {
sinon.assert.calledWith(
ctx.res.setHeader,
'X-Content-Type-Options',
'nosniff'
)
})
it('should stream the document to the response', function (ctx) {
sinon.assert.calledWith(ctx.fsSync.createReadStream, ctx.documentPath)
sinon.assert.calledWith(ctx.pipeline, ctx.readStream, ctx.res)
})
it('should clean up the conversion directory', function (ctx) {
sinon.assert.calledWith(ctx.fs.rm, sinon.match(uuidDirPattern), {
recursive: true,
force: true,
})
})
})
describe('when conversion fails', function () {
beforeEach(async function (ctx) {
ctx.next = sinon.stub()
ctx.ConversionManager.promises.convertLaTeXToDocumentInDirWithLock.rejects(
new Error('mock conversion error')
)
await ctx.ConversionController.convertProjectToDocument(
ctx.req,
ctx.res,
ctx.next
)
})
it('should pass the error to next', function (ctx) {
sinon.assert.calledOnce(ctx.next)
expect(ctx.next.firstCall.args[0]).to.be.instanceOf(Error)
})
it('should still clean up the conversion directory', function (ctx) {
sinon.assert.calledWith(ctx.fs.rm, sinon.match(uuidDirPattern), {
recursive: true,
force: true,
})
})
})
})
})