diff --git a/spec/CloudCode.spec.js b/spec/CloudCode.spec.js index c02999ad51..ef6f9928a8 100644 --- a/spec/CloudCode.spec.js +++ b/spec/CloudCode.spec.js @@ -3812,6 +3812,73 @@ describe('saveFile hooks', () => { ); } }); + + it('can run find hooks', async () => { + await reconfigureServer({ + fileUpload: { + enableForPublic: true, + enableForAnonymousUser: true, + enableForAuthenticatedUser: true, + enableLegacyAccess: false, + }, + }); + const user = new Parse.User(); + user.setUsername('triggeruser'); + user.setPassword('triggeruser'); + await user.signUp(); + const base64 = 'V29ya2luZyBhdCBQYXJzZSBpcyBncmVhdCE='; + const file = new Parse.File('myfile.txt', { base64 }); + await file.save({ sessionToken: user.getSessionToken() }); + const hooks = { + beforeFind(req) { + expect(req.user.id).toEqual(user.id); + expect(req.file instanceof Parse.File).toBeTrue(); + expect(req.file._name).toEqual(file._name); + expect(req.file._url).toEqual(file._url.split('?')[0]); + }, + afterFind(req) { + expect(req.user.id).toEqual(user.id); + expect(req.file instanceof Parse.File).toBeTrue(); + expect(req.file._name).toEqual(file._name); + expect(req.file._url).toEqual(file._url.split('?')[0]); + }, + }; + for (const key in hooks) { + spyOn(hooks, key).and.callThrough(); + Parse.Cloud[key](Parse.File, hooks[key]); + } + const response = await request({ url: file.url() }); + expect(response.text).toEqual('Working at Parse is great!'); + for (const key in hooks) { + expect(hooks[key]).toHaveBeenCalled(); + } + }); + + it('can clean up files', async () => { + const server = await reconfigureServer(); + const base64 = 'V29ya2luZyBhdCBQYXJzZSBpcyBncmVhdCE='; + const file = new Parse.File('myfile.txt', { base64 }); + const obj = await new Parse.Object('TestObject').save({ file }); + await new Promise(resolve => setTimeout(resolve, 1000)); + await Promise.all([ + (async () => { + const objects = await new Parse.Query('_FileObject').find({ useMasterKey: true }); + expect(objects.length).toBe(1); + })(), + (async () => { + const objects = await new Parse.Query('_FileReference').find({ useMasterKey: true }); + expect(objects.length).toBe(1); + })(), + ]); + await obj.destroy(); + await new Promise(resolve => setTimeout(resolve, 1000)); + const references = await new Parse.Query('_FileReference').find({ useMasterKey: true }); + expect(references.length).toBe(0); + await server.cleanupFiles(); + await new Promise(resolve => setTimeout(resolve, 1000)); + const objects = await new Parse.Query('_FileObject').find({ useMasterKey: true }); + expect(objects.length).toBe(0); + }); }); describe('sendEmail', () => { diff --git a/spec/FilesController.spec.js b/spec/FilesController.spec.js index 8fee5aca2f..9c6c1f2c2c 100644 --- a/spec/FilesController.spec.js +++ b/spec/FilesController.spec.js @@ -21,11 +21,11 @@ const mockAdapter = { // Small additional tests to improve overall coverage describe('FilesController', () => { - it('should properly expand objects', done => { + it('should properly expand objects', async () => { const config = Config.get(Parse.applicationId); const gridFSAdapter = new GridFSBucketAdapter('mongodb://localhost:27017/parse'); const filesController = new FilesController(gridFSAdapter); - const result = filesController.expandFilesInObject(config, function () {}); + const result = await filesController.expandFilesInObject(config, function () {}); expect(result).toBeUndefined(); @@ -37,10 +37,8 @@ describe('FilesController', () => { const anObject = { aFile: fullFile, }; - filesController.expandFilesInObject(config, anObject); + await filesController.expandFilesInObject(config, anObject); expect(anObject.aFile.url).toEqual('http://an.url'); - - done(); }); it_only_db('mongo')('should pass databaseOptions to GridFSBucketAdapter', async () => { diff --git a/spec/ParseFile.spec.js b/spec/ParseFile.spec.js index ed21304d39..1e93e44363 100644 --- a/spec/ParseFile.spec.js +++ b/spec/ParseFile.spec.js @@ -265,7 +265,15 @@ describe('Parse.File testing', () => { ok(objectAgain.get('file') instanceof Parse.File); }); - it('autosave file in object', async done => { + it('autosave file in object', async () => { + await reconfigureServer({ + fileUpload: { + enableForPublic: true, + enableForAnonymousUser: true, + enableForAuthenticatedUser: true, + enableLegacyAccess: false, + }, + }); let file = new Parse.File('hello.txt', data, 'text/plain'); ok(!file.url()); const object = new Parse.Object('TestObject'); @@ -276,7 +284,60 @@ describe('Parse.File testing', () => { ok(file.name()); ok(file.url()); notEqual(file.name(), 'hello.txt'); - done(); + await Promise.all([ + (async () => { + const fileObjects = await new Parse.Query('_FileObject').find({ + useMasterKey: true, + json: true, + }); + expect(fileObjects.length).toBe(1); + const fileObject = fileObjects[0]; + expect(fileObject.className).toBe('_FileObject'); + expect(fileObject.file).toEqual({ + __type: 'File', + name: file.name(), + url: file.url().split('?token')[0], + }); + expect(fileObject.ACL).toEqual({ + '*': { + read: true, + }, + }); + })(), + (async () => { + const fileReferences = await new Parse.Query('_FileReference').find({ + useMasterKey: true, + json: true, + }); + expect(fileReferences.length).toBe(1); + const fileReference = fileReferences[0]; + expect(fileReference.className).toBe('_FileReference'); + expect(fileReference.file).toEqual({ + __type: 'Pointer', + className: '_FileObject', + objectId: fileReference.file.objectId, + }); + expect(fileReference.referenceId).toBe(objectAgain.id); + expect(fileReference.referenceClass).toBe('TestObject'); + })(), + (async () => { + const fileSessions = await new Parse.Query('_FileSession').find({ + useMasterKey: true, + json: true, + }); + expect(fileSessions.length).toBe(3); + const fileSession = fileSessions[2]; + expect(fileSession.file).toEqual({ + __type: 'Pointer', + className: '_FileObject', + objectId: fileSession.file.objectId, + }); + expect(fileSession.token).toBe(file.url().split('?token=')[1]); + expect(fileSession.master).toBe(false); + expect(new Date(fileSession.expiry.iso) instanceof Date).toBeTrue(); + })(), + ]); + expect(file.url()).toContain('?token='); }); it('autosave file in object in object', async done => { @@ -390,7 +451,7 @@ describe('Parse.File testing', () => { }); }); - it('supports array of files', done => { + it('supports array of files', async () => { const file = { __type: 'File', url: 'http://meep.meep', @@ -399,19 +460,12 @@ describe('Parse.File testing', () => { const files = [file, file]; const obj = new Parse.Object('FilesArrayTest'); obj.set('files', files); - obj - .save() - .then(() => { - const query = new Parse.Query('FilesArrayTest'); - return query.first(); - }) - .then(result => { - const filesAgain = result.get('files'); - expect(filesAgain.length).toEqual(2); - expect(filesAgain[0].name()).toEqual('meep'); - expect(filesAgain[0].url()).toEqual('http://meep.meep'); - done(); - }); + await obj.save(); + const result = await new Parse.Query('FilesArrayTest').first(); + const filesAgain = result.get('files'); + expect(filesAgain.length).toEqual(2); + expect(filesAgain[0].name()).toEqual('meep'); + expect(filesAgain[0].url()).toEqual('http://meep.meep'); }); it('validates filename characters', done => { @@ -486,58 +540,6 @@ describe('Parse.File testing', () => { }); }); - it('creates correct url for old files hosted on files.parsetfss.com', done => { - const file = { - __type: 'File', - url: 'http://irrelevant.elephant/', - name: 'tfss-123.txt', - }; - const obj = new Parse.Object('OldFileTest'); - obj.set('oldfile', file); - obj - .save() - .then(() => { - const query = new Parse.Query('OldFileTest'); - return query.first(); - }) - .then(result => { - const fileAgain = result.get('oldfile'); - expect(fileAgain.url()).toEqual('http://files.parsetfss.com/test/tfss-123.txt'); - done(); - }) - .catch(e => { - jfail(e); - done(); - }); - }); - - it('creates correct url for old files hosted on files.parse.com', done => { - const file = { - __type: 'File', - url: 'http://irrelevant.elephant/', - name: 'd6e80979-a128-4c57-a167-302f874700dc-123.txt', - }; - const obj = new Parse.Object('OldFileTest'); - obj.set('oldfile', file); - obj - .save() - .then(() => { - const query = new Parse.Query('OldFileTest'); - return query.first(); - }) - .then(result => { - const fileAgain = result.get('oldfile'); - expect(fileAgain.url()).toEqual( - 'http://files.parse.com/test/d6e80979-a128-4c57-a167-302f874700dc-123.txt' - ); - done(); - }) - .catch(e => { - jfail(e); - done(); - }); - }); - it('supports files in objects without urls', done => { const file = { __type: 'File', diff --git a/spec/ParseUser.spec.js b/spec/ParseUser.spec.js index 4d3beaf349..df3915cbb5 100644 --- a/spec/ParseUser.spec.js +++ b/spec/ParseUser.spec.js @@ -1361,30 +1361,19 @@ describe('Parse.User testing', () => { .catch(done.fail); }); - it('log in with provider with files', done => { + it('log in with provider with files', async () => { const provider = getMockFacebookProvider(); Parse.User._registerAuthenticationProvider(provider); const file = new Parse.File('yolo.txt', [1, 2, 3], 'text/plain'); - file - .save() - .then(file => { - const user = new Parse.User(); - user.set('file', file); - return user._linkWith('facebook', {}); - }) - .then(user => { - expect(user._isLinked('facebook')).toBeTruthy(); - return Parse.User._logInWith('facebook', {}); - }) - .then(user => { - const fileAgain = user.get('file'); - expect(fileAgain.name()).toMatch(/yolo.txt$/); - expect(fileAgain.url()).toMatch(/yolo.txt$/); - }) - .then(() => { - done(); - }) - .catch(done.fail); + await file.save(); + let user = new Parse.User(); + user.set('file', file); + await user._linkWith('facebook', {}); + expect(user._isLinked('facebook')).toBeTruthy(); + user = await Parse.User._logInWith('facebook', {}); + const fileAgain = user.get('file'); + expect(fileAgain.name()).toMatch(/yolo.txt$/); + expect(fileAgain.url()).toMatch(/yolo.txt$/); }); it('log in with provider twice', async done => { diff --git a/spec/helper.js b/spec/helper.js index 445de26509..29e62b0f55 100644 --- a/spec/helper.js +++ b/spec/helper.js @@ -82,7 +82,7 @@ on_db( ); let logLevel; -let silent = true; +let silent = false; if (process.env.VERBOSE) { silent = false; logLevel = 'verbose'; @@ -112,6 +112,8 @@ const defaultConfiguration = { enableForPublic: true, enableForAnonymousUser: true, enableForAuthenticatedUser: true, + enableLegacyAccess: true, + tokenValidityDuration: 5 * 60, }, push: { android: { diff --git a/src/Config.js b/src/Config.js index 812d28c367..783dbd02ba 100644 --- a/src/Config.js +++ b/src/Config.js @@ -460,6 +460,19 @@ export class Config { } else if (typeof fileUpload.enableForAuthenticatedUser !== 'boolean') { throw 'fileUpload.enableForAuthenticatedUser must be a boolean value.'; } + if (fileUpload.enableLegacyAccess === undefined) { + fileUpload.enableLegacyAccess = FileUploadOptions.enableLegacyAccess.default; + } else if (typeof fileUpload.enableLegacyAccess !== 'boolean') { + throw 'fileUpload.enableLegacyAccess must be a boolean value.'; + } + if (fileUpload.tokenValidityDuration === undefined) { + fileUpload.tokenValidityDuration = FileUploadOptions.tokenValidityDuration.default; + } else if ( + typeof fileUpload.tokenValidityDuration !== 'number' || + fileUpload.tokenValidityDuration <= 0 + ) { + throw 'fileUpload.tokenValidityDuration must be a positive number.'; + } } static validateIps(field, masterKeyIps) { diff --git a/src/Controllers/DatabaseController.js b/src/Controllers/DatabaseController.js index e3ac5723ab..c82ce4f260 100644 --- a/src/Controllers/DatabaseController.js +++ b/src/Controllers/DatabaseController.js @@ -1697,9 +1697,30 @@ class DatabaseController { ...SchemaController.defaultColumns._Idempotency, }, }; + const requiredFileObjectFields = { + fields: { + ...SchemaController.defaultColumns._Default, + ...SchemaController.defaultColumns._FileObject, + }, + }; + const requiredFileSessionFields = { + fields: { + ...SchemaController.defaultColumns._Default, + ...SchemaController.defaultColumns._FileSession, + }, + }; + const requiredFileReferenceFields = { + fields: { + ...SchemaController.defaultColumns._Default, + ...SchemaController.defaultColumns._FileReference, + }, + }; await this.loadSchema().then(schema => schema.enforceClassExists('_User')); await this.loadSchema().then(schema => schema.enforceClassExists('_Role')); await this.loadSchema().then(schema => schema.enforceClassExists('_Idempotency')); + await this.loadSchema().then(schema => schema.enforceClassExists('_FileObject')); + await this.loadSchema().then(schema => schema.enforceClassExists('_FileSession')); + await this.loadSchema().then(schema => schema.enforceClassExists('_FileReference')); await this.adapter.ensureUniqueness('_User', requiredUserFields, ['username']).catch(error => { logger.warn('Unable to ensure uniqueness for usernames: ', error); @@ -1743,6 +1764,30 @@ class DatabaseController { throw error; }); + await this.adapter + .ensureUniqueness('_FileObject', requiredFileObjectFields, ['file']) + .catch(error => { + logger.warn('Unable to ensure uniqueness for file object: ', error); + throw error; + }); + + await this.adapter + .ensureUniqueness('_FileSession', requiredFileSessionFields, ['token']) + .catch(error => { + logger.warn('Unable to ensure uniqueness for file object: ', error); + throw error; + }); + + await this.adapter + .ensureUniqueness('_FileReference', requiredFileReferenceFields, [ + 'referenceId', + 'referenceClass', + ]) + .catch(error => { + logger.warn('Unable to ensure uniqueness for file object: ', error); + throw error; + }); + const isMongoAdapter = this.adapter instanceof MongoStorageAdapter; const isPostgresAdapter = this.adapter instanceof PostgresStorageAdapter; if (isMongoAdapter || isPostgresAdapter) { diff --git a/src/Controllers/FilesController.js b/src/Controllers/FilesController.js index aaff8511fe..8ec66e3e12 100644 --- a/src/Controllers/FilesController.js +++ b/src/Controllers/FilesController.js @@ -4,11 +4,9 @@ import AdaptableController from './AdaptableController'; import { validateFilename, FilesAdapter } from '../Adapters/Files/FilesAdapter'; import path from 'path'; import mime from 'mime'; -const Parse = require('parse').Parse; - -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}-.*' -); +import { Parse } from 'parse/node'; +import RestQuery from '../RestQuery'; +import { randomString } from '../cryptoUtils'; export class FilesController extends AdaptableController { getFileData(config, filename) { @@ -55,39 +53,98 @@ export class FilesController extends AdaptableController { * with the current mount point and app id. * Object may be a single object or list of REST-format objects. */ - expandFilesInObject(config, object) { - if (object instanceof Array) { - object.map(obj => this.expandFilesInObject(config, obj)); - return; + async expandFilesInObject(config, object, className, auth, op) { + if (Array.isArray(object)) { + return Promise.all( + object.map(obj => this.expandFilesInObject(config, obj, className, auth, op)) + ); } if (typeof object !== 'object') { return; } - for (const key in object) { - const fileObject = object[key]; - if (fileObject && fileObject['__type'] === 'File') { - if (fileObject['url']) { - continue; - } - const filename = fileObject['name']; - // all filenames starting with "tfss-" should be from files.parsetfss.com - // all filenames starting with a "-" seperated UUID should be from files.parse.com - // all other filenames have been migrated or created from Parse Server - if (config.fileKey === undefined) { - fileObject['url'] = this.adapter.getFileLocation(config, filename); - } else { - if (filename.indexOf('tfss-') === 0) { - fileObject['url'] = - 'http://files.parsetfss.com/' + config.fileKey + '/' + encodeURIComponent(filename); - } else if (legacyFilesRegex.test(filename)) { - fileObject['url'] = - 'http://files.parse.com/' + config.fileKey + '/' + encodeURIComponent(filename); - } else { - fileObject['url'] = this.adapter.getFileLocation(config, filename); + await Promise.all( + Object.keys(object).map(async key => { + const fileObject = object[key]; + if (fileObject && fileObject['__type'] === 'File') { + const filename = fileObject['name']; + if (!fileObject.url) { + fileObject.url = this.adapter.getFileLocation(config, filename); + } + if (className.charAt(0) !== '_' && op !== 'delete') { + const file = new Parse.File(filename); + file._url = fileObject.url; + const files = await new RestQuery( + config, + auth, + '_FileObject', + { file: file.toJSON() }, + { limit: 1 } + ).execute(); + if (files.results.length === 0 && !config.fileUpload.enableLegacyAccess) { + delete object[key]; + return; + } + const [token] = await Promise.all([ + this.createFileSession(config, auth, files.results[0].objectId), + (async () => { + try { + const refFile = Parse.Object.extend('_FileObject').createWithoutData( + files.results[0].objectId + ); + const reference = await new Parse.Query('_FileReference') + .equalTo({ + file: refFile, + referenceId: object.objectId, + referenceClass: className, + }) + .first({ useMasterKey: true }); + if (!reference) { + const fileReference = new Parse.Object('_FileReference'); + fileReference.set({ + file: Parse.Object.extend('_FileObject').createWithoutData( + files.results[0].objectId + ), + referenceId: object.objectId, + referenceClass: className, + }); + await fileReference.save(null, { useMasterKey: true }); + } + } catch (e) { + /* */ + } + })(), + ]); + fileObject['url'] = `${fileObject['url']}?token=${token}`; } } - } - } + }) + ); + } + + async createFileSession(config, auth, objectId) { + const fileObj = Parse.Object.extend('_FileObject').createWithoutData(objectId); + const token = randomString(32); + const expiry = new Date(); + expiry.setTime(expiry.getTime() + config.fileUpload.tokenValidityDuration * 1000); + const fileSession = new Parse.Object('_FileSession'); + fileSession.set({ + file: fileObj, + token, + expiry, + master: auth?.isMaster, + sessionToken: auth?.user?.getSessionToken(), + installationId: auth?.installationId, + }); + await fileSession.save(null, { useMasterKey: true }); + + clearTimeout(this.clearExpiredFileSessions); + this.clearExpiredFileSessions = setTimeout(() => { + new Parse.Query('_FileSession') + .lessThan('expiry', new Date()) + .each(session => session.destroy({ useMasterKey: true }), { useMasterKey: true }); + }, 5000); + + return token; } expectedAdapterType() { diff --git a/src/Controllers/SchemaController.js b/src/Controllers/SchemaController.js index ad3699aaa5..a760d883ec 100644 --- a/src/Controllers/SchemaController.js +++ b/src/Controllers/SchemaController.js @@ -148,6 +148,22 @@ const defaultColumns: { [string]: SchemaFields } = Object.freeze({ reqId: { type: 'String' }, expire: { type: 'Date' }, }, + _FileObject: { + file: { type: 'File' }, + }, + _FileSession: { + file: { type: 'Pointer', targetClass: '_FileObject' }, + master: { type: 'Boolean' }, + sessionToken: { type: 'String' }, + installationId: { type: 'String' }, + token: { type: 'String' }, + expiry: { type: 'Date' }, + }, + _FileReference: { + file: { type: 'Pointer', targetClass: '_FileObject' }, + referenceId: { type: 'String' }, + referenceClass: { type: 'String' }, + }, }); // fields required for read or write operations on their respective classes. @@ -174,6 +190,9 @@ const systemClasses = Object.freeze([ '_JobSchedule', '_Audience', '_Idempotency', + '_FileObject', + '_FileSession', + '_FileReference', ]); const volatileClasses = Object.freeze([ @@ -185,6 +204,9 @@ const volatileClasses = Object.freeze([ '_JobSchedule', '_Audience', '_Idempotency', + '_FileObject', + '_FileSession', + '_FileReference', ]); // Anything that start with role @@ -654,6 +676,27 @@ const _IdempotencySchema = convertSchemaToAdapterSchema( classLevelPermissions: {}, }) ); +const _FileSchema = convertSchemaToAdapterSchema( + injectDefaultSchema({ + className: '_FileObject', + fields: defaultColumns._FileObject, + classLevelPermissions: {}, + }) +); +const _FileSessionSchema = convertSchemaToAdapterSchema( + injectDefaultSchema({ + className: '_FileSession', + fields: defaultColumns._FileSession, + classLevelPermissions: {}, + }) +); +const _FileReferencechema = convertSchemaToAdapterSchema( + injectDefaultSchema({ + className: '_FileReference', + fields: defaultColumns._FileReference, + classLevelPermissions: {}, + }) +); const VolatileClassesSchemas = [ _HooksSchema, _JobStatusSchema, @@ -663,6 +706,9 @@ const VolatileClassesSchemas = [ _GraphQLConfigSchema, _AudienceSchema, _IdempotencySchema, + _FileSchema, + _FileSessionSchema, + _FileReferencechema, ]; const dbTypeMatchesObjectType = (dbType: SchemaField | string, objectType: SchemaField) => { diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index d6acc948e3..6b49fed73e 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -969,6 +969,19 @@ module.exports.FileUploadOptions = { action: parsers.booleanParser, default: false, }, + enableLegacyAccess: { + env: 'PARSE_SERVER_FILE_UPLOAD_ENABLE_LEGACY_ACCESS', + help: + 'Is true if files that do not have a corresponding _FileObject should be publicly accessable.', + action: parsers.booleanParser, + default: true, + }, + tokenValidityDuration: { + env: 'PARSE_SERVER_FILE_UPLOAD_TOKEN_VALIDITY_DURATION', + help: 'Duration of the file token in seconds', + action: parsers.numberParser('tokenValidityDuration'), + default: 300, + }, }; module.exports.DatabaseOptions = { enableSchemaHooks: { diff --git a/src/Options/docs.js b/src/Options/docs.js index 8ebf63b97d..3aadfaee15 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -221,6 +221,8 @@ * @property {Boolean} enableForAnonymousUser Is true if file upload should be allowed for anonymous users. * @property {Boolean} enableForAuthenticatedUser Is true if file upload should be allowed for authenticated users. * @property {Boolean} enableForPublic Is true if file upload should be allowed for anyone, regardless of user authentication. + * @property {Boolean} enableLegacyAccess Is true if files that do not have a corresponding _FileObject should be publicly accessable. + * @property {Number} tokenValidityDuration Duration of the file token in seconds */ /** diff --git a/src/Options/index.js b/src/Options/index.js index 59e040b57f..8396c8017a 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -546,6 +546,12 @@ export interface FileUploadOptions { /* Is true if file upload should be allowed for anyone, regardless of user authentication. :DEFAULT: false */ enableForPublic: ?boolean; + /* Is true if files that do not have a corresponding _FileObject should be publicly accessable. + :DEFAULT: true */ + enableLegacyAccess: ?boolean; + /* Duration of the file token in seconds + :DEFAULT: 300 */ + tokenValidityDuration: ?number; } export interface DatabaseOptions { diff --git a/src/ParseServer.js b/src/ParseServer.js index 04379ecfd3..4189fdae9b 100644 --- a/src/ParseServer.js +++ b/src/ParseServer.js @@ -154,6 +154,23 @@ class ParseServer { return this._app; } + cleanupFiles() { + new Parse.Query('_FileObject').each( + async obj => { + const file = obj.get('file'); + const reference = await new Parse.Query('_FileReference') + .equalTo('file', obj) + .first({ useMasterKey: true }); + if (!reference) { + console.log(`Deleting orphaned file ${file.url()}`); + await file.destroy({ useMasterKey: true }); + } + }, + { useMasterKey: true } + ); + console.log('Beginning to cleanup files...'); + } + handleShutdown() { const promises = []; const { adapter: databaseAdapter } = this.config.databaseController; diff --git a/src/RestQuery.js b/src/RestQuery.js index fe3617eb1b..ecf41136bd 100644 --- a/src/RestQuery.js +++ b/src/RestQuery.js @@ -646,10 +646,10 @@ RestQuery.prototype.replaceEquality = function () { // Returns a promise for whether it was successful. // Populates this.response with an object that only has 'results'. -RestQuery.prototype.runFind = function (options = {}) { +RestQuery.prototype.runFind = async function (options = {}) { if (this.findOptions.limit === 0) { this.response = { results: [] }; - return Promise.resolve(); + return; } const findOptions = Object.assign({}, this.findOptions); if (this.keys) { @@ -660,24 +660,32 @@ RestQuery.prototype.runFind = function (options = {}) { if (options.op) { findOptions.op = options.op; } - return this.config.database - .find(this.className, this.restWhere, findOptions, this.auth) - .then(results => { - if (this.className === '_User' && !findOptions.explain) { - for (var result of results) { - this.cleanResultAuthData(result); - } - } + const results = await this.config.database.find( + this.className, + this.restWhere, + findOptions, + this.auth + ); + if (this.className === '_User' && !findOptions.explain) { + for (const result of results) { + this.cleanResultAuthData(result); + } + } - this.config.filesController.expandFilesInObject(this.config, results); + await this.config.filesController.expandFilesInObject( + this.config, + results, + this.className, + this.auth, + findOptions.op + ); - if (this.redirectClassName) { - for (var r of results) { - r.className = this.redirectClassName; - } - } - this.response = { results: results }; - }); + if (this.redirectClassName) { + for (const r of results) { + r.className = this.redirectClassName; + } + } + this.response = { results: results }; }; // Returns a promise for whether it was successful. diff --git a/src/RestWrite.js b/src/RestWrite.js index c703ee50bb..9dad74331f 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -150,6 +150,9 @@ RestWrite.prototype.execute = function () { .then(() => { return this.runAfterSaveTrigger(); }) + .then(() => { + return this.buildFileReferences(); + }) .then(() => { return this.cleanUserAuthData(); }) @@ -314,7 +317,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.className, + this.auth + ); const user = triggers.inflate(extraData, userData); @@ -1343,13 +1351,26 @@ RestWrite.prototype.handleInstallation = function () { // If we short-circuited 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 () { - // 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); +RestWrite.prototype.expandFilesForExistingObjects = async function () { + if (this.response?.response) { + await this.config.filesController.expandFilesInObject( + this.config, + this.response.response, + this.className, + this.auth + ); } }; +RestWrite.prototype.buildFileReferences = async function () { + await this.config.filesController.expandFilesInObject( + this.config, + this.data, + this.className, + this.auth + ); +}; + RestWrite.prototype.runDatabaseOperation = function () { if (this.response) { return; diff --git a/src/Routers/FilesRouter.js b/src/Routers/FilesRouter.js index e911d772a4..1732e8d897 100644 --- a/src/Routers/FilesRouter.js +++ b/src/Routers/FilesRouter.js @@ -5,6 +5,8 @@ import Parse from 'parse/node'; import Config from '../Config'; import mime from 'mime'; import logger from '../logger'; +import Auth from '../Auth'; +import RestQuery from '../RestQuery'; const triggers = require('../triggers'); const http = require('http'); const Utils = require('../Utils'); @@ -67,7 +69,7 @@ export class FilesRouter { return router; } - getHandler(req, res) { + async getHandler(req, res) { const config = Config.get(req.params.appId); if (!config) { res.status(403); @@ -75,6 +77,7 @@ export class FilesRouter { res.json({ code: err.code, error: err.message }); return; } + const token = req.param('token'); const filesController = config.filesController; const filename = req.params.filename; const contentType = mime.getType(filename); @@ -84,20 +87,61 @@ export class FilesRouter { 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(() => { - res.status(404); - res.set('Content-Type', 'text/plain'); - res.end('File not found.'); + return; + } + try { + const fileSession = await new Parse.Query('_FileSession') + .equalTo('token', token) + .first({ useMasterKey: true }); + if (!fileSession) { + throw 'File not found'; + } + let auth = new Auth.Auth({ + config, + isMaster: false, + }); + if (fileSession.get('master')) { + auth = new Auth.Auth({ + config, + installationId: fileSession.get('installationId'), + isMaster: true, + }); + } else if (fileSession.get('sessionToken')) { + auth = await Auth.getAuthForSessionToken({ + config, + installationId: fileSession.get('installationId'), + sessionToken: await fileSession.get('sessionToken'), }); + } + const fileObject = new Parse.File(filename); + const conf = { ...config }; + if (!conf.mount) { + conf.mount = conf.publicServerURL || conf.serverURL; + } + fileObject._url = filesController.adapter.getFileLocation(conf, filename); + fileObject.contentType = contentType; + const triggerResult = await triggers.maybeRunFileTrigger( + triggers.Types.beforeFind, + { file: fileObject }, + config, + auth + ); + const data = await filesController.getFileData(config, triggerResult.file.name()); + fileObject._data = data; + await triggers.maybeRunFileTrigger( + triggers.Types.afterFind, + { file: fileObject }, + config, + auth + ); + res.status(200); + res.set('Content-Type', contentType); + res.set('Content-Length', data.length); + res.end(data); + } catch (e) { + res.status(404); + res.set('Content-Type', 'text/plain'); + res.end(e || 'File not found.'); } } @@ -126,7 +170,7 @@ export class FilesRouter { return; } const filesController = config.filesController; - const { filename } = req.params; + const { filename, acl } = req.params; const contentType = req.get('Content-type'); if (!req.body || !req.body.length) { @@ -218,6 +262,23 @@ export class FilesRouter { url: createFileResult.url, name: createFileResult.name, }; + const fileObj = new Parse.Object('_FileObject'); + const file = new Parse.File(fileObject.file._name); + file._url = fileObject.file._url; + fileObj.set('file', file); + fileObj.setACL( + new Parse.ACL( + acl || + user?.id || { + '*': { + read: true, + }, + } + ) + ); + await fileObj.save(null, { useMasterKey: true }); + const token = await filesController.createFileSession(config, req.auth, fileObj.id); + saveResult.url = saveResult.url + '?token=' + token; } // run afterSaveFile trigger await triggers.maybeRunFileTrigger(triggers.Types.afterSave, fileObject, config, req.auth); @@ -242,6 +303,16 @@ export class FilesRouter { const file = new Parse.File(filename); file._url = filesController.adapter.getFileLocation(req.config, filename); const fileObject = { file, fileSize: null }; + const files = await new RestQuery( + req.config, + req.auth, + '_FileObject', + { file: file.toJSON() }, + { limit: 1 } + ).execute(); + if (files.results.length === 0 && !req.config.fileUpload.enableLegacyAccess) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'File not found.'); + } await triggers.maybeRunFileTrigger( triggers.Types.beforeDelete, fileObject, @@ -257,6 +328,27 @@ export class FilesRouter { req.config, req.auth ); + const fileId = files.results[0]?.objectId; + if (fileId) { + const fileObj = Parse.Object.extend('_FileObject').createWithoutData(fileId); + fileObj + .destroy({ useMasterKey: true }) + .then(() => { + new Parse.Query('_FileReference').equalTo({ file: fileObj }).each( + obj => { + obj.destroy(null, { useMasterKey: true }); + }, + { useMasterKey: true } + ); + new Parse.Query('_FileSession').equalTo({ file: fileObj }).each( + obj => { + obj.destroy(null, { useMasterKey: true }); + }, + { useMasterKey: true } + ); + }) + .catch(e => e); + } res.status(200); // TODO: return useful JSON here? res.end(); diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index feca46e802..50dd29417e 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -246,7 +246,7 @@ 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, '_User', req.auth); // Before login trigger; throws if failure await maybeRunTrigger( diff --git a/src/rest.js b/src/rest.js index e1e53668a6..fa6b7ca0b9 100644 --- a/src/rest.js +++ b/src/rest.js @@ -152,14 +152,27 @@ function del(config, auth, className, objectId, context) { } } - return config.database.destroy( - className, - { - objectId: objectId, - }, - options, - schemaController - ); + return Promise.all([ + config.database.destroy( + className, + { + objectId: objectId, + }, + options, + schemaController + ), + config.database + .destroy( + '_FileReference', + { + referenceId: objectId, + referenceClass: className, + }, + {}, + schemaController + ) + .catch(e => e), + ]); }) .then(() => { // Notify LiveQuery server if possible