mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-29 12:13:43 -05:00
Add support for redirecting to signed URLs
This commit is contained in:
parent
2b9b165d72
commit
3637cd70ae
10 changed files with 217 additions and 21 deletions
|
@ -74,6 +74,11 @@ async function getFileStream(location, name, opts) {
|
||||||
return fs.createReadStream(null, opts)
|
return fs.createReadStream(null, opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getRedirectUrl() {
|
||||||
|
// not implemented
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
async function getFileSize(location, filename) {
|
async function getFileSize(location, filename) {
|
||||||
const fullPath = path.join(location, filterName(filename))
|
const fullPath = path.join(location, filterName(filename))
|
||||||
|
|
||||||
|
@ -211,6 +216,7 @@ module.exports = {
|
||||||
sendFile: callbackify(sendFile),
|
sendFile: callbackify(sendFile),
|
||||||
sendStream: callbackify(sendStream),
|
sendStream: callbackify(sendStream),
|
||||||
getFileStream: callbackify(getFileStream),
|
getFileStream: callbackify(getFileStream),
|
||||||
|
getRedirectUrl: callbackify(getRedirectUrl),
|
||||||
getFileSize: callbackify(getFileSize),
|
getFileSize: callbackify(getFileSize),
|
||||||
getFileMd5Hash: callbackify(getFileMd5Hash),
|
getFileMd5Hash: callbackify(getFileMd5Hash),
|
||||||
copyFile: callbackify(copyFile),
|
copyFile: callbackify(copyFile),
|
||||||
|
@ -222,6 +228,7 @@ module.exports = {
|
||||||
sendFile,
|
sendFile,
|
||||||
sendStream,
|
sendStream,
|
||||||
getFileStream,
|
getFileStream,
|
||||||
|
getRedirectUrl,
|
||||||
getFileSize,
|
getFileSize,
|
||||||
getFileMd5Hash,
|
getFileMd5Hash,
|
||||||
copyFile,
|
copyFile,
|
||||||
|
|
|
@ -46,31 +46,42 @@ function getFile(req, res, next) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
FileHandler.getFile(bucket, key, options, function(err, fileStream) {
|
FileHandler.getRedirectUrl(bucket, key, options, function(err, redirectUrl) {
|
||||||
if (err) {
|
if (err) {
|
||||||
if (err instanceof Errors.NotFoundError) {
|
metrics.inc('file_redirect_error')
|
||||||
res.sendStatus(404)
|
|
||||||
} else {
|
|
||||||
next(err)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.query.cacheWarm) {
|
if (redirectUrl) {
|
||||||
return res.sendStatus(200).end()
|
metrics.inc('file_redirect')
|
||||||
|
return res.redirect(redirectUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
pipeline(fileStream, res, err => {
|
FileHandler.getFile(bucket, key, options, function(err, fileStream) {
|
||||||
if (err && err.code === 'ERR_STREAM_PREMATURE_CLOSE') {
|
if (err) {
|
||||||
res.end()
|
if (err instanceof Errors.NotFoundError) {
|
||||||
} else if (err) {
|
res.sendStatus(404)
|
||||||
next(
|
} else {
|
||||||
new Errors.ReadError({
|
next(err)
|
||||||
message: 'error transferring stream',
|
}
|
||||||
info: { bucket, key, format, style }
|
return
|
||||||
}).withCause(err)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (req.query.cacheWarm) {
|
||||||
|
return res.sendStatus(200).end()
|
||||||
|
}
|
||||||
|
|
||||||
|
pipeline(fileStream, res, err => {
|
||||||
|
if (err && err.code === 'ERR_STREAM_PREMATURE_CLOSE') {
|
||||||
|
res.end()
|
||||||
|
} else if (err) {
|
||||||
|
next(
|
||||||
|
new Errors.ReadError({
|
||||||
|
message: 'error transferring stream',
|
||||||
|
info: { bucket, key, format, style }
|
||||||
|
}).withCause(err)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,10 +13,12 @@ module.exports = {
|
||||||
deleteFile: callbackify(deleteFile),
|
deleteFile: callbackify(deleteFile),
|
||||||
deleteProject: callbackify(deleteProject),
|
deleteProject: callbackify(deleteProject),
|
||||||
getFile: callbackify(getFile),
|
getFile: callbackify(getFile),
|
||||||
|
getRedirectUrl: callbackify(getRedirectUrl),
|
||||||
getFileSize: callbackify(getFileSize),
|
getFileSize: callbackify(getFileSize),
|
||||||
getDirectorySize: callbackify(getDirectorySize),
|
getDirectorySize: callbackify(getDirectorySize),
|
||||||
promises: {
|
promises: {
|
||||||
getFile,
|
getFile,
|
||||||
|
getRedirectUrl,
|
||||||
insertFile,
|
insertFile,
|
||||||
deleteFile,
|
deleteFile,
|
||||||
deleteProject,
|
deleteProject,
|
||||||
|
@ -73,6 +75,24 @@ async function getFile(bucket, key, opts) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getRedirectUrl(bucket, key, opts) {
|
||||||
|
// if we're doing anything unusual with options, or the request isn't for
|
||||||
|
// one of the default buckets, return null so that we proxy the file
|
||||||
|
opts = opts || {}
|
||||||
|
if (
|
||||||
|
!opts.start &&
|
||||||
|
!opts.end &&
|
||||||
|
!opts.format &&
|
||||||
|
!opts.style &&
|
||||||
|
Object.values(Settings.filestore.stores).includes(bucket) &&
|
||||||
|
Settings.filestore.allowRedirects
|
||||||
|
) {
|
||||||
|
return PersistorManager.promises.getRedirectUrl(bucket, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
async function getFileSize(bucket, key) {
|
async function getFileSize(bucket, key) {
|
||||||
return PersistorManager.promises.getFileSize(bucket, key)
|
return PersistorManager.promises.getFileSize(bucket, key)
|
||||||
}
|
}
|
||||||
|
|
|
@ -36,6 +36,7 @@ const GcsPersistor = {
|
||||||
sendFile: callbackify(sendFile),
|
sendFile: callbackify(sendFile),
|
||||||
sendStream: callbackify(sendStream),
|
sendStream: callbackify(sendStream),
|
||||||
getFileStream: callbackify(getFileStream),
|
getFileStream: callbackify(getFileStream),
|
||||||
|
getRedirectUrl: callbackify(getRedirectUrl),
|
||||||
getFileMd5Hash: callbackify(getFileMd5Hash),
|
getFileMd5Hash: callbackify(getFileMd5Hash),
|
||||||
deleteDirectory: callbackify(deleteDirectory),
|
deleteDirectory: callbackify(deleteDirectory),
|
||||||
getFileSize: callbackify(getFileSize),
|
getFileSize: callbackify(getFileSize),
|
||||||
|
@ -47,6 +48,7 @@ const GcsPersistor = {
|
||||||
sendFile,
|
sendFile,
|
||||||
sendStream,
|
sendStream,
|
||||||
getFileStream,
|
getFileStream,
|
||||||
|
getRedirectUrl,
|
||||||
getFileMd5Hash,
|
getFileMd5Hash,
|
||||||
deleteDirectory,
|
deleteDirectory,
|
||||||
getFileSize,
|
getFileSize,
|
||||||
|
@ -141,6 +143,26 @@ async function getFileStream(bucketName, key, _opts = {}) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getRedirectUrl(bucketName, key) {
|
||||||
|
try {
|
||||||
|
const [url] = await storage
|
||||||
|
.bucket(bucketName)
|
||||||
|
.file(key)
|
||||||
|
.getSignedUrl({
|
||||||
|
action: 'read',
|
||||||
|
expires: new Date().getTime() + settings.filestore.signedUrlExpiryInMs
|
||||||
|
})
|
||||||
|
return url
|
||||||
|
} catch (err) {
|
||||||
|
throw PersistorHelper.wrapError(
|
||||||
|
err,
|
||||||
|
'error generating signed url for GCS file',
|
||||||
|
{ bucketName, key },
|
||||||
|
ReadError
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function getFileSize(bucketName, key) {
|
async function getFileSize(bucketName, key) {
|
||||||
try {
|
try {
|
||||||
const [metadata] = await storage
|
const [metadata] = await storage
|
||||||
|
|
|
@ -203,6 +203,7 @@ module.exports = function(primary, fallback) {
|
||||||
sendFile: primary.sendFile,
|
sendFile: primary.sendFile,
|
||||||
sendStream: primary.sendStream,
|
sendStream: primary.sendStream,
|
||||||
getFileStream: callbackify(getFileStreamWithFallback),
|
getFileStream: callbackify(getFileStreamWithFallback),
|
||||||
|
getRedirectUrl: primary.getRedirectUrl,
|
||||||
getFileMd5Hash: callbackify(_wrapFallbackMethod('getFileMd5Hash')),
|
getFileMd5Hash: callbackify(_wrapFallbackMethod('getFileMd5Hash')),
|
||||||
deleteDirectory: callbackify(
|
deleteDirectory: callbackify(
|
||||||
_wrapMethodOnBothPersistors('deleteDirectory')
|
_wrapMethodOnBothPersistors('deleteDirectory')
|
||||||
|
@ -216,6 +217,7 @@ module.exports = function(primary, fallback) {
|
||||||
sendFile: primary.promises.sendFile,
|
sendFile: primary.promises.sendFile,
|
||||||
sendStream: primary.promises.sendStream,
|
sendStream: primary.promises.sendStream,
|
||||||
getFileStream: getFileStreamWithFallback,
|
getFileStream: getFileStreamWithFallback,
|
||||||
|
getRedirectUrl: primary.promises.getRedirectUrl,
|
||||||
getFileMd5Hash: _wrapFallbackMethod('getFileMd5Hash'),
|
getFileMd5Hash: _wrapFallbackMethod('getFileMd5Hash'),
|
||||||
deleteDirectory: _wrapMethodOnBothPersistors('deleteDirectory'),
|
deleteDirectory: _wrapMethodOnBothPersistors('deleteDirectory'),
|
||||||
getFileSize: _wrapFallbackMethod('getFileSize'),
|
getFileSize: _wrapFallbackMethod('getFileSize'),
|
||||||
|
|
|
@ -24,6 +24,7 @@ const S3Persistor = {
|
||||||
sendFile: callbackify(sendFile),
|
sendFile: callbackify(sendFile),
|
||||||
sendStream: callbackify(sendStream),
|
sendStream: callbackify(sendStream),
|
||||||
getFileStream: callbackify(getFileStream),
|
getFileStream: callbackify(getFileStream),
|
||||||
|
getRedirectUrl: callbackify(getRedirectUrl),
|
||||||
getFileMd5Hash: callbackify(getFileMd5Hash),
|
getFileMd5Hash: callbackify(getFileMd5Hash),
|
||||||
deleteDirectory: callbackify(deleteDirectory),
|
deleteDirectory: callbackify(deleteDirectory),
|
||||||
getFileSize: callbackify(getFileSize),
|
getFileSize: callbackify(getFileSize),
|
||||||
|
@ -35,6 +36,7 @@ const S3Persistor = {
|
||||||
sendFile,
|
sendFile,
|
||||||
sendStream,
|
sendStream,
|
||||||
getFileStream,
|
getFileStream,
|
||||||
|
getRedirectUrl,
|
||||||
getFileMd5Hash,
|
getFileMd5Hash,
|
||||||
deleteDirectory,
|
deleteDirectory,
|
||||||
getFileSize,
|
getFileSize,
|
||||||
|
@ -146,6 +148,11 @@ async function getFileStream(bucketName, key, opts) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getRedirectUrl() {
|
||||||
|
// not implemented
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteDirectory(bucketName, key) {
|
async function deleteDirectory(bucketName, key) {
|
||||||
let response
|
let response
|
||||||
|
|
||||||
|
|
|
@ -70,6 +70,9 @@ settings =
|
||||||
buckets: JSON.parse(process.env['FALLBACK_BUCKET_MAPPING'] || '{}')
|
buckets: JSON.parse(process.env['FALLBACK_BUCKET_MAPPING'] || '{}')
|
||||||
copyOnMiss: process.env['COPY_ON_MISS'] == 'true'
|
copyOnMiss: process.env['COPY_ON_MISS'] == 'true'
|
||||||
|
|
||||||
|
allowRedirects: if process.env['ALLOW_REDIRECTS'] == 'true' then true else false
|
||||||
|
signedUrlExpiryInMs: parseInt(process.env['LINK_EXPIRY_TIMEOUT'] || 60000)
|
||||||
|
|
||||||
path:
|
path:
|
||||||
uploadFolder: Path.resolve(__dirname + "/../uploads")
|
uploadFolder: Path.resolve(__dirname + "/../uploads")
|
||||||
|
|
||||||
|
|
|
@ -42,7 +42,8 @@ describe('FileController', function() {
|
||||||
deleteFile: sinon.stub().yields(),
|
deleteFile: sinon.stub().yields(),
|
||||||
deleteProject: sinon.stub().yields(),
|
deleteProject: sinon.stub().yields(),
|
||||||
insertFile: sinon.stub().yields(),
|
insertFile: sinon.stub().yields(),
|
||||||
getDirectorySize: sinon.stub().yields(null, fileSize)
|
getDirectorySize: sinon.stub().yields(null, fileSize),
|
||||||
|
getRedirectUrl: sinon.stub().yields(null, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
LocalFileWriter = {}
|
LocalFileWriter = {}
|
||||||
|
@ -91,6 +92,11 @@ describe('FileController', function() {
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('getFile', function() {
|
describe('getFile', function() {
|
||||||
|
it('should try and get a redirect url first', function() {
|
||||||
|
FileController.getFile(req, res, next)
|
||||||
|
expect(FileHandler.getRedirectUrl).to.have.been.calledWith(bucket, key)
|
||||||
|
})
|
||||||
|
|
||||||
it('should pipe the stream', function() {
|
it('should pipe the stream', function() {
|
||||||
FileController.getFile(req, res, next)
|
FileController.getFile(req, res, next)
|
||||||
expect(stream.pipeline).to.have.been.calledWith(fileStream, res)
|
expect(stream.pipeline).to.have.been.calledWith(fileStream, res)
|
||||||
|
@ -111,6 +117,46 @@ describe('FileController', function() {
|
||||||
expect(next).to.have.been.calledWith(error)
|
expect(next).to.have.been.calledWith(error)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('with a redirect url', function() {
|
||||||
|
const redirectUrl = 'https://wombat.potato/giraffe'
|
||||||
|
|
||||||
|
beforeEach(function() {
|
||||||
|
FileHandler.getRedirectUrl.yields(null, redirectUrl)
|
||||||
|
res.redirect = sinon.stub()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should redirect', function() {
|
||||||
|
FileController.getFile(req, res, next)
|
||||||
|
expect(res.redirect).to.have.been.calledWith(redirectUrl)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not get a file stream', function() {
|
||||||
|
FileController.getFile(req, res, next)
|
||||||
|
expect(FileHandler.getFile).not.to.have.been.called
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('when there is an error getting the redirect url', function() {
|
||||||
|
beforeEach(function() {
|
||||||
|
FileHandler.getRedirectUrl.yields(new Error('wombat herding error'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not redirect', function() {
|
||||||
|
FileController.getFile(req, res, next)
|
||||||
|
expect(res.redirect).not.to.have.been.called
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not return an error', function() {
|
||||||
|
FileController.getFile(req, res, next)
|
||||||
|
expect(next).not.to.have.been.called
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should proxy the file', function() {
|
||||||
|
FileController.getFile(req, res, next)
|
||||||
|
expect(FileHandler.getFile).to.have.been.calledWith(bucket, key)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('with a range header', function() {
|
describe('with a range header', function() {
|
||||||
let expectedOptions
|
let expectedOptions
|
||||||
|
|
||||||
|
|
|
@ -24,6 +24,7 @@ describe('FileHandler', function() {
|
||||||
const projectKey = `${ObjectId()}/`
|
const projectKey = `${ObjectId()}/`
|
||||||
const sourceStream = 'sourceStream'
|
const sourceStream = 'sourceStream'
|
||||||
const convertedKey = 'convertedKey'
|
const convertedKey = 'convertedKey'
|
||||||
|
const redirectUrl = 'https://wombat.potato/giraffe'
|
||||||
const readStream = {
|
const readStream = {
|
||||||
stream: 'readStream',
|
stream: 'readStream',
|
||||||
on: sinon.stub()
|
on: sinon.stub()
|
||||||
|
@ -33,6 +34,7 @@ describe('FileHandler', function() {
|
||||||
PersistorManager = {
|
PersistorManager = {
|
||||||
promises: {
|
promises: {
|
||||||
getFileStream: sinon.stub().resolves(sourceStream),
|
getFileStream: sinon.stub().resolves(sourceStream),
|
||||||
|
getRedirectUrl: sinon.stub().resolves(redirectUrl),
|
||||||
checkIfFileExists: sinon.stub().resolves(),
|
checkIfFileExists: sinon.stub().resolves(),
|
||||||
deleteFile: sinon.stub().resolves(),
|
deleteFile: sinon.stub().resolves(),
|
||||||
deleteDirectory: sinon.stub().resolves(),
|
deleteDirectory: sinon.stub().resolves(),
|
||||||
|
@ -299,6 +301,64 @@ describe('FileHandler', function() {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('getRedirectUrl', function() {
|
||||||
|
beforeEach(function() {
|
||||||
|
Settings.filestore = {
|
||||||
|
allowRedirects: true,
|
||||||
|
stores: {
|
||||||
|
userFiles: bucket
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return a redirect url', function(done) {
|
||||||
|
FileHandler.getRedirectUrl(bucket, key, (err, url) => {
|
||||||
|
expect(err).not.to.exist
|
||||||
|
expect(url).to.equal(redirectUrl)
|
||||||
|
done()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should call the persistor to get a redirect url', function(done) {
|
||||||
|
FileHandler.getRedirectUrl(bucket, key, () => {
|
||||||
|
expect(
|
||||||
|
PersistorManager.promises.getRedirectUrl
|
||||||
|
).to.have.been.calledWith(bucket, key)
|
||||||
|
done()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return null if options are supplied', function(done) {
|
||||||
|
FileHandler.getRedirectUrl(
|
||||||
|
bucket,
|
||||||
|
key,
|
||||||
|
{ start: 100, end: 200 },
|
||||||
|
(err, url) => {
|
||||||
|
expect(err).not.to.exist
|
||||||
|
expect(url).to.be.null
|
||||||
|
done()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return null if the bucket is not one of the defined ones', function(done) {
|
||||||
|
FileHandler.getRedirectUrl('a_different_bucket', key, (err, url) => {
|
||||||
|
expect(err).not.to.exist
|
||||||
|
expect(url).to.be.null
|
||||||
|
done()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return null if redirects are not enabled', function(done) {
|
||||||
|
Settings.filestore.allowRedirects = false
|
||||||
|
FileHandler.getRedirectUrl(bucket, key, (err, url) => {
|
||||||
|
expect(err).not.to.exist
|
||||||
|
expect(url).to.be.null
|
||||||
|
done()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('getDirectorySize', function() {
|
describe('getDirectorySize', function() {
|
||||||
it('should call the filestore manager to get directory size', function(done) {
|
it('should call the filestore manager to get directory size', function(done) {
|
||||||
FileHandler.getDirectorySize(bucket, key, err => {
|
FileHandler.getDirectorySize(bucket, key, err => {
|
||||||
|
|
|
@ -17,6 +17,7 @@ describe('GcsPersistorTests', function() {
|
||||||
const filesSize = 33
|
const filesSize = 33
|
||||||
const md5 = 'ffffffff00000000ffffffff00000000'
|
const md5 = 'ffffffff00000000ffffffff00000000'
|
||||||
const WriteStream = 'writeStream'
|
const WriteStream = 'writeStream'
|
||||||
|
const redirectUrl = 'https://wombat.potato/giraffe'
|
||||||
|
|
||||||
let Metrics,
|
let Metrics,
|
||||||
Logger,
|
Logger,
|
||||||
|
@ -97,7 +98,8 @@ describe('GcsPersistorTests', function() {
|
||||||
getMetadata: sinon.stub().resolves([files[0].metadata]),
|
getMetadata: sinon.stub().resolves([files[0].metadata]),
|
||||||
createWriteStream: sinon.stub().returns(WriteStream),
|
createWriteStream: sinon.stub().returns(WriteStream),
|
||||||
copy: sinon.stub().resolves(),
|
copy: sinon.stub().resolves(),
|
||||||
exists: sinon.stub().resolves([true])
|
exists: sinon.stub().resolves([true]),
|
||||||
|
getSignedUrl: sinon.stub().resolves([redirectUrl])
|
||||||
}
|
}
|
||||||
|
|
||||||
GcsBucket = {
|
GcsBucket = {
|
||||||
|
@ -260,6 +262,22 @@ describe('GcsPersistorTests', function() {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('getFile', function() {
|
||||||
|
let signedUrl
|
||||||
|
|
||||||
|
beforeEach(async function() {
|
||||||
|
signedUrl = await GcsPersistor.promises.getRedirectUrl(bucket, key)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should request a signed URL', function() {
|
||||||
|
expect(GcsFile.getSignedUrl).to.have.been.called
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return the url', function() {
|
||||||
|
expect(signedUrl).to.equal(redirectUrl)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('getFileSize', function() {
|
describe('getFileSize', function() {
|
||||||
describe('when called with valid parameters', function() {
|
describe('when called with valid parameters', function() {
|
||||||
let size
|
let size
|
||||||
|
|
Loading…
Reference in a new issue