diff --git a/spec/ParseUser.spec.js b/spec/ParseUser.spec.js index 0380589057..a3743f75d8 100644 --- a/spec/ParseUser.spec.js +++ b/spec/ParseUser.spec.js @@ -81,6 +81,59 @@ describe('Parse.User testing', () => { } }); + it('logs username taken with configured log level', async () => { + await reconfigureServer({ logEvents: { usernameAlreadyExists: 'warn' } }); + const logger = require('../lib/logger').default; + loggerErrorSpy = spyOn(logger, 'error').and.callThrough(); + const loggerWarnSpy = spyOn(logger, 'warn').and.callThrough(); + + const user = new Parse.User(); + user.setUsername('dupUser'); + user.setPassword('pass'); + await user.signUp(); + + const user2 = new Parse.User(); + user2.setUsername('dupUser'); + user2.setPassword('pass2'); + + expect(loggerWarnSpy).not.toHaveBeenCalled(); + + try { + await user2.signUp(); + fail('should have thrown'); + } catch (e) { + expect(e.code).toBe(Parse.Error.USERNAME_TAKEN); + } + + expect(loggerWarnSpy).toHaveBeenCalledTimes(1); + expect(loggerErrorSpy.calls.count()).toBe(0); + }); + + it('can silence username taken log event', async () => { + await reconfigureServer({ logEvents: { usernameAlreadyExists: 'silent' } }); + const logger = require('../lib/logger').default; + loggerErrorSpy = spyOn(logger, 'error').and.callThrough(); + const loggerWarnSpy = spyOn(logger, 'warn').and.callThrough(); + + const user = new Parse.User(); + user.setUsername('dupUser'); + user.setPassword('pass'); + await user.signUp(); + + const user2 = new Parse.User(); + user2.setUsername('dupUser'); + user2.setPassword('pass2'); + try { + await user2.signUp(); + fail('should have thrown'); + } catch (e) { + expect(e.code).toBe(Parse.Error.USERNAME_TAKEN); + } + + expect(loggerWarnSpy).not.toHaveBeenCalled(); + expect(loggerErrorSpy.calls.count()).toBe(0); + }); + it('user login with context', async () => { let hit = 0; const context = { foo: 'bar' }; diff --git a/src/Config.js b/src/Config.js index 241edf9771..3e218c6e19 100644 --- a/src/Config.js +++ b/src/Config.js @@ -128,6 +128,7 @@ export class Config { requestKeywordDenylist, allowExpiredAuthDataToken, logLevels, + logEvents, rateLimit, databaseOptions, extendSessionOnUse, @@ -170,6 +171,7 @@ export class Config { this.validateRequestKeywordDenylist(requestKeywordDenylist); this.validateRateLimit(rateLimit); this.validateLogLevels(logLevels); + this.validateLogEvents(logEvents); this.validateDatabaseOptions(databaseOptions); this.validateCustomPages(customPages); this.validateAllowClientClassCreation(allowClientClassCreation); @@ -641,6 +643,19 @@ export class Config { } } + static validateLogEvents(logEvents) { + for (const key of Object.keys(LogLevels)) { + if (logEvents[key]) { + // We validate that each configured event uses a valid log *level* (same list as logLevels). + if (validLogLevels.indexOf(logEvents[key]) === -1) { + throw `'${key}' must be one of ${JSON.stringify(validLogLevels)}`; + } + } else { + logEvents[key] = LogLevels[key].default; + } + } + } + static validateDatabaseOptions(databaseOptions) { if (databaseOptions == undefined) { return; diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index 66c1d8bcea..3d0a7d2085 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -352,6 +352,13 @@ module.exports.ParseServerOptions = { action: parsers.objectParser, type: 'LiveQueryServerOptions', }, + logEvents: { + env: 'PARSE_SERVER_LOG_EVENTS', + help: '(Optional) Overrides the log levels used by specific log events.', + action: parsers.objectParser, + type: 'LogLevels', + default: {}, + }, loggerAdapter: { env: 'PARSE_SERVER_LOGGER_ADAPTER', help: 'Adapter module for the logging sub-system', @@ -1506,4 +1513,10 @@ module.exports.LogLevels = { 'Log level used by the Cloud Code Triggers `beforeSave`, `beforeDelete`, `beforeFind`, `beforeLogin` on success. Default is `info`. See [LogLevel](LogLevel.html) for available values.', default: 'info', }, + usernameAlreadyExists: { + env: 'PARSE_SERVER_LOG_LEVELS_USERNAME_ALREADY_EXISTS', + help: + 'Log level used when a sign-up fails because the username already exists. Default is `error`. See [LogLevel](LogLevel.html) for available values.', + default: 'error', + }, }; diff --git a/src/Options/docs.js b/src/Options/docs.js index 9569239ef7..ac9abc603d 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -63,6 +63,7 @@ * @property {Boolean} jsonLogs Log as structured JSON objects * @property {LiveQueryOptions} liveQuery parse-server's LiveQuery configuration object * @property {LiveQueryServerOptions} liveQueryServerOptions Live query server configuration options (will start the liveQuery server) + * @property {LogLevels} logEvents (Optional) Overrides the log levels used by specific log events. * @property {Adapter} loggerAdapter Adapter module for the logging sub-system * @property {String} logLevel Sets the level for logs * @property {LogLevels} logLevels (Optional) Overrides the log levels used internally by Parse Server to log events. @@ -328,4 +329,5 @@ * @property {String} triggerAfter Log level used by the Cloud Code Triggers `afterSave`, `afterDelete`, `afterFind`, `afterLogout`. Default is `info`. See [LogLevel](LogLevel.html) for available values. * @property {String} triggerBeforeError Log level used by the Cloud Code Triggers `beforeSave`, `beforeDelete`, `beforeFind`, `beforeLogin` on error. Default is `error`. See [LogLevel](LogLevel.html) for available values. * @property {String} triggerBeforeSuccess Log level used by the Cloud Code Triggers `beforeSave`, `beforeDelete`, `beforeFind`, `beforeLogin` on success. Default is `info`. See [LogLevel](LogLevel.html) for available values. + * @property {String} usernameAlreadyExists Log level used when a sign-up fails because the username already exists. Default is `error`. See [LogLevel](LogLevel.html) for available values. */ diff --git a/src/Options/index.js b/src/Options/index.js index cdeb7cd846..f442c6b643 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -98,6 +98,9 @@ export interface ParseServerOptions { /* (Optional) Overrides the log levels used internally by Parse Server to log events. :DEFAULT: {} */ logLevels: ?LogLevels; + /* (Optional) Overrides the log levels used by specific log events. + :DEFAULT: {} */ + logEvents: ?LogLevels; /* Maximum number of logs to keep. If not set, no logs will be removed. This can be a number of files or number of days. If using days, add 'd' as the suffix. (default: null) */ maxLogFiles: ?NumberOrString; /* Disables console output @@ -789,4 +792,8 @@ export interface LogLevels { :DEFAULT: error */ cloudFunctionError: ?string; + /* Log level used when a sign-up fails because the username already exists. Default is `error`. See [LogLevel](LogLevel.html) for available values. + :DEFAULT: error + */ + usernameAlreadyExists: ?string; } diff --git a/src/middlewares.js b/src/middlewares.js index 2fedce8f08..edcf96bca6 100644 --- a/src/middlewares.js +++ b/src/middlewares.js @@ -466,6 +466,8 @@ export function handleParseErrors(err, req, res, next) { if (req.config && req.config.enableExpressErrorHandler) { return next(err); } + const usernameAlreadyExistsLevel = + req.config?.logEvents?.usernameAlreadyExists || 'info'; let httpStatus; // TODO: fill out this mapping switch (err.code) { @@ -480,7 +482,17 @@ export function handleParseErrors(err, req, res, next) { } res.status(httpStatus); res.json({ code: err.code, error: err.message }); - log.error('Parse error: ', err); + if (err.code === Parse.Error.USERNAME_TAKEN) { + if (usernameAlreadyExistsLevel !== 'silent') { + const loggerMethod = + typeof log[usernameAlreadyExistsLevel] === 'function' + ? log[usernameAlreadyExistsLevel].bind(log) + : log.error.bind(log); + loggerMethod('Parse error: ', err); + } + } else { + log.error('Parse error: ', err); + } } else if (err.status && err.message) { res.status(err.status); res.json({ error: err.message }); diff --git a/types/Options/index.d.ts b/types/Options/index.d.ts index ad11050648..e6618e65bf 100644 --- a/types/Options/index.d.ts +++ b/types/Options/index.d.ts @@ -47,6 +47,7 @@ export interface ParseServerOptions { verbose?: boolean; logLevel?: string; logLevels?: LogLevels; + logEvents?: LogLevels; maxLogFiles?: NumberOrString; silent?: boolean; databaseURI: string; @@ -297,5 +298,6 @@ export interface LogLevels { triggerBeforeError?: string; cloudFunctionSuccess?: string; cloudFunctionError?: string; + usernameAlreadyExists?: string; } export {};