From a15878c7d709473c5b0d4eec2062e9425c9b5e31 Mon Sep 17 00:00:00 2001 From: NickOvt Date: Thu, 21 Dec 2023 10:42:09 +0200 Subject: [PATCH] fix(docs): /users API docs ZMS-110 (#575) * /users GET endpoint added * added /users POST to api generation * added /users/resolve/:username GET endpoint for api generation * fixes + request user info GET endpoint added * Added Update User information api path to api docs generation * added Log out User endpoint to api generation * Added Recalculate User quota endpoint to API generation * added Recalculate Quota for all users endpoint to api generation * added Export data endpoint to API generation * added Import user data endpoint to API generation * Reset password for a User endpoint added to API generation * Reset password for a User endpoint add param to res * add missing response types * added Delete a User, Return recovery info for a deleted User, Cancel user deletion task - endpoints to API generation * fix typo in users.js --- lib/api/users.js | 1037 ++++++++++++++++++----- lib/schemas/response/general-schemas.js | 25 +- lib/schemas/response/users-schemas.js | 29 + 3 files changed, 873 insertions(+), 218 deletions(-) create mode 100644 lib/schemas/response/users-schemas.js diff --git a/lib/api/users.js b/lib/api/users.js index 10bf48ff..5eb121eb 100644 --- a/lib/api/users.js +++ b/lib/api/users.js @@ -26,6 +26,9 @@ const { const TaskHandler = require('../task-handler'); const { publish, FORWARD_ADDED } = require('../events'); const { ExportStream, ImportStream } = require('../export'); +const { successRes, totalRes, pageRes, previousCursorRes, nextCursorRes, quotaRes } = require('../schemas/response/general-schemas'); +const { GetUsersResult } = require('../schemas/response/users-schemas'); +const { userId } = require('../schemas/request/general-schemas'); const FEATURE_FLAGS = ['indexing']; @@ -33,23 +36,57 @@ module.exports = (db, server, userHandler, settingsHandler) => { const taskHandler = new TaskHandler({ database: db.database }); server.get( - { name: 'users', path: '/users' }, + { + name: 'users', + path: '/users', + summary: 'List registered Users', + tags: ['Users'], + validationObjs: { + pathParams: {}, + requestBody: {}, + queryParams: { + query: Joi.string().empty('').lowercase().max(255).description('Partial match of username or default email address'), + forward: Joi.string().empty('').lowercase().max(255).description('Partial match of a forward email address or URL'), + tags: Joi.string().trim().empty('').max(1024).description('Comma separated list of tags. The User must have at least one to be set'), + requiredTags: Joi.string() + .trim() + .empty('') + .max(1024) + .description('Comma separated list of tags. The User must have all listed tags to be set'), + metaData: booleanSchema.description('If true, then includes metaData in the response'), + internalData: booleanSchema.description('If true, then includes internalData in the response. Not shown for user-role tokens.'), + limit: Joi.number().default(20).min(1).max(250).description('How many records to return'), + next: nextPageCursorSchema, + previous: previousPageCursorSchema, + page: pageNrSchema, + sess: sessSchema, + ip: sessIPSchema + }, + response: { + 200: { + description: 'Success', + model: Joi.object({ + success: successRes, + total: totalRes, + page: pageRes, + previousCursor: previousCursorRes, + nextCursor: nextCursorRes, + query: Joi.string().required().description('Partial match of username or default email address'), + results: Joi.array().items(GetUsersResult).required().description('User listing') + }) + } + } + } + }, tools.responseWrapper(async (req, res) => { res.charSet('utf-8'); - const schema = Joi.object().keys({ - query: Joi.string().empty('').lowercase().max(255), - forward: Joi.string().empty('').lowercase().max(255), - tags: Joi.string().trim().empty('').max(1024), - requiredTags: Joi.string().trim().empty('').max(1024), - metaData: booleanSchema, - internalData: booleanSchema, - limit: Joi.number().default(20).min(1).max(250), - next: nextPageCursorSchema, - previous: previousPageCursorSchema, - page: pageNrSchema, - sess: sessSchema, - ip: sessIPSchema + const { pathParams, requestBody, queryParams } = req.route.spec.validationObjs; + + const schema = Joi.object({ + ...pathParams, + ...requestBody, + ...queryParams }); const result = schema.validate(req.params, { @@ -262,91 +299,166 @@ module.exports = (db, server, userHandler, settingsHandler) => { ); server.post( - '/users', + { + path: '/users', + summary: 'Create new user', + tags: ['Users'], + validationObjs: { + requestBody: { + username: usernameSchema + .required() + .description('Username of the User. Dots are allowed but informational only ("user.name" is the same as "username").'), + password: Joi.string() + .max(256) + .allow(false, '') + .required() + .description( + 'Password for the account. Set to boolean false to disable password usage for the master scope, Application Specific Passwords would still be allowed' + ), + hashedPassword: booleanSchema + .default(false) + .description( + 'If true then password is already hashed, so store as is. Supported hashes: pbkdf2, bcrypt ($2a, $2y, $2b), md5 ($1), sha512 ($6), sha256 ($5), argon2 ($argon2d, $argon2i, $argon2id). Stored hashes are rehashed to pbkdf2 on first successful password check.' + ), + allowUnsafe: booleanSchema + .default(true) + .description( + 'If false then validates provided passwords against Have I Been Pwned API. Experimental, so validation is disabled by default but will be enabled automatically in some future version of WildDuck.' + ), + + address: Joi.string().email({ tlds: false }).description('Default email address for the User (autogenerated if not set)'), + emptyAddress: booleanSchema + .default(false) + .description( + 'If true then do not autogenerate missing email address for the User. Only needed if you want to create a user account that does not have any email address associated' + ), + + language: Joi.string().empty('').max(20).description('Language code for the User'), + + retention: Joi.number().min(0).default(0).description('Default retention time (in ms). Set to 0 to disable'), + + name: Joi.string().max(256).description('Name of the User'), + targets: Joi.array() + .items( + Joi.string().email({ tlds: false }), + Joi.string().uri({ + scheme: [/smtps?/, /https?/], + allowRelative: false, + relativeOnly: false + }) + ) + .description( + 'An array of forwarding targets. The value could either be an email address or a relay url to next MX server ("smtp://mx2.zone.eu:25") or an URL where mail contents are POSTed to' + ), + + spamLevel: Joi.number() + .min(0) + .max(100) + .default(50) + .description('Relative scale for detecting spam. 0 means that everything is spam, 100 means that nothing is spam'), + + quota: Joi.number().min(0).default(0).description('Allowed quota of the user in bytes'), + recipients: Joi.number().min(0).default(0).description('How many messages per 24 hour can be sent'), + forwards: Joi.number().min(0).default(0).description('How many messages per 24 hour can be forwarded'), + + filters: Joi.number().min(0).default(0).description('How many filters are allowed for this account'), + + requirePasswordChange: booleanSchema + .default(false) + .description('If true then requires the user to change password, useful if password for the account was autogenerated'), + + imapMaxUpload: Joi.number().min(0).default(0).description('How many bytes can be uploaded via IMAP during 24 hour'), + imapMaxDownload: Joi.number().min(0).default(0).description('How many bytes can be downloaded via IMAP during 24 hour'), + pop3MaxDownload: Joi.number().min(0).default(0).description('How many bytes can be downloaded via POP3 during 24 hour'), + pop3MaxMessages: Joi.number().min(0).default(0).description('How many latest messages to list in POP3 session'), + imapMaxConnections: Joi.number().min(0).default(0).description('How many parallel IMAP connections are alowed'), + receivedMax: Joi.number().min(0).default(0).description('How many messages can be received from MX during 60 seconds'), + + fromWhitelist: Joi.array() + .items(Joi.string().trim().max(128)) + .description('A list of additional email addresses this user can send mail from. Wildcard is allowed.'), + + tags: Joi.array().items(Joi.string().trim().max(128)).description('A list of tags associated with this user'), + addTagsToAddress: booleanSchema.default(false).description('If true then autogenerated address gets the same tags as the user'), + + uploadSentMessages: booleanSchema + .default(false) + .description( + 'If true then all messages sent through MSA are also uploaded to the Sent Mail folder. Might cause duplicates with some email clients, so disabled by default.' + ), + + mailboxes: Joi.object() + .keys({ + sent: Joi.string() + .empty('') + .regex(/\/{2,}|\/$/, { invert: true }), + trash: Joi.string() + .empty('') + .regex(/\/{2,}|\/$/, { invert: true }), + junk: Joi.string() + .empty('') + .regex(/\/{2,}|\/$/, { invert: true }), + drafts: Joi.string() + .empty('') + .regex(/\/{2,}|\/$/, { invert: true }) + }) + .description('Optional names for special mailboxes') + .$_setFlag('objectName', 'Mailboxes'), + + disabledScopes: Joi.array() + .items( + Joi.string() + .valid(...consts.SCOPES) + .$_setFlag('objectName', 'DisabledScopes') + ) + .unique() + .default([]) + .description('List of scopes that are disabled for this user ("imap", "pop3", "smtp")'), + + metaData: metaDataSchema.label('metaData').description('Optional metadata, must be an object or JSON formatted string'), + internalData: metaDataSchema + .label('internalData') + .description( + 'Optional metadata for internal use, must be an object or JSON formatted string of an object. Not available for user-role tokens' + ), + + pubKey: Joi.string() + .empty('') + .trim() + .regex(/^-----BEGIN PGP PUBLIC KEY BLOCK-----/, 'PGP key format') + .description('Public PGP key for the User that is used for encryption. Use empty string to remove the key'), + encryptMessages: booleanSchema.default(false).description('If true then received messages are encrypted'), + encryptForwarded: booleanSchema.default(false).description('If true then forwarded messages are encrypted'), + + featureFlags: Joi.object(Object.fromEntries(FEATURE_FLAGS.map(flag => [flag, booleanSchema.default(false)]))).description( + 'Feature flags to specify' + ), + + sess: sessSchema, + ip: sessIPSchema + }, + pathParams: {}, + queryParams: {}, + response: { + 200: { + description: 'Success', + model: Joi.object({ + success: successRes, + id: userId + }) + } + } + } + }, tools.responseWrapper(async (req, res) => { res.charSet('utf-8'); - const schema = Joi.object().keys({ - username: usernameSchema.required(), - password: Joi.string().max(256).allow(false, '').required(), - hashedPassword: booleanSchema.default(false), - allowUnsafe: booleanSchema.default(true), + const { pathParams, requestBody, queryParams } = req.route.spec.validationObjs; - address: Joi.string().email({ tlds: false }), - emptyAddress: booleanSchema.default(false), - - language: Joi.string().empty('').max(20), - - retention: Joi.number().min(0).default(0), - - name: Joi.string().max(256), - targets: Joi.array().items( - Joi.string().email({ tlds: false }), - Joi.string().uri({ - scheme: [/smtps?/, /https?/], - allowRelative: false, - relativeOnly: false - }) - ), - - spamLevel: Joi.number().min(0).max(100).default(50), - - quota: Joi.number().min(0).default(0), - recipients: Joi.number().min(0).default(0), - forwards: Joi.number().min(0).default(0), - - filters: Joi.number().min(0).default(0), - - requirePasswordChange: booleanSchema.default(false), - - imapMaxUpload: Joi.number().min(0).default(0), - imapMaxDownload: Joi.number().min(0).default(0), - pop3MaxDownload: Joi.number().min(0).default(0), - pop3MaxMessages: Joi.number().min(0).default(0), - imapMaxConnections: Joi.number().min(0).default(0), - receivedMax: Joi.number().min(0).default(0), - - fromWhitelist: Joi.array().items(Joi.string().trim().max(128)), - - tags: Joi.array().items(Joi.string().trim().max(128)), - addTagsToAddress: booleanSchema.default(false), - - uploadSentMessages: booleanSchema.default(false), - - mailboxes: Joi.object().keys({ - sent: Joi.string() - .empty('') - .regex(/\/{2,}|\/$/, { invert: true }), - trash: Joi.string() - .empty('') - .regex(/\/{2,}|\/$/, { invert: true }), - junk: Joi.string() - .empty('') - .regex(/\/{2,}|\/$/, { invert: true }), - drafts: Joi.string() - .empty('') - .regex(/\/{2,}|\/$/, { invert: true }) - }), - - disabledScopes: Joi.array() - .items(Joi.string().valid(...consts.SCOPES)) - .unique() - .default([]), - - metaData: metaDataSchema.label('metaData'), - internalData: metaDataSchema.label('internalData'), - - pubKey: Joi.string() - .empty('') - .trim() - .regex(/^-----BEGIN PGP PUBLIC KEY BLOCK-----/, 'PGP key format'), - encryptMessages: booleanSchema.default(false), - encryptForwarded: booleanSchema.default(false), - - featureFlags: Joi.object(Object.fromEntries(FEATURE_FLAGS.map(flag => [flag, booleanSchema.default(false)]))), - - sess: sessSchema, - ip: sessIPSchema + const schema = Joi.object({ + ...pathParams, + ...requestBody, + ...queryParams }); const result = schema.validate(req.params, { @@ -534,14 +646,43 @@ module.exports = (db, server, userHandler, settingsHandler) => { ); server.get( - '/users/resolve/:username', + { + path: '/users/resolve/:username', + summary: 'Resolve ID for a username', + tags: ['Users'], + validationObjs: { + requestBody: {}, + queryParams: { + sess: sessSchema, + ip: sessIPSchema + }, + pathParams: { + username: usernameSchema + .required() + .description( + 'Username of the User. Alphanumeric value. Must start with a letter, dots are allowed but informational only ("user.name" is the same as "username")' + ) + }, + response: { + 200: { + description: 'Success', + model: Joi.object({ + success: successRes.example(true), + id: userId.description('Unique ID (24 byte hex)').example('609d201236d1d936948f23b1') + }) + } + } + } + }, tools.responseWrapper(async (req, res) => { res.charSet('utf-8'); - const schema = Joi.object().keys({ - username: usernameSchema.required(), - sess: sessSchema, - ip: sessIPSchema + const { pathParams, requestBody, queryParams } = req.route.spec.validationObjs; + + const schema = Joi.object({ + ...pathParams, + ...requestBody, + ...queryParams }); const result = schema.validate(req.params, { @@ -609,14 +750,163 @@ module.exports = (db, server, userHandler, settingsHandler) => { ); server.get( - '/users/:user', + { + path: '/users/:user', + summary: 'Request User information', + tags: ['Users'], + validationObjs: { + requestBody: {}, + queryParams: { + sess: sessSchema, + ip: sessIPSchema + }, + pathParams: { + user: userId + }, + repsonse: { + 200: { + description: 'Success', + model: Joi.object({ + success: successRes, + id: userId.description('Users unique ID (24 byte hex)'), + username: Joi.string().required().description('Username of the User'), + name: Joi.string().required().description('Name of the User'), + address: Joi.string().required().description('Main email address of the User'), + retention: Joi.number().required().description('Default retention time (in ms). false if not enabled'), + enabled2fa: Joi.array().items(Joi.string()).required().description('List of enabled 2FA methods'), + autoreply: booleanSchema + .required() + .description('Is autoreply enabled or not (start time may still be in the future or end time in the past)'), + encryptMessages: booleanSchema.required().description('If true then received messages are encrypted'), + encryptForwarded: booleanSchema.required().description('If true then forwarded messages are encrypted'), + pubKey: Joi.string().required().description('Public PGP key for the User that is used for encryption'), + keyInfo: Joi.object({ + name: Joi.string().required().description('Name listed in public key'), + address: Joi.string().required().description('E-mail address listed in public key'), + fingerprint: Joi.string().required().description('Fingerprint of the public key') + }) + .$_setFlag('objectName', 'KeyInfo') + .required() + .description('Information about public key or false if key is not available'), + metaData: metaDataSchema.required().description('Custom metadata object set for this user'), + internalData: Joi.object({}) + .required() + .description('Custom internal metadata object set for this user. Not available for user-role tokens'), + targets: Joi.array().items(Joi.string()).required().description('List of forwarding targets'), + spamLevel: Joi.number() + .required() + .description('Relative scale for detecting spam. 0 means that everything is spam, 100 means that nothing is spam'), + limits: Joi.object({ + quota: quotaRes, + recipients: Joi.object({ + allowed: Joi.number().required().description('How many messages per 24 hours can be send'), + used: Joi.number().required().description('How many messages are sent during current 24 hour period'), + ttl: Joi.number().required().description('Time until the end of current 24 hour period') + }) + .required() + .$_setFlag('objectName', 'Recipients') + .description('Sending quota'), + filters: Joi.object({ + allowed: Joi.number().required().description('How many filters are allowed'), + used: Joi.number().required().description('How many filters have been created') + }) + .required() + .$_setFlag('objectName', 'Filters') + .description('Sending quota'), + forwards: Joi.object({ + allowed: Joi.number().required().description('How many messages per 24 hours can be forwarded'), + used: Joi.number().required().description('How many messages are forwarded during current 24 hour period'), + ttl: Joi.number().required().description('Time until the end of current 24 hour period') + }) + .required() + .$_setFlag('objectName', 'Forwards') + .description('Forwarding quota'), + received: Joi.object({ + allowed: Joi.number().required().description('How many messages per 1 hour can be received'), + used: Joi.number().required().description('How many messages are received during current 1 hour period'), + ttl: Joi.number().required().description('Time until the end of current 1 hour period') + }) + .required() + .$_setFlag('objectName', 'Received') + .description('Receiving quota'), + imapUpload: Joi.object({ + allowed: Joi.number() + .required() + .description( + 'How many bytes per 24 hours can be uploaded via IMAP. Only message contents are counted, not protocol overhead.' + ), + used: Joi.number().required().description('How many bytes are uploaded during current 24 hour period'), + ttl: Joi.number().required().description('Time until the end of current 24 hour period') + }) + .required() + .description('IMAP upload quota') + .$_setFlag('objectName', 'ImapUpload'), + imapDownload: Joi.object({ + allowed: Joi.number() + .required() + .description( + 'How many bytes per 24 hours can be downloaded via IMAP. Only message contents are counted, not protocol overhead.' + ), + used: Joi.number().required().description('How many bytes are downloaded during current 24 hour period'), + ttl: Joi.number().required().description('Time until the end of current 24 hour period') + }) + .required() + .description('IMAP download quota') + .$_setFlag('objectName', 'ImapDownload'), + pop3Download: Joi.object({ + allowed: Joi.number() + .required() + .description( + 'How many bytes per 24 hours can be downloaded via POP3. Only message contents are counted, not protocol overhead.' + ), + used: Joi.number().required().description('How many bytes are downloaded during current 24 hour period'), + ttl: Joi.number().required().description('Time until the end of current 24 hour period') + }) + .required() + .description('POP3 download quota') + .$_setFlag('objectName', 'Pop3Download'), + imapMaxConnections: Joi.object({ + allowed: Joi.number().required().description('How many parallel IMAP connections are permitted'), + used: Joi.number().required().description('How many parallel IMAP connections are currenlty in use') + }) + .description('a') + .$_setFlag('objectName', 'ImapMaxConnections') + }) + .required() + .description('Account limits and usage') + .$_setFlag('objectName', 'UserLimits'), + tags: Joi.array().items(Joi.string()).required().description('List of tags associated with the User'), + fromWhitelist: Joi.array() + .items(Joi.string()) + .description('A list of additional email addresses this user can send mail from. Wildcard is allowed.'), + disabledScopes: Joi.array() + .items( + Joi.string() + .valid(...consts.SCOPES) + .$_setFlag('objectName', 'DisabledScopes') + ) + .unique() + .required() + .default([]) + .description('Disabled scopes for this user'), + hasPasswordSet: booleanSchema.required().description('If true then the User has a password set and can authenticate'), + activated: booleanSchema.required().description('Is the account activated'), + disabled: booleanSchema.required().description('If true then the user can not authenticate or receive any new mail'), + suspended: booleanSchema.required().description('If true then the user can not authenticate') + }) + } + } + } + }, tools.responseWrapper(async (req, res) => { res.charSet('utf-8'); - const schema = Joi.object().keys({ - user: Joi.string().hex().lowercase().length(24).required(), - sess: sessSchema, - ip: sessIPSchema + const { pathParams, requestBody, queryParams } = req.route.spec.validationObjs; + + const schema = Joi.object({ + ...pathParams, + ...requestBody, + ...queryParams }); const result = schema.validate(req.params, { @@ -855,78 +1145,137 @@ module.exports = (db, server, userHandler, settingsHandler) => { ); server.put( - '/users/:user', + { + path: '/users/:user', + summary: 'Update User information', + tags: ['Users'], + validationObjs: { + requestBody: { + existingPassword: Joi.string() + .empty('') + .min(1) + .max(256) + .description('If provided then validates against account password before applying any changes'), + + password: Joi.string() + .max(256) + .allow(false, '') + .description( + 'New password for the account. Set to boolean false to disable password usage for the master scope, Application Specific Passwords would still be allowed' + ), + hashedPassword: booleanSchema + .default(false) + .description( + 'If true then password is already hashed, so store as is. Supported hashes: pbkdf2, bcrypt ($2a, $2y, $2b), md5 ($1), sha512 ($6), sha256 ($5), argon2 ($argon2d, $argon2i, $argon2id). Stored hashes are rehashed to pbkdf2 on first successful password check.' + ), + allowUnsafe: booleanSchema + .default(true) + .description( + 'If false then validates provided passwords against Have I Been Pwned API. Experimental, so validation is disabled by default but will be enabled automatically in some future version of WildDuck.' + ), + + language: Joi.string().empty('').max(20).description('Language code for the User'), + + name: Joi.string().empty('').max(256).description('Name of the User'), + targets: Joi.array() + .items( + Joi.string().email({ tlds: false }), + Joi.string().uri({ + scheme: [/smtps?/, /https?/], + allowRelative: false, + relativeOnly: false + }) + ) + .description( + 'An array of forwarding targets. The value could either be an email address or a relay url to next MX server ("smtp://mx2.zone.eu:25") or an URL where mail contents are POSTed to' + ), + + spamLevel: Joi.number() + .min(0) + .max(100) + .description('Relative scale for detecting spam. 0 means that everything is spam, 100 means that nothing is spam'), + + uploadSentMessages: booleanSchema + .default(false) + .description( + 'If true then all messages sent through MSA are also uploaded to the Sent Mail folder. Might cause duplicates with some email clients, so disabled by default.' + ), + + fromWhitelist: Joi.array() + .items(Joi.string().trim().max(128)) + .description('A list of additional email addresses this user can send mail from. Wildcard is allowed.'), + + metaData: metaDataSchema.label('metaData').description('Optional metadata, must be an object or JSON formatted string'), + internalData: metaDataSchema + .label('internalData') + .description('Optional internal metadata, must be an object or JSON formatted string of an object. Not available for user-role tokens'), + + pubKey: Joi.string() + .empty('') + .trim() + .regex(/^-----BEGIN PGP PUBLIC KEY BLOCK-----/, 'PGP key format') + .description('Public PGP key for the User that is used for encryption. Use empty string to remove the key'), + encryptMessages: booleanSchema.description('If true then received messages are encrypted'), + encryptForwarded: booleanSchema.description('If true then forwarded messages are encrypted'), + retention: Joi.number().min(0).description('Default retention time (in ms). Set to 0 to disable'), + + quota: Joi.number().min(0).description('Allowed quota of the user in bytes'), + recipients: Joi.number().min(0).description('How many messages per 24 hour can be sent'), + forwards: Joi.number().min(0).description('How many messages per 24 hour can be forwarded'), + + filters: Joi.number().min(0).description('How many filters are allowed for this account'), + + imapMaxUpload: Joi.number().min(0).description('How many bytes can be uploaded via IMAP during 24 hour'), + imapMaxDownload: Joi.number().min(0).description('How many bytes can be downloaded via IMAP during 24 hour'), + pop3MaxDownload: Joi.number().min(0).description('How many bytes can be downloaded via POP3 during 24 hour'), + pop3MaxMessages: Joi.number().min(0).description('How many latest messages to list in POP3 session'), + imapMaxConnections: Joi.number().min(0).description('How many parallel IMAP connections are alowed'), + + receivedMax: Joi.number().min(0).description('How many messages can be received from MX during 60 seconds'), + + disable2fa: booleanSchema.description('If true, then disables 2FA for this user'), + + tags: Joi.array().items(Joi.string().trim().max(128)).description('A list of tags associated with this user'), + + disabledScopes: Joi.array() + .items(Joi.string().valid(...consts.SCOPES)) + .unique() + .description('List of scopes that are disabled for this user ("imap", "pop3", "smtp")'), + + disabled: booleanSchema.description('If true then disables user account (can not login, can not receive messages)'), + + featureFlags: Joi.object(Object.fromEntries(FEATURE_FLAGS.map(flag => [flag, booleanSchema.default(false)]))).description( + 'Enabled feature flags' + ), + + suspended: booleanSchema.description('If true then disables authentication'), + + sess: sessSchema, + ip: sessIPSchema + }, + queryParams: {}, + pathParams: { + user: userId + }, + response: { + 200: { + description: 'Success', + model: Joi.object({ + success: successRes + }) + } + } + } + }, tools.responseWrapper(async (req, res) => { res.charSet('utf-8'); - const schema = Joi.object().keys({ - user: Joi.string().hex().lowercase().length(24).required(), + const { pathParams, requestBody, queryParams } = req.route.spec.validationObjs; - existingPassword: Joi.string().empty('').min(1).max(256), - - password: Joi.string().max(256).allow(false, ''), - hashedPassword: booleanSchema.default(false), - allowUnsafe: booleanSchema.default(true), - - language: Joi.string().empty('').max(20), - - name: Joi.string().empty('').max(256), - targets: Joi.array().items( - Joi.string().email({ tlds: false }), - Joi.string().uri({ - scheme: [/smtps?/, /https?/], - allowRelative: false, - relativeOnly: false - }) - ), - - spamLevel: Joi.number().min(0).max(100), - - uploadSentMessages: booleanSchema.default(false), - - fromWhitelist: Joi.array().items(Joi.string().trim().max(128)), - - metaData: metaDataSchema.label('metaData'), - internalData: metaDataSchema.label('internalData'), - - pubKey: Joi.string() - .empty('') - .trim() - .regex(/^-----BEGIN PGP PUBLIC KEY BLOCK-----/, 'PGP key format'), - encryptMessages: booleanSchema, - encryptForwarded: booleanSchema, - retention: Joi.number().min(0), - - quota: Joi.number().min(0), - recipients: Joi.number().min(0), - forwards: Joi.number().min(0), - - filters: Joi.number().min(0), - - imapMaxUpload: Joi.number().min(0), - imapMaxDownload: Joi.number().min(0), - pop3MaxDownload: Joi.number().min(0), - pop3MaxMessages: Joi.number().min(0), - imapMaxConnections: Joi.number().min(0), - - receivedMax: Joi.number().min(0), - - disable2fa: booleanSchema, - - tags: Joi.array().items(Joi.string().trim().max(128)), - - disabledScopes: Joi.array() - .items(Joi.string().valid(...consts.SCOPES)) - .unique(), - - disabled: booleanSchema, - - featureFlags: Joi.object(Object.fromEntries(FEATURE_FLAGS.map(flag => [flag, booleanSchema.default(false)]))), - - suspended: booleanSchema, - - sess: sessSchema, - ip: sessIPSchema + const schema = Joi.object({ + ...pathParams, + ...requestBody, + ...queryParams }); const result = schema.validate(req.params, { @@ -1124,15 +1473,39 @@ module.exports = (db, server, userHandler, settingsHandler) => { ); server.put( - '/users/:user/logout', + { + path: '/users/:user/logout', + summary: 'Log out User', + tags: ['Users'], + validationObjs: { + requestBody: { + reason: Joi.string().empty('').max(128).description('Message to be shown to connected IMAP client'), + sess: sessSchema, + ip: sessIPSchema + }, + queryParams: {}, + pathParams: { + user: userId + }, + response: { + 200: { + description: 'Success', + model: Joi.object({ + success: successRes + }) + } + } + } + }, tools.responseWrapper(async (req, res) => { res.charSet('utf-8'); - const schema = Joi.object().keys({ - user: Joi.string().hex().lowercase().length(24).required(), - reason: Joi.string().empty('').max(128), - sess: sessSchema, - ip: sessIPSchema + const { pathParams, requestBody, queryParams } = req.route.spec.validationObjs; + + const schema = Joi.object({ + ...pathParams, + ...requestBody, + ...queryParams }); const result = schema.validate(req.params, { @@ -1174,14 +1547,42 @@ module.exports = (db, server, userHandler, settingsHandler) => { ); server.post( - '/users/:user/quota/reset', + { + path: '/users/:user/quota/reset', + description: + 'This method recalculates quota usage for a User. Normally not needed, only use it if quota numbers are way off. This method is not transactional, so if the user is currently receiving new messages then the resulting value is not exact.', + summary: 'Recalculate User quota', + tags: ['Users'], + validationObjs: { + requestBody: { + sess: sessSchema, + ip: sessIPSchema + }, + queryParams: {}, + pathParams: { + user: userId + }, + response: { + 200: { + description: 'Success', + model: Joi.object({ + success: successRes, + storageUsed: Joi.number().description('Calculated quota usage for the user').required(), + previousStorageUsed: Joi.number().description('Previous storage used').required() + }) + } + } + } + }, tools.responseWrapper(async (req, res) => { res.charSet('utf-8'); - const schema = Joi.object().keys({ - user: Joi.string().hex().lowercase().length(24).required(), - sess: sessSchema, - ip: sessIPSchema + const { pathParams, requestBody, queryParams } = req.route.spec.validationObjs; + + const schema = Joi.object({ + ...pathParams, + ...requestBody, + ...queryParams }); const result = schema.validate(req.params, { @@ -1322,13 +1723,36 @@ module.exports = (db, server, userHandler, settingsHandler) => { ); server.post( - '/quota/reset', + { + path: '/quota/reset', + description: + 'This method recalculates quota usage for all Users. Normally not needed, only use it if quota numbers are way off. This method is not transactional, so if the user is currently receiving new messages then the resulting value is not exact.', + summary: 'Recalculate Quota for all Users', + tags: ['Users'], + validationObjs: { + requestBody: { + sess: sessSchema, + ip: sessIPSchema + }, + queryParams: {}, + pathParams: {}, + response: { + 200: { + description: 'Success', + model: Joi.object({ success: successRes, task: Joi.string().required().description('Task ID') }) + } + } + } + }, tools.responseWrapper(async (req, res) => { res.charSet('utf-8'); - const schema = Joi.object().keys({ - sess: sessSchema, - ip: sessIPSchema + const { pathParams, requestBody, queryParams } = req.route.spec.validationObjs; + + const schema = Joi.object({ + ...pathParams, + ...requestBody, + ...queryParams }); const result = schema.validate(req.params, { @@ -1367,15 +1791,42 @@ module.exports = (db, server, userHandler, settingsHandler) => { ); server.post( - '/data/export', + { + path: '/data/export', + tags: ['Export'], + summary: 'Export data', + description: + 'Export data for matching users. Export dump does not include emails, only account structure (user data, password hashes, mailboxes, filters, etc.). A special "export"-role access token is required for exporting and importing.', + validationObjs: { + requestBody: { + users: Joi.array().single().items(Joi.string().hex().lowercase().length(24).required()).description('An array of User ID values to export'), + tags: Joi.array() + .single() + .items(Joi.string().trim().empty('').max(1024)) + .description('An array of user tags to export. If set then at least one tag must exist on an user.'), + sess: sessSchema, + ip: sessIPSchema + }, + pathParams: {}, + queryParams: {}, + response: { + 200: { + description: 'Success', + model: Joi.binary() + } + } + }, + responseType: 'application/octet-stream' + }, tools.responseWrapper(async (req, res) => { res.charSet('utf-8'); - const schema = Joi.object().keys({ - users: Joi.array().single().items(Joi.string().hex().lowercase().length(24).required()), - tags: Joi.array().single().items(Joi.string().trim().empty('').max(1024)), - sess: sessSchema, - ip: sessIPSchema + const { pathParams, requestBody, queryParams } = req.route.spec.validationObjs; + + const schema = Joi.object({ + ...pathParams, + ...requestBody, + ...queryParams }); const result = schema.validate(req.params, { @@ -1487,7 +1938,30 @@ module.exports = (db, server, userHandler, settingsHandler) => { ); server.post( - '/data/import', + { + path: '/data/import', + summary: 'Import user data', + description: + 'Import data from an export dump. If a database entry already exists, it is not modified. A special "export"-role access token is required for exporting and importing.', + tags: ['Export'], + applicationType: 'application/octet-stream', + validationObjs: { + requestBody: {}, + pathParams: {}, + queryParams: {}, + response: { + 200: { + description: 'Success', + model: Joi.object({ + entries: Joi.number().description('How many database entries were found from the export file'), + imported: Joi.number().description('How many database entries were imported from the export file'), + failed: Joi.number().description('How many database entries were not imported due to some error'), + existing: Joi.number().description('How many database existing entries were not imported') + }) + } + } + } + }, tools.responseWrapper(async (req, res) => { res.charSet('utf-8'); @@ -1657,15 +2131,42 @@ module.exports = (db, server, userHandler, settingsHandler) => { ); server.post( - '/users/:user/password/reset', + { + path: '/users/:user/password/reset', + summary: 'Reset password for a User', + description: 'This method generates a new temporary password for a User. Additionally it removes all two-factor authentication settings', + tags: ['Users'], + validationObjs: { + requestBody: { + validAfter: Joi.date().empty('').allow(false).description('Allow using the generated password not earlier than provided time'), + sess: sessSchema, + ip: sessIPSchema + }, + queryParams: {}, + pathParams: { + user: userId + }, + response: { + 200: { + description: 'Success', + model: Joi.object({ + success: successRes, + password: Joi.string().required().description('Temporary password'), + validAfter: Joi.date().empty('').description('The date password is valid after') + }) + } + } + } + }, tools.responseWrapper(async (req, res) => { res.charSet('utf-8'); - const schema = Joi.object().keys({ - user: Joi.string().hex().lowercase().length(24).required(), - validAfter: Joi.date().empty('').allow(false), - sess: sessSchema, - ip: sessIPSchema + const { pathParams, requestBody, queryParams } = req.route.spec.validationObjs; + + const schema = Joi.object({ + ...pathParams, + ...requestBody, + ...queryParams }); const result = schema.validate(req.params, { @@ -1707,15 +2208,54 @@ module.exports = (db, server, userHandler, settingsHandler) => { ); server.del( - '/users/:user', + { + path: '/users/:user', + summary: 'Delete a User', + description: + 'This method deletes user and address entries from DB and schedules a background task to delete messages. You can call this method several times even if the user has already been deleted, in case there are still some pending messages.', + tags: ['Users'], + validationObjs: { + requestBody: {}, + queryParams: { + deleteAfter: Joi.date() + .empty('') + .allow(false) + .default(false) + .description( + 'Delete user entry from registry but keep all user data until provided date. User account is fully recoverable up to that date.' + ), + sess: sessSchema, + ip: sessIPSchema + }, + pathParams: { + user: userId + }, + response: { + 200: { + description: 'Success', + model: Joi.object({ + success: successRes, + code: Joi.string().example('TaskScheduled').description('Task code. Should be TaskScheduled'), + user: Joi.string().description('User ID'), + addresses: Joi.object({ + deleted: Joi.number().description('Number of deleted addresses') + }), + deleteAfter: Joi.date().description('Delete after date'), + task: Joi.string().description('Task ID') + }) + } + } + } + }, tools.responseWrapper(async (req, res) => { res.charSet('utf-8'); - const schema = Joi.object().keys({ - user: Joi.string().hex().lowercase().length(24).required(), - deleteAfter: Joi.date().empty('').allow(false).default(false), - sess: sessSchema, - ip: sessIPSchema + const { pathParams, requestBody, queryParams } = req.route.spec.validationObjs; + + const schema = Joi.object({ + ...pathParams, + ...requestBody, + ...queryParams }); const result = schema.validate(req.params, { @@ -1761,14 +2301,44 @@ module.exports = (db, server, userHandler, settingsHandler) => { ); server.get( - '/users/:user/restore', + { + path: '/users/:user/restore', + summary: 'Return recovery info for a deleted user', + tags: ['Users'], + validationObjs: { + requestBody: {}, + queryParams: { + sess: sessSchema, + ip: sessIPSchema + }, + pathParams: { + user: userId + }, + response: { + 200: { + description: 'Success', + model: Joi.object({ + success: successRes, + user: Joi.string().description('ID of the deleted User').required(), + username: Joi.string().description('Username of the User').required(), + storageUsed: Joi.number().description('Calculated quota usage for the user').required(), + tags: Joi.array().items(Joi.string()).description('List of tags associated with the User').required(), + deleted: Joi.date().description('Datestring of the time the user was deleted').required(), + recoverableAddresses: Joi.array().items(Joi.string()).description('List of email addresses that can be restored').required() + }) + } + } + } + }, tools.responseWrapper(async (req, res) => { res.charSet('utf-8'); - const schema = Joi.object().keys({ - user: Joi.string().hex().lowercase().length(24).required(), - sess: sessSchema, - ip: sessIPSchema + const { pathParams, requestBody, queryParams } = req.route.spec.validationObjs; + + const schema = Joi.object({ + ...pathParams, + ...requestBody, + ...queryParams }); const result = schema.validate(req.params, { @@ -1817,14 +2387,47 @@ module.exports = (db, server, userHandler, settingsHandler) => { ); server.post( - '/users/:user/restore', + { + path: '/users/:user/restore', + summary: 'Cancel user deletion task', + description: + 'Use this endpoint to cancel a timed deletion task scheduled by DELETE /user/{id}. If user data is not yet deleted then the account is fully recovered, except any email addresses that might have been already recycled', + tags: ['Users'], + validationObjs: { + requestBody: { + sess: sessSchema, + ip: sessIPSchema + }, + queryParams: {}, + pathParams: { + user: userId + }, + response: { + 200: { + description: 'Success', + model: Joi.object({ + success: successRes, + code: Joi.string().required().description('Task status code'), + user: Joi.string().description('User ID'), + task: Joi.string().description('Existing task id'), + addresses: Joi.object({ + recovered: Joi.number().description('Number of recovered addresses'), + main: Joi.string().description('Main address') + }) + }) + } + } + } + }, tools.responseWrapper(async (req, res) => { res.charSet('utf-8'); - const schema = Joi.object().keys({ - user: Joi.string().hex().lowercase().length(24).required(), - sess: sessSchema, - ip: sessIPSchema + const { pathParams, requestBody, queryParams } = req.route.spec.validationObjs; + + const schema = Joi.object({ + ...pathParams, + ...requestBody, + ...queryParams }); const result = schema.validate(req.params, { diff --git a/lib/schemas/response/general-schemas.js b/lib/schemas/response/general-schemas.js index 208eebd7..1b969349 100644 --- a/lib/schemas/response/general-schemas.js +++ b/lib/schemas/response/general-schemas.js @@ -1,9 +1,32 @@ 'use strict'; +const Joi = require('joi'); const { booleanSchema } = require('../../schemas'); const successRes = booleanSchema.required().description('Indicates successful response'); +const totalRes = Joi.number().required().description('How many results were found'); +const pageRes = Joi.number().required().description('Current page number. Derived from page query argument'); +const previousCursorRes = Joi.alternatives() + .try(Joi.string(), booleanSchema) + .required() + .description('Either a cursor string or false if there are not any previous results'); +const nextCursorRes = Joi.alternatives() + .try(Joi.string(), booleanSchema) + .required() + .description('Either a cursor string or false if there are not any next results'); + +const quotaRes = Joi.object({ + allowed: Joi.number().required().description('Allowed quota of the user in bytes'), + used: Joi.number().required().description('Space used in bytes') +}) + .$_setFlag('objectName', 'Quota') + .description('Quota usage limits'); module.exports = { - successRes + successRes, + totalRes, + pageRes, + previousCursorRes, + nextCursorRes, + quotaRes }; diff --git a/lib/schemas/response/users-schemas.js b/lib/schemas/response/users-schemas.js new file mode 100644 index 00000000..4e2ababe --- /dev/null +++ b/lib/schemas/response/users-schemas.js @@ -0,0 +1,29 @@ +'use strict'; + +const Joi = require('joi'); +const { booleanSchema } = require('../../schemas'); +const { quotaRes } = require('./general-schemas'); + +const GetUsersResult = Joi.object({ + id: Joi.string().required().description('Users unique ID (24byte hex)'), + username: Joi.string().required().description('Username of the User'), + name: Joi.string().required().description('Name of the User'), + address: Joi.string().required().description('Main email address of the User'), + tags: Joi.array().items(Joi.string()).required().description('List of tags associated with the User'), + targets: Joi.array().items(Joi.string()).required().description('List of forwarding targets'), + enabled2fa: Joi.array().items(Joi.string()).required().description('List of enabled 2FA methods'), + autoreply: booleanSchema.required().description('Is autoreply enabled or not (start time may still be in the future or end time in the past)'), + encryptMessages: booleanSchema.required().description('If true then received messages are encrypted'), + encryptForwarded: booleanSchema.required().description('If true then forwarded messages are encrypted'), + quota: quotaRes, + metaData: Joi.object().description('Custom metadata value. Included if metaData query argument was true'), + internalData: Joi.object().description( + 'Custom metadata value for internal use. Included if internalData query argument was true and request was not made using user-role token' + ), + hasPasswordSet: booleanSchema.required().description('If true then the User has a password set and can authenticate'), + activated: booleanSchema.required().description('Is the account activated'), + disabled: booleanSchema.required().description('If true then user can not authenticate or receive any new mail'), + suspended: booleanSchema.required().description('If true then user can not authenticate') +}).$_setFlag('objectName', 'GetUsersResult'); + +module.exports = { GetUsersResult };