diff --git a/spec/DatabaseController.spec.js b/spec/DatabaseController.spec.js index 98103ce6e4..3cf03b4154 100644 --- a/spec/DatabaseController.spec.js +++ b/spec/DatabaseController.spec.js @@ -1,9 +1,10 @@ +const Config = require('../lib/Config'); const DatabaseController = require('../lib/Controllers/DatabaseController.js'); const validateQuery = DatabaseController._validateQuery; -describe('DatabaseController', function () { - describe('validateQuery', function () { - it('should not restructure simple cases of SERVER-13732', done => { +describe('DatabaseController', () => { + describe('validateQuery', () => { + it('should not restructure simple cases of SERVER-13732', () => { const query = { $or: [{ a: 1 }, { a: 2 }], _rperm: { $in: ['a', 'b'] }, @@ -15,10 +16,9 @@ describe('DatabaseController', function () { _rperm: { $in: ['a', 'b'] }, foo: 3, }); - done(); }); - it('should not restructure SERVER-13732 queries with $nears', done => { + it('should not restructure SERVER-13732 queries with $nears', () => { let query = { $or: [{ a: 1 }, { b: 1 }], c: { $nearSphere: {} } }; validateQuery(query); expect(query).toEqual({ @@ -28,10 +28,9 @@ describe('DatabaseController', function () { query = { $or: [{ a: 1 }, { b: 1 }], c: { $near: {} } }; validateQuery(query); expect(query).toEqual({ $or: [{ a: 1 }, { b: 1 }], c: { $near: {} } }); - done(); }); - it('should not push refactored keys down a tree for SERVER-13732', done => { + it('should not push refactored keys down a tree for SERVER-13732', () => { const query = { a: 1, $or: [{ $or: [{ b: 1 }, { b: 2 }] }, { $or: [{ c: 1 }, { c: 2 }] }], @@ -41,13 +40,10 @@ describe('DatabaseController', function () { a: 1, $or: [{ $or: [{ b: 1 }, { b: 2 }] }, { $or: [{ c: 1 }, { c: 2 }] }], }); - - done(); }); - it('should reject invalid queries', done => { + it('should reject invalid queries', () => { expect(() => validateQuery({ $or: { a: 1 } })).toThrow(); - done(); }); it('should accept valid queries', done => { @@ -56,7 +52,7 @@ describe('DatabaseController', function () { }); }); - describe('addPointerPermissions', function () { + describe('addPointerPermissions', () => { const CLASS_NAME = 'Foo'; const USER_ID = 'userId'; const ACL_GROUP = [USER_ID]; @@ -69,7 +65,7 @@ describe('DatabaseController', function () { 'getExpectedType', ]); - it('should not decorate query if no pointer CLPs are present', done => { + it('should not decorate query if no pointer CLPs are present', () => { const clp = buildCLP(); const query = { a: 'b' }; @@ -87,11 +83,9 @@ describe('DatabaseController', function () { ); expect(output).toEqual({ ...query }); - - done(); }); - it('should decorate query if a pointer CLP entry is present', done => { + it('should decorate query if a pointer CLP entry is present', () => { const clp = buildCLP(['user']); const query = { a: 'b' }; @@ -112,11 +106,9 @@ describe('DatabaseController', function () { ); expect(output).toEqual({ ...query, user: createUserPointer(USER_ID) }); - - done(); }); - it('should decorate query if an array CLP entry is present', done => { + it('should decorate query if an array CLP entry is present', () => { const clp = buildCLP(['users']); const query = { a: 'b' }; @@ -140,11 +132,9 @@ describe('DatabaseController', function () { ...query, users: { $all: [createUserPointer(USER_ID)] }, }); - - done(); }); - it('should decorate query if an object CLP entry is present', done => { + it('should decorate query if an object CLP entry is present', () => { const clp = buildCLP(['user']); const query = { a: 'b' }; @@ -168,11 +158,9 @@ describe('DatabaseController', function () { ...query, user: createUserPointer(USER_ID), }); - - done(); }); - it('should decorate query if a pointer CLP is present and the same field is part of the query', done => { + it('should decorate query if a pointer CLP is present and the same field is part of the query', () => { const clp = buildCLP(['user']); const query = { a: 'b', user: 'a' }; @@ -195,11 +183,9 @@ describe('DatabaseController', function () { expect(output).toEqual({ $and: [{ user: createUserPointer(USER_ID) }, { ...query }], }); - - done(); }); - it('should transform the query to an $or query if multiple array/pointer CLPs are present', done => { + it('should transform the query to an $or query if multiple array/pointer CLPs are present', () => { const clp = buildCLP(['user', 'users', 'userObject']); const query = { a: 'b' }; @@ -232,11 +218,9 @@ describe('DatabaseController', function () { { ...query, userObject: createUserPointer(USER_ID) }, ], }); - - done(); }); - it('should not return a $or operation if the query involves one of the two fields also used as array/pointer permissions', done => { + it('should not return an $or operation if the query involves one of the two fields also used as array/pointer permissions', () => { const clp = buildCLP(['users', 'user']); const query = { a: 'b', user: createUserPointer(USER_ID) }; schemaController.testPermissionsForClassName @@ -257,10 +241,9 @@ describe('DatabaseController', function () { ACL_GROUP ); expect(output).toEqual({ ...query, user: createUserPointer(USER_ID) }); - done(); }); - it('should not return a $or operation if the query involves one of the fields also used as array/pointer permissions', done => { + it('should not return an $or operation if the query involves one of the fields also used as array/pointer permissions', () => { const clp = buildCLP(['user', 'users', 'userObject']); const query = { a: 'b', user: createUserPointer(USER_ID) }; schemaController.testPermissionsForClassName @@ -284,10 +267,9 @@ describe('DatabaseController', function () { ACL_GROUP ); expect(output).toEqual({ ...query, user: createUserPointer(USER_ID) }); - done(); }); - it('should throw an error if for some unexpected reason the property specified in the CLP is neither a pointer nor an array', done => { + it('should throw an error if for some unexpected reason the property specified in the CLP is neither a pointer nor an array', () => { const clp = buildCLP(['user']); const query = { a: 'b' }; @@ -312,21 +294,18 @@ describe('DatabaseController', function () { `An unexpected condition occurred when resolving pointer permissions: ${CLASS_NAME} user` ) ); - - done(); }); }); describe('reduceOperations', function () { const databaseController = new DatabaseController(); - it('objectToEntriesStrings', done => { + it('objectToEntriesStrings', () => { const output = databaseController.objectToEntriesStrings({ a: 1, b: 2, c: 3 }); expect(output).toEqual(['"a":1', '"b":2', '"c":3']); - done(); }); - it('reduceOrOperation', done => { + it('reduceOrOperation', () => { expect(databaseController.reduceOrOperation({ a: 1 })).toEqual({ a: 1 }); expect(databaseController.reduceOrOperation({ $or: [{ a: 1 }, { b: 2 }] })).toEqual({ $or: [{ a: 1 }, { b: 2 }], @@ -341,10 +320,9 @@ describe('DatabaseController', function () { expect( databaseController.reduceOrOperation({ $or: [{ b: 2 }, { a: 1, b: 2, c: 3 }] }) ).toEqual({ b: 2 }); - done(); }); - it('reduceAndOperation', done => { + it('reduceAndOperation', () => { expect(databaseController.reduceAndOperation({ a: 1 })).toEqual({ a: 1 }); expect(databaseController.reduceAndOperation({ $and: [{ a: 1 }, { b: 2 }] })).toEqual({ $and: [{ a: 1 }, { b: 2 }], @@ -358,7 +336,190 @@ describe('DatabaseController', function () { expect( databaseController.reduceAndOperation({ $and: [{ a: 1, b: 2, c: 3 }, { b: 2 }] }) ).toEqual({ a: 1, b: 2, c: 3 }); - done(); + }); + }); + + describe('disableCollation', () => { + const dummyStorageAdapter = { + find: () => Promise.resolve([]), + watch: () => Promise.resolve(), + getAllClasses: () => Promise.resolve([]), + }; + + beforeEach(() => { + Config.get(Parse.applicationId).schemaCache.clear(); + }); + + it('should force caseInsensitive to false with disableCollation option', async () => { + const databaseController = new DatabaseController(dummyStorageAdapter, { + disableCollation: true, + }); + const spy = spyOn(dummyStorageAdapter, 'find'); + spy.and.callThrough(); + await databaseController.find('SomeClass', {}, { caseInsensitive: true }); + expect(spy.calls.all()[0].args[3].caseInsensitive).toEqual(false); + }); + + it('should support caseInsensitive without disableCollation option', async () => { + const databaseController = new DatabaseController(dummyStorageAdapter, {}); + const spy = spyOn(dummyStorageAdapter, 'find'); + spy.and.callThrough(); + await databaseController.find('_User', {}, { caseInsensitive: true }); + expect(spy.calls.all()[0].args[3].caseInsensitive).toEqual(true); + }); + + it_only_db('mongo')('should create insensitive indexes without disableCollation', async () => { + await reconfigureServer({ + databaseURI: 'mongodb://localhost:27017/disableCollationFalse', + databaseAdapter: undefined, + }); + const user = new Parse.User(); + await user.save({ + username: 'example', + password: 'password', + email: 'example@example.com', + }); + const schemas = await Parse.Schema.all(); + const UserSchema = schemas.find(({ className }) => className === '_User'); + expect(UserSchema.indexes).toEqual({ + _id_: { _id: 1 }, + username_1: { username: 1 }, + case_insensitive_username: { username: 1 }, + case_insensitive_email: { email: 1 }, + email_1: { email: 1 }, + }); + }); + + it_only_db('mongo')('should not create insensitive indexes with disableCollation', async () => { + await reconfigureServer({ + disableCollation: true, + databaseURI: 'mongodb://localhost:27017/disableCollationTrue', + databaseAdapter: undefined, + }); + const user = new Parse.User(); + await user.save({ + username: 'example', + password: 'password', + email: 'example@example.com', + }); + const schemas = await Parse.Schema.all(); + const UserSchema = schemas.find(({ className }) => className === '_User'); + expect(UserSchema.indexes).toEqual({ + _id_: { _id: 1 }, + username_1: { username: 1 }, + email_1: { email: 1 }, + }); + }); + }); + + describe('transformEmailAndUsernameToLowerCase', () => { + const dummyStorageAdapter = { + createObject: () => Promise.resolve({ ops: [{}] }), + findOneAndUpdate: () => Promise.resolve({}), + watch: () => Promise.resolve(), + getAllClasses: () => + Promise.resolve([ + { + className: '_User', + fields: { username: 'String', email: 'String' }, + indexes: {}, + classLevelPermissions: { protectedFields: {} }, + }, + ]), + }; + const dates = { + createdAt: { iso: undefined, __type: 'Date' }, + updatedAt: { iso: undefined, __type: 'Date' }, + }; + + it('should not transform email and username to lower case without transformEmailAndUsernameToLowerCase option on create', async () => { + const databaseController = new DatabaseController(dummyStorageAdapter, {}); + const spy = spyOn(dummyStorageAdapter, 'createObject'); + spy.and.callThrough(); + await databaseController.create('_User', { + username: 'EXAMPLE', + email: 'EXAMPLE@EXAMPLE.COM', + }); + expect(spy.calls.all()[0].args[2]).toEqual({ + username: 'EXAMPLE', + email: 'EXAMPLE@EXAMPLE.COM', + ...dates, + }); + }); + + it('should transform email and username to lower case with transformEmailAndUsernameToLowerCase option on create', async () => { + const databaseController = new DatabaseController(dummyStorageAdapter, { + transformEmailAndUsernameToLowerCase: true, + }); + const spy = spyOn(dummyStorageAdapter, 'createObject'); + spy.and.callThrough(); + await databaseController.create('_User', { + username: 'EXAMPLE', + email: 'EXAMPLE@EXAMPLE.COM', + }); + expect(spy.calls.all()[0].args[2]).toEqual({ + username: 'example', + email: 'example@example.com', + ...dates, + }); + }); + + it('should not transform email and username to lower case without transformEmailAndUsernameToLowerCase option on update', async () => { + const databaseController = new DatabaseController(dummyStorageAdapter, {}); + const spy = spyOn(dummyStorageAdapter, 'findOneAndUpdate'); + spy.and.callThrough(); + await databaseController.update( + '_User', + { id: 'example' }, + { username: 'EXAMPLE', email: 'EXAMPLE@EXAMPLE.COM' } + ); + expect(spy.calls.all()[0].args[3]).toEqual({ + username: 'EXAMPLE', + email: 'EXAMPLE@EXAMPLE.COM', + }); + }); + + it('should transform email and username to lower case with transformEmailAndUsernameToLowerCase option on update', async () => { + const databaseController = new DatabaseController(dummyStorageAdapter, { + transformEmailAndUsernameToLowerCase: true, + }); + const spy = spyOn(dummyStorageAdapter, 'findOneAndUpdate'); + spy.and.callThrough(); + await databaseController.update( + '_User', + { id: 'example' }, + { username: 'EXAMPLE', email: 'EXAMPLE@EXAMPLE.COM' } + ); + expect(spy.calls.all()[0].args[3]).toEqual({ + username: 'example', + email: 'example@example.com', + }); + }); + + it('should not find a case insensitive user by username or email with transformEmailAndUsernameToLowerCase', async () => { + await reconfigureServer({ transformEmailAndUsernameToLowerCase: true }); + const user = new Parse.User(); + await user.save({ username: 'EXAMPLE', email: 'EXAMPLE@EXAMPLE.COM', password: 'password' }); + + const query = new Parse.Query(Parse.User); + query.equalTo('username', 'EXAMPLE'); + const result = await query.find({ useMasterKey: true }); + expect(result.length).toEqual(0); + + const query2 = new Parse.Query(Parse.User); + query2.equalTo('email', 'EXAMPLE@EXAMPLE.COM'); + const result2 = await query2.find({ useMasterKey: true }); + expect(result2.length).toEqual(0); + + const query3 = new Parse.Query(Parse.User); + query3.equalTo('username', 'example'); + const result3 = await query3.find({ useMasterKey: true }); + expect(result3.length).toEqual(1); + + const query4 = new Parse.Query(Parse.User); + query4.equalTo('email', 'example@example.com'); + const result4 = await query4.find({ useMasterKey: true }); + expect(result4.length).toEqual(1); }); }); }); diff --git a/src/Controllers/DatabaseController.js b/src/Controllers/DatabaseController.js index defb7976c4..e21f03113b 100644 --- a/src/Controllers/DatabaseController.js +++ b/src/Controllers/DatabaseController.js @@ -368,6 +368,17 @@ const relationSchema = { fields: { relatedId: { type: 'String' }, owningId: { type: 'String' } }, }; +const transformEmailAndUsernameToLowerCase = (object, className, options) => { + if (className === '_User' && options.transformEmailAndUsernameToLowerCase) { + const toLowerCaseFields = ['email', 'username']; + toLowerCaseFields.forEach(key => { + if (typeof object[key] === 'string') { + object[key] = object[key].toLowerCase(); + } + }); + } +}; + class DatabaseController { adapter: StorageAdapter; schemaCache: any; @@ -573,6 +584,7 @@ class DatabaseController { } } update = transformObjectACL(update); + transformEmailAndUsernameToLowerCase(update, className, this.options); transformAuthData(className, update, schema); if (validateOnly) { return this.adapter.find(className, schema, query, {}).then(result => { @@ -822,6 +834,7 @@ class DatabaseController { const originalObject = object; object = transformObjectACL(object); + transformEmailAndUsernameToLowerCase(object, className, this.options); object.createdAt = { iso: object.createdAt, __type: 'Date' }; object.updatedAt = { iso: object.updatedAt, __type: 'Date' }; @@ -1215,7 +1228,7 @@ class DatabaseController { keys, readPreference, hint, - caseInsensitive, + caseInsensitive: this.options.disableCollation ? false : caseInsensitive, explain, }; Object.keys(sort).forEach(fieldName => { @@ -1719,25 +1732,27 @@ class DatabaseController { throw error; }); - await this.adapter - .ensureIndex('_User', requiredUserFields, ['username'], 'case_insensitive_username', true) - .catch(error => { - logger.warn('Unable to create case insensitive username index: ', error); - throw error; - }); + if (!this.options.disableCollation) { + await this.adapter + .ensureIndex('_User', requiredUserFields, ['username'], 'case_insensitive_username', true) + .catch(error => { + logger.warn('Unable to create case insensitive username index: ', error); + throw error; + }); + + await this.adapter + .ensureIndex('_User', requiredUserFields, ['email'], 'case_insensitive_email', true) + .catch(error => { + logger.warn('Unable to create case insensitive email index: ', error); + throw error; + }); + } await this.adapter.ensureUniqueness('_User', requiredUserFields, ['email']).catch(error => { logger.warn('Unable to ensure uniqueness for user email addresses: ', error); throw error; }); - await this.adapter - .ensureIndex('_User', requiredUserFields, ['email'], 'case_insensitive_email', true) - .catch(error => { - logger.warn('Unable to create case insensitive email index: ', error); - throw error; - }); - await this.adapter.ensureUniqueness('_Role', requiredRoleFields, ['name']).catch(error => { logger.warn('Unable to ensure uniqueness for role name: ', error); throw error; diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index f35300b287..582c94ed93 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -175,6 +175,12 @@ module.exports.ParseServerOptions = { action: parsers.booleanParser, default: true, }, + disableCollation: { + env: 'PARSE_SERVER_DISABLE_COLLATION', + help: + 'Disable case insensitivity (collation) on queries and indexes, needed if you use MongoDB serverless or AWS DocumentDB.', + action: parsers.booleanParser, + }, dotNetKey: { env: 'PARSE_SERVER_DOT_NET_KEY', help: 'Key for Unity and .Net SDK', @@ -533,6 +539,12 @@ module.exports.ParseServerOptions = { help: 'Starts the liveQuery server', action: parsers.booleanParser, }, + transformEmailAndUsernameToLowerCase: { + env: 'PARSE_SERVER_TRANSFORM_EMAIL_AND_USERNAME_TO_LOWER_CASE', + help: + 'Transform Username and Email to lowercase on create/update/login/signup. On queries client needs to ensure to send Email/Username in lowercase format.', + action: parsers.booleanParser, + }, trustProxy: { env: 'PARSE_SERVER_TRUST_PROXY', help: diff --git a/src/Options/docs.js b/src/Options/docs.js index cf672cc6cb..84e2892aea 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -34,6 +34,7 @@ * @property {String} databaseURI The full URI to your database. Supported databases are mongodb or postgres. * @property {Number} defaultLimit Default value for limit option on queries, defaults to `100`. * @property {Boolean} directAccess Set to `true` if Parse requests within the same Node.js environment as Parse Server should be routed to Parse Server directly instead of via the HTTP interface. Default is `false`.

If set to `false` then Parse requests within the same Node.js environment as Parse Server are executed as HTTP requests sent to Parse Server via the `serverURL`. For example, a `Parse.Query` in Cloud Code is calling Parse Server via a HTTP request. The server is essentially making a HTTP request to itself, unnecessarily using network resources such as network ports.

⚠️ In environments where multiple Parse Server instances run behind a load balancer and Parse requests within the current Node.js environment should be routed via the load balancer and distributed as HTTP requests among all instances via the `serverURL`, this should be set to `false`. + * @property {Boolean} disableCollation Disable case insensitivity (collation) on queries and indexes, needed if you use MongoDB serverless or AWS DocumentDB. * @property {String} dotNetKey Key for Unity and .Net SDK * @property {Adapter} emailAdapter Adapter module for email sending * @property {Boolean} emailVerifyTokenReuseIfValid Set to `true` if a email verification token should be reused in case another token is requested but there is a token that is still valid, i.e. has not expired. This avoids the often observed issue that a user requests multiple emails and does not know which link contains a valid token because each newly generated token would invalidate the previous token.

Default is `false`.
Requires option `verifyUserEmails: true`. @@ -96,6 +97,7 @@ * @property {Number} sessionLength Session duration, in seconds, defaults to 1 year * @property {Boolean} silent Disables console output * @property {Boolean} startLiveQueryServer Starts the liveQuery server + * @property {Boolean} transformEmailAndUsernameToLowerCase Transform Username and Email to lowercase on create/update/login/signup. On queries client needs to ensure to send Email/Username in lowercase format. * @property {Any} trustProxy The trust proxy settings. It is important to understand the exact setup of the reverse proxy, since this setting will trust values provided in the Parse Server API request. See the express trust proxy settings documentation. Defaults to `false`. * @property {String[]} userSensitiveFields Personally identifiable information fields in the user table the should be removed for non-authorized users. Deprecated @see protectedFields * @property {Boolean} verbose Set the logging to verbose diff --git a/src/Options/index.js b/src/Options/index.js index 996512e36e..33df866cfc 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -103,6 +103,10 @@ export interface ParseServerOptions { databaseOptions: ?DatabaseOptions; /* Adapter module for the database; any options that are not explicitly described here are passed directly to the database client. */ databaseAdapter: ?Adapter; + /* Disable case insensitivity (collation) on queries and indexes, needed if you use MongoDB serverless or AWS DocumentDB. */ + disableCollation: ?boolean; + /* Transform Username and Email to lowercase on create/update/login/signup. On queries client needs to ensure to send Email/Username in lowercase format. */ + transformEmailAndUsernameToLowerCase: ?boolean; /* Full path to your cloud code main.js */ cloud: ?string; /* A collection prefix for the classes