diff --git a/resources/buildConfigDefinitions.js b/resources/buildConfigDefinitions.js index aee5403613..0c72d0126d 100644 --- a/resources/buildConfigDefinitions.js +++ b/resources/buildConfigDefinitions.js @@ -53,6 +53,9 @@ function getENVPrefix(iface) { if (options[iface.id.name]) { return options[iface.id.name] } + if (iface.id.name === 'DashboardOptions') { + return 'PARSE_SERVER_DASHBOARD_OPTIONS_'; + } } function processProperty(property, iface) { @@ -172,6 +175,13 @@ function parseDefaultValue(elt, value, t) { }); literalValue = t.objectExpression(props); } + if (type == 'DashboardOptions') { + const object = parsers.objectParser(value); + const props = Object.keys(object).map((key) => { + return t.objectProperty(key, object[value]); + }); + literalValue = t.objectExpression(props); + } if (type == 'ProtectedFields') { const prop = t.objectProperty( t.stringLiteral('_User'), t.objectPattern([ diff --git a/spec/CloudCode.spec.js b/spec/CloudCode.spec.js index 7f0fe70aa0..0d4fb3b348 100644 --- a/spec/CloudCode.spec.js +++ b/spec/CloudCode.spec.js @@ -38,6 +38,234 @@ describe('Cloud Code', () => { }); }); }); + const masterKeyHeaders = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Rest-API-Key': 'rest', + 'X-Parse-Master-Key': 'test', + 'Content-Type': 'application/json', + }; + const masterKeyOptions = { + headers: masterKeyHeaders, + json: true, + }; + it('can load cloud code file from dashboard', async done => { + const cloudDir = './spec/cloud/cloudCodeAbsoluteFile.js'; + await reconfigureServer({ + cloud: cloudDir, + dashboardOptions: { + cloudFileView: true, + }, + }); + const options = Object.assign({}, masterKeyOptions, { + method: 'GET', + url: Parse.serverURL + '/releases/latest', + }); + request(options) + .then(res => { + expect(Array.isArray(res.data)).toBe(true); + const first = res.data[0]; + expect(first.userFiles).toBeDefined(); + expect(first.checksums).toBeDefined(); + expect(first.userFiles).toContain(cloudDir); + expect(first.checksums).toContain(cloudDir); + options.url = Parse.serverURL + '/scripts/spec/cloud/cloudCodeAbsoluteFile.js'; + return request(options); + }) + .then(res => { + const response = res.data; + expect(response).toContain('It is possible to define cloud code in a file.'); + done(); + }); + }); + + it('can load multiple cloud code files from dashboard', async done => { + const cloudDir = './spec/cloud/cloudCodeRequireFiles.js'; + await reconfigureServer({ + cloud: cloudDir, + dashboardOptions: { + cloudFileView: true, + }, + }); + const options = Object.assign({}, masterKeyOptions, { + method: 'GET', + url: Parse.serverURL + '/releases/latest', + }); + request(options).then(res => { + expect(Array.isArray(res.data)).toBe(true); + const first = res.data[0]; + expect(first.userFiles).toBeDefined(); + expect(first.checksums).toBeDefined(); + expect(first.userFiles).toContain(cloudDir); + expect(first.checksums).toContain(cloudDir); + expect(first.userFiles).toContain('spec/cloud/cloudCodeAbsoluteFile.js'); + expect(first.checksums).toContain('spec/cloud/cloudCodeAbsoluteFile.js'); + expect(first.userFiles).toContain('spec/cloud/cloudCodeRelativeFile.js'); + expect(first.checksums).toContain('spec/cloud/cloudCodeRelativeFile.js'); + done(); + }); + }); + + it('can server info for for file options', async () => { + const cloudDir = './spec/cloud/cloudCodeRequireFiles.js'; + await reconfigureServer({ + cloud: cloudDir, + }); + const options = Object.assign({}, masterKeyOptions, { + method: 'GET', + url: Parse.serverURL + '/serverInfo', + }); + let { data } = await request(options); + expect(data).not.toBe(null); + expect(data.features).not.toBe(null); + expect(data.features.cloudCode).not.toBe(null); + expect(data.features.cloudCode.viewCode).toBe(false); + expect(data.features.cloudCode.editCode).toBe(false); + + await reconfigureServer({ + cloud: cloudDir, + dashboardOptions: { + cloudFileView: true, + }, + }); + data = (await request(options)).data; + expect(data).not.toBe(null); + expect(data.features).not.toBe(null); + expect(data.features.cloudCode).not.toBe(null); + expect(data.features.cloudCode.viewCode).toBe(true); + expect(data.features.cloudCode.editCode).toBe(false); + await reconfigureServer({ + cloud: cloudDir, + dashboardOptions: { + cloudFileView: true, + cloudFileEdit: true, + }, + }); + data = (await request(options)).data; + expect(data).not.toBe(null); + expect(data.features).not.toBe(null); + expect(data.features.cloudCode).not.toBe(null); + expect(data.features.cloudCode.viewCode).toBe(true); + expect(data.features.cloudCode.editCode).toBe(true); + }); + + it('cannot view or edit cloud files by default', async () => { + const options = Object.assign({}, masterKeyOptions, { + method: 'GET', + url: Parse.serverURL + '/releases/latest', + }); + try { + await request(options); + fail('should not have been able to get cloud files'); + } catch (e) { + expect(e.text).toBe('{"code":101,"error":"Dashboard file viewing is not active."}'); + } + try { + options.url = Parse.serverURL + '/scripts/spec/cloud/cloudCodeRequireFiles.js'; + await request(options); + fail('should not have been able to get cloud files'); + } catch (e) { + expect(e.text).toBe('{"code":101,"error":"Dashboard file viewing is not active."}'); + } + try { + options.method = 'POST'; + options.url = Parse.serverURL + '/scripts/spec/cloud/cloudCodeRequireFiles.js'; + options.body = { + data: 'new file text', + }; + await request(options); + fail('should not have been able to get cloud files'); + } catch (e) { + expect(e.text).toBe('{"code":101,"error":"Dashboard file editing is not active."}'); + } + }); + + it('can view cloud code file from dashboard', async () => { + const cloudDir = './spec/cloud/cloudCodeRequireFiles.js'; + await reconfigureServer({ + cloud: cloudDir, + dashboardOptions: { + cloudFileView: true, + }, + }); + const options = Object.assign({}, masterKeyOptions, { + method: 'GET', + url: Parse.serverURL + '/releases/latest', + }); + let res = await request(options); + expect(Array.isArray(res.data)).toBe(true); + const first = res.data[0]; + expect(first.userFiles).toBeDefined(); + expect(first.checksums).toBeDefined(); + expect(first.userFiles).toContain(cloudDir); + expect(first.checksums).toContain(cloudDir); + options.url = Parse.serverURL + '/scripts/spec/cloud/cloudCodeRequireFiles.js'; + res = await request(options); + let response = res.data; + expect(response).toContain(`require('./cloudCodeAbsoluteFile.js`); + response = response + '\nconst additionalData;\n'; + options.method = 'POST'; + options.url = Parse.serverURL + '/scripts/spec/cloud/cloudCodeRequireFiles.js'; + options.body = { + data: response, + }; + try { + await request(options); + fail('should have failed to save'); + } catch (e) { + expect(e.text).toBe('{"code":101,"error":"Dashboard file editing is not active."}'); + } + }); + + it('can edit cloud code file from dashboard', async done => { + const cloudDir = './spec/cloud/cloudCodeRequireFiles.js'; + await reconfigureServer({ + cloud: cloudDir, + dashboardOptions: { + cloudFileView: true, + cloudFileEdit: true, + }, + }); + const options = Object.assign({}, masterKeyOptions, { + method: 'GET', + url: Parse.serverURL + '/releases/latest', + }); + let originalFile = ''; + request(options) + .then(res => { + expect(Array.isArray(res.data)).toBe(true); + const first = res.data[0]; + expect(first.userFiles).toBeDefined(); + expect(first.checksums).toBeDefined(); + expect(first.userFiles).toContain(cloudDir); + expect(first.checksums).toContain(cloudDir); + options.url = Parse.serverURL + '/scripts/spec/cloud/cloudCodeRequireFiles.js'; + return request(options); + }) + .then(res => { + originalFile = res.data; + let response = res.data; + expect(response).toContain(`require('./cloudCodeAbsoluteFile.js`); + response = response + '\nconst additionalData;\n'; + options.method = 'POST'; + options.url = Parse.serverURL + '/scripts/spec/cloud/cloudCodeRequireFiles.js'; + options.body = { + data: response, + }; + return request(options); + }) + .then(res => { + expect(res.data).toBe('This file has been saved.'); + options.method = 'POST'; + options.url = Parse.serverURL + '/scripts/spec/cloud/cloudCodeRequireFiles.js'; + options.body = { + data: originalFile, + }; + return request(options); + }) + .then(() => { + done(); + }); + }); it('can create functions', done => { Parse.Cloud.define('hello', () => { diff --git a/spec/cloud/cloudCodeRequireFiles.js b/spec/cloud/cloudCodeRequireFiles.js new file mode 100644 index 0000000000..086b63c525 --- /dev/null +++ b/spec/cloud/cloudCodeRequireFiles.js @@ -0,0 +1,2 @@ +require('./cloudCodeAbsoluteFile.js'); +require('./cloudCodeRelativeFile.js'); diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index 0a3eddcdb0..38e2490ea5 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -93,6 +93,13 @@ module.exports.ParseServerOptions = { action: parsers.objectParser, default: {}, }, + dashboardOptions: { + env: 'PARSE_SERVER_DASHBOARD_OPTIONS', + help: + 'Options for Parse dashboard. Caution, do not use cloudFileEdit on a multi-instance production server.', + action: parsers.objectParser, + default: {}, + }, databaseAdapter: { env: 'PARSE_SERVER_DATABASE_ADAPTER', help: 'Adapter module for the database', @@ -558,6 +565,21 @@ module.exports.IdempotencyOptions = { default: 300, }, }; +module.exports.DashboardOptions = { + cloudFileEdit: { + env: 'PARSE_SERVER_DASHBOARD_OPTIONS_CLOUD_FILE_EDIT', + help: + 'Whether the Parse Dashboard can edit cloud files. If set to true, dashboard can view and edit cloud code files. Do not use on multi-instance servers otherwise your cloud files will be inconsistent.', + action: parsers.booleanParser, + default: false, + }, + cloudFileView: { + env: 'PARSE_SERVER_DASHBOARD_OPTIONS_CLOUD_FILE_VIEW', + help: 'Whether the Parse Dashboard can view cloud files.', + action: parsers.booleanParser, + default: false, + }, +}; module.exports.AccountLockoutOptions = { duration: { env: 'PARSE_SERVER_ACCOUNT_LOCKOUT_DURATION', diff --git a/src/Options/docs.js b/src/Options/docs.js index 8b8e52e54c..c93d9fd9d2 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -17,6 +17,7 @@ * @property {Number|Boolean} cluster Run with cluster, optionally set the number of processes default to os.cpus().length * @property {String} collectionPrefix A collection prefix for the classes * @property {CustomPagesOptions} customPages custom pages for password validation and reset + * @property {DashboardOptions} dashboardOptions Options for Parse dashboard. Caution, do not use cloudFileEdit on a multi-instance production server. * @property {Adapter} databaseAdapter Adapter module for the database * @property {Any} databaseOptions Options to pass to the mongodb client * @property {String} databaseURI The full URI to your database. Supported databases are mongodb or postgres. @@ -122,6 +123,12 @@ * @property {Number} ttl The duration in seconds after which a request record is discarded from the database, defaults to 300s. */ +/** + * @interface DashboardOptions + * @property {Boolean} cloudFileEdit Whether the Parse Dashboard can edit cloud files. If set to true, dashboard can view and edit cloud code files. Do not use on multi-instance servers otherwise your cloud files will be inconsistent. + * @property {Boolean} cloudFileView Whether the Parse Dashboard can view cloud files. + */ + /** * @interface AccountLockoutOptions * @property {Number} duration number of minutes that a locked-out account remains locked out before automatically becoming unlocked. diff --git a/src/Options/index.js b/src/Options/index.js index b8fd077f1c..2437f25a32 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -198,6 +198,10 @@ export interface ParseServerOptions { :ENV: PARSE_SERVER_EXPERIMENTAL_IDEMPOTENCY_OPTIONS :DEFAULT: false */ idempotencyOptions: ?IdempotencyOptions; + /* Options for Parse dashboard. Caution, do not use cloudFileEdit on a multi-instance production server. + :ENV: PARSE_SERVER_DASHBOARD_OPTIONS + :DEFAULT: false */ + dashboardOptions: ?DashboardOptions; /* Options for file uploads :ENV: PARSE_SERVER_FILE_UPLOAD_OPTIONS :DEFAULT: {} */ @@ -296,6 +300,15 @@ export interface IdempotencyOptions { ttl: ?number; } +export interface DashboardOptions { + /* Whether the Parse Dashboard can view cloud files. + :DEFAULT: false */ + cloudFileView: ?boolean; + /* Whether the Parse Dashboard can edit cloud files. If set to true, dashboard can view and edit cloud code files. Do not use on multi-instance servers otherwise your cloud files will be inconsistent. + :DEFAULT: false */ + cloudFileEdit: ?boolean; +} + export interface AccountLockoutOptions { /* number of minutes that a locked-out account remains locked out before automatically becoming unlocked. */ duration: ?number; diff --git a/src/Routers/CloudCodeRouter.js b/src/Routers/CloudCodeRouter.js index 327353123a..7695fcb880 100644 --- a/src/Routers/CloudCodeRouter.js +++ b/src/Routers/CloudCodeRouter.js @@ -3,6 +3,8 @@ import Parse from 'parse/node'; import rest from '../rest'; const triggers = require('../triggers'); const middleware = require('../middlewares'); +const fs = require('fs'); +const path = require('path'); function formatJobSchedule(job_schedule) { if (typeof job_schedule.startAfter === 'undefined') { @@ -53,6 +55,24 @@ export class CloudCodeRouter extends PromiseRouter { middleware.promiseEnforceMasterKeyAccess, CloudCodeRouter.deleteJob ); + this.route( + 'GET', + '/releases/latest', + middleware.promiseEnforceMasterKeyAccess, + CloudCodeRouter.getCloudCode + ); + this.route( + 'GET', + '/scripts/*', + middleware.promiseEnforceMasterKeyAccess, + CloudCodeRouter.getCloudFile + ); + this.route( + 'POST', + '/scripts/*', + middleware.promiseEnforceMasterKeyAccess, + CloudCodeRouter.saveCloudFile + ); } static getJobs(req) { @@ -120,4 +140,83 @@ export class CloudCodeRouter extends PromiseRouter { }; }); } + static saveCloudFile(req) { + const config = req.config || {}; + const dashboardOptions = config.dashboardOptions || {}; + if (!dashboardOptions.cloudFileEdit) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Dashboard file editing is not active.'); + } + const file = req.url.replace('/scripts', ''); + const dirName = __dirname.split('lib')[0].split('node_modules')[0]; + const filePath = path.join(dirName, file); + const data = req.body.data; + if (!data) { + throw new Parse.Error(Parse.Error.INTERNAL_SERVER_ERROR, 'No data to save.'); + } + fs.writeFileSync(filePath, data); + return { + response: 'This file has been saved.', + }; + } + static getCloudFile(req) { + const config = req.config || {}; + const dashboardOptions = config.dashboardOptions || {}; + if (!(dashboardOptions.cloudFileView || dashboardOptions.cloudFileEdit)) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Dashboard file viewing is not active.'); + } + const file = req.url.replace('/scripts', ''); + const dirName = __dirname.split('lib')[0].split('node_modules')[0]; + const filePath = path.join(dirName, file); + if (!fs.existsSync(filePath) || !fs.lstatSync(filePath).isFile()) { + throw new Parse.Error(Parse.Error.INTERNAL_SERVER_ERROR, 'Invalid file url.'); + } + return { + response: fs.readFileSync(filePath, 'utf8'), + }; + } + static getCloudCode(req) { + const config = req.config || {}; + const dashboardOptions = config.dashboardOptions || {}; + if (!(dashboardOptions.cloudFileView || dashboardOptions.cloudFileEdit)) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Dashboard file viewing is not active.'); + } + const dirName = __dirname.split('node_modules')[0]; + const cloudLocation = ('' + config.cloud).replace(dirName, ''); + const cloudFiles = []; + const getRequiredFromFile = (file, directory) => { + try { + const fileData = fs.readFileSync(file, 'utf8'); + const requireStatements = fileData.split('require('); + for (let reqStatement of requireStatements) { + reqStatement = reqStatement.split(')')[0].slice(1, -1); + const filePath = path.join(directory, reqStatement); + if (!fs.existsSync(filePath) || !fs.lstatSync(filePath).isFile()) { + continue; + } + const requireData = fs.readFileSync(filePath, 'utf8'); + const newFilePath = filePath.replace(dirName, ''); + cloudFiles.push(newFilePath); + if (requireData.includes('require(')) { + getRequiredFromFile(newFilePath, path.dirname(filePath)); + } + } + } catch (e) { + /* */ + } + }; + cloudFiles.push(cloudLocation); + getRequiredFromFile(cloudLocation, path.dirname(config.cloud)); + const response = {}; + for (const file of cloudFiles) { + response[file] = new Date(); + } + return { + response: [ + { + checksums: JSON.stringify({ cloud: response }), + userFiles: JSON.stringify({ cloud: response }), + }, + ], + }; + } } diff --git a/src/Routers/FeaturesRouter.js b/src/Routers/FeaturesRouter.js index 649cefcb3a..67750437b7 100644 --- a/src/Routers/FeaturesRouter.js +++ b/src/Routers/FeaturesRouter.js @@ -21,6 +21,8 @@ export class FeaturesRouter extends PromiseRouter { }, cloudCode: { jobs: true, + viewCode: (config.dashboardOptions || {}).cloudFileView || false, + editCode: (config.dashboardOptions || {}).cloudFileEdit || false, }, logs: { level: true,