web/api/lib.js
// Ideally, this would be moved entirely to front/js.js to share functionality backend API with Next.js.
// There is just one thing that we haven't managed/tried to much to do on Next.js but which works on Express:
// to raise error pages like 404 by throwing exceptions. So we are keeping only the exception throwing stuff
// here for now. This type of exception interface is very convenient, as it allows you to stop processing
// immediately and return an error from subcalls.
const pluralize = require('pluralize')
const config = require('../front/config')
const front = require('../front/js')
function checkMaxNewPerTimePeriod({
errs,
loggedInUser,
newCountLastHour,
newCountLastMinute,
objectName,
}) {
if (!config.isTest && !loggedInUser.admin) {
if (newCountLastMinute > loggedInUser.maxIssuesPerMinute) {
errs.push(`maximum number of new ${pluralize(objectName)} per minute reached: ${loggedInUser.maxIssuesPerMinute}`)
}
if (newCountLastHour > loggedInUser.maxIssuesPerHour) {
errs.push(`maximum number of new ${pluralize(objectName)} per hour reached: ${loggedInUser.maxIssuesPerHour}`)
}
}
}
// Make user like an object such as an Article, Issue or Comment
async function likeObject({
req,
res,
getObject,
objectName,
joinModel,
validateLike,
}) {
const sequelize = req.app.get('sequelize')
const [
obj,
loggedInUser,
likeCountByLoggedInUserLastMinute,
likeCountByLoggedInUserLastHour,
] = await Promise.all([
getObject(req, res),
sequelize.models.User.findByPk(req.payload.id),
joinModel.count({ where: {
userId: req.payload.id,
createdAt: { [sequelize.Sequelize.Op.gt]: oneMinuteAgo() }
}}),
joinModel.count({ where: {
userId: req.payload.id,
createdAt: { [sequelize.Sequelize.Op.gt]: oneHourAgo() }
}}),
])
await validateLike(req, res, loggedInUser, obj, true)
const errs = []
checkMaxNewPerTimePeriod({
errs,
objectName,
loggedInUser,
newCountLastHour: likeCountByLoggedInUserLastHour,
newCountLastMinute: likeCountByLoggedInUserLastMinute,
})
if (errs.length) { throw new ValidationError(errs, 403) }
if (objectName === 'article') {
await loggedInUser.addArticleLikeSideEffects(obj)
} else if (objectName === 'issue') {
await loggedInUser.addIssueLikeSideEffects(obj)
} else {
throw new Error(`unknown object name: ${objectName}`)
}
const newObj = await getObject(req, res)
return res.json({ [objectName]: await newObj.toJson(loggedInUser) })
}
function validateBodySize(loggedInUser, bodySource) {
if (!loggedInUser.admin && bodySource.length > loggedInUser.maxArticleSize) {
throw new ValidationError(
`The body size (${bodySource.length} bytes) was larger than you maximum article size (${loggedInUser.maxArticleSize} bytes). bodySource:\n${bodySource}`,
403,
)
}
}
async function getArticle(req, res, options={}) {
const slug = validateParam(req.query, 'id')
const sequelize = req.app.get('sequelize')
const article = await sequelize.models.Article.getArticle(Object.assign({ sequelize, slug }, options))
if (!article) {
throw new ValidationError(
[`Article slug not found: "${slug}"`],
404,
)
}
return article
}
function getOrder(req, opts={}) {
const [sort, err] = front.getOrder(req, opts)
if (err) {
throw new ValidationError(
[`Invalid sort value: '${sort}'`],
422,
)
}
return sort
}
function getLimitAndOffset(req, res, opts={}) {
let { defaultLimit, limitMax } = opts
if (limitMax === undefined) {
limitMax = config.articleLimitMax
}
if (defaultLimit === undefined) {
defaultLimit = limitMax
}
return [
validateParam(req.query, 'limit', {
typecast: front.typecastInteger,
validators: [
front.isNonNegativeInteger,
front.isSmallerOrEqualTo(limitMax),
],
defaultValue: defaultLimit
}),
validateParam(req.query, 'offset', {
typecast: front.typecastInteger,
validators: [front.isNonNegativeInteger],
defaultValue: 0
}),
]
}
const MILLIS_PER_MINUTE = 1000 * 60
const MILLIS_PER_HOUR = 60 * MILLIS_PER_MINUTE
/**
* https://stackoverflow.com/questions/19700283/how-to-convert-time-in-milliseconds-to-hours-min-sec-format-in-javascript/32180863#32180863
*
* Approximate milliseconds to the nearest time unit.
*
* @param {Number} ms
* @return {string} Sample outputs:
* 1.0 Sec
* 10.0 Sec
* 5.0 Min
* 1.0 Hrs
* 1.0 Days
*/
function msToRoundedTime(ms) {
let seconds = (ms / 1000).toFixed(1)
let minutes = (ms / (MILLIS_PER_MINUTE)).toFixed(1)
let hours = (ms / (MILLIS_PER_HOUR)).toFixed(1)
let days = (ms / (1000 * 60 * 60 * 24)).toFixed(1)
if (seconds < 60) return seconds + " seconds"
else if (minutes < 60) return minutes + " minutes"
else if (hours < 24) return hours + " hours"
else return days + " days"
}
function oneMinuteAgo() {
return new Date(new Date - 1000 * 60)
}
function oneHourAgo() {
return new Date(new Date - MILLIS_PER_HOUR)
}
async function sendEmail({
fromName='OurBigBook.com',
html,
subject,
text,
to,
}) {
if (!config.isTest) {
if (process.env.OURBIGBOOK_SEND_EMAIL === '1' || config.isProduction) {
const sgMail = require('@sendgrid/mail')
sgMail.setApiKey(process.env.SENDGRID_API_KEY)
const msg = {
to,
from: {
email: 'notification@ourbigbook.com',
name: fromName,
},
subject,
text,
html,
}
await sgMail.send(msg)
} else {
console.log(`Email sent:
to: ${to}
fromName: ${fromName}
subject: ${subject}
text: ${text}
html: ${html}`)
}
}
}
// When this class is thrown and would blows up on toplevel, we catch it instead
// and gracefully return the specified error to the client instead of doing a 500.
class ValidationError extends Error {
constructor(errors, status, opts={}) {
super()
this.info = opts.info
this.errors = errors
if (status === undefined) {
status = 422
}
this.status = status
}
}
function validate(inputString, validators, prop) {
if (validators === undefined) {
validators = [front.isTruthy]
}
for (const validator of validators) {
if (!validator(inputString)) {
throw new ValidationError(
{ [prop]: [`validator ${validator.name} failed on ${prop} = "${inputString}"`] },
422,
)
}
}
}
/* Validate some input parameter, e.g. either URL GET param or parsed JSON body.
*
* Every such param should be validated like this before getting used, otherwise
* 500s are likely
*
* - typecast: converts strings to other types e.g. integer. This ensures that the type is correct afterwards.
* so you don't need to add a type validator to validators.
*
* Body JSON is preparsed by Express for us as a JavaScript object, and types are already converted,
* so typecast is not necessary. But then you have to check that types are correct instead.
* - validators: if any of them returs false, return an error code.
* They are not run if the value was not given, the defaultValue is used directly
* if it was given in that case.
* - defaultValue: if not given, will blow up if the param is missing. Can be undefined however
* to allow a default value of undefined.
**/
function validateParam(obj, prop, opts={}) {
const { typecast, validators, defaultValue } = opts
let param = obj[prop]
if (typeof param === 'undefined') {
if (!('defaultValue' in opts)) {
throw new ValidationError(
{ [prop]: [`missing mandatory argument`] },
422,
)
}
return defaultValue
} else {
if (typecast !== undefined) {
let ok
;[param, ok] = typecast(param)
if (!ok) {
throw new ValidationError(
{ [prop]: [`typecast ${typecast.name} failed on ${prop} = "${param}"`] },
422,
)
}
}
validate(param, validators, prop)
return param
}
}
module.exports = {
ValidationError,
checkMaxNewPerTimePeriod,
getArticle,
getLimitAndOffset,
getOrder,
likeObject,
oneHourAgo,
oneMinuteAgo,
MILLIS_PER_HOUR,
MILLIS_PER_MINUTE,
msToRoundedTime,
sendEmail,
validate,
validateBodySize,
validateParam,
}