web/api/users.js
const url = require('url');
const { htmlEscapeAttr } = require('ourbigbook')
const { hashToHex, sendJsonHttp } = require('ourbigbook/web_api')
const axios = require('axios')
const router = require('express').Router()
const passport = require('passport')
const sharp = require('sharp')
const { ValidationError: SequelizeValidationError } = require('sequelize')
const auth = require('../auth')
const lib = require('./lib')
const {
validateParam,
ValidationError,
} = lib
const { cant } = require('../front/cant')
const front = require('../front/js')
const { ipBlockedForSignupMessage } = front
const config = require('../front/config')
const routes = require('../front/routes')
const { isIpBlockedForSignup } = require('../back/webpack_safe')
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)
})
async function unlistUserContent(sequelize, user, transaction) {
const { Article, Comment, Issue, Upload, User } = sequelize.models
const counts = []
for (const Model of [Article, Issue, Comment]) {
const [count] = await Model.update(
{ list: false },
{ where: { authorId: user.id, list: true }, transaction },
)
counts.push(count)
}
const [uploadCount] = await Upload.update(
{ list: false },
{ where: { ...Upload.fileIndexWhere(user.id), list: true }, transaction },
)
counts.push(uploadCount)
await User.update(
{ discussionCount: 0, commentCount: 0 },
{ where: { id: user.id }, transaction },
)
return counts
}
// Login to the website.
router.post('/login', async function(req, res, next) {
try {
const body = validateParam(req, 'body')
const user = validateParam(body, 'user')
const username = validateParam(user, 'username')
const password = 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 = validateParam(req, 'body')
const emailOrUsername = 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 ValidationError([`email or username is not registered: ${emailOrUsername}`])
if (!user.verified)
throw new 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 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({
req,
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 = validateParam(req, 'body')
const email = validateParam(body,
'email',
{ validators: [front.isString, front.isTruthy] }
)
const password = validateParam(body,
'password',
{ validators: [front.isString, front.isTruthy] }
)
const code = 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 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 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 ValidationError(['reCAPTCHA failed'])
}
}
}
function getTimeToWaitForNextEmailMs(user) {
const next = user.nextVerificationEmailAt()
return next ? next.getTime() - Date.now() : -1
}
// Create a new user.
router.post('/users', async function(req, res, next) {
try {
const body = validateParam(req, 'body')
const userPost = validateParam(body, 'user')
const username = validateParam(userPost, 'username')
const email = validateParam(userPost, 'email')
const password = validateParam(userPost, 'password')
const displayName = validateParam(userPost, 'displayName', {
validators: [front.isString, front.isTruthy],
defaultValue: undefined,
})
const sequelize = req.app.get('sequelize')
const { User } = sequelize.models
let user
const ip = front.getClientIp(req)
await sequelize.transaction(async (transaction) => {
const [ipBlockPrefix,] = await Promise.all([
isIpBlockedForSignup(sequelize, ip),
validateCaptcha(config, req, res),
])
if (ipBlockPrefix) {
throw new ValidationError([ipBlockedForSignupMessage(ip, ipBlockPrefix.ip)])
}
// Check if VPN and block if yes.
if (
ip &&
config.ipapiIsApiKey
) {
const response = await axios.get(`https://api.ipapi.is?q=${ip}&key=${config.ipapiIsApiKey}`)
if (response.status === 200) {
const data = response.data
if (data.is_vpn) {
console.log(`ipapi.js VPN detected: ${JSON.stringify(data)}`)
throw new ValidationError([
`Your IP ${ip} is from a VPN according to https://ipapi.is ` +
`which is not allowed due to past abuse. Please try again ` +
`from another network, or contact a site admin to create the account for you.`
])
}
} else {
console.log('ipapi error')
console.log(response.data)
}
}
// We fetch the existing account by email.
// https://github.com/ourbigbook/ourbigbook/issues/329
const existingUser = await User.findOne({ where: { email }, transaction})
let adminsPromise
if (existingUser) {
user = existingUser
if (user.verified) {
throw new ValidationError([`email already taken: ${email}`])
}
// Re-send the email if enough time passed.
const timeToWaitMs = getTimeToWaitForNextEmailMs(user)
if (timeToWaitMs > 0) {
throw new 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 = ip
if (config.isTest) {
// Authenticate all users automatically.
user.verified = true
}
adminsPromise = User.findAll({ where: { admin: true }, transaction })
}
user.displayName = displayName
User.setPassword(user, password)
user.verificationCodeSent = new Date()
const [, admins] = await Promise.all([
user.saveSideEffects({ transaction }),
adminsPromise,
])
const verifyUrl = `${routes.host(req)}${routes.userVerify()}?email=${encodeURIComponent(user.email)}&code=${user.verificationCode}`
const sendEmailsPromises = []
sendEmailsPromises.push(lib.sendEmail({
req,
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({
req,
to: admin.email,
subject: `A new user signed up: ${user.displayName} (@${user.username}, ${user.email}, ${user.ip})!`,
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)
})
if (config.isTest) {
// TODO get rid of this horror.
return authenticate(req, res, next, { forceVerify: true })
} else {
return res.json({ user: await user.toJson(user) })
}
} catch(error) {
next(error);
}
})
async function validateEmailChange(User, user, email, transaction) {
const candidate = User.build({ email })
try {
await candidate.validate({ fields: ['email'], hooks: false })
} catch (error) {
if (!(error instanceof SequelizeValidationError)) throw error
throw new ValidationError({ email: error.errors.map(item => item.message) })
}
const existing = await User.findOne({ attributes: ['id'], where: { email: candidate.email }, transaction })
if (existing && existing.id !== user.id) {
throw new ValidationError({ email: 'This email is taken.' })
}
return candidate.email
}
async function prepareEmailChange(req, user, email, transaction) {
if (!user.verified) throw new ValidationError({ email: 'Verify your current email before changing it.' })
const User = user.sequelize.models.User
email = await validateEmailChange(User, user, email, transaction)
if (email === user.email) throw new ValidationError({ email: 'This is already your current email.' })
const wait = getTimeToWaitForNextEmailMs(user)
if (wait > 0) {
throw new ValidationError({ email: `You can send a new email in ${lib.msToRoundedTime(wait)}` }, 429)
}
user.pendingEmail = email
user.emailChangeCode = User.generateVerificationCode()
user.verificationCodeN += 1
user.verificationCodeSent = new Date()
const verifyUrl = `${routes.host(req)}${routes.userVerify()}?emailChange=${encodeURIComponent(user.username)}&code=${user.emailChangeCode}`
return {
req,
to: email,
subject: 'Verify your new OurBigBook.com email address',
html: `<p>Please <a href="${htmlEscapeAttr(verifyUrl)}">verify your new email address</a> for your OurBigBook account.</p><p>If you did not request this change, ignore this email.</p>`,
text: `Verify your new email address for your OurBigBook account: ${verifyUrl}
If you did not request this change, ignore this email.`,
}
}
router.post('/users/:username/email-change', auth.required, async function(req, res, next) {
try {
const sequelize = req.app.get('sequelize')
const { User } = sequelize.models
const loggedInUser = await User.findByPk(req.payload.id)
if (cant.setUserEmail(loggedInUser, req.user)) throw new ValidationError(['You cannot change this email'], 403)
let user
await sequelize.transaction(async transaction => {
user = await User.findByPk(req.user.id, { transaction, lock: transaction.LOCK.UPDATE })
const email = validateParam(req.body, 'email', {
defaultValue: user.pendingEmail,
validators: [front.isString, front.isTruthy],
})
const mail = await prepareEmailChange(req, user, email, transaction)
await user.saveSideEffects({ transaction })
await lib.sendEmail(mail)
})
return res.json({ user: await user.toJson(loggedInUser) })
} catch (error) {
next(error)
}
})
// Check availability only for accounts the caller can edit.
router.post('/users/:username/email-check', auth.required, async function(req, res, next) {
res.set('Cache-Control', 'no-store')
try {
const { User } = req.app.get('sequelize').models
const loggedInUser = await User.findByPk(req.payload.id)
if (cant.setUserEmail(loggedInUser, req.user)) {
throw new ValidationError(['You cannot check email availability for this user'], 403)
}
const email = validateParam(req.body, 'email', { validators: [front.isString, front.isTruthy] })
await validateEmailChange(User, req.user, email)
return res.json({ available: true })
} catch (error) {
next(error)
}
})
// Modify information about the given user. Backend for the settings page.
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 ValidationError([msg], 403)
}
await sequelize.transaction(async transaction => {
await user.reload({ transaction, lock: transaction.LOCK.UPDATE })
let mail
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 ValidationError(
[`username cannot be modified currently, would change from ${user.username} to ${userArg.username}`],
)
}
}
if (typeof userArg.email !== 'undefined') {
const email = validateParam(userArg, 'email', { validators: [front.isString, front.isTruthy] })
if (user.email !== email.toLowerCase()) {
if (loggedInUser.admin) {
user.email = email
user.pendingEmail = null
user.emailChangeCode = null
user.verificationCode = null
user.verificationCodeN = 0
} else {
mail = await prepareEmailChange(req, user, email, transaction)
}
}
}
if (typeof userArg.displayName !== 'undefined') {
const displayName = validateParam(userArg, 'displayName', {
validators: [front.isString, front.isTruthy],
defaultValue: undefined,
})
user.displayName = displayName
}
const emailNotifications = validateParam(userArg, 'emailNotifications', {
validators: [front.isBoolean],
defaultValue: undefined,
})
if (emailNotifications !== undefined) {
user.emailNotifications = userArg.emailNotifications
}
const emailNotificationsForArticleAnnouncement = validateParam(userArg, 'emailNotificationsForArticleAnnouncement', {
validators: [front.isBoolean],
defaultValue: undefined,
})
if (emailNotificationsForArticleAnnouncement !== undefined) {
user.emailNotificationsForArticleAnnouncement = userArg.emailNotificationsForArticleAnnouncement
}
const hideArticleDates = validateParam(userArg, 'hideArticleDates', {
validators: [front.isBoolean],
defaultValue: undefined,
})
if (hideArticleDates !== undefined) {
user.hideArticleDates = userArg.hideArticleDates
}
// User limits
{
const msg = cant.setUserLimits(loggedInUser)
function set(val, key) {
if (val !== undefined) {
if (msg) {
throw new ValidationError([msg], 403)
} else {
user[key] = val
}
}
}
set(
validateParam(userArg, 'maxArticles', {
typecast: front.typecastInteger,
validators: [front.isPositiveInteger],
defaultValue: undefined,
}),
'maxArticles',
)
set(
validateParam(userArg, 'maxArticleSize', {
typecast: front.typecastInteger,
validators: [front.isPositiveInteger],
defaultValue: undefined,
}),
'maxArticleSize',
)
set(
validateParam(userArg, 'maxUploads', {
typecast: front.typecastInteger,
validators: [front.isPositiveInteger],
defaultValue: undefined,
}),
'maxUploads',
)
set(
validateParam(userArg, 'maxUploadSize', {
typecast: front.typecastInteger,
validators: [front.isPositiveInteger],
defaultValue: undefined,
}),
'maxUploadSize',
)
set(
validateParam(userArg, 'maxIssuesPerMinute', {
typecast: front.typecastInteger,
validators: [front.isPositiveInteger],
defaultValue: undefined,
}),
'maxIssuesPerMinute',
)
set(
validateParam(userArg, 'maxIssuesPerHour', {
typecast: front.typecastInteger,
validators: [front.isPositiveInteger],
defaultValue: undefined,
}),
'maxIssuesPerHour',
)
set(
validateParam(userArg, 'locked', {
validators: [front.isBoolean],
defaultValue: undefined,
}),
'locked',
)
}
if (typeof userArg.password !== 'undefined') {
sequelize.models.User.setPassword(user, userArg.password)
}
await user.saveSideEffects({ transaction })
if (mail) await lib.sendEmail(mail)
}
})
user.token = user.generateJWT()
return res.json({ user: await user.toJson(user) })
} catch(error) {
next(error);
}
})
router.put('/users/:username/unlist-content', auth.required, async function(req, res, next) {
try {
const sequelize = req.app.get('sequelize')
const ret = await sequelize.transaction(async transaction => {
const loggedInUser = await sequelize.models.User.findByPk(req.payload.id, { transaction })
const msg = cant.updateSiteSettings(loggedInUser)
if (msg) {
throw new ValidationError([msg], 403)
}
const counts = await unlistUserContent(sequelize, req.user, transaction)
return { count: counts.reduce((sum, count) => sum + count, 0), counts }
})
res.json(ret)
} catch(error) {
next(error)
}
})
router.put('/users/:username/spammer', auth.required, async function(req, res, next) {
try {
const sequelize = req.app.get('sequelize')
const ret = await sequelize.transaction(async transaction => {
const { SignupBlacklistIp, User } = sequelize.models
const loggedInUser = await User.findByPk(req.payload.id, { transaction })
const msg = cant.updateSiteSettings(loggedInUser)
if (msg) {
throw new ValidationError([msg], 403)
}
const counts = await unlistUserContent(sequelize, req.user, transaction)
await req.user.update({ locked: true }, { transaction })
if (req.user.ip) {
await SignupBlacklistIp.bulkCreate(
[{ ip: req.user.ip }],
{ transaction, updateOnDuplicate: ['ip'] },
)
}
return { count: counts.reduce((sum, count) => sum + count, 0), counts }
})
res.json(ret)
} catch(error) {
next(error)
}
})
// Modify image of the currently logged in user.
// Backend for settings page on the web UI.
router.put('/users/:username/profile-picture', auth.required, async function(req, res, next) {
try {
let t0
if (config.log.perf) {
t0 = performance.now()
}
const sequelize = req.app.get('sequelize')
const user = req.user
const { Upload, User } = sequelize.models
const loggedInUser = await User.findByPk(req.payload.id)
const msg = cant.editUser(loggedInUser, user)
if (msg) {
throw new ValidationError([msg], 403)
}
const body = validateParam(req, 'body')
const dataUrl = validateParam(body, 'bytes', { validators: [front.isString] })
const [contentType, bytesOrig] = lib.parseDataUriBase64(dataUrl)
if (!config.allowedImageContentTypes.has(contentType)) {
throw new ValidationError([
`content type is not allowed: "${contentType}". ` +
`Allowed content types: ${config.allowedImageContentTypesArr.join(', ')}`
], 422)
}
const sizeOrig = bytesOrig.length
if (sizeOrig > config.profilePictureMaxUploadSize) {
throw new ValidationError([
`image is too large: ${sizeOrig} bytes, maximum size is ${config.profilePictureMaxUploadSize}`
], 422)
}
let bytes
try {
bytes = await sharp(bytesOrig).resize(250, 250, { fit: 'fill' }).toBuffer()
} catch(err) {
throw new ValidationError(`Image conversion failed with: ${err.message}`)
}
user.image = `${config.profilePicturePath}/${user.id}`
t0 = lib.logPerf(t0, 'PUT /users/:username/profile-picture before transaction')
await sequelize.transaction(async (transaction) => {
await Upload.upsertSideEffects(
{
bytes,
contentType,
path: `${config.profilePicturePathComponent}/${user.id}`,
size: bytes.length,
hash: hashToHex(bytes),
},
{ transaction }
)
await user.saveSideEffects({ transaction })
})
t0 = lib.logPerf(t0, 'PUT /users/:username/profile-picture after transaction')
return res.json({
image: user.image,
imageDataUrl: `data:${contentType};base64,${bytes.toString('base64')}`,
})
} catch(error) {
next(error);
}
})
// Follow
async function validateFollow(user, loggedInUser, isFollow) {
if ((await loggedInUser.hasFollow(user)) === isFollow) {
throw new ValidationError(
[`User '${loggedInUser.username}' ${isFollow ? 'already follows' : 'does not follow'} user '${user.username}'`],
403,
)
}
}
// Follow user.
router.post('/users/:username/follow', auth.required, async function(req, res, next) {
try {
const sequelize = req.app.get('sequelize')
const { User } = sequelize.models
const loggedInUser = await User.findByPk(req.payload.id)
const user = req.user
const msg = cant.followUser(loggedInUser, user)
if (msg) {
throw new ValidationError([msg], 403)
}
await validateFollow(user, loggedInUser, true)
await loggedInUser.addFollowSideEffects(user)
const newUser = await User.findOne({ where: { username: loggedInUser.username } })
return res.json({ user: await user.toJson(newUser) })
} catch(error) {
next(error)
}
})
// Unfollow user.
router.delete('/users/:username/follow', auth.required, async function(req, res, next) {
try {
const sequelize = req.app.get('sequelize')
const { User } = sequelize.models
const loggedInUser = await User.findByPk(req.payload.id)
const user = req.user
const msg = cant.followUser(loggedInUser, user)
if (msg) {
throw new ValidationError([msg], 403)
}
await validateFollow(user, loggedInUser, false)
await loggedInUser.removeFollowSideEffects(req.user)
const newUser = await User.findOne({ where: { username: user.username } })
return res.json({ user: await req.user.toJson(newUser) })
} catch(error) {
next(error)
}
})
module.exports = router