[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
@@ -28,7 +28,11 @@ describe('ProjectDownloadsController', function () {
)
vi.doMock('../../../../app/src/Features/Project/ProjectGetter.mjs', () => ({
default: (ctx.ProjectGetter = {}),
default: (ctx.ProjectGetter = {
promises: {
getProject: sinon.stub(),
},
}),
}))
vi.doMock(
@@ -47,6 +51,32 @@ describe('ProjectDownloadsController', function () {
})
)
vi.doMock(
'../../../../app/src/Features/Authentication/SessionManager.mjs',
() => ({
default: (ctx.SessionManager = {
getLoggedInUserId: sinon
.stub()
.callsFake(session => session?.user?._id),
}),
})
)
vi.doMock(
'../../../../app/src/Features/Uploads/DocumentConversionManager.mjs',
() => ({
default: (ctx.DocumentConversionManager = {
promises: {
convertProjectToDocument: sinon.stub(),
},
}),
})
)
vi.doMock('node:stream/promises', () => ({
pipeline: (ctx.pipeline = sinon.stub().resolves()),
}))
ctx.ProjectDownloadsController = (await import(modulePath)).default
})
@@ -208,4 +238,107 @@ describe('ProjectDownloadsController', function () {
}
})
})
describe('exportProjectConversion', function () {
describe('when an unsupported type is requested', function () {
beforeEach(async function (ctx) {
ctx.req.params = { Project_id: 'test-project-id', type: 'unsupported' }
ctx.req.session = { user: { _id: 'test-user-id' } }
await ctx.ProjectDownloadsController.exportProjectConversion(
ctx.req,
ctx.res,
ctx.next
)
})
it('should return 400', function (ctx) {
expect(ctx.res.statusCode).to.equal(400)
})
it('should not call the conversion manager', function (ctx) {
sinon.assert.notCalled(
ctx.DocumentConversionManager.promises.convertProjectToDocument
)
})
})
describe('with a supported type', function () {
beforeEach(async function (ctx) {
ctx.projectId = 'test-project-id'
ctx.userId = 'test-user-id'
ctx.projectName = 'My Test Project'
ctx.exportStream = { pipe: sinon.stub() }
ctx.contentLength = 9876
ctx.req.params = { Project_id: ctx.projectId, type: 'docx' }
ctx.req.session = { user: { _id: ctx.userId } }
ctx.req.ip = '192.168.1.1'
ctx.res.attachment = sinon.stub().returns(ctx.res)
ctx.SessionManager.getLoggedInUserId.returns(ctx.userId)
ctx.ProjectGetter.promises.getProject.resolves({
name: ctx.projectName,
})
ctx.DocumentConversionManager.promises.convertProjectToDocument.resolves(
{
stream: ctx.exportStream,
contentLength: ctx.contentLength,
}
)
await ctx.ProjectDownloadsController.exportProjectConversion(
ctx.req,
ctx.res,
ctx.next
)
})
it('should call convertProjectToDocument with the docx type', function (ctx) {
sinon.assert.calledWith(
ctx.DocumentConversionManager.promises.convertProjectToDocument,
ctx.projectId,
ctx.userId,
'docx'
)
})
it('should set the Content-Length header', function (ctx) {
expect(ctx.res.headers['Content-Length']).to.equal(ctx.contentLength)
})
it('should set the attachment filename with safe project name', function (ctx) {
sinon.assert.calledWith(ctx.res.attachment, 'My_Test_Project.docx')
})
it('should set the X-Content-Type-Options header', function (ctx) {
expect(ctx.res.headers['X-Content-Type-Options']).to.equal('nosniff')
})
it('should set the X-Accel-Buffering header', function (ctx) {
expect(ctx.res.headers['X-Accel-Buffering']).to.equal('no')
})
it('should add an audit log entry', function (ctx) {
sinon.assert.calledWith(
ctx.ProjectAuditLogHandler.addEntryInBackground,
ctx.projectId,
'project-exported-docx',
ctx.userId,
ctx.req.ip
)
})
it('should record the action via Metrics', function (ctx) {
ctx.Metrics.inc
.calledWith('document-exports', 1, { type: 'docx' })
.should.equal(true)
})
it('should stream the document to the response', function (ctx) {
sinon.assert.calledWith(ctx.pipeline, ctx.exportStream, ctx.res)
})
})
})
})