web/models/user.js
const crypto = require('crypto')
const Sequelize = require('sequelize')
const jwt = require('jsonwebtoken')
const ourbigbook = require('ourbigbook')
const convert = require('../convert')
const { cant } = require('../front/cant')
const config = require('../front/config')
const { DataTypes, Op } = Sequelize
sampleUsername = ', here is a good example: my-good-username-123'
module.exports = (sequelize) => {
let User = sequelize.define(
'User',
{
username: {
type: DataTypes.STRING(config.usernameMaxLength),
unique: {
msg: 'This username is taken.'
},
validate: {
len: {
args: [config.usernameMinLength, config.usernameMaxLength],
msg: `Usernames must be between ${config.usernameMinLength} and ${config.usernameMaxLength} characters`
},
is: {
args: /^[a-z]/,
msg: 'Usernames must start with a letter lowercase letter (a-z)' + sampleUsername
},
isNotReserved(value) {
if (value in config.reservedUsernames) {
throw new Error(`This username is reserved: ${value}`)
}
if (/[^a-z0-9-]/.test(value)) {
throw new Error('Usernames can only contain lowercase letters (a-z), numbers (0-9) and dashes (-)' + sampleUsername)
}
if (/--/.test(value)) {
throw new Error('Usernames cannot contain a double dash (-)' + sampleUsername)
}
if (/-$/.test(value)) {
throw new Error('Usernames cannot end in a dash (-)' + sampleUsername)
}
},
}
},
ip: {
// IP user account was created from.
type: DataTypes.STRING,
allowNull: true,
},
displayName: {
type: DataTypes.STRING(256),
allowNull: false,
},
email: {
type: DataTypes.STRING,
set(v) {
this.setDataValue('email', v.toLowerCase())
},
unique: {
msg: 'This email is taken.'
},
validate: {
isEmail: {
msg: 'This email does not seem valid.'
},
max: {
args: 254,
msg: 'This email is too long, the maximum size is 254 characters.'
}
}
},
image: DataTypes.STRING(2048),
hash: DataTypes.STRING(1024),
salt: DataTypes.STRING,
score: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
},
followerCount: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
},
admin: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
verified: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
verificationCode: {
type: DataTypes.STRING(1024),
allowNull: false,
},
verificationCodeSent: {
type: DataTypes.DATE,
allowNull: true,
},
maxArticles: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: config.maxArticles,
},
maxArticleSize: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: config.maxArticleSize,
},
hideArticleDates: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
// A more general way would be to have a separate limits table.
// with custom times KISS this time.
maxIssuesPerMinute: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: config.maxIssuesPerMinute,
},
maxIssuesPerHour: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: config.maxIssuesPerHour,
},
emailNotifications: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: true,
},
newScoreLastCheck: {
// Last time the user checked for new upvotes received.
type: DataTypes.DATE,
allowNull: true,
},
nestedSetNeedsUpdate: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
},
{
hooks: {
beforeValidate: (user, options) => {
user.verificationCode = crypto.randomBytes(sequelize.models.User.tableAttributes.verificationCode.type.options.length / 2).toString('hex')
options.fields.push('verificationCode');
},
afterCreate: async (user, options) => {
// Create the index page for the user.
return convert.convertArticle({
author: user,
bodySource: User.defaultIndexBody,
path: ourbigbook.INDEX_BASENAME_NOEXT,
sequelize,
titleSource: ourbigbook.capitalizeFirstLetter(ourbigbook.INDEX_BASENAME_NOEXT),
transaction: options.transaction
})
}
},
indexes: [
{ fields: ['admin'] },
{ fields: ['createdAt'] },
{ fields: ['email'] },
{ fields: ['followerCount'] },
{ fields: ['score'] },
{ fields: ['username'] },
]
}
)
User.prototype.generateJWT = function() {
let today = new Date()
let exp = new Date(today)
exp.setDate(today.getDate() + 60)
return jwt.sign(
{
id: this.id,
username: this.username,
exp: parseInt(exp.getTime() / 1000)
},
config.secret
)
},
User.prototype.toJson = async function(loggedInUser) {
const ret = {
id: this.id,
username: this.username,
displayName: this.displayName,
image: this.image,
effectiveImage: this.image || config.defaultProfileImage,
followerCount: this.followerCount,
score: this.score,
admin: this.admin,
createdAt: this.createdAt.toISOString(),
// Until there are ever private articles/paid plans, you can always get
// a lower bound on their capacities. Let's just make them public for now then.
maxArticles: this.maxArticles,
maxArticleSize: this.maxArticleSize,
maxIssuesPerMinute: this.maxIssuesPerMinute,
maxIssuesPerHour: this.maxIssuesPerHour,
verified: this.verified,
}
if (loggedInUser) {
ret.following = await loggedInUser.hasFollow(this.id)
// Private data.
if (!cant.viewUserSettings(loggedInUser, this)) {
ret.ip = this.ip
ret.email = this.email
ret.emailNotifications = this.emailNotifications
ret.hideArticleDates = this.hideArticleDates
if (loggedInUser.token) {
ret.token = loggedInUser.token
}
if (this.newScoreLastCheck) {
ret.newScoreLastCheck = this.newScoreLastCheck.toISOString()
}
ret.nestedSetNeedsUpdate = this.nestedSetNeedsUpdate
}
} else {
ret.following = false
}
return ret
}
User.findArticleLikesReceivedArgs = function(uid, opts={}) {
let { order, offset, since } = opts
if (offset === undefined) {
offset = 0
}
if (order === undefined) {
order = 'createdAt'
}
const args = {
include: [
{
model: sequelize.models.Article,
as: 'article',
required: true,
subQuery: false,
include: [{
model: sequelize.models.File,
as: 'file',
required: true,
subQuery: false,
include: [{
model: sequelize.models.User,
as: 'author',
where: { id: uid },
required: true,
subQuery: false,
}]
}]
},
{
model: sequelize.models.User,
as: 'user',
required: true,
subQuery: false,
},
],
order: [[order, 'DESC']],
offset,
}
if (since) {
args.where = { createdAt: { [Op.gt]: since } }
}
return args
}
User.findAndCountArticleLikesReceived = async function(uid, opts={}) {
return sequelize.models.UserLikeArticle.findAndCountAll(this.findArticleLikesReceivedArgs(uid, opts))
}
User.countArticleLikesReceived = async function(uid, opts={}) {
return sequelize.models.UserLikeArticle.count(this.findArticleLikesReceivedArgs(uid, opts))
}
User.prototype.findAndCountArticlesByFollowed = async function(offset, limit, order) {
if (!order) {
order = 'createdAt'
}
return sequelize.models.Article.findAndCountAll({
offset,
limit,
subQuery: false,
order: [[
order,
'DESC'
]],
include: [
{
model: sequelize.models.File,
as: 'file',
required: true,
include: [
{
model: sequelize.models.User,
as: 'author',
required: true,
include: [
{
model: sequelize.models.UserFollowUser,
on: {
followId: { [Op.col]: 'file.author.id' },
},
attributes: [],
where: { userId: this.id },
}
],
}
],
},
],
})
}
User.prototype.findAndCountArticlesByFollowedToJson = async function (
offset,
limit,
order
) {
const { count: articlesCount, rows: articles } =
await this.findAndCountArticlesByFollowed(offset, limit, order)
const articlesJson = await Promise.all(
articles.map((article) => {
return article.toJson(this)
})
)
return {
articles: articlesJson,
articlesCount,
}
}
User.prototype.addArticleFollowSideEffects = async function(article, opts={}) {
return this.addFollowedArticle(article.id, { transaction: opts.transaction })
}
User.prototype.removeArticleFollowSideEffects = async function(article, opts={}) {
return this.removeFollowedArticle(article.id, { transaction: opts.transaction })
}
/** If already following, do nothing. */
User.prototype.addIssueFollowSideEffects = async function(article, opts={}) {
return this.addFollowedIssue(article.id, { transaction: opts.transaction })
}
User.prototype.removeIssueFollowSideEffects = async function(article, opts={}) {
return this.removeFollowedIssue(article.id, { transaction: opts.transaction })
}
User.prototype.addArticleLikeSideEffects = async function(article, opts={}) {
const transaction = opts.transaction
return this.addLikedArticle(article.id, { transaction }).then(
// Update the article topic, possibly updating the preferred title.
// Needs to come after score has been updated. Article score has been
// updated with trigger at this point.
() => sequelize.models.Topic.updateTopics([ article ], { transaction })
)
}
User.prototype.removeArticleLikeSideEffects = async function(article, opts={}) {
const transaction = opts.transaction
return this.removeLikedArticle(article.id, { transaction }).then(
() => sequelize.models.Topic.updateTopics([ article ], { transaction })
)
}
User.prototype.addIssueLikeSideEffects = async function(article, opts={}) {
return this.addLikedIssue(article.id, { transaction: opts.transaction })
}
User.prototype.removeIssueLikeSideEffects = async function(article, opts={}) {
return this.removeLikedIssue(article.id, { transaction: opts.transaction })
}
User.prototype.addFollowSideEffects = async function(otherUser, opts={}) {
return this.addFollow(otherUser.id, { transaction: opts.transaction })
}
User.prototype.removeFollowSideEffects = async function(otherUser, opts={}) {
return this.removeFollow(otherUser.id, { transaction: opts.transaction })
}
User.prototype.saveSideEffects = async function(options = {}) {
const transaction = options.transaction
return this.save({ transaction })
}
User.prototype.canEditIssue = function(issue) {
return issue.authorId === this.id
}
User.defaultIndexTitle = 'Index'
User.defaultIndexBody = 'Welcome to my home page!\n'
User.getUsers = async function({
limit,
following,
followedBy,
offset,
order,
sequelize,
username,
}) {
const include = []
if (following) {
include.push({
model: sequelize.models.User,
as: 'follows',
where: { username: following },
attributes: [],
through: { attributes: [] }
})
}
if (followedBy) {
include.push({
model: sequelize.models.User,
as: 'followed',
where: { username: followedBy },
attributes: [],
through: { attributes: [] }
})
}
const orderList = [[order, 'DESC']]
const where = {}
if (username) {
where.username = username
}
if (order !== 'createdAt') {
// To make results deterministic.
orderList.push(['createdAt', 'DESC'])
}
return sequelize.models.User.findAndCountAll({
include,
limit,
offset,
order: orderList,
subQuery: false,
where,
})
}
User.validPassword = function(user, password) {
let hash = crypto.pbkdf2Sync(password, user.salt, 10000, 512, 'sha512').toString('hex')
return user.hash === hash
}
User.setPassword = function(user, password) {
user.salt = crypto.randomBytes(16).toString('hex')
user.hash = crypto.pbkdf2Sync(password, user.salt, 10000, 512, 'sha512').toString('hex')
}
return User
}