This repository has been archived on 2022-11-26. You can view files and clone it, but cannot push or open issues or pull requests.
IcyNet.eu/server/api/index.js

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