OurBigBook logoOurBigBook Docs OurBigBook logoOurBigBook.comSite Source code
web/api/users.js
const url = require('url');

const { sendJsonHttp } = require('ourbigbook/web_api')

const router = require('express').Router()
const passport = require('passport')

const auth = require('../auth')
const lib = require('./lib')
const { cant } = require('../front/cant')
const front = require('../front/js')
const config = require('../front/config')
const routes = require('../front/routes')

async function authenticate(req, res, next, opts={}) {
  const { forceVerify } = opts
  passport.authenticate('local', { session: false }, async function(err, user, info) {
    if (err) {
      return next(err)
    }
    if (user) {
      if (user.verified || forceVerify) {
        user.token = user.generateJWT()
        verified = true
      } else {
        verified = false
      }
      return res.json({
        user: await user.toJson(user),
        verified,
      })
    } else {
      return res.status(422).json(info)
    }
  })(req, res, next)
}

// Preload user profile on routes with ':username'
router.param('username', function(req, res, next, username) {
  req.app.get('sequelize').models.User.findOne({ where: { username } })
    .then(function(user) {
      if (!user) {
        return res.sendStatus(404)
      }
      req.user = user
      return next()
    })
    .catch(next)
})

// Login to the website.
router.post('/login', async function(req, res, next) {
  try {
    const body = lib.validateParam(req, 'body')
    const user = lib.validateParam(body, 'user')
    const username = lib.validateParam(user, 'username')
    const password = lib.validateParam(user, 'password')
    await authenticate(req, res, next)
  } catch(error) {
    next(error);
  }
})

router.post('/reset-password-request', async function(req, res, next) {
  try {
    const body = lib.validateParam(req, 'body')
    const emailOrUsername = lib.validateParam(body,
      'emailOrUsername',
      { validators: [front.isString, front.isTruthy] }
    )
    await validateCaptcha(config, req, res)
    const sequelize = req.app.get('sequelize')
    const where = {}
    if (front.isEmail(emailOrUsername)) {
      where.email = emailOrUsername
    } else {
      where.username = emailOrUsername
    }
    const User = sequelize.models.User
    const user = await User.findOne({ where })
    if (!user)
      throw new lib.ValidationError([`email or username is not registered: ${emailOrUsername}`])
    if (!user.verified)
      throw new lib.ValidationError([`user is not verified, you must verify your account before you can reset your password`])
    const timeToWaitMs = getTimeToWaitForNextEmailMs(user)
    if (timeToWaitMs > 0) {
      throw new lib.ValidationError([`Email already registered but not verified. You can re-send a confirmation email in: ${lib.msToRoundedTime(timeToWaitMs)}`])
    }
    const verificationCode = User.generateVerificationCode()
    const resetPasswordUrl = `${routes.host(req)}${routes.resetPasswordUpdate()}?email=${encodeURIComponent(user.email)}&code=${verificationCode}`
    user.verificationCode = verificationCode
    user.verificationCodeN += 1
    user.verificationCodeSent = new Date()
    await Promise.all([
      user.saveSideEffects(),
      lib.sendEmail({
        to: user.email,
        subject: `Change your OurBigBook.com password`,
        html:
          `<p>Hello, ${user.displayName}!</p>` +
          `<p>Someone (hopefully you) requested a password reset for your account.</p>` +
          `<p>If that was you, please <a href="${resetPasswordUrl}">click this link to change your password</a>.</p>` +
          `<p>If it wasn't, you can safely ignore this email.</p>`
        ,
        text: `Hello, ${user.displayName}!

Someone (hopefully you) requested a password reset for your account.

If that was you, please click this link to change your password: ${resetPasswordUrl}

If it wasn't, you can safely ignore this email.
`,
      })
    ])
    res.sendStatus(200)
  } catch(error) {
    next(error);
  }
})

router.post('/reset-password', async function(req, res, next) {
  try {
    const body = lib.validateParam(req, 'body')
    const email = lib.validateParam(body,
      'email',
      { validators: [front.isString, front.isTruthy] }
    )
    const password = lib.validateParam(body,
      'password',
      { validators: [front.isString, front.isTruthy] }
    )
    const code = lib.validateParam(body,
      'code',
      { validators: [front.isString, front.isTruthy] }
    )
    const sequelize = req.app.get('sequelize')
    const User = sequelize.models.User
    const user = await User.findOne({ where: { email } })
    if (!user)
      throw new lib.ValidationError([`email not registered: ${email}`])
    if (code === user.verificationCode) {
      res.sendStatus(200)
      user.verificationCode = null
      user.verificationCodeN = 0
      User.setPassword(user, password)
      await user.saveSideEffects()
    } else {
      throw new lib.ValidationError(['verification code invalid. Please send a new one.'])
    }
  } catch(error) {
    next(error);
  }
})

router.get('/users', auth.optional, async function(req, res, next) {
  try {
    const sequelize = req.app.get('sequelize')
    const { User } = sequelize.models
    const [limit, offset] = lib.getLimitAndOffset(req, res)
    const [loggedInUser, {count: usersCount, rows: users}] = await Promise.all([
      req.payload ? User.findByPk(req.payload.id) : null,
      User.getUsers({
        // https://github.com/ourbigbook/ourbigbook/issues/260
        followedBy: req.query.followedBy,
        following: req.query.following,
        limit,
        offset,
        order: lib.getOrder(req, {
          allowedSorts: User.ALLOWED_SORTS,
          allowedSortsExtra: User.ALLOWED_SORTS_EXTRA,
        }),
        sequelize,
        username: req.query.username,
      }),
    ])
    return res.json({
      users: await Promise.all(users.map((user) => {
        return user.toJson(loggedInUser)
      })),
      usersCount,
    })
  } catch(error) {
    next(error);
  }
})

async function validateCaptcha(config, req, res) {
  if (config.useCaptcha) {
    const {data, status} = await sendJsonHttp(
      'POST',
      '/recaptcha/api/siteverify',
      {
        contentType: 'application/x-www-form-urlencoded',
        https: true,
        hostname: 'www.google.com',
        validateStatus: () => true,
        body: new url.URLSearchParams({
          secret: process.env.RECAPTCHA_SECRET_KEY,
          response: req.body.recaptchaToken,
        }).toString(),
      }
    )
    if (status !== 200) {
      return res.sendStatus(503)
    }
    if (!data.success) {
      console.error(`recaptcha error: ${data}`);
      throw new lib.ValidationError(['reCAPTCHA failed'])
    }
  }
}

function getTimeToWaitForNextEmailMs(user) {
  if (user.verificationCodeN === 0)
    return -1
  return (
    user.verificationCodeSent.getTime() +
    lib.MILLIS_PER_MINUTE * user.sequelize.models.User.verificationCodeNToTimeDeltaMinutes(user.verificationCodeN)
  ) - (new Date()).getTime()
}

// Create a new user.
router.post('/users', async function(req, res, next) {
  try {
    const body = lib.validateParam(req, 'body')
    const userPost = lib.validateParam(body, 'user')
    const username = lib.validateParam(userPost, 'username')
    const email = lib.validateParam(userPost, 'email')
    const password = lib.validateParam(userPost, 'password')
    const displayName = lib.validateParam(userPost, 'displayName', {
      validators: [front.isString, front.isTruthy],
      defaultValue: undefined,
    })
    await validateCaptcha(config, req, res)
    const sequelize = req.app.get('sequelize')
    const User = sequelize.models.User
    // We fetch the existing account by email.
    // https://github.com/ourbigbook/ourbigbook/issues/329
    const existingUser = await User.findOne({ where: { email }})
    let user
    let adminsPromise
    if (existingUser) {
      user = existingUser
      if (user.verified) {
        throw new lib.ValidationError([`email already taken: ${email}`])
      }
      // Re-send the email if enough time passed.
      const timeToWaitMs = getTimeToWaitForNextEmailMs(user)
      if (timeToWaitMs > 0) {
        throw new lib.ValidationError([`Email already registered but not verified. You can re-send a confirmation email in: ${lib.msToRoundedTime(timeToWaitMs)}`])
      }
      user.verificationCode = User.generateVerificationCode()
      user.verificationCodeN += 1
      adminsPromise = null
    } else {
      user = new (User)()
      // username is set only in this else.
      // https://github.com/ourbigbook/ourbigbook/issues/329
      user.username = username
      user.verificationCodeN = 1
      user.email = email
      user.ip = front.getClientIp(req)
      if (config.isTest) {
        // Authenticate all users automatically.
        user.verified = true
      }
      adminsPromise = User.findAll({ where: { admin: true }})
    }
    user.displayName = displayName
    User.setPassword(user, password)
    user.verificationCodeSent = new Date()
    const [, admins] = await Promise.all([
      user.saveSideEffects(),
      adminsPromise,
    ])
    if (config.isTest) {
      return authenticate(req, res, next, { forceVerify: true })
    }
    const verifyUrl = `${routes.host(req)}${routes.userVerify()}?email=${encodeURIComponent(user.email)}&code=${user.verificationCode}`
    const sendEmailsPromises = []
    sendEmailsPromises.push(lib.sendEmail({
      to: user.email,
      subject: `Verify your new OurBigBook.com account`,
      html:
        `<p>Welcome to OurBigBook.com, ${user.displayName}!</p>` +
        `<p>Please <a href="${verifyUrl}">click this link to verify your account</a>.</p>`
      ,
      text: `Welcome to OurBigBook.com, ${user.displayName}!

Please click this link to verify your account: ${verifyUrl}
`,
    }))
    const profileUrl = `${routes.host(req)}${routes.user(user.username)}`
    if (admins) {
      for (const admin of admins) {
        sendEmailsPromises.push(lib.sendEmail({
          to: admin.email,
          subject: `A new user signed up: ${user.displayName} (@${user.username}, ${user.email})!`,
          html: `<p><a href="${profileUrl}">${profileUrl}</a></p><p>Another step towards world domination is taken!</p>`,
          text: `${profileUrl}

Another step towards world domination is taken!
`,
        }))
      }
    }
    await Promise.all(sendEmailsPromises)
    return res.json({ user: await user.toJson(user) })
  } catch(error) {
    next(error);
  }
})

// Modify information about the currently logged in user.
// Backend for settings page on the web UI.
router.put('/users/:username', auth.required, async function(req, res, next) {
  try {
    const sequelize = req.app.get('sequelize')
    const user = req.user
    const loggedInUser = await sequelize.models.User.findByPk(req.payload.id)
    const msg = cant.editUser(loggedInUser, user)
    if (msg) {
      throw new lib.ValidationError( [msg], 403)
    }
    const userArg = req.body.user
    if (userArg) {
      // only update fields that were actually passed...
      if (typeof userArg.username !== 'undefined') {
        //user.username = userArg.username
        if (user.username !== userArg.username) {
          throw new lib.ValidationError(
            [`username cannot be modified currently, would change from ${user.username} to ${userArg.username}`],
          )
        }
      }
      if (typeof userArg.email !== 'undefined') {
        //user.email = userArg.email
        if (user.email !== userArg.email) {
          throw new lib.ValidationError(
            [`email cannot be modified currently, would change from ${user.email} to ${userArg.email}`],
          )
        }
      }
      if (typeof userArg.displayName !== 'undefined') {
        const displayName = lib.validateParam(userArg, 'displayName', {
          validators: [front.isString, front.isTruthy],
          defaultValue: undefined,
        })
        user.displayName = displayName
      }
      if (typeof userArg.image !== 'undefined') {
        user.image = userArg.image
      }
      const emailNotifications = lib.validateParam(userArg, 'emailNotifications', {
        validators: [front.isBoolean],
        defaultValue: undefined,
      })
      if (emailNotifications !== undefined) {
        user.emailNotifications = userArg.emailNotifications
      }
      const hideArticleDates = lib.validateParam(userArg, 'hideArticleDates', {
        validators: [front.isBoolean],
        defaultValue: undefined,
      })
      if (hideArticleDates !== undefined) {
        user.hideArticleDates = userArg.hideArticleDates
      }
      if (!cant.setUserLimits(loggedInUser)) {
        const maxArticles = lib.validateParam(userArg, 'maxArticles', {
          typecast: front.typecastInteger,
          validators: [front.isPositiveInteger],
          defaultValue: undefined,
        })
        if (maxArticles !== undefined) {
          user.maxArticles = maxArticles
        }
        const maxArticleSize = lib.validateParam(userArg, 'maxArticleSize', {
          typecast: front.typecastInteger,
          validators: [front.isPositiveInteger],
          defaultValue: undefined,
        })
        if (maxArticleSize !== undefined) {
          user.maxArticleSize = maxArticleSize
        }
        const maxIssuesPerMinute = lib.validateParam(userArg, 'maxIssuesPerMinute', {
          typecast: front.typecastInteger,
          validators: [front.isPositiveInteger],
          defaultValue: undefined,
        })
        if (maxIssuesPerMinute !== undefined) {
          user.maxIssuesPerMinute = maxIssuesPerMinute
        }
        const maxIssuesPerHour = lib.validateParam(userArg, 'maxIssuesPerHour', {
          typecast: front.typecastInteger,
          validators: [front.isPositiveInteger],
          defaultValue: undefined,
        })
        if (maxIssuesPerHour !== undefined) {
          user.maxIssuesPerHour = maxIssuesPerHour
        }
      }
      if (typeof userArg.password !== 'undefined') {
        sequelize.models.User.setPassword(user, userArg.password)
      }
      await user.saveSideEffects()
    }
    user.token = user.generateJWT()
    return res.json({ user: await user.toJson(user) })
  } catch(error) {
    next(error);
  }
})

// Follow

async function validateFollow(req, res, user, isFollow) {
  if ((await user.hasFollow(req.user)) === isFollow) {
    throw new lib.ValidationError(
      [`User '${user.username}' ${isFollow ? 'already follows' : 'does not follow'} user '${req.user.username}'`],
      403,
    )
  }
}

// Follow user.
router.post('/users/:username/follow', auth.required, async function(req, res, next) {
  try {
    const user = await req.app.get('sequelize').models.User.findByPk(req.payload.id)
    await validateFollow(req, res, user, true)
    await user.addFollowSideEffects(req.user)
    const newUser = await req.app.get('sequelize').models.User.findOne({
      where: { username: user.username } })
    return res.json({ user: await req.user.toJson(newUser) })
  } catch(error) {
    next(error);
  }
})

// Unfollow user.
router.delete('/users/:username/follow', auth.required, async function(req, res, next) {
  try {
    const user = await req.app.get('sequelize').models.User.findByPk(req.payload.id)
    await validateFollow(req, res, user, false)
    await user.removeFollowSideEffects(req.user)
    const newUser = await req.app.get('sequelize').models.User.findOne({
      where: { username: user.username } })
    return res.json({ user: await req.user.toJson(user) })
  } catch(error) {
    next(error);
  }
})

module.exports = router