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 } }