From 1057cf1c486796faf33882bff8c2d9fd73178c43 Mon Sep 17 00:00:00 2001 From: dblythy Date: Tue, 10 Nov 2020 18:28:33 +1100 Subject: [PATCH 1/7] Initial Commit --- package.json | 1 + spec/ParseFile.spec.js | 97 ++++++++++++++++++- spec/helper.js | 1 + .../Postgres/PostgresStorageAdapter.js | 10 +- src/Controllers/FilesController.js | 57 ++++++++++- src/Controllers/SchemaController.js | 16 +++ src/RestQuery.js | 13 ++- src/RestWrite.js | 14 ++- src/Routers/FilesRouter.js | 38 +++++++- src/Routers/UsersRouter.js | 6 +- src/rest.js | 1 + 11 files changed, 232 insertions(+), 22 deletions(-) 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..ba211e2dba 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,93 @@ 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'); + const fileData = await query.first(); + expect(fileData).toBeUndefined(); + 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..a05237f3c9 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 (req, 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); @@ -187,6 +220,8 @@ export class FilesRouter { config, req.auth ); + fileObject._ACL = acl; + await createFileData(req, fileObject); res.status(201); res.set('Location', saveResult.url); res.json(saveResult); @@ -198,7 +233,6 @@ export class FilesRouter { next(new Parse.Error(Parse.Error.FILE_SAVE_ERROR, errorMessage)); } } - async deleteHandler(req, res, next) { try { const { filesController } = req.config; 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) { From e0bea0f77959e2fce521c56de3be4b1327ebd7f5 Mon Sep 17 00:00:00 2001 From: dblythy Date: Tue, 10 Nov 2020 19:08:44 +1100 Subject: [PATCH 2/7] FIle ACL --- spec/ParseFile.spec.js | 13 ++++++++++--- src/Routers/FilesRouter.js | 20 +++++++++++++++----- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/spec/ParseFile.spec.js b/spec/ParseFile.spec.js index ba211e2dba..fb30331dbc 100644 --- a/spec/ParseFile.spec.js +++ b/spec/ParseFile.spec.js @@ -957,8 +957,15 @@ describe('Parse.File testing', () => { file.setTags({ acl: acl.toJSON() }); await file.save({ sessionToken: user.getSessionToken() }); const query = new Parse.Query('_File'); - const fileData = await query.first(); - expect(fileData).toBeUndefined(); - done(); + 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/src/Routers/FilesRouter.js b/src/Routers/FilesRouter.js index a05237f3c9..9577d8ab75 100644 --- a/src/Routers/FilesRouter.js +++ b/src/Routers/FilesRouter.js @@ -42,7 +42,7 @@ const errorMessageFromError = e => { } return undefined; }; -const createFileData = async (req, fileObject) => { +const createFileData = async fileObject => { const fileData = new Parse.Object('_File'); fileData.set('references', 0); fileData.set('file', fileObject.file); @@ -213,15 +213,18 @@ 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, config, req.auth ); - fileObject._ACL = acl; - await createFileData(req, fileObject); res.status(201); res.set('Location', saveResult.url); res.json(saveResult); @@ -235,7 +238,7 @@ export class FilesRouter { } 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); @@ -249,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, From bb1842d340e31c6bc0b1cd55f02ff132834af745 Mon Sep 17 00:00:00 2001 From: dblythy Date: Mon, 21 Dec 2020 04:31:24 +1100 Subject: [PATCH 3/7] WIP --- package.json | 1 - spec/ParseFile.spec.js | 146 +++++++++++++++++++- src/Controllers/FilesController.js | 204 +++++++++++++++++++++------- src/Controllers/SchemaController.js | 15 +- src/RestQuery.js | 3 +- src/RestWrite.js | 12 +- src/Routers/FilesRouter.js | 125 ++++++++++++----- src/Routers/UsersRouter.js | 3 +- src/cloud-code/Parse.Cloud.js | 3 + src/triggers.js | 8 +- 10 files changed, 428 insertions(+), 92 deletions(-) diff --git a/package.json b/package.json index 74aa8ef45c..65ad17251b 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,6 @@ "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 fb30331dbc..fed6355c49 100644 --- a/spec/ParseFile.spec.js +++ b/spec/ParseFile.spec.js @@ -4,6 +4,7 @@ 'use strict'; const request = require('../lib/request'); +const Config = require('../lib/Config'); const str = 'Hello World!'; const data = []; @@ -882,6 +883,7 @@ describe('Parse.File testing', () => { 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); @@ -890,7 +892,6 @@ describe('Parse.File testing', () => { 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(), }); @@ -908,6 +909,107 @@ describe('Parse.File testing', () => { 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({ @@ -918,6 +1020,7 @@ describe('Parse.File testing', () => { 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); @@ -926,7 +1029,6 @@ describe('Parse.File testing', () => { 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'); @@ -944,6 +1046,45 @@ describe('Parse.File testing', () => { 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 config = Config.get('test'); + let [fileObject] = await config.database.find('_File', { + file: file.toJSON(), + }); + expect(fileObject).toBeDefined(); + expect(fileObject.references.length).toBe(0); + + const object = new Parse.Object('TestObject'); + await object.save({ file: file }); + + [fileObject] = await config.database.find('_File', { + file: file.toJSON(), + }); + expect(fileObject.references.length).toBe(1); + done(); + }); + it('can query file data', async done => { const user = new Parse.User(); await user.signUp({ @@ -954,6 +1095,7 @@ describe('Parse.File testing', () => { 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('_File'); diff --git a/src/Controllers/FilesController.js b/src/Controllers/FilesController.js index 3c09e4ec61..e2e4eb505d 100644 --- a/src/Controllers/FilesController.js +++ b/src/Controllers/FilesController.js @@ -4,8 +4,9 @@ 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; +import { randomString } from '../cryptoUtils'; +import { Parse } from 'parse/node'; +import { getAuthForSessionToken } from '../Auth'; 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}-.*' @@ -45,72 +46,171 @@ export class FilesController extends AdaptableController { deleteFile(config, filename) { return this.adapter.deleteFile(filename); } + async canViewFile(config, fileObject, user) { + const acl = new Parse.ACL(fileObject.ACL); + if (!acl || acl.getPublicReadAccess()) { + return true; + } + if (!user) { + return false; + } + if (acl.getReadAccess(user.id)) { + return true; + } + const auth = getAuthForSessionToken({ + config, + sessionToken: user.getSessionToken(), + }); + + // 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; + }); + } getMetadata(filename) { if (typeof this.adapter.getMetadata === 'function') { return this.adapter.getMetadata(filename); } return Promise.resolve({}); } - async getAuthForFile(config, file, auth) { + async updateReferences(data, originalData, className) { + 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 fileData = await fileQuery.find({ useMasterKey: true }); + const filesToSave = []; + for (const fileObject of fileData) { + const { _name } = fileObject.get('file'); + if (referencesToAdd.includes(_name)) { + fileObject.addUnique('references', { + objectId: data.objectId, + className, + }); + } else { + fileObject.remove('references', { objectId: data.objectId, className }); + } + filesToSave.push(fileObject); + } + await Parse.Object.saveAll(filesToSave, { useMasterKey: true }); + } + async getAuthForFile(config, file, auth, object, className) { + if (className === '_File') { + return; + } 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; + if (!fileObject) { + return; + } + let toSave = false; + const tokens = fileObject.tokens || []; + const allowed = await this.canViewFile(config, fileObject, auth.user); + if (allowed) { + const url = file.url.split('?'); + let appendToken = ''; + if (url.length > 1) { + appendToken = `${url[1]}&`; } - 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; + const token = randomString(25); + appendToken += `token=${token}`; + file.url = `${url[0]}?${appendToken}`; + const expiry = new Date(new Date().getTime() + 30 * 60000); + tokens.push({ + token, + expiry, + user: auth.user, + }); + toSave = true; + } + const references = fileObject.references || []; + if (!references.includes({ objectId: object.objectId, className })) { + references.push({ objectId: object.objectId, className }); + toSave = true; + } + for (var i = tokens.length - 1; i >= 0; i--) { + const token = tokens[i]; + const expiry = token.expiry; + if (!expiry || expiry < new Date()) { + tokens.splice(i, 1); + toSave = true; } } + if (toSave) { + fileObject.tokens = tokens; + await this.config.database.update('_File', { file }, fileObject); + } } /** * 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. */ - async expandFilesInObject(config, object, auth) { + async expandFilesInObject(config, object, auth, className) { + const promises = []; if (object instanceof Array) { - await Promise.all( - object.map( - async obj => await this.expandFilesInObject(config, obj, auth) - ) + object.map(obj => + promises.push(this.expandFilesInObject(config, obj, auth, className)) ); + } + if (promises.length != 0) { + await Promise.all(promises); return; } if (typeof object !== 'object') { @@ -120,7 +220,13 @@ export class FilesController extends AdaptableController { const fileObject = object[key]; if (fileObject && fileObject['__type'] === 'File') { if (fileObject['url']) { - await this.getAuthForFile(config, fileObject, auth); + await this.getAuthForFile( + config, + fileObject, + auth, + object, + className + ); continue; } const filename = fileObject['name']; @@ -146,7 +252,7 @@ export class FilesController extends AdaptableController { fileObject['url'] = this.adapter.getFileLocation(config, filename); } } - await this.getAuthForFile(config, fileObject, auth); + await this.getAuthForFile(config, fileObject, auth, object, className); } } } diff --git a/src/Controllers/SchemaController.js b/src/Controllers/SchemaController.js index 7f18c962a4..9456400b18 100644 --- a/src/Controllers/SchemaController.js +++ b/src/Controllers/SchemaController.js @@ -78,9 +78,8 @@ const defaultColumns: { [string]: SchemaFields } = Object.freeze({ }, _File: { file: { type: 'File' }, - references: { type: 'Number' }, - authSecret: { type: 'String' }, - authACL: { type: 'Object' }, + references: { type: 'Array' }, + ACL: { type: 'Object' }, }, _Product: { productIdentifier: { type: 'String' }, @@ -685,7 +684,15 @@ const _FileSchema = convertSchemaToAdapterSchema( injectDefaultSchema({ className: '_File', fields: defaultColumns._File, - classLevelPermissions: {}, + classLevelPermissions: { + find: { + '*': true, + }, + get: { + '*': true, + }, + create: { requiresAuthentication: true }, + }, }) ); const VolatileClassesSchemas = [ diff --git a/src/RestQuery.js b/src/RestQuery.js index 78f77ae078..babe28e5d5 100644 --- a/src/RestQuery.js +++ b/src/RestQuery.js @@ -677,7 +677,8 @@ RestQuery.prototype.runFind = function (options = {}) { return this.config.filesController.expandFilesInObject( this.config, results, - this.auth + this.auth, + this.className ); }) .then(() => { diff --git a/src/RestWrite.js b/src/RestWrite.js index 6c6e46e3dd..7f399cd447 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -334,7 +334,8 @@ RestWrite.prototype.runBeforeLoginTrigger = async function (userData) { await this.config.filesController.expandFilesInObject( this.config, userData, - this.auth + this.auth, + this.className ); const user = triggers.inflate(extraData, userData); @@ -1403,7 +1404,14 @@ RestWrite.prototype.expandFilesForExistingObjects = async function () { await this.config.filesController.expandFilesInObject( this.config, this.response.response, - this.auth + this.auth, + this.className + ); + } else if (this.className !== '_File') { + await this.config.filesController.updateReferences( + this.data, + this.originalData, + this.className ); } }; diff --git a/src/Routers/FilesRouter.js b/src/Routers/FilesRouter.js index 9577d8ab75..4e2bb0ac3e 100644 --- a/src/Routers/FilesRouter.js +++ b/src/Routers/FilesRouter.js @@ -5,7 +5,6 @@ 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'); @@ -44,11 +43,9 @@ const errorMessageFromError = e => { }; const createFileData = async fileObject => { const fileData = new Parse.Object('_File'); - fileData.set('references', 0); + fileData.set('references', []); fileData.set('file', fileObject.file); - fileData.set('authACL', fileObject._ACL); - authenticator.options = { step: 600, digits: 10 }; - fileData.set('authSecret', authenticator.generateSecret()); + fileData.set('ACL', fileObject._ACL || { '*': { read: true } }); await fileData.save(null, { useMasterKey: true }); }; @@ -88,21 +85,48 @@ export class FilesRouter { 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 [fileObject] = await config.database.find('_File', { + let [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, - }) - ) { + if (!fileObject) { + fileObject = { + ACL: { '*': { read: true } }, + references: [], + file: { + __type: 'File', + name: filename, + }, + tokens: [], + }; + } + const requestToken = req.query.token; + let user; + const tokens = fileObject.tokens || []; + let toSave = false; + for (var i = tokens.length - 1; i >= 0; i--) { + const token = tokens[i]; + const expiry = token.expiry; + if (!expiry || expiry < new Date()) { + tokens.splice(i, 1); + toSave = true; + } else if (token.token === requestToken) { + user = token.user; + } + } + if (toSave) { + fileObject.tokens = tokens; + this.config.database.update('_File', { file }, fileObject); + } + if (fileObject.ACL) { + const allowed = await filesController.canViewFile( + config, + fileObject, + user + ); + if (!allowed) { res.status(404); res.set('Content-Type', 'text/plain'); res.end('File not found.'); @@ -120,24 +144,65 @@ 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 triggerResult = await triggers.maybeRunFileTrigger( + triggers.Types.beforeFind, + { file }, + config, + { user } + ); + 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.'); + } } } async createHandler(req, res, next) { const config = req.config; + 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'); @@ -148,13 +213,11 @@ export class FilesRouter { ); 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 || {}; diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index a3c221dea7..0cacd41558 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -246,7 +246,8 @@ export class UsersRouter extends ClassesRouter { await req.config.filesController.expandFilesInObject( req.config, user, - req.auth + req.auth, + '_User' ); // Before login trigger; throws if failure diff --git a/src/cloud-code/Parse.Cloud.js b/src/cloud-code/Parse.Cloud.js index 01d1569bc1..f4d47c2ec3 100644 --- a/src/cloud-code/Parse.Cloud.js +++ b/src/cloud-code/Parse.Cloud.js @@ -9,6 +9,9 @@ function isParseObjectConstructor(object) { } function getClassName(parseClass) { + if (parseClass === Parse.File) { + return '@File'; + } if (parseClass && parseClass.className) { return parseClass.className; } diff --git a/src/triggers.js b/src/triggers.js index a806d095c3..41c242dbb0 100644 --- a/src/triggers.js +++ b/src/triggers.js @@ -746,7 +746,13 @@ export async function maybeRunFileTrigger( config, auth ) { - const fileTrigger = getFileTrigger(triggerType, config.applicationId); + let fileTrigger = getFileTrigger(triggerType, config.applicationId); + if (!fileTrigger) { + fileTrigger = getFileTrigger( + triggerType.replace('File', ''), + config.applicationId + ); + } if (typeof fileTrigger === 'function') { try { const request = getRequestFileObject( From a2d08b2f8aada7afac2855f97ad12911497be6d0 Mon Sep 17 00:00:00 2001 From: dblythy Date: Mon, 21 Dec 2020 04:47:04 +1100 Subject: [PATCH 4/7] fix lint --- src/Routers/FilesRouter.js | 46 +++++++++++--------------------------- 1 file changed, 13 insertions(+), 33 deletions(-) diff --git a/src/Routers/FilesRouter.js b/src/Routers/FilesRouter.js index d43110da04..91b1c9f72a 100644 --- a/src/Routers/FilesRouter.js +++ b/src/Routers/FilesRouter.js @@ -33,15 +33,6 @@ const addFileDataIfNeeded = async file => { return file; }; -const errorMessageFromError = e => { - if (typeof e === 'string') { - return e; - } else if (e && e.message) { - return e.message; - } - return undefined; -}; - const createFileData = async fileObject => { const fileData = new Parse.Object('_File'); fileData.set('references', []); @@ -120,11 +111,7 @@ export class FilesRouter { this.config.database.update('_File', { file }, fileObject); } if (fileObject.ACL) { - const allowed = await filesController.canViewFile( - config, - fileObject, - user - ); + const allowed = await filesController.canViewFile(config, fileObject, user); if (!allowed) { res.status(404); res.set('Content-Type', 'text/plain'); @@ -164,12 +151,7 @@ export class FilesRouter { res.set('Content-Length', data.length); res.end(data); try { - await triggers.maybeRunFileTrigger( - triggers.Types.afterFind, - { file }, - config, - { user } - ); + await triggers.maybeRunFileTrigger(triggers.Types.afterFind, { file }, config, { user }); } catch (e) { /* */ } @@ -187,24 +169,25 @@ 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( @@ -214,10 +197,7 @@ export class FilesRouter { ); if (!schemaPerms) { next( - new Parse.Error( - Parse.Error.FILE_SAVE_ERROR, - 'You are not authorized to upload a file.' - ) + new Parse.Error(Parse.Error.FILE_SAVE_ERROR, 'You are not authorized to upload a file.') ); return; } From ad639e625f8bb51cb024bb5204b940798561dc5c Mon Sep 17 00:00:00 2001 From: dblythy Date: Mon, 21 Dec 2020 05:07:20 +1100 Subject: [PATCH 5/7] fix some tests --- spec/FilesController.spec.js | 4 ++-- src/Controllers/FilesController.js | 18 ++++-------------- 2 files changed, 6 insertions(+), 16 deletions(-) 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/src/Controllers/FilesController.js b/src/Controllers/FilesController.js index 12c567b2dc..edb335199e 100644 --- a/src/Controllers/FilesController.js +++ b/src/Controllers/FilesController.js @@ -64,9 +64,7 @@ export class FilesController extends AdaptableController { 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:') - ); + const acl_has_roles = Object.keys(acl.permissionsById).some(key => key.startsWith('role:')); if (!acl_has_roles) { return false; } @@ -102,7 +100,7 @@ export class FilesController extends AdaptableController { keyOriginalData = {}; } for (const key in keyData) { - const val = keyData[key]; + const val = keyData[key] || {}; const original = keyOriginalData[key] || {}; if (typeof val !== 'object') { continue; @@ -203,9 +201,7 @@ export class FilesController extends AdaptableController { async expandFilesInObject(config, object, auth, className) { const promises = []; if (object instanceof Array) { - object.map(obj => - promises.push(this.expandFilesInObject(config, obj, auth, className)) - ); + object.map(obj => promises.push(this.expandFilesInObject(config, obj, auth, className))); } if (promises.length != 0) { await Promise.all(promises); @@ -218,13 +214,7 @@ export class FilesController extends AdaptableController { const fileObject = object[key]; if (fileObject && fileObject['__type'] === 'File') { if (fileObject['url']) { - await this.getAuthForFile( - config, - fileObject, - auth, - object, - className - ); + await this.getAuthForFile(config, fileObject, auth, object, className); continue; } const filename = fileObject['name']; From 32c60bb907e3e38b938bb2389425ae4e61d418d7 Mon Sep 17 00:00:00 2001 From: dblythy Date: Mon, 21 Dec 2020 21:05:18 +1100 Subject: [PATCH 6/7] fix test --- spec/ParseUser.spec.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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(); From 1e116a5d387924c85aa93887dc0aeb938273b8ed Mon Sep 17 00:00:00 2001 From: dblythy Date: Tue, 22 Dec 2020 03:39:18 +1100 Subject: [PATCH 7/7] update --- spec/ParseFile.spec.js | 109 +++++++++-------- src/Controllers/FilesController.js | 176 +++++++++++++++------------- src/Controllers/SchemaController.js | 19 ++- src/RestWrite.js | 2 +- src/Routers/FilesRouter.js | 102 ++++++++++------ src/rest.js | 3 +- 6 files changed, 233 insertions(+), 178 deletions(-) diff --git a/spec/ParseFile.spec.js b/spec/ParseFile.spec.js index 66359cef6d..37b4a9f65b 100644 --- a/spec/ParseFile.spec.js +++ b/spec/ParseFile.spec.js @@ -4,7 +4,6 @@ 'use strict'; const request = require('../lib/request'); -const Config = require('../lib/Config'); const Definitions = require('../src/Options/Definitions'); const str = 'Hello World!'; @@ -29,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'); @@ -51,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 { @@ -78,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(); @@ -101,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'); @@ -143,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', @@ -189,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'); @@ -359,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(); }); }); @@ -959,10 +968,12 @@ describe('Parse.File testing', () => { 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(); @@ -1057,20 +1068,20 @@ describe('Parse.File testing', () => { const file = new Parse.File('hello.txt', data, 'text/plain'); await file.save(); - const config = Config.get('test'); - let [fileObject] = await config.database.find('_File', { - file: file.toJSON(), - }); - expect(fileObject).toBeDefined(); - expect(fileObject.references.length).toBe(0); + 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 }); - [fileObject] = await config.database.find('_File', { - file: file.toJSON(), - }); - expect(fileObject.references.length).toBe(1); + references = await fileRelation.find({ useMasterKey: true }); + expect(references.length).toBe(1); done(); }); @@ -1087,14 +1098,14 @@ describe('Parse.File testing', () => { // 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('_File'); + const query = new Parse.Query('_FileToken'); try { await query.first(); - fail('Should not have been able to query _Files'); + 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 _File collection." + "Clients aren't allowed to perform the find operation on the _FileToken collection." ); done(); } @@ -1106,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( @@ -1155,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.' + ) ); }); @@ -1195,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.' + ) ); }); @@ -1217,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.' + ) ); }); @@ -1244,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.' @@ -1281,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/src/Controllers/FilesController.js b/src/Controllers/FilesController.js index edb335199e..bda2ce86bd 100644 --- a/src/Controllers/FilesController.js +++ b/src/Controllers/FilesController.js @@ -6,7 +6,6 @@ import path from 'path'; import mime from 'mime'; import { randomString } from '../cryptoUtils'; import { Parse } from 'parse/node'; -import { getAuthForSessionToken } from '../Auth'; 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}-.*' @@ -44,45 +43,6 @@ export class FilesController extends AdaptableController { deleteFile(config, filename) { return this.adapter.deleteFile(filename); } - async canViewFile(config, fileObject, user) { - const acl = new Parse.ACL(fileObject.ACL); - if (!acl || acl.getPublicReadAccess()) { - return true; - } - if (!user) { - return false; - } - if (acl.getReadAccess(user.id)) { - return true; - } - const auth = getAuthForSessionToken({ - config, - sessionToken: user.getSessionToken(), - }); - - // 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; - }); - } getMetadata(filename) { if (typeof this.adapter.getMetadata === 'function') { return this.adapter.getMetadata(filename); @@ -90,6 +50,9 @@ export class FilesController extends AdaptableController { return Promise.resolve({}); } async updateReferences(data, originalData, className) { + if (className === '_File' || className === '_FileToken') { + return; + } const referencesToAdd = []; const referencesToRemove = []; const searchForSubfiles = (keyData, keyOriginalData) => { @@ -129,69 +92,114 @@ export class FilesController extends AdaptableController { const filesToFind = allFiles.map(val => new Parse.File(val).toJSON()); const fileQuery = new Parse.Query('_File'); fileQuery.containedIn('file', filesToFind); - const fileData = await fileQuery.find({ useMasterKey: true }); + + 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)) { - fileObject.addUnique('references', { - objectId: data.objectId, - className, - }); + relation.add(fileReference); } else { - fileObject.remove('references', { objectId: data.objectId, className }); + relation.remove(fileReference); } filesToSave.push(fileObject); } await Parse.Object.saveAll(filesToSave, { useMasterKey: true }); } - async getAuthForFile(config, file, auth, object, className) { + async getAuthForFile(file, auth, object, className) { if (className === '_File') { return; } - const [fileObject] = await config.database.find('_File', { - file, - }); + 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; } - let toSave = false; - const tokens = fileObject.tokens || []; - const allowed = await this.canViewFile(config, fileObject, auth.user); - if (allowed) { - 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); - tokens.push({ - token, - expiry, - user: auth.user, - }); - toSave = true; + 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); } - const references = fileObject.references || []; - if (!references.includes({ objectId: object.objectId, className })) { - references.push({ objectId: object.objectId, className }); - toSave = true; + if (auth && auth.master) { + fileToken.set('master', true); } - for (var i = tokens.length - 1; i >= 0; i--) { - const token = tokens[i]; - const expiry = token.expiry; - if (!expiry || expiry < new Date()) { - tokens.splice(i, 1); - toSave = 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) { - fileObject.tokens = tokens; - await this.config.database.update('_File', { file }, 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 @@ -214,7 +222,7 @@ export class FilesController extends AdaptableController { const fileObject = object[key]; if (fileObject && fileObject['__type'] === 'File') { if (fileObject['url']) { - await this.getAuthForFile(config, fileObject, auth, object, className); + await this.getAuthForFile(fileObject, auth, object, className); continue; } const filename = fileObject['name']; @@ -234,7 +242,7 @@ export class FilesController extends AdaptableController { fileObject['url'] = this.adapter.getFileLocation(config, filename); } } - await this.getAuthForFile(config, fileObject, auth, object, className); + await this.getAuthForFile(fileObject, auth, object, className); } } } diff --git a/src/Controllers/SchemaController.js b/src/Controllers/SchemaController.js index 12d1453246..7e11883160 100644 --- a/src/Controllers/SchemaController.js +++ b/src/Controllers/SchemaController.js @@ -78,9 +78,22 @@ const defaultColumns: { [string]: SchemaFields } = Object.freeze({ }, _File: { file: { type: 'File' }, - references: { type: 'Array' }, + 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' }, @@ -168,6 +181,8 @@ const systemClasses = Object.freeze([ '_Role', '_Session', '_File', + '_FileReference', + '_FileToken', '_Product', '_PushStatus', '_JobStatus', @@ -186,6 +201,8 @@ const volatileClasses = Object.freeze([ '_Audience', '_Idempotency', '_File', + '_FileReference', + '_FileToken', ]); // Anything that start with role diff --git a/src/RestWrite.js b/src/RestWrite.js index c2b61705a2..cae5801008 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -1274,7 +1274,7 @@ RestWrite.prototype.expandFilesForExistingObjects = async function () { this.auth, this.className ); - } else if (this.className !== '_File') { + } else { await this.config.filesController.updateReferences( this.data, this.originalData, diff --git a/src/Routers/FilesRouter.js b/src/Routers/FilesRouter.js index 91b1c9f72a..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,12 +34,35 @@ const addFileDataIfNeeded = async file => { return file; }; -const createFileData = async fileObject => { +const createFileData = async (fileObject, auth) => { const fileData = new Parse.Object('_File'); - fileData.set('references', []); fileData.set('file', fileObject.file); fileData.set('ACL', fileObject._ACL || { '*': { read: true } }); - await fileData.save(null, { useMasterKey: 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 { @@ -78,9 +102,38 @@ export class FilesRouter { let filename = req.params.filename; const file = new Parse.File(filename); file._url = filesController.adapter.getFileLocation(config, filename); - let [fileObject] = await config.database.find('_File', { - file: file.toJSON(), - }); + + 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 } }, @@ -92,34 +145,6 @@ export class FilesRouter { tokens: [], }; } - const requestToken = req.query.token; - let user; - const tokens = fileObject.tokens || []; - let toSave = false; - for (var i = tokens.length - 1; i >= 0; i--) { - const token = tokens[i]; - const expiry = token.expiry; - if (!expiry || expiry < new Date()) { - tokens.splice(i, 1); - toSave = true; - } else if (token.token === requestToken) { - user = token.user; - } - } - if (toSave) { - fileObject.tokens = tokens; - this.config.database.update('_File', { file }, fileObject); - } - if (fileObject.ACL) { - const allowed = await filesController.canViewFile(config, fileObject, user); - if (!allowed) { - 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.handleFileStream(config, filename, req, res, contentType).catch(() => { @@ -129,11 +154,15 @@ export class FilesRouter { }); } else { try { + const request = { user }; + if (fileToken.get('master')) { + request.master = true; + } const triggerResult = await triggers.maybeRunFileTrigger( triggers.Types.beforeFind, { file }, config, - { user } + request ); let data; if (triggerResult instanceof Parse.File) { @@ -274,7 +303,8 @@ export class FilesRouter { } fileObject._ACL = acl; try { - await createFileData(fileObject); + const fileTokenURL = await createFileData(fileObject, req.auth); + saveResult.url = fileTokenURL; } catch (e) { /* */ } diff --git a/src/rest.js b/src/rest.js index 84f2aafd33..0ae4206e65 100644 --- a/src/rest.js +++ b/src/rest.js @@ -250,7 +250,8 @@ const classesWithMasterOnlyAccess = [ '_GlobalConfig', '_JobSchedule', '_Idempotency', - '_File', + '_FileToken', + '_FileReference', ]; // Disallowing access to the _Role collection except by master key function enforceRoleSecurity(method, className, auth) {