mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-21 20:47:08 -05:00
Allow individual docs to be downloaded from the file tree (#17137)
GitOrigin-RevId: d0b2ce9f3a252e34f452155ed83c3c04e7916ef0
This commit is contained in:
parent
bef2e4fbce
commit
6212f340d3
7 changed files with 164 additions and 5 deletions
|
@ -0,0 +1,50 @@
|
|||
const logger = require('@overleaf/logger')
|
||||
const DocumentUpdaterHandler = require('./DocumentUpdaterHandler')
|
||||
const ProjectLocator = require('../Project/ProjectLocator')
|
||||
const { plainTextResponse } = require('../../infrastructure/Response')
|
||||
const { expressify } = require('@overleaf/promise-utils')
|
||||
|
||||
async function getDoc(req, res) {
|
||||
const projectId = req.params.Project_id
|
||||
const docId = req.params.Doc_id
|
||||
|
||||
try {
|
||||
const { element: doc } = await ProjectLocator.promises.findElement({
|
||||
project_id: projectId,
|
||||
element_id: docId,
|
||||
type: 'doc',
|
||||
})
|
||||
|
||||
const { lines } = await DocumentUpdaterHandler.promises.getDocument(
|
||||
projectId,
|
||||
docId,
|
||||
-1 // latest version only
|
||||
)
|
||||
|
||||
res.setContentDisposition('attachment', { filename: doc.name })
|
||||
plainTextResponse(res, lines.join('\n'))
|
||||
} catch (err) {
|
||||
if (err.name === 'NotFoundError') {
|
||||
logger.warn(
|
||||
{ err, projectId, docId },
|
||||
'entity not found when downloading doc'
|
||||
)
|
||||
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
logger.err(
|
||||
{ err, projectId, docId },
|
||||
'error getting document for downloading'
|
||||
)
|
||||
|
||||
return res.sendStatus(500)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getDoc: expressify(getDoc),
|
||||
promises: {
|
||||
getDoc,
|
||||
},
|
||||
}
|
|
@ -6,6 +6,7 @@ const async = require('async')
|
|||
const logger = require('@overleaf/logger')
|
||||
const metrics = require('@overleaf/metrics')
|
||||
const { promisify } = require('util')
|
||||
const { promisifyMultiResult } = require('@overleaf/promise-utils')
|
||||
|
||||
module.exports = {
|
||||
flushProjectToMongo,
|
||||
|
@ -27,7 +28,12 @@ module.exports = {
|
|||
flushProjectToMongoAndDelete: promisify(flushProjectToMongoAndDelete),
|
||||
flushDocToMongo: promisify(flushDocToMongo),
|
||||
deleteDoc: promisify(deleteDoc),
|
||||
getDocument: promisify(getDocument),
|
||||
getDocument: promisifyMultiResult(getDocument, [
|
||||
'lines',
|
||||
'version',
|
||||
'ranges',
|
||||
'ops',
|
||||
]),
|
||||
setDocument: promisify(setDocument),
|
||||
getProjectDocsIfMatch: promisify(getProjectDocsIfMatch),
|
||||
clearProjectState: promisify(clearProjectState),
|
||||
|
|
|
@ -31,6 +31,7 @@ const ClsiCookieManager = require('./Features/Compile/ClsiCookieManager')(
|
|||
const HealthCheckController = require('./Features/HealthCheck/HealthCheckController')
|
||||
const ProjectDownloadsController = require('./Features/Downloads/ProjectDownloadsController')
|
||||
const FileStoreController = require('./Features/FileStore/FileStoreController')
|
||||
const DocumentUpdaterController = require('./Features/DocumentUpdater/DocumentUpdaterController')
|
||||
const HistoryController = require('./Features/History/HistoryController')
|
||||
const ExportsController = require('./Features/Exports/ExportsController')
|
||||
const PasswordResetRouter = require('./Features/PasswordReset/PasswordResetRouter')
|
||||
|
@ -490,6 +491,11 @@ function initialize(webRouter, privateApiRouter, publicApiRouter) {
|
|||
AuthorizationMiddleware.ensureUserCanReadProject,
|
||||
FileStoreController.getFile
|
||||
)
|
||||
webRouter.get(
|
||||
'/Project/:Project_id/doc/:Doc_id/download', // "download" suffix to avoid conflict with private API route at doc/:doc_id
|
||||
AuthorizationMiddleware.ensureUserCanReadProject,
|
||||
DocumentUpdaterController.getDoc
|
||||
)
|
||||
webRouter.post(
|
||||
'/project/:Project_id/settings',
|
||||
validate({
|
||||
|
|
|
@ -475,14 +475,19 @@ export const FileTreeActionableProvider: FC<{
|
|||
}
|
||||
}, [])
|
||||
|
||||
// build the path for downloading a single file
|
||||
// build the path for downloading a single file or doc
|
||||
const downloadPath = useMemo(() => {
|
||||
if (selectedEntityIds.size === 1) {
|
||||
const [selectedEntityId] = selectedEntityIds
|
||||
const selectedEntity = findInTree(fileTreeData, selectedEntityId)
|
||||
|
||||
if (selectedEntity?.type === 'fileRef') {
|
||||
return `/project/${projectId}/file/${selectedEntityId}`
|
||||
}
|
||||
|
||||
if (selectedEntity?.type === 'doc') {
|
||||
return `/project/${projectId}/doc/${selectedEntityId}/download`
|
||||
}
|
||||
}
|
||||
}, [fileTreeData, projectId, selectedEntityIds])
|
||||
|
||||
|
|
|
@ -550,7 +550,7 @@ class User {
|
|||
}
|
||||
|
||||
uploadFileInProject(projectId, folderId, file, name, contentType, callback) {
|
||||
const imageFile = fs.createReadStream(
|
||||
const fileStream = fs.createReadStream(
|
||||
Path.resolve(Path.join(__dirname, '..', '..', 'files', file))
|
||||
)
|
||||
|
||||
|
@ -563,7 +563,7 @@ class User {
|
|||
formData: {
|
||||
name,
|
||||
qqfile: {
|
||||
value: imageFile,
|
||||
value: fileStream,
|
||||
options: {
|
||||
filename: name,
|
||||
contentType,
|
||||
|
|
|
@ -0,0 +1,92 @@
|
|||
const sinon = require('sinon')
|
||||
const { expect } = require('chai')
|
||||
const SandboxedModule = require('sandboxed-module')
|
||||
const MockResponse = require('../helpers/MockResponse')
|
||||
|
||||
const MODULE_PATH =
|
||||
'../../../../app/src/Features/DocumentUpdater/DocumentUpdaterController.js'
|
||||
|
||||
describe('DocumentUpdaterController', function () {
|
||||
beforeEach(function () {
|
||||
this.DocumentUpdaterHandler = {
|
||||
promises: {
|
||||
getDocument: sinon.stub(),
|
||||
},
|
||||
}
|
||||
this.ProjectLocator = {
|
||||
promises: {
|
||||
findElement: sinon.stub(),
|
||||
},
|
||||
}
|
||||
this.controller = SandboxedModule.require(MODULE_PATH, {
|
||||
requires: {
|
||||
'@overleaf/settings': this.settings,
|
||||
'../Project/ProjectLocator': this.ProjectLocator,
|
||||
'./DocumentUpdaterHandler': this.DocumentUpdaterHandler,
|
||||
},
|
||||
})
|
||||
this.projectId = '2k3j1lk3j21lk3j'
|
||||
this.fileId = '12321kklj1lk3jk12'
|
||||
this.req = {
|
||||
params: {
|
||||
Project_id: this.projectId,
|
||||
Doc_id: this.docId,
|
||||
},
|
||||
get(key) {
|
||||
return undefined
|
||||
},
|
||||
}
|
||||
this.lines = ['test', '', 'testing']
|
||||
this.res = new MockResponse()
|
||||
this.doc = { name: 'myfile.tex' }
|
||||
})
|
||||
|
||||
describe('getDoc', function () {
|
||||
beforeEach(function () {
|
||||
this.DocumentUpdaterHandler.promises.getDocument.resolves({
|
||||
lines: this.lines,
|
||||
})
|
||||
this.ProjectLocator.promises.findElement.resolves({
|
||||
element: this.doc,
|
||||
})
|
||||
this.res = new MockResponse()
|
||||
})
|
||||
|
||||
it('should call the document updater handler with the project_id and doc_id', async function () {
|
||||
await this.controller.promises.getDoc(this.req, this.res)
|
||||
|
||||
expect(
|
||||
this.DocumentUpdaterHandler.promises.getDocument
|
||||
).to.have.been.calledOnceWith(
|
||||
this.req.params.Project_id,
|
||||
this.req.params.Doc_id,
|
||||
-1
|
||||
)
|
||||
})
|
||||
|
||||
it('should return the content', async function () {
|
||||
await this.controller.promises.getDoc(this.req, this.res)
|
||||
expect(this.res.statusCode).to.equal(200)
|
||||
expect(this.res.body).to.equal('test\n\ntesting')
|
||||
})
|
||||
|
||||
it('should find the doc in the project', async function () {
|
||||
await this.controller.promises.getDoc(this.req, this.res)
|
||||
expect(
|
||||
this.ProjectLocator.promises.findElement
|
||||
).to.have.been.calledOnceWith({
|
||||
project_id: this.projectId,
|
||||
element_id: this.docId,
|
||||
type: 'doc',
|
||||
})
|
||||
})
|
||||
|
||||
it('should set the Content-Disposition header', async function () {
|
||||
await this.controller.promises.getDoc(this.req, this.res)
|
||||
expect(this.res.setContentDisposition).to.have.been.calledWith(
|
||||
'attachment',
|
||||
{ filename: this.doc.name }
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
|
@ -18,7 +18,7 @@ const contentDisposition = require('content-disposition')
|
|||
class MockResponse {
|
||||
static initClass() {
|
||||
// Added via ExpressLocals.
|
||||
this.prototype.setContentDisposition = sinon.stub()
|
||||
this.prototype.setContentDisposition = sinon.stub() // FIXME: should be reset between each test
|
||||
}
|
||||
|
||||
constructor() {
|
||||
|
|
Loading…
Reference in a new issue