src/core/email.js

/**
 * 邮箱模块
 * @module core/email
 */
const url = require('url');
const ajv = new (require('ajv'))();
const bcrypt = require('bcrypt');
const {errorsEnum, coreOkay, coreValidate, coreThrow, coreAssert} = require('./errors');
const {escapeHtml} = require('../utils');

const sidRegex = /^[a-zA-Z_0-9]{40}$/;

const emailTemplates = {
  createUser(url) {
    const escapedUrl = escapeHtml(url);
    return {
      subject: '请确认您的账号',
      text: `点击或在浏览器中打开以下链接以激活您的账号:\n${url}`,
      html: `<p>点击<a href="${escapedUrl}">此处</a>或在浏览器中打开以下链接以激活您的账号:</p>
             <p><a href="${escapedUrl}">${escapedUrl}</a></p>`
    };
  },
  resetPassword(url) {
    const escapedUrl = escapeHtml(url);
    return {
      subject: '请重置您的密码',
      text: `点击或在浏览器中打开以下链接以重置您账号的密码:\n${url}`,
      html: `<p>点击<a href="${escapedUrl}">此处</a>或在浏览器中打开以下链接以重置您账号的密码:</p>
             <p><a href="${escapedUrl}">${escapedUrl}</a></p>`
    };
  }
};

const sendEmailSchema = ajv.compile({
  type: 'object',
  required: ['action', 'email', 'to'],
  properties: {
    action: {type: 'string', enum: ['create-user', 'reset-password']},
    email: {type: 'string', format: 'email'},
    to: {type: 'string'},
    username: {type: 'string', pattern: '^[a-zA-Z_0-9]+$'},
    password: {type: 'string', minLength: 8},
    roles: {
      type: 'array',
      items: {
        type: 'string',
        enum: ['SUBSCRIBER', 'PUBLISHER']
      },
      uniqueItems: true
    }
  },
  additionalProperties: false
});

/**
 * 发送邮件,用于创建用户或重置密码。通过以下两种方式暴露:
 *   - ajax: POST /api/email
 *   - socket.io: emit email:create
 * @param params {object} 请求数据
 *   - data {object} 请求的data
 *     - action {string} `create-user`或`reset-password`
 *     - email {string} 发送验证邮件
 *     - to {string} 邮件跳转的地址,必须符合同源要求
 *     - username {string} 如果是`create-user`,则英文数字下线符,不能为空
 *     - password {string} 如果是`create-user`,则长度必须大于8
 *     - roles {string[]} 如果是`create-user`,则只能包含`SUBSCRIBER`或`PUBLISHER`
 * @param global {object} 全局数据
 * @return {Promise<object>}
 */
async function sendEmail(params, global) {
  const {config, users, email} = global;
  coreValidate(sendEmailSchema, params.data);
  const to = new url.URL(params.data.to || '/', config.site);
  coreAssert(to.origin === config.site, errorsEnum.SCHEMA, 'Cross site redirection');
  const isCreateUser = params.data.action === 'create-user';
  coreAssert(isCreateUser === (params.data.username !== undefined),
    errorsEnum.SCHEMA, 'Invalid username field');
  coreAssert(isCreateUser === (params.data.password !== undefined),
    errorsEnum.SCHEMA, 'Invalid password field');
  coreAssert(isCreateUser === (params.data.roles !== undefined),
    errorsEnum.SCHEMA, 'Invalid roles field');
  if (isCreateUser) {
    const {createUserSession} = global;
    const duplicatedUser = await users.findOne({
      $or: [
        {username: params.data.username},
        {email: params.data.email}
      ]
    }).notDeleted();
    if (duplicatedUser)
      coreThrow(errorsEnum.INVALID,
        duplicatedUser.username === params.data.username
          ? 'Username has been taken' : 'Email has been taken'
      );
    params.data.password = await bcrypt.hash(params.data.password, 10);
    let role = 0;
    params.data.roles.forEach(x => role |= users.roleEnum[x]);
    params.data.roles = role;
    await createUserSession.removeByIndex({email: params.data.email});
    const token = await createUserSession.save(params.data);
    to.searchParams.set('token', token);
    to.searchParams.set('action', 'create-user');
    const template = Object.assign({}, emailTemplates.createUser(to.href), {
      from: `"${config.name}" <${config.email.auth.user}>`,
      to: params.data.email
    });
    await email.sendMail(template);
    return coreOkay();
  } else {
    const {resetPasswordSession} = global;
    const user = await users.findOne({email: params.data.email}).notDeleted();
    coreAssert(user, errorsEnum.INVALID, 'User does not exist');
    await resetPasswordSession.removeByIndex({email: params.data.email});
    const token = await resetPasswordSession.save({
      email: params.data.email,
      id: user._id.toString()
    });
    to.searchParams.set('token', token);
    to.searchParams.set('action', 'reset-password');
    const template = Object.assign({}, emailTemplates.resetPassword(to.href), {
      from: `"${config.name}" <${config.email.auth.user}>`,
      to: params.data.email
    });
    await email.sendMail(template);
    return coreOkay();
  }
}

const confirmEmailSchema = ajv.compile({
  type: 'object',
  required: ['action'],
  properties: {
    action: {type: 'string', enum: ['create-user', 'reset-password']},
    password: {type: 'string', minLength: 8}
  },
  additionalProperties: false
});

/**
 * 确认邮件链接,并执行对应的操作。通过以下两种方式暴露:
 *   - ajax: POST /api/email/:id
 *   - socket.io: confirm-email
 * @param params {object} 请求数据
 *  - id {string} 回话的id,长度为40
 *  - data {object} 请求的token
 *    - action {string} `create-user`或`reset-password`
 *    - password {string} `reset-password`时,为新的密码,长度至少为8。否则不能存在
 * @param global {object}
 * @return {Promise<object>}
 */
async function confirmEmail(params, global) {
  const {users} = global;
  coreValidate(confirmEmailSchema, params.data);
  coreAssert((params.data.action === 'create-user') === (params.data.password === undefined),
    errorsEnum.SCHEMA, 'Invalid password field');
  coreAssert(params.id && sidRegex.test(params.id), errorsEnum.SCHEMA, 'Invalid token field');
  if (params.data.action === 'create-user') {
    const {createUserSession} = global;
    const data = await createUserSession.loadAndRemove(params.id);
    coreAssert(data, errorsEnum.EXIST, 'Invalid token');
    data.role = parseInt(data.role);
    const duplicatedUser = await users.findOne({
      $or: [
        {username: data.username},
        {email: data.email}
      ]
    }).notDeleted();
    if (duplicatedUser)
      coreThrow(errorsEnum.INVALID,
        duplicatedUser.username === data.username
          ? 'Username has been taken' : 'Email has been taken'
      );
    const user = new users(data);
    await user.save();
    return coreOkay();
  } else {
    const {resetPasswordSession, jwt} = global;
    const data = await resetPasswordSession.loadAndRemove(params.id);
    coreAssert(data, errorsEnum.EXIST, 'Invalid token');
    const user = await users.findById(data.id).notDeleted();
    coreAssert(user, errorsEnum.INVALID, 'User does not exist');
    user.password = await bcrypt.hash(params.data.password, 10);
    await jwt.revoke(data.id);
    await user.save();
    return coreOkay();
  }
}

module.exports = {
  emailTemplates,
  sendEmail,
  confirmEmail
};