From 04874b9dad0fe5deb3e5965a4da68711796cd4d3 Mon Sep 17 00:00:00 2001 From: Nikolai Ovtsinnikov Date: Thu, 7 Dec 2023 11:32:33 +0200 Subject: [PATCH 01/15] /users GET endpoint added --- lib/api/users.js | 64 +++++++++++++++++++------ lib/schemas/response/general-schemas.js | 25 +++++++++- lib/schemas/response/users-schemas.js | 29 +++++++++++ 3 files changed, 103 insertions(+), 15 deletions(-) create mode 100644 lib/schemas/response/users-schemas.js diff --git a/lib/api/users.js b/lib/api/users.js index 10bf48ff..83762131 100644 --- a/lib/api/users.js +++ b/lib/api/users.js @@ -26,6 +26,8 @@ const { const TaskHandler = require('../task-handler'); const { publish, FORWARD_ADDED } = require('../events'); const { ExportStream, ImportStream } = require('../export'); +const { successRes, totalRes, pageRes, previousCursorRes, nextCursorRes } = require('../schemas/response/general-schemas'); +const { GetUsersResult } = require('../schemas/response/users-schemas'); const FEATURE_FLAGS = ['indexing']; @@ -33,23 +35,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('f 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.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, { 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..103e244d --- /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.items(Joi.string()).required().description('List of tags associated with the User'), + targets: Joi.items(Joi.string()).required().description('List of forwarding targets'), + enabled2fa: Joi.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 }; From 71b1d7beab83821a29e952a03ecfbe1867a97dec Mon Sep 17 00:00:00 2001 From: Nikolai Ovtsinnikov Date: Thu, 7 Dec 2023 12:26:21 +0200 Subject: [PATCH 02/15] added /users POST to api generation --- lib/api/users.js | 236 +++++++++++++++++++++++++++++++---------------- 1 file changed, 156 insertions(+), 80 deletions(-) diff --git a/lib/api/users.js b/lib/api/users.js index 83762131..eafd1766 100644 --- a/lib/api/users.js +++ b/lib/api/users.js @@ -28,6 +28,7 @@ const { publish, FORWARD_ADDED } = require('../events'); const { ExportStream, ImportStream } = require('../export'); const { successRes, totalRes, pageRes, previousCursorRes, nextCursorRes } = require('../schemas/response/general-schemas'); const { GetUsersResult } = require('../schemas/response/users-schemas'); +const { userId } = require('../schemas/request/general-schemas'); const FEATURE_FLAGS = ['indexing']; @@ -298,91 +299,166 @@ module.exports = (db, server, userHandler, settingsHandler) => { ); server.post( - '/users', - 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), - - 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() + { + 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('') - .regex(/\/{2,}|\/$/, { invert: true }) - }), - - disabledScopes: Joi.array() - .items(Joi.string().valid(...consts.SCOPES)) - .unique() - .default([]), + .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'), - metaData: metaDataSchema.label('metaData'), - internalData: metaDataSchema.label('internalData'), + featureFlags: Joi.object(Object.fromEntries(FEATURE_FLAGS.map(flag => [flag, booleanSchema.default(false)]))).description( + 'Feature flags to specify' + ), - pubKey: Joi.string() - .empty('') - .trim() - .regex(/^-----BEGIN PGP PUBLIC KEY BLOCK-----/, 'PGP key format'), - encryptMessages: booleanSchema.default(false), - encryptForwarded: booleanSchema.default(false), + 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'); - featureFlags: Joi.object(Object.fromEntries(FEATURE_FLAGS.map(flag => [flag, booleanSchema.default(false)]))), + const { pathParams, requestBody, queryParams } = req.route.spec.validationObjs; - sess: sessSchema, - ip: sessIPSchema + const schema = Joi.object({ + ...pathParams, + ...requestBody, + ...queryParams }); const result = schema.validate(req.params, { From b54ac8ae35569ace2e3d62fe85f5d1ab014329e7 Mon Sep 17 00:00:00 2001 From: Nikolai Ovtsinnikov Date: Fri, 8 Dec 2023 11:03:42 +0200 Subject: [PATCH 03/15] added /users/resolve/:username GET endpoint for api generation --- lib/api/users.js | 39 ++++++++++++++++++++++++++++++++++----- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/lib/api/users.js b/lib/api/users.js index eafd1766..2709e5cd 100644 --- a/lib/api/users.js +++ b/lib/api/users.js @@ -646,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, { From 045b61d9c285ff4f942c54947293f6ccccd9447c Mon Sep 17 00:00:00 2001 From: Nikolai Ovtsinnikov Date: Fri, 8 Dec 2023 12:50:10 +0200 Subject: [PATCH 04/15] fixes + request user info GET endpoint added --- lib/api/users.js | 163 ++++++++++++++++++++++++-- lib/schemas/response/users-schemas.js | 6 +- 2 files changed, 159 insertions(+), 10 deletions(-) diff --git a/lib/api/users.js b/lib/api/users.js index 2709e5cd..d357a02b 100644 --- a/lib/api/users.js +++ b/lib/api/users.js @@ -26,7 +26,7 @@ const { const TaskHandler = require('../task-handler'); const { publish, FORWARD_ADDED } = require('../events'); const { ExportStream, ImportStream } = require('../export'); -const { successRes, totalRes, pageRes, previousCursorRes, nextCursorRes } = require('../schemas/response/general-schemas'); +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'); @@ -72,7 +72,7 @@ module.exports = (db, server, userHandler, settingsHandler) => { previousCursor: previousCursorRes, nextCursor: nextCursorRes, query: Joi.string().required().description('Partial match of username or default email address'), - results: Joi.items(GetUsersResult).required().description('User listing') + results: Joi.array().items(GetUsersResult).required().description('User listing') }) } } @@ -750,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('') + .$_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, { diff --git a/lib/schemas/response/users-schemas.js b/lib/schemas/response/users-schemas.js index 103e244d..4e2ababe 100644 --- a/lib/schemas/response/users-schemas.js +++ b/lib/schemas/response/users-schemas.js @@ -9,9 +9,9 @@ const GetUsersResult = Joi.object({ 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.items(Joi.string()).required().description('List of tags associated with the User'), - targets: Joi.items(Joi.string()).required().description('List of forwarding targets'), - enabled2fa: Joi.items(Joi.string()).required().description('List of enabled 2FA methods'), + 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'), From 93515cac39ec250e5efa5521f6e990197cd7d366 Mon Sep 17 00:00:00 2001 From: Nikolai Ovtsinnikov Date: Thu, 14 Dec 2023 11:05:36 +0200 Subject: [PATCH 05/15] Added Update User information api path to api docs generation --- lib/api/users.js | 199 +++++++++++++++++++++++++++++++++-------------- 1 file changed, 141 insertions(+), 58 deletions(-) diff --git a/lib/api/users.js b/lib/api/users.js index d357a02b..3b5a28ee 100644 --- a/lib/api/users.js +++ b/lib/api/users.js @@ -869,7 +869,7 @@ module.exports = (db, server, userHandler, settingsHandler) => { 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('') + .description('a') .$_setFlag('objectName', 'ImapMaxConnections') }) .required() @@ -1145,78 +1145,137 @@ module.exports = (db, server, userHandler, settingsHandler) => { ); server.put( - '/users/:user', - tools.responseWrapper(async (req, res) => { - res.charSet('utf-8'); + { + 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'), - const schema = Joi.object().keys({ - user: Joi.string().hex().lowercase().length(24).required(), + 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.' + ), - existingPassword: Joi.string().empty('').min(1).max(256), + language: Joi.string().empty('').max(20).description('Language code for the User'), - password: Joi.string().max(256).allow(false, ''), - hashedPassword: booleanSchema.default(false), - allowUnsafe: booleanSchema.default(true), + 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' + ), - language: Joi.string().empty('').max(20), + 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'), - 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 - }) - ), + 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.' + ), - spamLevel: Joi.number().min(0).max(100), + 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.'), - uploadSentMessages: booleanSchema.default(false), + 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'), - fromWhitelist: Joi.array().items(Joi.string().trim().max(128)), + 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'), - metaData: metaDataSchema.label('metaData'), - internalData: metaDataSchema.label('internalData'), + 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'), - pubKey: Joi.string() - .empty('') - .trim() - .regex(/^-----BEGIN PGP PUBLIC KEY BLOCK-----/, 'PGP key format'), - encryptMessages: booleanSchema, - encryptForwarded: booleanSchema, - retention: Joi.number().min(0), + filters: Joi.number().min(0).description('How many filters are allowed for this account'), - quota: Joi.number().min(0), - recipients: Joi.number().min(0), - forwards: Joi.number().min(0), + 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'), - filters: Joi.number().min(0), + receivedMax: Joi.number().min(0).description('How many messages can be received from MX during 60 seconds'), - 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), + disable2fa: booleanSchema.description('If true, then disables 2FA for this user'), - receivedMax: Joi.number().min(0), + tags: Joi.array().items(Joi.string().trim().max(128)).description('A list of tags associated with this user'), - disable2fa: booleanSchema, + disabledScopes: Joi.array() + .items(Joi.string().valid(...consts.SCOPES)) + .unique() + .description('List of scopes that are disabled for this user ("imap", "pop3", "smtp")'), - tags: Joi.array().items(Joi.string().trim().max(128)), + disabled: booleanSchema.description('If true then disables user account (can not login, can not receive messages)'), - disabledScopes: Joi.array() - .items(Joi.string().valid(...consts.SCOPES)) - .unique(), + featureFlags: Joi.object(Object.fromEntries(FEATURE_FLAGS.map(flag => [flag, booleanSchema.default(false)]))).description( + 'Enabled feature flags' + ), - disabled: booleanSchema, + suspended: booleanSchema.description('If true then disables authentication'), - featureFlags: Joi.object(Object.fromEntries(FEATURE_FLAGS.map(flag => [flag, booleanSchema.default(false)]))), + 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'); - suspended: booleanSchema, + const { pathParams, requestBody, queryParams } = req.route.spec.validationObjs; - sess: sessSchema, - ip: sessIPSchema + const schema = Joi.object({ + ...pathParams, + ...requestBody, + ...queryParams }); const result = schema.validate(req.params, { @@ -1414,15 +1473,39 @@ module.exports = (db, server, userHandler, settingsHandler) => { ); server.put( - '/users/:user/logout', + { + path: '/users/:user/logout', + summary: '', + tags: ['Users'], + validationObjs: { + requestBody: { + reason: Joi.string().empty('').max(128), + sess: sessSchema, + ip: sessIPSchema + }, + queryParams: {}, + pathParams: { + user: Joi.string().hex().lowercase().length(24).required() + }, + 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, { From 46560ca2abd4087fb89a039f2dfa54597e6b6eab Mon Sep 17 00:00:00 2001 From: Nikolai Ovtsinnikov Date: Thu, 14 Dec 2023 11:08:04 +0200 Subject: [PATCH 06/15] added Log out User endpoint to api generation --- lib/api/users.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/api/users.js b/lib/api/users.js index 3b5a28ee..c6219c30 100644 --- a/lib/api/users.js +++ b/lib/api/users.js @@ -1475,17 +1475,17 @@ module.exports = (db, server, userHandler, settingsHandler) => { server.put( { path: '/users/:user/logout', - summary: '', + summary: 'Log out User', tags: ['Users'], validationObjs: { requestBody: { - reason: Joi.string().empty('').max(128), + reason: Joi.string().empty('').max(128).description('Message to be shown to connected IMAP client'), sess: sessSchema, ip: sessIPSchema }, queryParams: {}, pathParams: { - user: Joi.string().hex().lowercase().length(24).required() + user: userId }, response: { 200: { From 9b2e4415b0c13a905b39af1ad7d28c1cd1ac7246 Mon Sep 17 00:00:00 2001 From: Nikolai Ovtsinnikov Date: Thu, 14 Dec 2023 11:17:38 +0200 Subject: [PATCH 07/15] Added Recalculate User quota endpoint to API generation --- lib/api/users.js | 37 ++++++++++++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/lib/api/users.js b/lib/api/users.js index c6219c30..70b55743 100644 --- a/lib/api/users.js +++ b/lib/api/users.js @@ -1547,14 +1547,41 @@ 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() + }) + } + } + } + }, 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, { From 20b141248df4059f94ed0a0765b008117bb7a57d Mon Sep 17 00:00:00 2001 From: Nikolai Ovtsinnikov Date: Thu, 14 Dec 2023 11:30:11 +0200 Subject: [PATCH 08/15] added Recalculate Quota for all users endpoint to api generation --- lib/api/users.js | 31 +++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/lib/api/users.js b/lib/api/users.js index 70b55743..52e34a2a 100644 --- a/lib/api/users.js +++ b/lib/api/users.js @@ -1722,13 +1722,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 }) + } + } + } + }, 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, { From 91e3f15f942fa3efaa942303654fd2e82d137135 Mon Sep 17 00:00:00 2001 From: Nikolai Ovtsinnikov Date: Thu, 14 Dec 2023 11:35:57 +0200 Subject: [PATCH 09/15] added Export data endpoint to API generation --- lib/api/users.js | 39 +++++++++++++++++++++++++++++++++------ 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/lib/api/users.js b/lib/api/users.js index 52e34a2a..935fc904 100644 --- a/lib/api/users.js +++ b/lib/api/users.js @@ -1790,15 +1790,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, { From e6df312c3108945855cc67fff492d2a8fc9ba87b Mon Sep 17 00:00:00 2001 From: Nikolai Ovtsinnikov Date: Thu, 14 Dec 2023 11:50:01 +0200 Subject: [PATCH 10/15] added Import user data endpoint to API generation --- lib/api/users.js | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/lib/api/users.js b/lib/api/users.js index 935fc904..d26dda7c 100644 --- a/lib/api/users.js +++ b/lib/api/users.js @@ -1937,7 +1937,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'); From ae8570e814cdc59640ebab34c29ad8668b9e8e3a Mon Sep 17 00:00:00 2001 From: Nikolai Ovtsinnikov Date: Thu, 14 Dec 2023 11:53:56 +0200 Subject: [PATCH 11/15] Reset password for a User endpoint added to API generation --- lib/api/users.js | 38 ++++++++++++++++++++++++++++++++------ 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/lib/api/users.js b/lib/api/users.js index d26dda7c..8098ce0f 100644 --- a/lib/api/users.js +++ b/lib/api/users.js @@ -2130,15 +2130,41 @@ 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') + }) + } + } + } + }, 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, { From 793cb191b2fea2259d47e8ade81806ac5562f501 Mon Sep 17 00:00:00 2001 From: Nikolai Ovtsinnikov Date: Thu, 14 Dec 2023 11:55:21 +0200 Subject: [PATCH 12/15] Reset password for a User endpoint add param to res --- lib/api/users.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/api/users.js b/lib/api/users.js index 8098ce0f..f9eb20ae 100644 --- a/lib/api/users.js +++ b/lib/api/users.js @@ -2150,7 +2150,8 @@ module.exports = (db, server, userHandler, settingsHandler) => { description: 'Success', model: Joi.object({ success: successRes, - password: Joi.string().required().description('Temporary password') + password: Joi.string().required().description('Temporary password'), + validAfter: Joi.date().empty('').description('The date the password is valid after') }) } } From c8e05acfe58caeb3cdc5fb1a0839a5683cd23aa1 Mon Sep 17 00:00:00 2001 From: Nikolai Ovtsinnikov Date: Thu, 14 Dec 2023 11:59:08 +0200 Subject: [PATCH 13/15] add missing response types --- lib/api/users.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/api/users.js b/lib/api/users.js index f9eb20ae..c1b7d065 100644 --- a/lib/api/users.js +++ b/lib/api/users.js @@ -1567,7 +1567,8 @@ module.exports = (db, server, userHandler, settingsHandler) => { description: 'Success', model: Joi.object({ success: successRes, - storageUsed: Joi.number().description('Calculated quota usage for the user').required() + storageUsed: Joi.number().description('Calculated quota usage for the user').required(), + previousStorageUsed: Joi.number().description('Previous storage used').required() }) } } @@ -1738,7 +1739,7 @@ module.exports = (db, server, userHandler, settingsHandler) => { response: { 200: { description: 'Success', - model: Joi.object({ success: successRes }) + model: Joi.object({ success: successRes, task: Joi.string().required().description('Task ID') }) } } } @@ -2151,7 +2152,7 @@ module.exports = (db, server, userHandler, settingsHandler) => { model: Joi.object({ success: successRes, password: Joi.string().required().description('Temporary password'), - validAfter: Joi.date().empty('').description('The date the password is valid after') + validAfter: Joi.date().empty('').description('The date password is valid after') }) } } From 60b6636dc9bba6e4518a69a77c2d63cff879d36a Mon Sep 17 00:00:00 2001 From: Nikolai Ovtsinnikov Date: Fri, 15 Dec 2023 11:53:07 +0200 Subject: [PATCH 14/15] added Delete a User, Return recovery info for a deleted User, Cancel user deletion task - endpoints to API generation --- lib/api/users.js | 134 +++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 118 insertions(+), 16 deletions(-) diff --git a/lib/api/users.js b/lib/api/users.js index c1b7d065..4609ec2b 100644 --- a/lib/api/users.js +++ b/lib/api/users.js @@ -2208,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, { @@ -2262,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, { @@ -2318,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, { From 29052ca14044b81b3ca2bbb989d9b843f8cd8ebe Mon Sep 17 00:00:00 2001 From: Nikolai Ovtsinnikov Date: Thu, 21 Dec 2023 09:15:25 +0200 Subject: [PATCH 15/15] fix typo in users.js --- lib/api/users.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/api/users.js b/lib/api/users.js index 4609ec2b..5eb121eb 100644 --- a/lib/api/users.js +++ b/lib/api/users.js @@ -54,7 +54,7 @@ module.exports = (db, server, userHandler, settingsHandler) => { .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('f true, then includes internalData in the response. Not shown for user-role tokens.'), + 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,