Skip to content

Commit

Permalink
feat: added ability to set alias-specific and domain-wide storage quo…
Browse files Browse the repository at this point in the history
…ta limitations, drop pretty-bytes in favor of bytes
  • Loading branch information
titanism committed Aug 30, 2024
1 parent d35a4b4 commit b58a264
Show file tree
Hide file tree
Showing 46 changed files with 569 additions and 99 deletions.
15 changes: 12 additions & 3 deletions app/controllers/web/my-account/ensure-domain-admin.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@
const Boom = require('@hapi/boom');

function ensureDomainAdmin(ctx, next) {
if (ctx.state.domain.group === 'admin') return next();
if (ctx.state.domain.group === 'admin') {
if (typeof next !== 'function') return;
return next();
}

// if no `ctx.state.domain.group` property exists, then we can try to find it
if (
ctx.state.domain &&
Expand All @@ -20,10 +24,15 @@ function ensureDomainAdmin(ctx, next) {
return m.user.toString() === ctx.state.user.id;
return false;
});
if (member && member.group === 'admin') return next();
if (member && member.group === 'admin') {
if (typeof next !== 'function') return;
return next();
}
}

ctx.throw(Boom.badRequest(ctx.translateError('IS_NOT_ADMIN')));
const err = Boom.badRequest(ctx.translateError('IS_NOT_ADMIN'));
if (typeof next !== 'function') throw err;
ctx.throw(err);
}

module.exports = ensureDomainAdmin;
40 changes: 40 additions & 0 deletions app/controllers/web/my-account/update-domain.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,22 @@

const punycode = require('node:punycode');

const RE2 = require('re2');
const Boom = require('@hapi/boom');
const _ = require('lodash');
const bytes = require('bytes');
const isSANB = require('is-string-and-not-blank');
const { boolean } = require('boolean');
const { isPort } = require('validator');

const { Domains } = require('#models');

//
// NOTE: this regex is not safe according to `safe-regex2` so we use `re2` to wrap it
// https://github.com/visionmedia/bytes.js/blob/9ddc13b6c66e0cb293616fba246e05db4b6cef4d/index.js#L37C5-L37C16
//
const REGEX_BYTES = new RE2(/^((-|\+)?(\d+(?:\.\d+)?)) *(kb|mb|gb|tb|pb)$/i);

// eslint-disable-next-line complexity
async function updateDomain(ctx, next) {
ctx.state.domain = await Domains.findById(ctx.state.domain._id);
Expand Down Expand Up @@ -53,6 +61,25 @@ async function updateDomain(ctx, next) {
ctx.state.domain.bounce_webhook = undefined;
}

//
// max_quota_per_alias
// (string -> bytes)
// (number -> already bytes)
//
if (
typeof ctx.request.body.max_quota_per_alias !== 'undefined' &&
typeof ctx.request.body.max_quota_per_alias !== 'string'
)
throw Boom.badRequest(ctx.translateError('INVALID_BYTES'));
else if (isSANB(ctx.request.body.max_quota_per_alias)) {
// NOTE: this validation should be moved to `validate-domain.js`
if (!REGEX_BYTES.test(ctx.request.body.max_quota_per_alias))
throw Boom.badRequest(ctx.translateError('INVALID_BYTES'));
ctx.state.domain.max_quota_per_alias = bytes(
ctx.request.body.max_quota_per_alias
);
}

// require paid plan (note that the API middleware already does this)
for (const bool of [
'has_adult_content_protection',
Expand All @@ -75,6 +102,19 @@ async function updateDomain(ctx, next) {
break;
}

case 'max_quota_per_alias': {
// NOTE: this validation should be moved to `validate-domain.js`
if (
!isSANB(ctx.request.body.max_quota_per_alias) ||
!REGEX_BYTES.test(ctx.request.body.max_quota_per_alias)
)
throw Boom.badRequest(ctx.translateError('INVALID_BYTES'));
ctx.state.domain.max_quota_per_alias = bytes(
ctx.request.body.max_quota_per_alias
);
break;
}

case 'spam_scanner_settings': {
// require paid plan
if (ctx.state.domain.plan === 'free')
Expand Down
39 changes: 38 additions & 1 deletion app/controllers/web/my-account/validate-alias.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,63 @@
*/

const Boom = require('@hapi/boom');
const RE2 = require('re2');
const _ = require('lodash');
const bytes = require('bytes');
const isSANB = require('is-string-and-not-blank');
const slug = require('speakingurl');
const splitLines = require('split-lines');
const striptags = require('striptags');
const { boolean } = require('boolean');
// const { isEmail } = require('validator');

const ensureDomainAdmin = require('./ensure-domain-admin');

const config = require('#config');

//
// NOTE: this regex is not safe according to `safe-regex2` so we use `re2` to wrap it
// https://github.com/visionmedia/bytes.js/blob/9ddc13b6c66e0cb293616fba246e05db4b6cef4d/index.js#L37C5-L37C16
//
const REGEX_BYTES = new RE2(/^((-|\+)?(\d+(?:\.\d+)?)) *(kb|mb|gb|tb|pb)$/i);

// eslint-disable-next-line complexity
function validateAlias(ctx, next) {
const body = _.pick(ctx.request.body, [
'name',
'description',
'labels',
'recipients',
'error_code_if_disabled'
'error_code_if_disabled',
'max_quota'
]);

//
// NOTE: if body includes `max_quota` and user was not an admin of the domain
// then throw a permission/forbidden error (either through API or web form manipulation)
//
if (typeof body.max_quota !== 'undefined') ensureDomainAdmin(ctx); // this will throw an error

// validate `body.max_quota` if a value was passed
if (
typeof body.max_quota !== 'undefined' &&
typeof body.max_quota !== 'string'
)
throw Boom.badRequest(ctx.translateError('INVALID_BYTES'));

// indicates reset of the value
if (body.max_quota === '') {
body.max_quota = Number.isFinite(ctx.state.domain.max_quota_per_alias)
? ctx.state.domain.max_quota_per_alias
: config.maxQuotaPerAlias;
} else if (typeof body.max_quota === 'string') {
// test against bytes regex
if (!REGEX_BYTES.test(body.max_quota))
throw Boom.badRequest(ctx.translateError('INVALID_BYTES'));
// otherwise convert the value
body.max_quota = bytes(body.max_quota);
}

if (!isSANB(body.name)) delete body.name;

body.description = isSANB(body.description)
Expand Down
25 changes: 21 additions & 4 deletions app/models/aliases.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ const isSANB = require('is-string-and-not-blank');
const mongoose = require('mongoose');
const mongooseCommonPlugin = require('mongoose-common-plugin');
const ms = require('ms');
const prettyBytes = require('pretty-bytes');
const reservedAdminList = require('reserved-email-addresses-list/admin-list.json');
const reservedEmailAddressesList = require('reserved-email-addresses-list');
const slug = require('speakingurl');
Expand Down Expand Up @@ -538,6 +537,24 @@ Aliases.pre('save', async function (next) {
)
throw Boom.badRequest(i18n.translateError('INVALID_EMAIL', alias.locale));

//
// NOTE: we ensure that `alias.max_quota` cannot exceed `domain.max_quota_per_alias`
//
if (
Number.isFinite(domain.max_quota_per_alias) &&
Number.isFinite(alias.max_quota) &&
alias.max_quota > domain.max_quota_per_alias
)
throw Boom.badRequest(
i18n.translateError(
'ALIAS_QUOTA_EXCEEDS_DOMAIN',
alias.locale,
`${alias.name}@${domain.name}`,
bytes(alias.max_quota),
bytes(domain.max_quota_per_alias)
)
);

// determine the domain membership for the user
let member = domain.members.find((member) =>
user
Expand Down Expand Up @@ -851,9 +868,9 @@ Aliases.statics.isOverQuota = async function (
if (isOverQuota)
logger.fatal(
new TypeError(
`Alias ${alias.id} is over quota (${prettyBytes(
storageUsed + size
)}/${prettyBytes(maxQuotaPerAlias)})`
`Alias ${alias.id} is over quota (${bytes(storageUsed + size)}/${bytes(
maxQuotaPerAlias
)})`
)
);

Expand Down
105 changes: 91 additions & 14 deletions app/models/domains.js
Original file line number Diff line number Diff line change
Expand Up @@ -757,9 +757,53 @@ Domains.pre('validate', async function (next) {
_id: { $in: domain.members.map((m) => m.user) }
})
.lean()
.select('plan email')
.select(`id plan email ${config.userFields.maxQuotaPerAlias}`)
.exec();

//
// NOTE: we ensure that max quota per alias cannot exceed global
// max or value higher than any current admin's max quota
//
const adminUserIds = new Set(
domain.members
.filter((m) => m.group === 'admin' && m.user)
.map((m) =>
m?.user?._id
? m.user._id.toString()
: m?.user?.id || m.user.toString()
)
);
const adminUsers = users.filter((user) => adminUserIds.has(user.id));

const adminMaxQuota =
adminUsers.length === 0
? config.maxQuotaPerAlias
: _.max(
adminUsers.map((user) =>
typeof user[config.userFields.maxQuotaPerAlias] === 'number'
? user[config.userFields.maxQuotaPerAlias]
: config.maxQuotaPerAlias
)
);

//
// NOTE: hard-coded max of 100 GB (safeguard)
//
const maxQuota = _.clamp(adminMaxQuota, 0, bytes('100GB'));
if (
Number.isFinite(domain.max_quota_per_alias) &&
domain.max_quota_per_alias > maxQuota
)
throw Boom.badRequest(
i18n.translateError(
'DOMAIN_MAX_QUOTA_EXCEEDS_USER',
domain.locale,
domain.name,
bytes(domain.max_quota_per_alias),
bytes(maxQuota)
)
);

const hasValidPlan = users.some((user) => {
if (domain.plan === 'team' && user.plan === 'team') {
return true;
Expand Down Expand Up @@ -2217,14 +2261,33 @@ async function ensureUserHasValidPlan(user, locale) {

Domains.statics.ensureUserHasValidPlan = ensureUserHasValidPlan;

async function getMaxQuota(_id, locale = i18n.config.defaultLocale) {
const domain = await this.findById(_id)
.populate(
'members.user',
`_id id plan ${config.userFields.isBanned} ${config.userFields.maxQuotaPerAlias}`
)
.lean()
.exec();
//
// NOTE: in order to save db lookups, you can pass an alias object with `max_quota` property
// instead of a string for `aliasId` value, so it will re-use an existing alias object
//
async function getMaxQuota(_id, aliasId, locale = i18n.config.defaultLocale) {
if (typeof conn?.models?.Aliases?.findOne !== 'function')
throw new TypeError('Aliases model is not ready');

const [domain, alias] = await Promise.all([
this.findById(_id)
.populate(
'members.user',
`_id id plan ${config.userFields.isBanned} ${config.userFields.maxQuotaPerAlias}`
)
.lean()
.exec(),
typeof aliasId === 'string'
? conn.models.Aliases.findOne({ id: aliasId, domain: _id })
.select('max_quota')
.lean()
.exec()
: Promise.resolve(
typeof aliasId === 'object' && typeof aliasId.max_quota === 'number'
? aliasId
: null
)
]);

if (!domain)
throw Boom.badRequest(
Expand Down Expand Up @@ -2258,6 +2321,9 @@ async function getMaxQuota(_id, locale = i18n.config.defaultLocale) {
);
}

if (aliasId && !alias)
throw Boom.badRequest(i18n.translateError('ALIAS_DOES_NOT_EXIST', locale));

// Filter out a domain's members without actual users
const adminMembers = domain.members.filter(
(member) =>
Expand All @@ -2280,19 +2346,30 @@ async function getMaxQuota(_id, locale = i18n.config.defaultLocale) {
);
}

// TODO: add in form and API endpoint and API docs
// TODO: if the alias had a limit set on `alias.max_quota` by an admin
// TODO: if the domain had an alias-wide limit set on `domain.max_quota_per_alias`

// go through all admins and get the max value
const max = _.max(
let max = _.max(
adminMembers.map((member) =>
typeof member.user[config.userFields.maxQuotaPerAlias] === 'number'
? member.user[config.userFields.maxQuotaPerAlias]
: config.maxQuotaPerAlias
)
);

// if domain had a max value set, and it was less than `max`, then set new max
if (
Number.isFinite(domain.max_quota_per_alias) &&
domain.max_quota_per_alias < max
)
max = domain.max_quota_per_alias;

// if alias passed, and had a max value set, and it was less than `max`, then set new max
if (
alias &&
Number.isFinite(alias.max_quota) &&
alias.max_quota_per_alias < max
)
max = alias.max_quota_per_alias;

//
// NOTE: hard-coded max of 100 GB (safeguard)
//
Expand Down
4 changes: 4 additions & 0 deletions app/views/api/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -609,6 +609,7 @@ curl BASE_URI/v1/domains \
| `ignore_mx_check` | No | Boolean | Whether to ignore the MX record check on the domain for verification. This is mainly for users that have advanced MX exchange configuration rules and need to keep their existing MX exchange and forward to ours. |
| `retention_days` | No | Number | Integer between `0` and `30` that corresponds to the number of retention days to store outbound SMTP emails once successfully delivered or permanently errored. Defaults to `0`, which means that outbound SMTP emails are purged and redacted immediately for your security. |
| `bounce_webhook` | No | String (URL) or Boolean (false) | The `http://` or `https://` webhook URL of your choice to send bounce webhooks to. We will submit a `POST` request to this URL with information on outbound SMTP failures (e.g. soft or hard failures – so you can manage your subscribers and programmatically manage your outbound email). |
| `max_quota_per_alias` | No | String | Storage maximum quota for aliases on this domain name. Enter a value such as "1 GB" that will be parsed by [bytes](https://github.com/visionmedia/bytes.js). |

> Example Request:
Expand Down Expand Up @@ -656,6 +657,7 @@ curl BASE_URI/v1/domains/DOMAIN_NAME/verify-records \
| `ignore_mx_check` | No | Boolean | Whether to ignore the MX record check on the domain for verification. This is mainly for users that have advanced MX exchange configuration rules and need to keep their existing MX exchange and forward to ours. |
| `retention_days` | No | Number | Integer between `0` and `30` that corresponds to the number of retention days to store outbound SMTP emails once successfully delivered or permanently errored. Defaults to `0`, which means that outbound SMTP emails are purged and redacted immediately for your security. |
| `bounce_webhook` | No | String (URL) or Boolean (false) | The `http://` or `https://` webhook URL of your choice to send bounce webhooks to. We will submit a `POST` request to this URL with information on outbound SMTP failures (e.g. soft or hard failures – so you can manage your subscribers and programmatically manage your outbound email). |
| `max_quota_per_alias` | No | String | Storage maximum quota for aliases on this domain name. Enter a value such as "1 GB" that will be parsed by [bytes](https://github.com/visionmedia/bytes.js). |

> Example Request:
Expand Down Expand Up @@ -812,6 +814,7 @@ curl BASE_URI/v1/domains/DOMAIN_NAME/aliases?pagination=true \
| `has_imap` | No | Boolean | Whether to enable or disable IMAP storage for this alias (if disabled, then inbound emails received will not get stored to [IMAP storage](/blog/docs/best-quantum-safe-encrypted-email-service). If a value is passed, it is converted to a boolean using [boolean](https://github.com/thenativeweb/boolean#quick-start)) |
| `has_pgp` | No | Boolean | Whether to enable or disable [OpenPGP encryption](/faq#do-you-support-openpgpmime-end-to-end-encryption-e2ee-and-web-key-directory-wkd) for [IMAP/POP3/CalDAV encrypted email storage](/blog/docs/best-quantum-safe-encrypted-email-service) using the alias' `public_key`. |
| `public_key` | No | String | OpenPGP public key in ASCII Armor format ([click here to view an example](/.well-known/openpgpkey/hu/mxqp8ogw4jfq83a58pn1wy1ccc1cx3f5.txt); e.g. GPG key for `[email protected]`). This only applies if you have `has_pgp` set to `true`. [Learn more about end-to-end encryption in our FAQ](/faq#do-you-support-openpgpmime-end-to-end-encryption-e2ee-and-web-key-directory-wkd). |
| `max_quota` | No | String | Storage maximum quota for this alias. Leave blank to reset to domain current maximum quota or enter a value such as "1 GB" that will be parsed by [bytes](https://github.com/visionmedia/bytes.js). This value can only be adjusted by domain admins. |

> Example Request:
Expand Down Expand Up @@ -858,6 +861,7 @@ curl BASE_URI/v1/domains/:domain_name/aliases/:alias_name \
| `has_imap` | No | Boolean | Whether to enable or disable IMAP storage for this alias (if disabled, then inbound emails received will not get stored to [IMAP storage](/blog/docs/best-quantum-safe-encrypted-email-service). If a value is passed, it is converted to a boolean using [boolean](https://github.com/thenativeweb/boolean#quick-start)) |
| `has_pgp` | No | Boolean | Whether to enable or disable [OpenPGP encryption](/faq#do-you-support-openpgpmime-end-to-end-encryption-e2ee-and-web-key-directory-wkd) for [IMAP/POP3/CalDAV encrypted email storage](/blog/docs/best-quantum-safe-encrypted-email-service) using the alias' `public_key`. |
| `public_key` | No | String | OpenPGP public key in ASCII Armor format ([click here to view an example](/.well-known/openpgpkey/hu/mxqp8ogw4jfq83a58pn1wy1ccc1cx3f5.txt); e.g. GPG key for `[email protected]`). This only applies if you have `has_pgp` set to `true`. [Learn more about end-to-end encryption in our FAQ](/faq#do-you-support-openpgpmime-end-to-end-encryption-e2ee-and-web-key-directory-wkd). |
| `max_quota` | No | String | Storage maximum quota for this alias. Leave blank to reset to domain current maximum quota or enter a value such as "1 GB" that will be parsed by [bytes](https://github.com/visionmedia/bytes.js). This value can only be adjusted by domain admins. |

> Example Request:
Expand Down
Loading

0 comments on commit b58a264

Please sign in to comment.