diff --git a/package.json b/package.json index 65ad17251b..74aa8ef45c 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "lru-cache": "5.1.1", "mime": "2.4.6", "mongodb": "3.6.2", + "otplib": "^12.0.1", "parse": "2.17.0", "pg-promise": "10.6.2", "pluralize": "8.0.0", diff --git a/spec/ParseFile.spec.js b/spec/ParseFile.spec.js index bb1c5c512f..fb30331dbc 100644 --- a/spec/ParseFile.spec.js +++ b/spec/ParseFile.spec.js @@ -304,19 +304,19 @@ describe('Parse.File testing', () => { let firstName; let secondName; - const firstSave = file.save().then(function() { + const firstSave = file.save().then(function () { firstName = file.name(); }); - const secondSave = file.save().then(function() { + const secondSave = file.save().then(function () { secondName = file.name(); }); Promise.all([firstSave, secondSave]).then( - function() { + function () { equal(firstName, secondName); done(); }, - function(error) { + function (error) { ok(false, error); done(); } @@ -872,4 +872,100 @@ 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); + 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 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); + 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 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); + file.setTags({ acl: acl.toJSON() }); + await file.save({ sessionToken: user.getSessionToken() }); + const query = new Parse.Query('_File'); + try { + await query.first(); + fail('Should not have been able to query _Files'); + } catch (e) { + expect(e.code).toBe(119); + expect(e.message).toBe( + "Clients aren't allowed to perform the find operation on the _File collection." + ); + done(); + } + }); }); diff --git a/spec/helper.js b/spec/helper.js index 393921c9a2..c339f8fe6d 100644 --- a/spec/helper.js +++ b/spec/helper.js @@ -228,6 +228,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 3ca9cd43f9..cb5a9858d9 100644 --- a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js +++ b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js @@ -1210,6 +1210,7 @@ export class PostgresStorageAdapter implements StorageAdapter { '_GraphQLConfig', '_Audience', '_Idempotency', + '_File', ...results.map(result => result.className), ...joins, ]; @@ -2492,11 +2493,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 579570eccd..3c09e4ec61 100644 --- a/src/Controllers/FilesController.js +++ b/src/Controllers/FilesController.js @@ -4,6 +4,7 @@ import AdaptableController from './AdaptableController'; import { validateFilename, FilesAdapter } from '../Adapters/Files/FilesAdapter'; import path from 'path'; import mime from 'mime'; +import { authenticator } from 'otplib'; const Parse = require('parse').Parse; const legacyFilesRegex = new RegExp( @@ -51,15 +52,65 @@ export class FilesController extends AdaptableController { } return Promise.resolve({}); } + async getAuthForFile(config, file, auth) { + const [fileObject] = await config.database.find('_File', { + file, + }); + const user = auth.user; + if (fileObject && fileObject.authACL) { + const acl = new Parse.ACL(fileObject.authACL); + if (!acl || acl.getPublicReadAccess() || !user) { + return; + } + const isAllowed = () => { + if (acl.getReadAccess(user.id)) { + return true; + } + + // Check if the user has any roles that match the ACL + return Promise.resolve() + .then(async () => { + // Resolve false right away if the acl doesn't have any roles + const acl_has_roles = Object.keys(acl.permissionsById).some(key => + key.startsWith('role:') + ); + if (!acl_has_roles) { + return false; + } + const roleNames = await auth.getUserRoles(); + // Finally, see if any of the user's roles allow them read access + for (const role of roleNames) { + // We use getReadAccess as `role` is in the form `role:roleName` + if (acl.getReadAccess(role)) { + return true; + } + } + return false; + }) + .catch(() => { + return false; + }); + }; + const allowed = await isAllowed(); + if (allowed) { + const token = authenticator.generate(fileObject.authSecret); + file.url = file.url + '?token=' + token; + } + } + } /** * 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) { if (object instanceof Array) { - object.map(obj => this.expandFilesInObject(config, obj)); + await Promise.all( + object.map( + async obj => await this.expandFilesInObject(config, obj, auth) + ) + ); return; } if (typeof object !== 'object') { @@ -69,6 +120,7 @@ export class FilesController extends AdaptableController { const fileObject = object[key]; if (fileObject && fileObject['__type'] === 'File') { if (fileObject['url']) { + await this.getAuthForFile(config, fileObject, auth); continue; } const filename = fileObject['name']; @@ -94,6 +146,7 @@ export class FilesController extends AdaptableController { fileObject['url'] = this.adapter.getFileLocation(config, filename); } } + await this.getAuthForFile(config, fileObject, auth); } } } diff --git a/src/Controllers/SchemaController.js b/src/Controllers/SchemaController.js index fb559dfbfe..7f18c962a4 100644 --- a/src/Controllers/SchemaController.js +++ b/src/Controllers/SchemaController.js @@ -76,6 +76,12 @@ const defaultColumns: { [string]: SchemaFields } = Object.freeze({ expiresAt: { type: 'Date' }, createdWith: { type: 'Object' }, }, + _File: { + file: { type: 'File' }, + references: { type: 'Number' }, + authSecret: { type: 'String' }, + authACL: { type: 'Object' }, + }, _Product: { productIdentifier: { type: 'String' }, download: { type: 'File' }, @@ -160,6 +166,7 @@ const systemClasses = Object.freeze([ '_Installation', '_Role', '_Session', + '_File', '_Product', '_PushStatus', '_JobStatus', @@ -177,6 +184,7 @@ const volatileClasses = Object.freeze([ '_JobSchedule', '_Audience', '_Idempotency', + '_File', ]); // Anything that start with role @@ -673,6 +681,13 @@ const _IdempotencySchema = convertSchemaToAdapterSchema( classLevelPermissions: {}, }) ); +const _FileSchema = convertSchemaToAdapterSchema( + injectDefaultSchema({ + className: '_File', + fields: defaultColumns._File, + classLevelPermissions: {}, + }) +); const VolatileClassesSchemas = [ _HooksSchema, _JobStatusSchema, @@ -682,6 +697,7 @@ const VolatileClassesSchemas = [ _GraphQLConfigSchema, _AudienceSchema, _IdempotencySchema, + _FileSchema, ]; const dbTypeMatchesObjectType = ( diff --git a/src/RestQuery.js b/src/RestQuery.js index 1f6f4b520c..78f77ae078 100644 --- a/src/RestQuery.js +++ b/src/RestQuery.js @@ -663,17 +663,24 @@ 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 + ); + }) + .then(() => { if (this.redirectClassName) { for (var r of results) { r.className = this.redirectClassName; diff --git a/src/RestWrite.js b/src/RestWrite.js index bef89a03e4..6c6e46e3dd 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -331,8 +331,11 @@ 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 + ); const user = triggers.inflate(extraData, userData); // no need to return a response @@ -1394,12 +1397,13 @@ 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( + await this.config.filesController.expandFilesInObject( this.config, - this.response.response + this.response.response, + this.auth ); } }; diff --git a/src/Routers/FilesRouter.js b/src/Routers/FilesRouter.js index 705d7d0c84..9577d8ab75 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 { authenticator } from 'otplib'; const triggers = require('../triggers'); const http = require('http'); @@ -41,6 +42,15 @@ const errorMessageFromError = e => { } return undefined; }; +const createFileData = async fileObject => { + const fileData = new Parse.Object('_File'); + fileData.set('references', 0); + fileData.set('file', fileObject.file); + fileData.set('authACL', fileObject._ACL); + authenticator.options = { step: 600, digits: 10 }; + fileData.set('authSecret', authenticator.generateSecret()); + await fileData.save(null, { useMasterKey: true }); +}; export class FilesRouter { expressRouter({ maxUploadSize = '20Mb' } = {}) { @@ -75,10 +85,31 @@ 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; + const file = new Parse.File(filename); + file._url = filesController.adapter.getFileLocation(config, filename); + const [fileObject] = await config.database.find('_File', { + file: file.toJSON(), + }); + if (fileObject && fileObject.authACL) { + const acl = new Parse.ACL(fileObject.authACL); + if ( + !acl.getPublicReadAccess() && + !authenticator.verify({ + token: req.query.token, + secret: fileObject.authSecret, + }) + ) { + res.status(404); + res.set('Content-Type', 'text/plain'); + res.end('File not found.'); + return; + } + } + const contentType = mime.getType(filename); if (isFileStreamable(req, filesController)) { filesController @@ -127,6 +158,8 @@ export class FilesRouter { 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); @@ -180,7 +213,12 @@ export class FilesRouter { name: createFileResult.name, }; } - // run afterSaveFile trigger + fileObject._ACL = acl; + try { + await createFileData(fileObject); + } catch (e) { + /* */ + } await triggers.maybeRunFileTrigger( triggers.Types.afterSaveFile, fileObject, @@ -198,10 +236,9 @@ export class FilesRouter { next(new Parse.Error(Parse.Error.FILE_SAVE_ERROR, errorMessage)); } } - 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); @@ -215,6 +252,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 61448dace2..a3c221dea7 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -243,7 +243,11 @@ 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 + ); // Before login trigger; throws if failure await maybeRunTrigger( diff --git a/src/rest.js b/src/rest.js index c605e8b5fc..a67231d7d5 100644 --- a/src/rest.js +++ b/src/rest.js @@ -309,6 +309,7 @@ const classesWithMasterOnlyAccess = [ '_GlobalConfig', '_JobSchedule', '_Idempotency', + '_File', ]; // Disallowing access to the _Role collection except by master key function enforceRoleSecurity(method, className, auth) {