639 lines
17 KiB
JavaScript
639 lines
17 KiB
JavaScript
import path from 'path'
|
|
import cprog from 'child_process'
|
|
import crypto from 'crypto'
|
|
import notp from 'notp'
|
|
import base32 from 'thirty-two'
|
|
import { v1 as uuidV1 } from 'uuid'
|
|
import fs from 'fs-extra'
|
|
|
|
import config from '../../scripts/load-config'
|
|
import { httpPOST } from '../../scripts/http'
|
|
import * as models from './models'
|
|
import { pushMail } from './emailer'
|
|
|
|
const emailRe = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
|
|
|
|
// Fork a bcrypt process to hash and compare passwords
|
|
function bcryptTask (data) {
|
|
return new Promise((resolve, reject) => {
|
|
const proc = cprog.fork(path.join(__dirname, '../../scripts', 'bcrypt.js'))
|
|
let done = false
|
|
proc.send(data.task + ' ' + JSON.stringify(data))
|
|
proc.on('message', (chunk) => {
|
|
if (chunk == null) return reject(new Error('No response'))
|
|
const line = chunk.toString().trim()
|
|
done = true
|
|
if (line === 'error') {
|
|
return reject(new Error(line))
|
|
}
|
|
if (line === 'true' || line === 'false') {
|
|
return resolve(line === 'true')
|
|
}
|
|
resolve(line)
|
|
})
|
|
proc.on('exit', () => {
|
|
if (!done) {
|
|
reject(new Error('No response'))
|
|
}
|
|
})
|
|
})
|
|
}
|
|
|
|
// Make sure an object contains the keys specified in `required`
|
|
function keysAvailable (object, required) {
|
|
let found = true
|
|
|
|
for (const i in required) {
|
|
const key = required[i]
|
|
if (object[key] == null) {
|
|
found = false
|
|
}
|
|
}
|
|
|
|
return found
|
|
}
|
|
|
|
// Clean up the donation responses for ease of use
|
|
async function cleanUpDonation (obj, mcOnly, timeframe) {
|
|
if (timeframe && new Date(obj.created_at).getTime() < timeframe) {
|
|
return null
|
|
}
|
|
|
|
let user
|
|
|
|
if (obj.user_id) {
|
|
user = await User.get(obj.user_id)
|
|
}
|
|
|
|
const result = {
|
|
trackId: obj.id,
|
|
amount: obj.amount,
|
|
donated: obj.created_at
|
|
}
|
|
|
|
if (user) {
|
|
result.name = user.display_name
|
|
}
|
|
|
|
const sources = obj.source.split(',')
|
|
for (const i in sources) {
|
|
if (sources[i].indexOf('mcu:') === 0) {
|
|
const mcu = sources[i].split(':')[1]
|
|
if (mcu.match(/^([\w_]{2,16})$/i)) {
|
|
result.minecraft_username = mcu
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!result.minecraft_username && mcOnly) return null
|
|
|
|
return result
|
|
}
|
|
|
|
const txnStore = []
|
|
|
|
export function Hash (len) {
|
|
return crypto.randomBytes(len).toString('hex')
|
|
}
|
|
|
|
/* ppp - Posts Per Page; dcount - Post Count; page - number of current page */
|
|
export function Pagination (ppp, dcount, page) {
|
|
if (!ppp) ppp = 5
|
|
if (!dcount) return null
|
|
|
|
const pageCount = Math.ceil(dcount / ppp)
|
|
if (page > pageCount) page = pageCount
|
|
|
|
const offset = (page - 1) * ppp
|
|
|
|
return {
|
|
page: page,
|
|
perPage: ppp,
|
|
pages: pageCount,
|
|
offset: offset,
|
|
total: dcount
|
|
}
|
|
}
|
|
|
|
export class Login {
|
|
static async password (user, password) {
|
|
user = await User.ensureObject(user, ['password'])
|
|
if (!user.password) return false
|
|
return bcryptTask({ task: 'compare', password: password, hash: user.password })
|
|
}
|
|
|
|
static async activationToken (token) {
|
|
let getToken = await models.Token.query().where('token', token).andWhere('type', 1)
|
|
if (!getToken || !getToken.length) return false
|
|
|
|
getToken = getToken[0]
|
|
|
|
if (getToken.expires_at && new Date(getToken.expires_at).getTime() < Date.now()) return false
|
|
|
|
const user = await User.get(getToken.user_id)
|
|
if (!user) return false
|
|
|
|
await models.User.query().patchAndFetchById(user.id, { activated: 1 })
|
|
await models.Token.query().delete().where('id', getToken.id)
|
|
return true
|
|
}
|
|
|
|
static async totpTokenRequired (user) {
|
|
const getToken = await models.TotpToken.query().where('user_id', user.id)
|
|
|
|
if (!getToken || !getToken.length) return false
|
|
if (getToken[0].activated !== 1) return false
|
|
|
|
return true
|
|
}
|
|
|
|
static async totpCheck (user, code, emerg) {
|
|
user = await User.ensureObject(user)
|
|
let getToken = await models.TotpToken.query().where('user_id', user.id)
|
|
if (!getToken || !getToken.length) return false
|
|
getToken = getToken[0]
|
|
|
|
if (emerg) {
|
|
if (emerg === getToken.recovery_code) {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
const login = notp.totp.verify(code, getToken.token, {})
|
|
|
|
if (login) {
|
|
if (login.delta !== 0) {
|
|
return false
|
|
}
|
|
|
|
if (getToken.activated !== 1) {
|
|
// TODO: Send an email including the recovery code to the user
|
|
await models.TotpToken.query().patchAndFetchById(getToken.id, { activated: true })
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
static async purgeTotp (user, password) {
|
|
user = await User.ensureObject(user, ['password'])
|
|
const pwmatch = await Login.password(user, password)
|
|
if (!pwmatch) return false
|
|
|
|
// TODO: Inform user via email
|
|
await models.TotpToken.query().delete().where('user_id', user.id)
|
|
|
|
return true
|
|
}
|
|
|
|
static async totpAquire (user) {
|
|
user = await User.ensureObject(user, ['password'])
|
|
|
|
// Do not allow totp for users who have registered using an external service
|
|
if (!user.password || user.password === '') return null
|
|
|
|
// Get existing tokens for the user and delete them if found
|
|
const getToken = await models.TotpToken.query().where('user_id', user.id)
|
|
if (getToken && getToken.length) {
|
|
await models.TotpToken.query().delete().where('user_id', user.id)
|
|
}
|
|
|
|
const newToken = {
|
|
user_id: user.id,
|
|
token: Hash(16),
|
|
recovery_code: Hash(8),
|
|
activated: 0,
|
|
created_at: new Date()
|
|
}
|
|
|
|
const hashed = base32.encode(newToken.token)
|
|
const domain = 'icynet.eu'
|
|
|
|
await models.TotpToken.query().insert(newToken)
|
|
|
|
const uri = encodeURIComponent('otpauth://totp/' + user.username + '@' + domain + '?secret=' + hashed)
|
|
|
|
return uri
|
|
}
|
|
}
|
|
|
|
export class OAuth2 {
|
|
static async getUserAuthorizations (user) {
|
|
user = await User.ensureObject(user)
|
|
const auths = await models.OAuth2AuthorizedClient.query().where('user_id', user.id)
|
|
|
|
const nicelist = []
|
|
|
|
for (const i in auths) {
|
|
const auth = auths[i]
|
|
let client = await models.OAuth2Client.query().where('id', auth.client_id)
|
|
|
|
if (!client.length) continue
|
|
client = client[0]
|
|
|
|
const obj = {
|
|
id: client.id,
|
|
title: client.title,
|
|
description: client.description,
|
|
url: client.url,
|
|
icon: client.icon,
|
|
scope: client.scope.split(' '),
|
|
created_at: auth.created_at,
|
|
expires_at: auth.expires_at
|
|
}
|
|
nicelist.push(obj)
|
|
}
|
|
|
|
return nicelist
|
|
}
|
|
|
|
static async removeUserAuthorization (user, clientId) {
|
|
user = await User.ensureObject(user)
|
|
const auth = await models.OAuth2AuthorizedClient.query().where('user_id', user.id).andWhere('client_id', clientId)
|
|
if (!auth.length) return false
|
|
|
|
await models.OAuth2AccessToken.query().delete().where('client_id', clientId).andWhere('user_id', user.id)
|
|
await models.OAuth2RefreshToken.query().delete().where('client_id', clientId).andWhere('user_id', user.id)
|
|
|
|
for (const i in auth) {
|
|
await models.OAuth2AuthorizedClient.query().delete().where('id', auth[i].id)
|
|
}
|
|
|
|
return true
|
|
}
|
|
}
|
|
|
|
export class Register {
|
|
static async hashPassword (password) {
|
|
return bcryptTask({ task: 'hash', password: password })
|
|
}
|
|
|
|
static validateEmail (email) {
|
|
return emailRe.test(email)
|
|
}
|
|
|
|
static async newAccount (regdata) {
|
|
const email = config.email && config.email.enabled
|
|
const data = Object.assign(regdata, {
|
|
created_at: new Date(),
|
|
updated_at: new Date(),
|
|
uuid: uuidV1(),
|
|
activated: email ? 0 : 1
|
|
})
|
|
|
|
const userTest = await User.get(regdata.username)
|
|
if (userTest) {
|
|
throw new Error('This username is already taken!')
|
|
}
|
|
|
|
const emailTest = await User.get(regdata.email)
|
|
if (emailTest) {
|
|
throw new Error('This email address is already registered!')
|
|
}
|
|
|
|
// Create user
|
|
const user = await models.User.query().insert(data)
|
|
|
|
if (email) {
|
|
await Register.activationEmail(user, true)
|
|
}
|
|
|
|
return user
|
|
}
|
|
|
|
static async activationEmail (user, deleteOnFail = false) {
|
|
// Activation token
|
|
const activationToken = Hash(16)
|
|
|
|
await models.Token.query().insert({
|
|
expires_at: new Date(Date.now() + 86400000), // 1 day
|
|
token: activationToken,
|
|
user_id: user.id,
|
|
type: 1
|
|
})
|
|
|
|
console.debug('Activation token:', activationToken)
|
|
|
|
// Send Activation Email
|
|
try {
|
|
const em = await pushMail('activate', user.email, {
|
|
domain: config.server.domain,
|
|
display_name: user.display_name,
|
|
activation_token: activationToken
|
|
})
|
|
|
|
console.debug(em)
|
|
} catch (e) {
|
|
console.error(e)
|
|
|
|
if (deleteOnFail) {
|
|
await models.User.query().delete().where('id', user.id)
|
|
}
|
|
|
|
throw new Error('Invalid email address!')
|
|
}
|
|
|
|
return true
|
|
}
|
|
}
|
|
|
|
export class Reset {
|
|
static async reset (email, passRequired = true) {
|
|
const emailEnabled = config.email && config.email.enabled
|
|
|
|
if (!emailEnabled) throw new Error('Cannot reset password.')
|
|
|
|
const user = await User.get(email)
|
|
if (!user) throw new Error('This email address does not match any user in our database.')
|
|
if (!user.password && passRequired) throw new Error('The user associated with this email address has used an external website to log in, thus the password cannot be reset.')
|
|
|
|
const recentTokens = await models.Token.query().where('user_id', user.id).andWhere('expires_at', '>', new Date()).andWhere('type', 2)
|
|
if (recentTokens.length >= 2) {
|
|
throw new Error('You\'ve made too many reset requests recently. Please slow down.')
|
|
}
|
|
|
|
const resetToken = Hash(16)
|
|
await models.Token.query().insert({
|
|
expires_at: new Date(Date.now() + 86400000), // 1 day
|
|
token: resetToken,
|
|
user_id: user.id,
|
|
type: 2
|
|
})
|
|
|
|
// Send Reset Email
|
|
console.debug('Reset token:', resetToken)
|
|
if (email) {
|
|
try {
|
|
const em = await pushMail('reset_password', user.email, {
|
|
domain: config.server.domain,
|
|
display_name: user.display_name,
|
|
reset_token: resetToken
|
|
})
|
|
|
|
console.debug(em)
|
|
} catch (e) {
|
|
console.error(e)
|
|
throw new Error('Invalid email address!')
|
|
}
|
|
}
|
|
|
|
return resetToken
|
|
}
|
|
|
|
static async resetToken (token) {
|
|
let getToken = await models.Token.query().where('token', token).andWhere('type', 2)
|
|
if (!getToken || !getToken.length) return null
|
|
|
|
getToken = getToken[0]
|
|
|
|
if (getToken.expires_at && new Date(getToken.expires_at).getTime() < Date.now()) return null
|
|
|
|
const user = await User.get(getToken.user_id)
|
|
if (!user) return null
|
|
|
|
return user
|
|
}
|
|
|
|
static async changePassword (user, password, token) {
|
|
const hashed = await Register.hashPassword(password)
|
|
|
|
await models.User.query().patchAndFetchById(user.id, { password: hashed, updated_at: new Date() })
|
|
await models.Token.query().delete().where('token', token)
|
|
|
|
return true
|
|
}
|
|
}
|
|
|
|
export class User {
|
|
static async get (identifier) {
|
|
let scope = 'id'
|
|
if (typeof identifier === 'string') {
|
|
scope = 'username'
|
|
if (identifier.indexOf('@') !== -1) {
|
|
scope = 'email'
|
|
} else if (identifier.length === 36 && identifier.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i)) {
|
|
scope = 'uuid'
|
|
}
|
|
} else if (typeof identifier === 'object') {
|
|
if (identifier.id != null) {
|
|
identifier = identifier.id
|
|
} else if (identifier.username) {
|
|
scope = 'username'
|
|
identifier = identifier.username
|
|
} else {
|
|
return null
|
|
}
|
|
}
|
|
|
|
const user = await models.User.query().where(scope, identifier)
|
|
if (!user.length) return null
|
|
|
|
return user[0]
|
|
}
|
|
|
|
static async ensureObject (user, fieldsPresent = ['id']) {
|
|
if (typeof user !== 'object' || !keysAvailable(user, fieldsPresent)) {
|
|
return User.get(user)
|
|
}
|
|
|
|
if (user.id) {
|
|
return user
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
static async socialStatus (user) {
|
|
user = await User.ensureObject(user, ['password'])
|
|
if (!user) return null
|
|
const external = await models.External.query().orderBy('created_at', 'asc').where('user_id', user.id)
|
|
const enabled = {}
|
|
|
|
for (const i in external) {
|
|
const ext = external[i]
|
|
enabled[ext.service] = true
|
|
}
|
|
|
|
const accountSourceIsExternal = user.password === null || user.password === ''
|
|
const obj = {
|
|
enabled: enabled,
|
|
password: !accountSourceIsExternal
|
|
}
|
|
|
|
if (accountSourceIsExternal) {
|
|
obj.source = external[0].service
|
|
}
|
|
|
|
return obj
|
|
}
|
|
|
|
static async update (user, data) {
|
|
user = await User.ensureObject(user)
|
|
if (!user) throw new Error('No such user.')
|
|
|
|
data = Object.assign({
|
|
updated_at: new Date()
|
|
}, data)
|
|
|
|
return models.User.query().patchAndFetchById(user.id, data)
|
|
}
|
|
|
|
static async changeAvatar (user, fileName) {
|
|
user = await User.ensureObject(user, ['avatar_file'])
|
|
const uploadsDir = path.join(__dirname, '../../', 'usercontent', 'images')
|
|
const pathOf = path.join(uploadsDir, fileName)
|
|
|
|
if (!await fs.pathExists(pathOf)) {
|
|
throw new Error('No such file')
|
|
}
|
|
|
|
// Delete previous upload
|
|
if (user.avatar_file != null) {
|
|
const file = path.join(uploadsDir, user.avatar_file)
|
|
if (await fs.pathExists(file)) {
|
|
await fs.unlink(file)
|
|
}
|
|
}
|
|
|
|
await User.update(user, { avatar_file: fileName })
|
|
return fileName
|
|
}
|
|
|
|
static async removeAvatar (user) {
|
|
user = await User.ensureObject(user, ['avatar_file'])
|
|
const uploadsDir = path.join(__dirname, '../../', 'usercontent', 'images')
|
|
if (!user.avatar_file) return {}
|
|
|
|
const file = path.join(uploadsDir, user.avatar_file)
|
|
if (await fs.pathExists(file)) {
|
|
await fs.unlink(file)
|
|
}
|
|
|
|
return User.update(user, { avatar_file: null })
|
|
}
|
|
|
|
static async getBanStatus (field, ip = false) {
|
|
let bans
|
|
if (ip === true) {
|
|
bans = await models.Ban.query().where('associated_ip', field)
|
|
} else {
|
|
bans = await models.Ban.query().where('user_id', field)
|
|
}
|
|
|
|
const bansActive = []
|
|
|
|
for (const i in bans) {
|
|
const ban = bans[i]
|
|
|
|
// Check expiry
|
|
if (ban.expires_at && new Date(ban.expires_at).getTime() < Date.now()) continue
|
|
|
|
const banInfo = {
|
|
banned: ban.created_at,
|
|
reason: ban.reason,
|
|
expiry: ban.expires_at
|
|
}
|
|
|
|
bansActive.push(banInfo)
|
|
}
|
|
|
|
return bansActive
|
|
}
|
|
}
|
|
|
|
export class Payment {
|
|
static async handleIPN (body) {
|
|
const sandboxed = body.test_ipn === '1'
|
|
const url = 'https://ipnpb.' + (sandboxed ? 'sandbox.' : '') + 'paypal.com/cgi-bin/webscr'
|
|
|
|
console.debug('Incoming payment')
|
|
const verification = await httpPOST(url, {}, Object.assign({
|
|
cmd: '_notify-validate'
|
|
}, body))
|
|
|
|
if (verification !== 'VERIFIED') return null
|
|
|
|
// Ignore the adding of non-on-site donations
|
|
if (body.item_name && config.donations.name && body.item_name !== config.donations.name) {
|
|
return true
|
|
}
|
|
|
|
if (sandboxed) {
|
|
console.debug('Sandboxed payment:', body)
|
|
} else {
|
|
console.debug('IPN Verified Notification:', body)
|
|
}
|
|
|
|
if (body.txn_id) {
|
|
if (txnStore.indexOf(body.txn_id) !== -1) return true
|
|
txnStore.push(body.txn_id)
|
|
}
|
|
|
|
let user
|
|
const source = []
|
|
if (sandboxed) {
|
|
source.push('virtual')
|
|
}
|
|
|
|
// TODO: add hooks
|
|
const custom = body.custom.split(',')
|
|
for (const i in custom) {
|
|
const str = custom[i]
|
|
if (str.indexOf('userid:') === 0) {
|
|
body.user_id = parseInt(str.split(':')[1])
|
|
} else if (str.indexOf('mcu:') === 0) {
|
|
source.push('mcu:' + str.split(':')[1])
|
|
}
|
|
}
|
|
|
|
if (body.user_id != null) {
|
|
user = await User.get(body.user_id)
|
|
} else if (body.payer_email != null) {
|
|
user = await User.get(body.payer_email)
|
|
}
|
|
|
|
const donation = {
|
|
user_id: user ? user.id : null,
|
|
amount: (body.mc_gross || body.payment_gross || 'Unknown') + ' ' + (body.mc_currency || 'EUR'),
|
|
source: source.join(','),
|
|
note: body.memo || '',
|
|
read: 0,
|
|
created_at: new Date(body.payment_date)
|
|
}
|
|
|
|
console.log('Server receieved a successful PayPal IPN message.')
|
|
|
|
return models.Donation.query().insert(donation)
|
|
}
|
|
|
|
static async userContributions (user) {
|
|
user = await User.ensureObject(user)
|
|
|
|
const dbq = await models.Donation.query().orderBy('created_at', 'desc').where('user_id', user.id)
|
|
const contribs = []
|
|
|
|
for (const i in dbq) {
|
|
contribs.push(await cleanUpDonation(dbq[i]))
|
|
}
|
|
|
|
return contribs
|
|
}
|
|
|
|
static async allContributions (count, mcOnly, timeframe = 0) {
|
|
const dbq = await models.Donation.query().orderBy('created_at', 'desc').limit(count)
|
|
const contribs = []
|
|
|
|
for (const i in dbq) {
|
|
const obj = await cleanUpDonation(dbq[i], mcOnly, timeframe)
|
|
if (!obj) continue
|
|
contribs.push(obj)
|
|
}
|
|
|
|
return contribs
|
|
}
|
|
}
|