diff --git a/spec/FilesController.spec.js b/spec/FilesController.spec.js index 5b6a3d4ab2..a110ddd7ab 100644 --- a/spec/FilesController.spec.js +++ b/spec/FilesController.spec.js @@ -22,11 +22,11 @@ const mockAdapter = { // Small additional tests to improve overall coverage describe('FilesController', () => { - it('should properly expand objects', done => { + it('should properly expand objects', async done => { const config = Config.get(Parse.applicationId); const gridStoreAdapter = new GridFSBucketAdapter('mongodb://localhost:27017/parse'); const filesController = new FilesController(gridStoreAdapter); - const result = filesController.expandFilesInObject(config, function () {}); + const result = await filesController.expandFilesInObject(config, function () {}); expect(result).toBeUndefined(); diff --git a/spec/ParseFile.spec.js b/spec/ParseFile.spec.js index 0bd90426c6..37b4a9f65b 100644 --- a/spec/ParseFile.spec.js +++ b/spec/ParseFile.spec.js @@ -28,7 +28,9 @@ describe('Parse.File testing', () => { }).then(response => { const b = response.data; expect(b.name).toMatch(/_file.txt$/); - expect(b.url).toMatch(/^http:\/\/localhost:8378\/1\/files\/test\/.*file.txt$/); + expect(b.url.split('?')[0]).toMatch( + /^http:\/\/localhost:8378\/1\/files\/test\/.*file.txt$/ + ); request({ url: b.url }).then(response => { const body = response.text; expect(body).toEqual('argle bargle'); @@ -50,7 +52,9 @@ describe('Parse.File testing', () => { }).then(response => { const b = response.data; expect(b.name).toMatch(/_file.html/); - expect(b.url).toMatch(/^http:\/\/localhost:8378\/1\/files\/test\/.*file.html$/); + expect(b.url.split('?')[0]).toMatch( + /^http:\/\/localhost:8378\/1\/files\/test\/.*file.html$/ + ); request({ url: b.url }).then(response => { const body = response.text; try { @@ -77,7 +81,9 @@ describe('Parse.File testing', () => { }).then(response => { const b = response.data; expect(b.name).toMatch(/_file.txt$/); - expect(b.url).toMatch(/^http:\/\/localhost:8378\/1\/files\/test\/.*file.txt$/); + expect(b.url.split('?')[0]).toMatch( + /^http:\/\/localhost:8378\/1\/files\/test\/.*file.txt$/ + ); request({ url: b.url }).then(response => { expect(response.text).toEqual('argle bargle'); done(); @@ -100,7 +106,9 @@ describe('Parse.File testing', () => { }).then(response => { const b = response.data; expect(b.name).toMatch(/_testfile.txt$/); - expect(b.url).toMatch(/^http:\/\/localhost:8378\/1\/files\/test\/.*testfile.txt$/); + expect(b.url.split('?')[0]).toMatch( + /^http:\/\/localhost:8378\/1\/files\/test\/.*testfile.txt$/ + ); request({ url: b.url }).then(response => { const body = response.text; expect(body).toEqual('check one two'); @@ -142,7 +150,9 @@ describe('Parse.File testing', () => { body: 'the file body', }).then(response => { const b = response.data; - expect(b.url).toMatch(/^http:\/\/localhost:8378\/1\/files\/test\/.*thefile.jpg$/); + expect(b.url.split('?')[0]).toMatch( + /^http:\/\/localhost:8378\/1\/files\/test\/.*thefile.jpg$/ + ); // missing X-Parse-Master-Key header request({ method: 'DELETE', @@ -188,7 +198,7 @@ describe('Parse.File testing', () => { }).then(response => { const b = response.data; expect(b.name).toMatch(/_file.jpg$/); - expect(b.url).toMatch(/^http:\/\/localhost:8378\/1\/files\/.*file.jpg$/); + expect(b.url.split('?')[0]).toMatch(/^http:\/\/localhost:8378\/1\/files\/.*file.jpg$/); request({ url: b.url }).then(response => { const body = response.text; expect(body).toEqual('argle bargle'); @@ -358,7 +368,7 @@ describe('Parse.File testing', () => { body: 'oh emm gee', }).then(response => { const b = response.data; - expect(b.url).toMatch(/hello%20world/); + expect(b.url.split('?')[0]).toMatch(/hello%20world/); done(); }); }); @@ -861,6 +871,245 @@ describe('Parse.File testing', () => { }); }); }); + it('can save file and get', async done => { + const user = new Parse.User(); + await user.signUp({ + username: 'hello', + password: 'password', + }); + const file = new Parse.File('hello.txt', data, 'text/plain'); + const acl = new Parse.ACL(); + acl.setPublicReadAccess(false); + acl.setReadAccess(user, true); + // EXPERIMENTAL - NO WAY TO PASS ACL THROUGH IN SDK AT THE MOMENT + file.setTags({ acl: acl.toJSON() }); + const result = await file.save({ sessionToken: user.getSessionToken() }); + strictEqual(result, file); + ok(file.name()); + ok(file.url()); + notEqual(file.name(), 'hello.txt'); + const object = new Parse.Object('TestObject'); + await object.save({ file: file }, { sessionToken: user.getSessionToken() }); + const query = await new Parse.Query('TestObject').get(object.id, { + sessionToken: user.getSessionToken(), + }); + const aclFile = query.get('file'); + expect(aclFile instanceof Parse.File); + expect(aclFile.url()).toBeDefined(); + expect(aclFile.url()).toContain('token'); + try { + const response = await request({ + url: aclFile.url(), + }); + expect(response.text).toEqual('Hello World!'); + done(); + } catch (e) { + fail('should have been able to get file.'); + } + }); + + it('can run beforeFind on File', async done => { + const user = new Parse.User(); + await user.signUp({ + username: 'hello', + password: 'password', + }); + const file = new Parse.File('hello.txt', data, 'text/plain'); + + let callCount = 0; + Parse.Cloud.beforeSave(Parse.File, req => { + callCount++; + expect(req.file).toBeDefined(); + expect(req.file._name).toContain('hello.txt'); + expect(req.triggerName).toBe('beforeSaveFile'); + expect(req.master).toBe(false); + expect(req.user).toBeDefined(); + expect(req.user.id).toBe(user.id); + }); + Parse.Cloud.afterSave(Parse.File, req => { + callCount++; + expect(req.file).toBeDefined(); + expect(req.file._name).toContain('hello.txt'); + expect(req.triggerName).toBe('afterSaveFile'); + expect(req.master).toBe(false); + expect(req.user).toBeDefined(); + expect(req.user.id).toBe(user.id); + }); + Parse.Cloud.beforeFind(Parse.File, req => { + callCount++; + expect(req.file).toBeDefined(); + expect(req.file._name).toContain('hello.txt'); + expect(req.triggerName).toBe('beforeFind'); + expect(req.master).toBe(false); + expect(req.user).toBeDefined(); + expect(req.user.id).toBe(user.id); + const newStr = 'test'; + const newData = []; + for (let i = 0; i < newStr.length; i++) { + newData.push(newStr.charCodeAt(i)); + } + return new Parse.File('new.txt', newData, 'text/plain'); + }); + Parse.Cloud.afterFind(Parse.File, req => { + callCount++; + expect(req.file).toBeDefined(); + expect(req.file._name).toContain('hello.txt'); + expect(req.triggerName).toBe('afterFind'); + expect(req.master).toBe(false); + expect(req.user).toBeDefined(); + expect(req.user.id).toBe(user.id); + }); + + await file.save({ sessionToken: user.getSessionToken() }); + + const object = new Parse.Object('TestObject'); + await object.save({ file: file }, { sessionToken: user.getSessionToken() }); + + const query = await new Parse.Query('TestObject').get(object.id, { + sessionToken: user.getSessionToken(), + }); + + const aclFile = query.get('file'); + const response = await request({ + url: aclFile.url(), + }); + + expect(response.text).toEqual('test'); + expect(callCount).toBe(4); + done(); + }); + + it('can throw from beforeFind', async done => { + const user = new Parse.User(); + await user.signUp({ + username: 'hello', + password: 'password', + }); + const file = new Parse.File('hello.txt', data, 'text/plain'); + await file.save({ sessionToken: user.getSessionToken() }); + + const object = new Parse.Object('TestObject'); + await object.save({ file: file }, { sessionToken: user.getSessionToken() }); + + const query = await new Parse.Query('TestObject').get(object.id, { + sessionToken: user.getSessionToken(), + }); + const aclFile = query.get('file'); + Parse.Cloud.beforeFind(Parse.File, () => { + throw new Parse.Error(200, 'You are not allowed to access this file'); + }); + try { + await request({ + url: aclFile.url(), + }); + fail('should not have been able to get file.'); + } catch (e) { + expect(e.text).toEqual('You are not allowed to access this file'); + expect(e.status).toBe(404); + done(); + } + }); + + it('can save file and not get public', async done => { + const user = new Parse.User(); + await user.signUp({ + username: 'hello', + password: 'password', + }); + const file = new Parse.File('hello.txt', data, 'text/plain'); + const acl = new Parse.ACL(); + acl.setPublicReadAccess(false); + acl.setReadAccess(user, true); + // EXPERIMENTAL - NO WAY TO PASS ACL THROUGH IN SDK AT THE MOMENT + file.setTags({ acl: acl.toJSON() }); + const result = await file.save({ sessionToken: user.getSessionToken() }); + strictEqual(result, file); + ok(file.name()); + ok(file.url()); + notEqual(file.name(), 'hello.txt'); + const object = new Parse.Object('TestObject'); + await object.save({ file: file }, { sessionToken: user.getSessionToken() }); + await Parse.User.logOut(); + const query = await new Parse.Query('TestObject').get(object.id); + const aclFile = query.get('file'); + expect(aclFile instanceof Parse.File); + expect(aclFile.url()).toBeDefined(); + expect(aclFile.url()).not.toContain('token'); + try { + await request({ + url: aclFile.url(), + }); + fail('should not have been able to get file.'); + } catch (e) { + expect(e.text).toBe('File not found.'); + expect(e.status).toBe(404); + done(); + } + }); + + it('can set schema on _File', async done => { + // WIP - cannot get schema for _File + try { + const file = new Parse.File('hello.txt', data, 'text/plain'); + await file.save(); + } catch (e) { + console.log(e); + expect(e.text).toBe('You are not authorized to upload a file.'); + } + done(); + }); + + it('can track file references', async done => { + const user = new Parse.User(); + await user.signUp({ + username: 'hello', + password: 'password', + }); + const file = new Parse.File('hello.txt', data, 'text/plain'); + await file.save(); + + const fileQuery = new Parse.Query('_File'); + fileQuery.equalTo('file', file); + + const fileObject = await fileQuery.first({ useMasterKey: true }); + const fileRelation = fileObject.relation('references').query(); + + let references = await fileRelation.find({ useMasterKey: true }); + expect(references.length).toBe(0); + + const object = new Parse.Object('TestObject'); + await object.save({ file: file }); + + references = await fileRelation.find({ useMasterKey: true }); + expect(references.length).toBe(1); + done(); + }); + + it('can query file data', async done => { + const user = new Parse.User(); + await user.signUp({ + username: 'hello', + password: 'password', + }); + const file = new Parse.File('hello.txt', data, 'text/plain'); + const acl = new Parse.ACL(); + acl.setPublicReadAccess(false); + acl.setReadAccess(user, true); + // EXPERIMENTAL - NO WAY TO PASS ACL THROUGH IN SDK AT THE MOMENT + file.setTags({ acl: acl.toJSON() }); + await file.save({ sessionToken: user.getSessionToken() }); + const query = new Parse.Query('_FileToken'); + try { + await query.first(); + fail('Should not have been able to query _FileToken'); + } catch (e) { + expect(e.code).toBe(119); + expect(e.message).toBe( + "Clients aren't allowed to perform the find operation on the _FileToken collection." + ); + done(); + } + }); describe('file upload configuration', () => { it('allows file upload only for authenticated user by default', async () => { @@ -868,8 +1117,9 @@ describe('Parse.File testing', () => { fileUpload: { enableForPublic: Definitions.FileUploadOptions.enableForPublic.default, enableForAnonymousUser: Definitions.FileUploadOptions.enableForAnonymousUser.default, - enableForAuthenticatedUser: Definitions.FileUploadOptions.enableForAuthenticatedUser.default, - } + enableForAuthenticatedUser: + Definitions.FileUploadOptions.enableForAuthenticatedUser.default, + }, }); let file = new Parse.File('hello.txt', data, 'text/plain'); await expectAsync(file.save()).toBeRejectedWith( @@ -917,7 +1167,10 @@ describe('Parse.File testing', () => { file = new Parse.File('hello.txt', data, 'text/plain'); const authUser = await Parse.User.signUp('user', 'password'); await expectAsync(file.save({ sessionToken: authUser.getSessionToken() })).toBeRejectedWith( - new Parse.Error(Parse.Error.FILE_SAVE_ERROR, 'File upload by authenticated user is disabled.') + new Parse.Error( + Parse.Error.FILE_SAVE_ERROR, + 'File upload by authenticated user is disabled.' + ) ); }); @@ -957,7 +1210,10 @@ describe('Parse.File testing', () => { file = new Parse.File('hello.txt', data, 'text/plain'); const authUser = await Parse.User.signUp('user', 'password'); await expectAsync(file.save({ sessionToken: authUser.getSessionToken() })).toBeRejectedWith( - new Parse.Error(Parse.Error.FILE_SAVE_ERROR, 'File upload by authenticated user is disabled.') + new Parse.Error( + Parse.Error.FILE_SAVE_ERROR, + 'File upload by authenticated user is disabled.' + ) ); }); @@ -979,7 +1235,10 @@ describe('Parse.File testing', () => { file = new Parse.File('hello.txt', data, 'text/plain'); const authUser = await Parse.User.signUp('user', 'password'); await expectAsync(file.save({ sessionToken: authUser.getSessionToken() })).toBeRejectedWith( - new Parse.Error(Parse.Error.FILE_SAVE_ERROR, 'File upload by authenticated user is disabled.') + new Parse.Error( + Parse.Error.FILE_SAVE_ERROR, + 'File upload by authenticated user is disabled.' + ) ); }); @@ -1006,33 +1265,11 @@ describe('Parse.File testing', () => { }); it('rejects invalid fileUpload configuration', async () => { - const invalidConfigs = [ - { fileUpload: [] }, - { fileUpload: 1 }, - { fileUpload: "string" }, - ]; - const validConfigs = [ - { fileUpload: {} }, - { fileUpload: null }, - { fileUpload: undefined }, - ]; - const keys = [ - "enableForPublic", - "enableForAnonymousUser", - "enableForAuthenticatedUser", - ]; - const invalidValues = [ - [], - {}, - 1, - "string", - null, - ]; - const validValues = [ - undefined, - true, - false, - ]; + const invalidConfigs = [{ fileUpload: [] }, { fileUpload: 1 }, { fileUpload: 'string' }]; + const validConfigs = [{ fileUpload: {} }, { fileUpload: null }, { fileUpload: undefined }]; + const keys = ['enableForPublic', 'enableForAnonymousUser', 'enableForAuthenticatedUser']; + const invalidValues = [[], {}, 1, 'string', null]; + const validValues = [undefined, true, false]; for (const config of invalidConfigs) { await expectAsync(reconfigureServer(config)).toBeRejectedWith( 'fileUpload must be an object value.' @@ -1043,12 +1280,12 @@ describe('Parse.File testing', () => { } for (const key of keys) { for (const value of invalidValues) { - await expectAsync(reconfigureServer({ fileUpload: { [key]: value }})).toBeRejectedWith( + await expectAsync(reconfigureServer({ fileUpload: { [key]: value } })).toBeRejectedWith( `fileUpload.${key} must be a boolean value.` ); } for (const value of validValues) { - await expectAsync(reconfigureServer({ fileUpload: { [key]: value }})).toBeResolved(); + await expectAsync(reconfigureServer({ fileUpload: { [key]: value } })).toBeResolved(); } } }); diff --git a/spec/ParseUser.spec.js b/spec/ParseUser.spec.js index a44926caa4..105d543c60 100644 --- a/spec/ParseUser.spec.js +++ b/spec/ParseUser.spec.js @@ -1320,7 +1320,7 @@ describe('Parse.User testing', () => { .then(user => { const fileAgain = user.get('file'); expect(fileAgain.name()).toMatch(/yolo.txt$/); - expect(fileAgain.url()).toMatch(/yolo.txt$/); + expect(fileAgain.url()).toMatch(/yolo.txt/); }) .then(() => { done(); diff --git a/spec/helper.js b/spec/helper.js index 5c598277f5..c4152b2665 100644 --- a/spec/helper.js +++ b/spec/helper.js @@ -218,6 +218,7 @@ afterEach(function (done) { '_Installation', '_Role', '_Session', + '_File', '_Product', '_Audience', '_Idempotency', diff --git a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js index acacbac048..c42c524785 100644 --- a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js +++ b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js @@ -1119,6 +1119,7 @@ export class PostgresStorageAdapter implements StorageAdapter { '_GraphQLConfig', '_Audience', '_Idempotency', + '_File', ...results.map(result => result.className), ...joins, ]; @@ -2283,11 +2284,10 @@ export class PostgresStorageAdapter implements StorageAdapter { return (conn || this._client).tx(t => t.batch( indexes.map(i => { - return t.none('CREATE INDEX IF NOT EXISTS $1:name ON $2:name ($3:name)', [ - i.name, - className, - i.key, - ]); + return t.none( + 'CREATE INDEX IF NOT EXISTS $1:name ON $2:name ($3:name)', + [i.name, className, i.key] + ); }) ) ); diff --git a/src/Controllers/FilesController.js b/src/Controllers/FilesController.js index aaff8511fe..bda2ce86bd 100644 --- a/src/Controllers/FilesController.js +++ b/src/Controllers/FilesController.js @@ -4,7 +4,8 @@ import AdaptableController from './AdaptableController'; import { validateFilename, FilesAdapter } from '../Adapters/Files/FilesAdapter'; import path from 'path'; import mime from 'mime'; -const Parse = require('parse').Parse; +import { randomString } from '../cryptoUtils'; +import { Parse } from 'parse/node'; const legacyFilesRegex = new RegExp( '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}-.*' @@ -42,22 +43,176 @@ export class FilesController extends AdaptableController { deleteFile(config, filename) { return this.adapter.deleteFile(filename); } - getMetadata(filename) { if (typeof this.adapter.getMetadata === 'function') { return this.adapter.getMetadata(filename); } return Promise.resolve({}); } + async updateReferences(data, originalData, className) { + if (className === '_File' || className === '_FileToken') { + return; + } + const referencesToAdd = []; + const referencesToRemove = []; + const searchForSubfiles = (keyData, keyOriginalData) => { + if (typeof keyData !== 'object') { + return; + } + if (!keyOriginalData) { + keyOriginalData = {}; + } + for (const key in keyData) { + const val = keyData[key] || {}; + const original = keyOriginalData[key] || {}; + if (typeof val !== 'object') { + continue; + } + const { __type, name } = val; + if (__type === 'File' || original.__type == 'File') { + if (name === original.name) { + continue; + } + if (name && original.name == null) { + referencesToAdd.push(name); + } + if (original.name && name == null) { + referencesToRemove.push(name); + } + continue; + } + searchForSubfiles(val, original); + } + }; + searchForSubfiles(data, originalData); + const allFiles = referencesToAdd.concat(referencesToRemove); + if (allFiles.length == 0) { + return; + } + const filesToFind = allFiles.map(val => new Parse.File(val).toJSON()); + const fileQuery = new Parse.Query('_File'); + fileQuery.containedIn('file', filesToFind); + + const refFileQuery = new Parse.Query('_FileReference'); + refFileQuery.equalTo('reference', data.objectId); + refFileQuery.equalTo('class', className); + const promises = await Promise.all([ + fileQuery.find({ useMasterKey: true }), + refFileQuery.first({ useMasterKey: true }), + ]); + const fileData = promises[0]; + let fileReference = promises[1]; + if (!fileReference) { + fileReference = new Parse.Object('_FileReference'); + fileReference.set('reference', data.objectId); + fileReference.set('class', className); + await fileReference.save(null, { useMasterKey: true }); + } + const filesToSave = []; + for (const fileObject of fileData) { + const { _name } = fileObject.get('file'); + const relation = fileObject.get('references'); + + if (referencesToAdd.includes(_name)) { + relation.add(fileReference); + } else { + relation.remove(fileReference); + } + filesToSave.push(fileObject); + } + await Parse.Object.saveAll(filesToSave, { useMasterKey: true }); + } + async getAuthForFile(file, auth, object, className) { + if (className === '_File') { + return; + } + const fileQuery = new Parse.Query('_File'); + fileQuery.equalTo('file', file); + const getFileData = {}; + if (auth && auth.user && auth.user.getSessionToken()) { + getFileData.sessionToken = auth.user.getSessionToken(); + } + if (auth && auth.master) { + getFileData.useMasterKey = true; + } + let fileObject; + try { + fileObject = await fileQuery.first(getFileData); + } catch (e) { + console.log(e); + return; + } + if (!fileObject) { + return; + } + const toSave = []; + const url = file.url.split('?'); + let appendToken = ''; + if (url.length > 1) { + appendToken = `${url[1]}&`; + } + const token = randomString(25); + appendToken += `token=${token}`; + file.url = `${url[0]}?${appendToken}`; + const expiry = new Date(new Date().getTime() + 30 * 60000); + + const fileToken = new Parse.Object('_FileToken'); + fileToken.set('fileObject', fileObject); + fileToken.set('file', file); + fileToken.set('token', token); + fileToken.set('expiry', expiry); + if (auth && auth.user) { + fileToken.set('user', auth.user); + } + if (auth && auth.master) { + fileToken.set('master', true); + } + toSave.push(fileToken); + + const relation = fileObject.relation('references'); + const refQuery = relation.query(); + refQuery.equalTo('reference', object.objectId); + refQuery.equalTo('class', className); + + const refFileQuery = new Parse.Query('_FileReference'); + refFileQuery.equalTo('reference', object.objectId); + refFileQuery.equalTo('class', className); + + const promises = await Promise.all([ + refQuery.first({ useMasterKey: true }), + refFileQuery.first({ useMasterKey: true }), + ]); + const isAdded = promises[0]; + let fileReference = promises[1]; + if (!isAdded) { + if (!fileReference) { + fileReference = new Parse.Object('_FileReference'); + fileReference.set('reference', object.objectId); + fileReference.set('class', className); + await fileReference.save(null, { useMasterKey: true }); + } + relation.add(fileReference); + toSave.push(fileObject); + } + + if (toSave.length == 0) { + return; + } + await Parse.Object.saveAll(toSave, { useMasterKey: true }); + } /** * Find file references in REST-format object and adds the url key * with the current mount point and app id. * Object may be a single object or list of REST-format objects. */ - expandFilesInObject(config, object) { + async expandFilesInObject(config, object, auth, className) { + const promises = []; if (object instanceof Array) { - object.map(obj => this.expandFilesInObject(config, obj)); + object.map(obj => promises.push(this.expandFilesInObject(config, obj, auth, className))); + } + if (promises.length != 0) { + await Promise.all(promises); return; } if (typeof object !== 'object') { @@ -67,6 +222,7 @@ export class FilesController extends AdaptableController { const fileObject = object[key]; if (fileObject && fileObject['__type'] === 'File') { if (fileObject['url']) { + await this.getAuthForFile(fileObject, auth, object, className); continue; } const filename = fileObject['name']; @@ -86,6 +242,7 @@ export class FilesController extends AdaptableController { fileObject['url'] = this.adapter.getFileLocation(config, filename); } } + await this.getAuthForFile(fileObject, auth, object, className); } } } diff --git a/src/Controllers/SchemaController.js b/src/Controllers/SchemaController.js index a5e7d2838a..7e11883160 100644 --- a/src/Controllers/SchemaController.js +++ b/src/Controllers/SchemaController.js @@ -76,6 +76,24 @@ const defaultColumns: { [string]: SchemaFields } = Object.freeze({ expiresAt: { type: 'Date' }, createdWith: { type: 'Object' }, }, + _File: { + file: { type: 'File' }, + references: { type: 'Relation', targetClass: '_FileReference' }, + tokens: { type: 'Relation', targetClass: '_FileToken' }, + ACL: { type: 'Object' }, + }, + _FileReference: { + class: { type: 'String' }, + reference: { type: 'String' }, + }, + _FileToken: { + file: { type: 'File' }, + fileObject: { type: 'Pointer', targetClass: '_File' }, + token: { type: 'String' }, + expiry: { type: 'Date' }, + user: { type: 'Pointer', targetClass: '_User' }, + master: { type: 'Boolean' }, + }, _Product: { productIdentifier: { type: 'String' }, download: { type: 'File' }, @@ -162,6 +180,9 @@ const systemClasses = Object.freeze([ '_Installation', '_Role', '_Session', + '_File', + '_FileReference', + '_FileToken', '_Product', '_PushStatus', '_JobStatus', @@ -179,6 +200,9 @@ const volatileClasses = Object.freeze([ '_JobSchedule', '_Audience', '_Idempotency', + '_File', + '_FileReference', + '_FileToken', ]); // Anything that start with role @@ -648,6 +672,21 @@ const _IdempotencySchema = convertSchemaToAdapterSchema( classLevelPermissions: {}, }) ); +const _FileSchema = convertSchemaToAdapterSchema( + injectDefaultSchema({ + className: '_File', + fields: defaultColumns._File, + classLevelPermissions: { + find: { + '*': true, + }, + get: { + '*': true, + }, + create: { requiresAuthentication: true }, + }, + }) +); const VolatileClassesSchemas = [ _HooksSchema, _JobStatusSchema, @@ -657,6 +696,7 @@ const VolatileClassesSchemas = [ _GraphQLConfigSchema, _AudienceSchema, _IdempotencySchema, + _FileSchema, ]; const dbTypeMatchesObjectType = (dbType: SchemaField | string, objectType: SchemaField) => { diff --git a/src/RestQuery.js b/src/RestQuery.js index ef3846daec..c970f33898 100644 --- a/src/RestQuery.js +++ b/src/RestQuery.js @@ -641,17 +641,25 @@ RestQuery.prototype.runFind = function (options = {}) { if (options.op) { findOptions.op = options.op; } + let results = []; return this.config.database .find(this.className, this.restWhere, findOptions, this.auth) - .then(results => { + .then(response => { + results = response; if (this.className === '_User' && findOptions.explain !== true) { for (var result of results) { cleanResultAuthData(result); } } - this.config.filesController.expandFilesInObject(this.config, results); - + return this.config.filesController.expandFilesInObject( + this.config, + results, + this.auth, + this.className + ); + }) + .then(() => { if (this.redirectClassName) { for (var r of results) { r.className = this.redirectClassName; diff --git a/src/RestWrite.js b/src/RestWrite.js index 38b318100c..cae5801008 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -297,8 +297,12 @@ RestWrite.prototype.runBeforeLoginTrigger = async function (userData) { const extraData = { className: this.className }; // Expand file objects - this.config.filesController.expandFilesInObject(this.config, userData); - + await this.config.filesController.expandFilesInObject( + this.config, + userData, + this.auth, + this.className + ); const user = triggers.inflate(extraData, userData); // no need to return a response @@ -1261,10 +1265,21 @@ RestWrite.prototype.handleInstallation = function () { // If we short-circuted the object response - then we need to make sure we expand all the files, // since this might not have a query, meaning it won't return the full result back. // TODO: (nlutsenko) This should die when we move to per-class based controllers on _Session/_User -RestWrite.prototype.expandFilesForExistingObjects = function () { +RestWrite.prototype.expandFilesForExistingObjects = async function () { // Check whether we have a short-circuited response - only then run expansion. if (this.response && this.response.response) { - this.config.filesController.expandFilesInObject(this.config, this.response.response); + await this.config.filesController.expandFilesInObject( + this.config, + this.response.response, + this.auth, + this.className + ); + } else { + await this.config.filesController.updateReferences( + this.data, + this.originalData, + this.className + ); } }; diff --git a/src/Routers/FilesRouter.js b/src/Routers/FilesRouter.js index 1a8b2ca50b..8c50b3169a 100644 --- a/src/Routers/FilesRouter.js +++ b/src/Routers/FilesRouter.js @@ -5,6 +5,7 @@ import Parse from 'parse/node'; import Config from '../Config'; import mime from 'mime'; import logger from '../logger'; +import { randomString } from '../cryptoUtils'; const triggers = require('../triggers'); const http = require('http'); @@ -33,6 +34,37 @@ const addFileDataIfNeeded = async file => { return file; }; +const createFileData = async (fileObject, auth) => { + const fileData = new Parse.Object('_File'); + fileData.set('file', fileObject.file); + fileData.set('ACL', fileObject._ACL || { '*': { read: true } }); + + const url = fileObject.file._url.split('?'); + let appendToken = ''; + if (url.length > 1) { + appendToken = `${url[1]}&`; + } + const token = randomString(25); + appendToken += `token=${token}`; + const fileURL = `${url[0]}?${appendToken}`; + fileObject.file._url = fileURL; + const expiry = new Date(new Date().getTime() + 30 * 60000); + + const fileToken = new Parse.Object('_FileToken'); + fileToken.set('fileObject', fileData); + fileToken.set('file', fileObject.file); + fileToken.set('token', token); + fileToken.set('expiry', expiry); + if (auth && auth.user) { + fileToken.set('user', auth.user); + } + if (auth && auth.master) { + fileToken.set('master', true); + } + await Parse.Object.saveAll([fileData, fileToken], { useMasterKey: true }); + return fileURL; +}; + export class FilesRouter { expressRouter({ maxUploadSize = '20Mb' } = {}) { var router = express.Router(); @@ -64,10 +96,55 @@ export class FilesRouter { return router; } - getHandler(req, res) { + async getHandler(req, res) { const config = Config.get(req.params.appId); const filesController = config.filesController; - const filename = req.params.filename; + let filename = req.params.filename; + const file = new Parse.File(filename); + file._url = filesController.adapter.getFileLocation(config, filename); + + const fileQuery = new Parse.Query('_FileToken'); + fileQuery.equalTo('file', file); + fileQuery.equalTo('token', req.query.token); + fileQuery.greaterThan('expiry', new Date()); + fileQuery.include('user'); + const fileToken = await fileQuery.first({ useMasterKey: true }); + if (!fileToken || !fileToken.get('fileObject')) { + // token does not exist or has expired. + res.status(404); + res.set('Content-Type', 'text/plain'); + res.end('File not found.'); + return; + } + const user = fileToken.get('user'); + let fileObject = fileToken.get('fileObject'); + try { + const fetchData = {}; + if (user && user.getSessionToken()) { + fetchData.sessionToken = user.getSessionToken(); + } + if (fileToken.get('master')) { + fetchData.useMasterKey = true; + } + fileObject = await fileToken.get('fileObject').fetch(fetchData); + } catch (e) { + // if not found, you cannot view the file. + res.status(404); + res.set('Content-Type', 'text/plain'); + res.end('File not found.'); + return; + } + if (!fileObject) { + fileObject = { + ACL: { '*': { read: true } }, + references: [], + file: { + __type: 'File', + name: filename, + }, + tokens: [], + }; + } const contentType = mime.getType(filename); if (isFileStreamable(req, filesController)) { filesController.handleFileStream(config, filename, req, res, contentType).catch(() => { @@ -76,19 +153,42 @@ export class FilesRouter { 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(() => { - res.status(404); - res.set('Content-Type', 'text/plain'); - res.end('File not found.'); - }); + try { + const request = { user }; + if (fileToken.get('master')) { + request.master = true; + } + const triggerResult = await triggers.maybeRunFileTrigger( + triggers.Types.beforeFind, + { file }, + config, + request + ); + let data; + if (triggerResult instanceof Parse.File) { + if (triggerResult._data) { + data = Buffer.from(triggerResult._data, 'base64'); + } else if (triggerResult._name) { + filename = triggerResult._name; + } + } + if (!data) { + data = await filesController.getFileData(config, filename); + } + res.status(200); + res.set('Content-Type', contentType); + res.set('Content-Length', data.length); + res.end(data); + try { + await triggers.maybeRunFileTrigger(triggers.Types.afterFind, { file }, config, { user }); + } catch (e) { + /* */ + } + } catch (e) { + res.status(404); + res.set('Content-Type', 'text/plain'); + res.end((e && e.message) || e || 'File not found.'); + } } } @@ -98,23 +198,38 @@ export class FilesRouter { const isMaster = req.auth.isMaster; const isLinked = user && Parse.AnonymousUtils.isLinked(user); if (!isMaster && !config.fileUpload.enableForAnonymousUser && isLinked) { - next(new Parse.Error( - Parse.Error.FILE_SAVE_ERROR, - 'File upload by anonymous user is disabled.' - )); + next( + new Parse.Error(Parse.Error.FILE_SAVE_ERROR, 'File upload by anonymous user is disabled.') + ); return; } if (!isMaster && !config.fileUpload.enableForAuthenticatedUser && !isLinked && user) { - next(new Parse.Error( - Parse.Error.FILE_SAVE_ERROR, - 'File upload by authenticated user is disabled.' - )); + next( + new Parse.Error( + Parse.Error.FILE_SAVE_ERROR, + 'File upload by authenticated user is disabled.' + ) + ); return; } if (!isMaster && !config.fileUpload.enableForPublic && !user) { next(new Parse.Error(Parse.Error.FILE_SAVE_ERROR, 'File upload by public is disabled.')); return; } + + const schema = await config.database.loadSchema(); + // CLP for _File always returns {}, even though I thought I set default CLP in SchemaController.js line 694 + const schemaPerms = schema.testPermissionsForClassName( + '_File', + [req.auth.user && req.auth.user.id], + 'create' + ); + if (!schemaPerms) { + next( + new Parse.Error(Parse.Error.FILE_SAVE_ERROR, 'You are not authorized to upload a file.') + ); + return; + } const filesController = config.filesController; const { filename } = req.params; const contentType = req.get('Content-type'); @@ -123,16 +238,16 @@ export class FilesRouter { next(new Parse.Error(Parse.Error.FILE_SAVE_ERROR, 'Invalid file upload.')); return; } - const error = filesController.validateFilename(filename); if (error) { next(error); return; } - const base64 = req.body.toString('base64'); const file = new Parse.File(filename, { base64 }, contentType); const { metadata = {}, tags = {} } = req.fileData || {}; + const acl = tags.acl; + delete tags.acl; file.setTags(tags); file.setMetadata(metadata); const fileSize = Buffer.byteLength(req.body); @@ -186,7 +301,13 @@ export class FilesRouter { name: createFileResult.name, }; } - // run afterSaveFile trigger + fileObject._ACL = acl; + try { + const fileTokenURL = await createFileData(fileObject, req.auth); + saveResult.url = fileTokenURL; + } catch (e) { + /* */ + } await triggers.maybeRunFileTrigger( triggers.Types.afterSaveFile, fileObject, @@ -205,10 +326,9 @@ export class FilesRouter { next(error); } } - async deleteHandler(req, res, next) { try { - const { filesController } = req.config; + const { filesController, database } = req.config; const { filename } = req.params; // run beforeDeleteFile trigger const file = new Parse.File(filename); @@ -222,6 +342,13 @@ export class FilesRouter { ); // delete file await filesController.deleteFile(req.config, filename); + try { + await database.destroy('_File', { + file: file.toJSON(), + }); + } catch (e) { + /**/ + } // run afterDeleteFile trigger await triggers.maybeRunFileTrigger( triggers.Types.afterDeleteFile, diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index 7843cf4674..d137031b03 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -207,7 +207,12 @@ export class UsersRouter extends ClassesRouter { // Remove hidden properties. UsersRouter.removeHiddenProperties(user); - req.config.filesController.expandFilesInObject(req.config, user); + await req.config.filesController.expandFilesInObject( + req.config, + user, + req.auth, + '_User' + ); // Before login trigger; throws if failure await maybeRunTrigger( diff --git a/src/cloud-code/Parse.Cloud.js b/src/cloud-code/Parse.Cloud.js index c04c48205e..80ce6a025f 100644 --- a/src/cloud-code/Parse.Cloud.js +++ b/src/cloud-code/Parse.Cloud.js @@ -6,6 +6,9 @@ function isParseObjectConstructor(object) { } function getClassName(parseClass) { + if (parseClass === Parse.File) { + return '@File'; + } if (parseClass && parseClass.className) { return parseClass.className; } diff --git a/src/rest.js b/src/rest.js index fca3497a5d..0ae4206e65 100644 --- a/src/rest.js +++ b/src/rest.js @@ -250,6 +250,8 @@ const classesWithMasterOnlyAccess = [ '_GlobalConfig', '_JobSchedule', '_Idempotency', + '_FileToken', + '_FileReference', ]; // Disallowing access to the _Role collection except by master key function enforceRoleSecurity(method, className, auth) { diff --git a/src/triggers.js b/src/triggers.js index 47331675b0..b14b68e839 100644 --- a/src/triggers.js +++ b/src/triggers.js @@ -885,8 +885,20 @@ export function getRequestFileObject(triggerType, auth, fileObject, config) { return request; } -export async function maybeRunFileTrigger(triggerType, fileObject, config, auth) { - const fileTrigger = getFileTrigger(triggerType, config.applicationId); + +export async function maybeRunFileTrigger( + triggerType, + fileObject, + config, + auth +) { + let fileTrigger = getFileTrigger(triggerType, config.applicationId); + if (!fileTrigger) { + fileTrigger = getFileTrigger( + triggerType.replace('File', ''), + config.applicationId + ); + } if (typeof fileTrigger === 'function') { try { const request = getRequestFileObject(triggerType, auth, fileObject, config);