diff --git a/.babelrc b/.babelrc index 9151969bde..a199154b82 100644 --- a/.babelrc +++ b/.babelrc @@ -7,7 +7,8 @@ ["@babel/preset-env", { "targets": { "node": "12" - } + }, + "exclude": ["proposal-dynamic-import"] }] ], "sourceMaps": "inline" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 75791824a6..0acbc4a612 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -191,7 +191,7 @@ jobs: image: redis ports: - 6379:6379 - env: + env: MONGODB_VERSION: ${{ matrix.MONGODB_VERSION }} MONGODB_TOPOLOGY: ${{ matrix.MONGODB_TOPOLOGY }} MONGODB_STORAGE_ENGINE: ${{ matrix.MONGODB_STORAGE_ENGINE }} diff --git a/README.md b/README.md index b70f42ae95..42313a30cc 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ ![parse-repository-header-server](https://user-images.githubusercontent.com/5673677/138278489-7d0cebc5-1e31-4d3c-8ffb-53efcda6f29d.png) --- - + [![Build Status](https://github.com/parse-community/parse-server/workflows/ci/badge.svg?branch=alpha)](https://github.com/parse-community/parse-server/actions?query=workflow%3Aci+branch%3Aalpha) [![Snyk Badge](https://snyk.io/test/github/parse-community/parse-server/badge.svg)](https://snyk.io/test/github/parse-community/parse-server) [![Coverage](https://img.shields.io/codecov/c/github/parse-community/parse-server/alpha.svg)](https://codecov.io/github/parse-community/parse-server?branch=alpha) @@ -393,7 +393,7 @@ const server = ParseServer({ }, // The password policy - passwordPolicy: { + passwordPolicy: { // Enforce a password of at least 8 characters which contain at least 1 lower case, 1 upper case and 1 digit validatorPattern: /^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.{8,})/, // Do not allow the username as part of the password @@ -434,7 +434,7 @@ const api = new ParseServer({ The above route can be invoked by sending a `GET` request to: `https://[parseServerPublicUrl]/[parseMount]/[pagesEndpoint]/[appId]/[customRoute]` - + The `handler` receives the `request` and returns a `custom_page.html` webpage from the `pages.pagesPath` directory as response. The advantage of building a custom route this way is that it automatically makes use of Parse Server's built-in capabilities, such as [page localization](#pages) and [dynamic placeholders](#dynamic-placeholders). ### Reserved Paths @@ -522,7 +522,7 @@ Parse Server allows developers to choose from several options when hosting files `GridFSBucketAdapter` is used by default and requires no setup, but if you're interested in using Amazon S3, Google Cloud Storage, or local file storage, additional configuration information is available in the [Parse Server guide](http://docs.parseplatform.org/parse-server/guide/#configuring-file-adapters). ## Idempotency Enforcement - + **Caution, this is an experimental feature that may not be appropriate for production.** This feature deduplicates identical requests that are received by Parse Server multiple times, typically due to network issues or network adapter access restrictions on mobile operating systems. diff --git a/spec/CloudCode.spec.js b/spec/CloudCode.spec.js index bf1a2c8c42..0556368656 100644 --- a/spec/CloudCode.spec.js +++ b/spec/CloudCode.spec.js @@ -1,6 +1,7 @@ 'use strict'; const Config = require('../lib/Config'); const Parse = require('parse/node'); +const ParseServer = require('../lib/index').ParseServer; const request = require('../lib/request'); const InMemoryCacheAdapter = require('../lib/Adapters/Cache/InMemoryCacheAdapter') .InMemoryCacheAdapter; @@ -39,6 +40,63 @@ describe('Cloud Code', () => { }); }); + it('should wait for cloud code to load', async () => { + const initiated = new Date(); + const parseServer = await new ParseServer({ + appId: 'test2', + masterKey: 'abc', + serverURL: 'http://localhost:12668/parse', + silent: true, + async cloud() { + await new Promise(resolve => setTimeout(resolve, 1000)); + Parse.Cloud.beforeSave('Test', () => { + throw 'Cannot save.'; + }); + }, + }).startApp(); + const express = require('express'); + const app = express(); + app.use('/parse', parseServer); + const server = app.listen(12668); + + const now = new Date(); + expect(now.getTime() - initiated.getTime() > 1000).toBeTrue(); + await expectAsync(new Parse.Object('Test').save()).toBeRejectedWith( + new Parse.Error(141, 'Cannot save.') + ); + await new Promise(resolve => server.close(resolve)); + }); + + it('can call startApp twice', async () => { + const server = await new ParseServer({ + appId: 'test2', + masterKey: 'abc', + serverURL: 'http://localhost:12668/parse', + silent: true, + async cloud() { + await new Promise(resolve => setTimeout(resolve, 1000)); + Parse.Cloud.beforeSave('Test', () => { + throw 'Cannot save.'; + }); + }, + }).startApp(); + await server.startApp(); + }); + + it('can load cloud code as a module', async () => { + process.env.npm_package_type = 'module'; + await reconfigureServer({ cloud: './spec/cloud/cloudCodeModuleFile.js' }); + const result = await Parse.Cloud.run('cloudCodeInFile'); + expect(result).toEqual('It is possible to define cloud code in a file.'); + delete process.env.npm_package_type; + }); + + it('cloud code must be valid type', async () => { + await expectAsync(reconfigureServer({ cloud: true })).toBeRejectedWith( + "argument 'cloud' must either be a string or a function" + ); + }); + it('can create functions', done => { Parse.Cloud.define('hello', () => { return 'Hello world!'; diff --git a/spec/DefinedSchemas.spec.js b/spec/DefinedSchemas.spec.js index 0b1f2a3443..f055b296e2 100644 --- a/spec/DefinedSchemas.spec.js +++ b/spec/DefinedSchemas.spec.js @@ -631,7 +631,7 @@ describe('DefinedSchemas', () => { const logger = require('../lib/logger').logger; spyOn(DefinedSchemas.prototype, 'wait').and.resolveTo(); spyOn(logger, 'error').and.callThrough(); - spyOn(Parse.Schema, 'all').and.callFake(() => { + spyOn(DefinedSchemas.prototype, 'createDeleteSession').and.callFake(() => { throw error; }); @@ -642,6 +642,7 @@ describe('DefinedSchemas', () => { expect(logger.error).toHaveBeenCalledWith(`Failed to run migrations: ${error.toString()}`); }); + it('should perform migration in parallel without failing', async () => { const server = await reconfigureServer(); const logger = require('../lib/logger').logger; diff --git a/spec/cloud/cloudCodeModuleFile.js b/spec/cloud/cloudCodeModuleFile.js new file mode 100644 index 0000000000..a62b4fcc24 --- /dev/null +++ b/spec/cloud/cloudCodeModuleFile.js @@ -0,0 +1,3 @@ +Parse.Cloud.define('cloudCodeInFile', () => { + return 'It is possible to define cloud code in a file.'; +}); diff --git a/spec/helper.js b/spec/helper.js index f769c0f521..ace9da57c3 100644 --- a/spec/helper.js +++ b/spec/helper.js @@ -204,7 +204,9 @@ beforeAll(async () => { } } await reconfigureServer(); +}); +beforeEach(() => { Parse.initialize('test', 'test', 'test'); Parse.serverURL = 'http://localhost:' + port + '/1'; }); diff --git a/spec/index.spec.js b/spec/index.spec.js index dd9be1792b..06538de90c 100644 --- a/spec/index.spec.js +++ b/spec/index.spec.js @@ -531,6 +531,57 @@ describe('server', () => { .catch(done.fail); }); + it('should wait for schemas to load', async () => { + const spy = spyOn(process.stdout, 'write'); + const config = { + appId: 'aTestApp', + masterKey: 'aTestMasterKey', + serverURL: 'http://localhost:12669/parse', + schema: { + definitions: [ + { className: '_User' }, + { className: 'Test', classLevelPermissions: { addField: { '*': true } } }, + ], + }, + serverStartComplete() {}, + }; + const startSpy = spyOn(config, 'serverStartComplete').and.callFake(() => {}); + const parseServer = await new ParseServer.ParseServer(config).startApp(); + expect(startSpy).toHaveBeenCalled(); + expect(Parse.applicationId).toEqual('aTestApp'); + expect(spy).toHaveBeenCalledWith('info: Running Migrations Completed\n'); + const app = express(); + app.use('/parse', parseServer); + const server = app.listen(12669); + const schema = await Parse.Schema.all(); + expect(schema.length).toBeGreaterThanOrEqual(3); + await new Promise(resolve => server.close(resolve)); + }); + + it('call call startApp with mountPublicRoutes', async () => { + const config = { + appId: 'aTestApp', + masterKey: 'aTestMasterKey', + serverURL: 'http://localhost:12701/parse', + holdPublicRoutes: true, + }; + const parseServer = await new ParseServer.ParseServer(config).startApp(); + expect(Parse.applicationId).toEqual('aTestApp'); + expect(Parse.serverURL).toEqual('http://localhost:12701/parse'); + const app = express(); + app.use('/parse', parseServer); + const server = app.listen(12701); + const testObject = new Parse.Object('TestObject'); + // api usage requires masterKey until mountPublicRoutes is called + await expectAsync(testObject.save(null, { useMasterKey: true })).toBeResolved(); + await expectAsync(testObject.save()).toBeRejectedWith( + new Parse.Error(undefined, 'unauthorized: master key is required') + ); + parseServer.mountPublicRoutes(); + await expectAsync(testObject.save()).toBeResolved(); + await new Promise(resolve => server.close(resolve)); + }); + it('should not fail when Google signin is introduced without the optional clientId', done => { const jwt = require('jsonwebtoken'); diff --git a/src/Config.js b/src/Config.js index 04834d3291..f94da6e15b 100644 --- a/src/Config.js +++ b/src/Config.js @@ -79,6 +79,7 @@ export class Config { enforcePrivateUsers, schema, requestKeywordDenylist, + holdPublicRoutes, }) { if (masterKey === readOnlyMasterKey) { throw new Error('masterKey and readOnlyMasterKey should be different'); @@ -103,6 +104,10 @@ export class Config { throw 'revokeSessionOnPasswordReset must be a boolean value'; } + if (typeof holdPublicRoutes !== 'boolean') { + throw 'holdPublicRoutes must be a boolean value'; + } + if (publicServerURL) { if (!publicServerURL.startsWith('http://') && !publicServerURL.startsWith('https://')) { throw 'publicServerURL should be a valid HTTPS URL starting with https://'; diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index c9a316db36..8cc574a393 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -236,6 +236,13 @@ module.exports.ParseServerOptions = { env: 'PARSE_SERVER_GRAPH_QLSCHEMA', help: 'Full path to your GraphQL custom schema.graphql file', }, + holdPublicRoutes: { + env: 'PARSE_SERVER_HOLD_PUBLIC_ROUTES', + help: + 'Set to true if Parse Server should require masterKey access before `mountPublicRoutes()` is called.', + action: parsers.booleanParser, + default: false, + }, host: { env: 'PARSE_SERVER_HOST', help: 'The host to serve ParseServer on, defaults to 0.0.0.0', diff --git a/src/Options/docs.js b/src/Options/docs.js index e8601bd4e5..d101e89f55 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -46,6 +46,7 @@ * @property {FileUploadOptions} fileUpload Options for file uploads * @property {String} graphQLPath Mount path for the GraphQL endpoint, defaults to /graphql * @property {String} graphQLSchema Full path to your GraphQL custom schema.graphql file + * @property {Boolean} holdPublicRoutes Set to true if Parse Server should require masterKey access before `mountPublicRoutes()` is called. * @property {String} host The host to serve ParseServer on, defaults to 0.0.0.0 * @property {IdempotencyOptions} idempotencyOptions Options for request idempotency to deduplicate identical requests that may be caused by network issues. Caution, this is an experimental feature that may not be appropriate for production. * @property {String} javascriptKey Key for the Javascript SDK diff --git a/src/Options/index.js b/src/Options/index.js index c298bc78e2..475fa78f4f 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -282,6 +282,9 @@ export interface ParseServerOptions { /* An array of keys and values that are prohibited in database read and write requests to prevent potential security vulnerabilities. It is possible to specify only a key (`{"key":"..."}`), only a value (`{"value":"..."}`) or a key-value pair (`{"key":"...","value":"..."}`). The specification can use the following types: `boolean`, `numeric` or `string`, where `string` will be interpreted as a regex notation. Request data is deep-scanned for matching definitions to detect also any nested occurrences. Defaults are patterns that are likely to be used in malicious requests. Setting this option will override the default patterns. :DEFAULT: [{"key":"_bsontype","value":"Code"},{"key":"constructor"},{"key":"__proto__"}] */ requestKeywordDenylist: ?(RequestKeywordDenylist[]); + /* Set to true if Parse Server should require masterKey access before `mountPublicRoutes()` is called. + :DEFAULT: false */ + holdPublicRoutes: ?boolean; } export interface SecurityOptions { diff --git a/src/ParseServer.js b/src/ParseServer.js index e6b30d1918..1317c20a05 100644 --- a/src/ParseServer.js +++ b/src/ParseServer.js @@ -83,45 +83,83 @@ class ParseServer { logging.setLogger(loggerController); // Note: Tests will start to fail if any validation happens after this is called. - databaseController - .performInitialization() - .then(() => hooksController.load()) - .then(async () => { + (async () => { + try { + await databaseController.performInitialization(); + await hooksController.load(); if (schema) { await new DefinedSchemas(schema, this.config).execute(); } + if (cloud) { + addParseCloud(); + if (typeof cloud === 'function') { + await cloud(Parse); + } else if (typeof cloud === 'string') { + if (process.env.npm_package_type === 'module') { + await import(path.resolve(process.cwd(), cloud)); + } else { + require(path.resolve(process.cwd(), cloud)); + } + } else { + throw "argument 'cloud' must either be a string or a function"; + } + } if (serverStartComplete) { - serverStartComplete(); + await Promise.resolve(serverStartComplete()); + } + if (this.startCallbackSuccess) { + this.startCallbackSuccess(this.app); + } + this.started = true; + } catch (error) { + if (this.startCallbackError) { + this.startCallbackError(error); } - }) - .catch(error => { if (serverStartComplete) { serverStartComplete(error); } else { console.error(error); process.exit(1); } - }); - - if (cloud) { - addParseCloud(); - if (typeof cloud === 'function') { - cloud(Parse); - } else if (typeof cloud === 'string') { - require(path.resolve(process.cwd(), cloud)); - } else { - throw "argument 'cloud' must either be a string or a function"; } - } + })(); if (security && security.enableCheck && security.enableCheckLog) { new CheckRunner(options.security).run(); } } + /** + * Starts the Parse Server to be served as an express. Resolves when parse-server is ready to accept external traffic. + * Note: when using `await startApp()`, server JS SDK methods will not be available until expressApp.listen(port) is called. + * @returns {Promise} express middleware + */ + + startApp() { + return new Promise((resolve, reject) => { + if (this.started) { + resolve(this.app); + return; + } + this.startCallbackSuccess = resolve; + this.startCallbackError = reject; + }); + } + + /** + * Method that is called when Parse Server is ready to + * @returns {Promise} express middleware + */ + + mountPublicRoutes() { + this.config.publicRoutesAvailable = true; + } + get app() { if (!this._app) { this._app = ParseServer.app(this.config); + this._app.startApp = async () => await this.startApp(); + this._app.mountPublicRoutes = () => this.mountPublicRoutes(); } return this._app; } @@ -152,7 +190,7 @@ class ParseServer { * Create an express app for the parse server * @param {Object} options let you specify the maxUploadSize when creating the express app */ static app(options) { - const { maxUploadSize = '20mb', appId, directAccess, pages } = options; + const { maxUploadSize = '20mb', appId, directAccess, pages, holdPublicRoutes } = options; // This app serves the Parse API directly. // It's the equivalent of https://api.parse.com/1 in the hosted Parse API. var api = express(); @@ -184,6 +222,17 @@ class ParseServer { api.use(middlewares.allowMethodOverride); api.use(middlewares.handleParseHeaders); + if (holdPublicRoutes) { + options.publicRoutesAvailable = false; + api.use((req, res, next) => { + if (options.publicRoutesAvailable) { + next(); + return; + } + middlewares.enforceMasterKeyAccess(req, res, next); + }); + } + const appRouter = ParseServer.promiseRouter({ appId }); api.use(appRouter.expressRouter()); diff --git a/src/SchemaMigrations/DefinedSchemas.js b/src/SchemaMigrations/DefinedSchemas.js index 5ab737122f..1f506a303b 100644 --- a/src/SchemaMigrations/DefinedSchemas.js +++ b/src/SchemaMigrations/DefinedSchemas.js @@ -7,9 +7,11 @@ import { internalCreateSchema, internalUpdateSchema } from '../Routers/SchemasRo import { defaultColumns, systemClasses } from '../Controllers/SchemaController'; import { ParseServerOptions } from '../Options'; import * as Migrations from './Migrations'; +import Auth from '../Auth'; +import rest from '../rest'; export class DefinedSchemas { - config: ParseServerOptions; + config: any; schemaOptions: Migrations.SchemaOptions; localSchemas: Migrations.JSONSchema[]; retries: number; @@ -96,9 +98,9 @@ export class DefinedSchemas { }, 20000); } - // Hack to force session schema to be created await this.createDeleteSession(); - this.allCloudSchemas = await Parse.Schema.all(); + const schemaController = await this.config.database.loadSchema(); + this.allCloudSchemas = await schemaController.getAllClasses(); clearTimeout(timeout); await Promise.all(this.localSchemas.map(async localSchema => this.saveOrUpdate(localSchema))); @@ -171,9 +173,8 @@ export class DefinedSchemas { // Create a fake session since Parse do not create the _Session until // a session is created async createDeleteSession() { - const session = new Parse.Session(); - await session.save(null, { useMasterKey: true }); - await session.destroy({ useMasterKey: true }); + const { response } = await rest.create(this.config, Auth.master(this.config), '_Session', {}); + await rest.del(this.config, Auth.master(this.config), '_Session', response.objectId); } async saveOrUpdate(localSchema: Migrations.JSONSchema) {