diff --git a/spec/CloudCode.spec.js b/spec/CloudCode.spec.js index e26a310655..59ae534df2 100644 --- a/spec/CloudCode.spec.js +++ b/spec/CloudCode.spec.js @@ -3929,6 +3929,126 @@ describe('saveFile hooks', () => { }); }); +describe('Parse.File hooks', () => { + it('find hooks should run', async () => { + const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain'); + await file.save({ useMasterKey: true }); + const user = await Parse.User.signUp('username', 'password'); + const hooks = { + beforeFind(req) { + expect(req).toBeDefined(); + expect(req.file).toBeDefined(); + expect(req.triggerName).toBe('beforeFind'); + expect(req.master).toBeFalse(); + expect(req.log).toBeDefined(); + }, + afterFind(req) { + expect(req).toBeDefined(); + expect(req.file).toBeDefined(); + expect(req.triggerName).toBe('afterFind'); + expect(req.master).toBeFalse(); + expect(req.log).toBeDefined(); + expect(req.forceDownload).toBeFalse(); + }, + }; + for (const hook in hooks) { + spyOn(hooks, hook).and.callThrough(); + Parse.Cloud[hook](Parse.File, hooks[hook]); + } + await request({ + url: file.url(), + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': user.getSessionToken(), + }, + }); + for (const hook in hooks) { + expect(hooks[hook]).toHaveBeenCalled(); + } + }); + + it('beforeFind can throw', async () => { + const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain'); + await file.save({ useMasterKey: true }); + const user = await Parse.User.signUp('username', 'password'); + const hooks = { + beforeFind() { + throw 'unauthorized'; + }, + afterFind() {}, + }; + for (const hook in hooks) { + spyOn(hooks, hook).and.callThrough(); + Parse.Cloud[hook](Parse.File, hooks[hook]); + } + await expectAsync( + request({ + url: file.url(), + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': user.getSessionToken(), + }, + }).catch(e => { + throw new Parse.Error(e.data.code, e.data.error); + }) + ).toBeRejectedWith(new Parse.Error(Parse.Error.SCRIPT_FAILED, 'unauthorized')); + + expect(hooks.beforeFind).toHaveBeenCalled(); + expect(hooks.afterFind).not.toHaveBeenCalled(); + }); + + it('afterFind can throw', async () => { + const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain'); + await file.save({ useMasterKey: true }); + const user = await Parse.User.signUp('username', 'password'); + const hooks = { + beforeFind() {}, + afterFind() { + throw 'unauthorized'; + }, + }; + for (const hook in hooks) { + spyOn(hooks, hook).and.callThrough(); + Parse.Cloud[hook](Parse.File, hooks[hook]); + } + await expectAsync( + request({ + url: file.url(), + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': user.getSessionToken(), + }, + }).catch(e => { + throw new Parse.Error(e.data.code, e.data.error); + }) + ).toBeRejectedWith(new Parse.Error(Parse.Error.SCRIPT_FAILED, 'unauthorized')); + for (const hook in hooks) { + expect(hooks[hook]).toHaveBeenCalled(); + } + }); + + it('can force download', async () => { + const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain'); + await file.save({ useMasterKey: true }); + const user = await Parse.User.signUp('username', 'password'); + Parse.Cloud.afterFind(Parse.File, req => { + req.forceDownload = true; + }); + const response = await request({ + url: file.url(), + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': user.getSessionToken(), + }, + }); + expect(response.headers['content-disposition']).toBe(`attachment;filename=${file._name}`); + }); + }); + describe('Cloud Config hooks', () => { function testConfig() { return Parse.Config.save({ internal: 'i', string: 's', number: 12 }, { internal: true }); diff --git a/src/Routers/FilesRouter.js b/src/Routers/FilesRouter.js index bdf5eb89d4..0bec64c9aa 100644 --- a/src/Routers/FilesRouter.js +++ b/src/Routers/FilesRouter.js @@ -73,30 +73,68 @@ export class FilesRouter { res.json({ code: err.code, error: err.message }); return; } - const filesController = config.filesController; - const filename = req.params.filename; - const mime = (await import('mime')).default; - const contentType = mime.getType(filename); - if (isFileStreamable(req, filesController)) { - filesController.handleFileStream(config, filename, req, res, contentType).catch(() => { - res.status(404); - res.set('Content-Type', 'text/plain'); - res.end('File not found.'); - }); - } else { - filesController - .getFileData(config, filename) - .then(data => { - res.status(200); - res.set('Content-Type', contentType); - res.set('Content-Length', data.length); - res.end(data); - }) - .catch(() => { + + let filename = req.params.filename; + try { + const filesController = config.filesController; + const mime = (await import('mime')).default; + let contentType = mime.getType(filename); + let file = new Parse.File(filename, { base64: '' }, contentType); + const triggerResult = await triggers.maybeRunFileTrigger( + triggers.Types.beforeFind, + { file }, + config, + req.auth + ); + if (triggerResult?.file?._name) { + filename = triggerResult?.file?._name; + contentType = mime.getType(filename); + } + + if (isFileStreamable(req, filesController)) { + filesController.handleFileStream(config, filename, req, res, contentType).catch(() => { res.status(404); res.set('Content-Type', 'text/plain'); res.end('File not found.'); }); + return; + } + + let data = await filesController.getFileData(config, filename).catch(() => { + res.status(404); + res.set('Content-Type', 'text/plain'); + res.end('File not found.'); + }); + if (!data) { + return; + } + file = new Parse.File(filename, { base64: data.toString('base64') }, contentType); + const afterFind = await triggers.maybeRunFileTrigger( + triggers.Types.afterFind, + { file, forceDownload: false }, + config, + req.auth + ); + + if (afterFind?.file) { + contentType = mime.getType(afterFind.file._name); + data = Buffer.from(afterFind.file._data, 'base64'); + } + + res.status(200); + res.set('Content-Type', contentType); + res.set('Content-Length', data.length); + if (afterFind.forceDownload) { + res.set('Content-Disposition', `attachment;filename=${afterFind.file._name}`); + } + res.end(data); + } catch (e) { + const err = triggers.resolveError(e, { + code: Parse.Error.SCRIPT_FAILED, + message: `Could not find file: ${filename}.`, + }); + res.status(403); + res.json({ code: err.code, error: err.message }); } } diff --git a/src/triggers.js b/src/triggers.js index 0f1b632078..2dfbeff7ac 100644 --- a/src/triggers.js +++ b/src/triggers.js @@ -1004,6 +1004,9 @@ export async function maybeRunFileTrigger(triggerType, fileObject, config, auth) return fileObject; } const result = await fileTrigger(request); + if (request.forceDownload) { + fileObject.forceDownload = true; + } logTriggerSuccessBeforeHook( triggerType, 'Parse.File',