diff --git a/package-lock.json b/package-lock.json index aefe5e1..e3ca338 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2104,6 +2104,16 @@ "version": "https://registry.npmjs.org/fs-exists-sync/-/fs-exists-sync-0.1.0.tgz", "integrity": "sha1-mC1ok6+RjnLQjeyehnP/K1qNat0=" }, + "fs-extra": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-4.0.2.tgz", + "integrity": "sha1-+RcExT0bRh+JNFKwwwfZmXZHq2s=", + "requires": { + "graceful-fs": "4.1.11", + "jsonfile": "4.0.0", + "universalify": "0.1.1" + } + }, "fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -2269,8 +2279,7 @@ "graceful-fs": { "version": "4.1.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.1.11.tgz", - "integrity": "sha1-Dovf5NHduIVNZOBOp8AOKgJuVlg=", - "dev": true + "integrity": "sha1-Dovf5NHduIVNZOBOp8AOKgJuVlg=" }, "graceful-readlink": { "version": "https://registry.npmjs.org/graceful-readlink/-/graceful-readlink-1.0.1.tgz", @@ -2822,6 +2831,14 @@ "version": "https://registry.npmjs.org/json5/-/json5-0.5.1.tgz", "integrity": "sha1-Hq3nrMASA0rYTiOWdn6tn6VJWCE=" }, + "jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=", + "requires": { + "graceful-fs": "4.1.11" + } + }, "jsonify": { "version": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.0.tgz", "integrity": "sha1-LHS27kHZPKUbe1qu6PUDYx0lKnM=" @@ -5082,6 +5099,11 @@ "integrity": "sha1-sxxa6CVIRKOoKBVBzisEuGWnNP8=", "dev": true }, + "universalify": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.1.tgz", + "integrity": "sha1-+nG63UQ3r0wUiEHjs7Fl+enlkLc=" + }, "unpipe": { "version": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=" diff --git a/package.json b/package.json index 6aae3eb..8b3a1f3 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "express": "^4.15.3", "express-rate-limit": "^2.9.0", "express-session": "^1.15.3", + "fs-extra": "^4.0.2", "gm": "^1.23.0", "knex": "^0.13.0", "multiparty": "^4.1.3", diff --git a/scripts/existsSync.js b/scripts/existsSync.js deleted file mode 100644 index 42adacd..0000000 --- a/scripts/existsSync.js +++ /dev/null @@ -1,17 +0,0 @@ -import path from 'path' -import Promise from 'bluebird' -import fs from 'fs' - -const access = Promise.promisify(fs.access) - -async function exists (fpath) { - try { - await access(path.resolve(fpath)) - } catch (e) { - return false - } - - return true -} - -module.exports = exists diff --git a/scripts/logger.js b/scripts/logger.js index 516d645..30c998d 100644 --- a/scripts/logger.js +++ b/scripts/logger.js @@ -1,9 +1,7 @@ import config from './load-config' import path from 'path' import util from 'util' - -import Promise from 'bluebird' -const fs = Promise.promisifyAll(require('fs')) +import fs from 'fs-extra' let lfs @@ -21,16 +19,15 @@ function dateFormat (date) { } // Console.log/error/warn "middleware" - add timestamp and write to file -function stampAndWrite (fnc, prfx, message) { +function stampAndWrite (fnc, color, prfx, message) { let prefix = '[' + prfx + '] [' + dateFormat(new Date()) + '] ' - message = prefix + message + message = color + prefix + message + message = message.replace(/\\u001b/g, '\x1b') if (lfs) { - lfs.write(message + '\n') + lfs.write(message.replace(/(\u001b\[\d\d?m)/g, '') + '\n') } - message = message.replace(/\\u001b/g, '\x1b') - fnc.call(this, message) } @@ -38,26 +35,26 @@ function stampAndWrite (fnc, prfx, message) { const realConsoleLog = console.log console.log = function () { let message = util.format.apply(null, arguments) - stampAndWrite.call(this, realConsoleLog, 'info', message) + stampAndWrite.call(this, realConsoleLog, '', 'info', message) } const realConsoleWarn = console.warn console.warn = function () { let message = util.format.apply(null, arguments) - stampAndWrite.call(this, realConsoleWarn, 'warn', message) + stampAndWrite.call(this, realConsoleWarn, '\x1b[33m', 'warn', message) } const realConsoleError = console.error console.error = function () { let message = util.format.apply(null, arguments) - stampAndWrite.call(this, realConsoleError, ' err', message) + stampAndWrite.call(this, realConsoleError, '\x1b[31m', ' err', message) } async function initializeLogger () { let logPath = path.resolve(config.logger.file) try { - await fs.accessAsync(logPath, fs.W_OK) + await fs.access(logPath, fs.W_OK) lfs = fs.createWriteStream(logPath, {flags: 'a'}) } catch (e) { lfs = null diff --git a/server/api/admin.js b/server/api/admin.js index cbc9b98..79f8b80 100644 --- a/server/api/admin.js +++ b/server/api/admin.js @@ -84,7 +84,7 @@ const API = { getAllUsers: async function (page, adminId) { let count = await Models.User.query().count('id as ids') if (!count.length || !count[0]['ids'] || isNaN(page)) { - return { error: 'No users found' } + return { error: 'No users found in database' } } count = count[0].ids @@ -108,7 +108,7 @@ const API = { getAllClients: async function (page) { let count = await Models.OAuth2Client.query().count('id as ids') if (!count.length || !count[0]['ids'] || isNaN(page)) { - return { error: 'No clients found' } + return { error: 'No clients' } } count = count[0].ids @@ -141,7 +141,7 @@ const API = { ] data = dataFilter(data, fields, ['scope', 'verified']) - if (!data) return { error: 'Missing fields' } + if (!data) throw new Error('Missing fields') data.verified = (data.verified != null ? 1 : 0) @@ -149,20 +149,20 @@ const API = { await Models.OAuth2Client.query().patchAndFetchById(id, data) await Models.OAuth2AuthorizedClient.query().delete().where('client_id', id) } catch (e) { - return { error: 'No such client' } + throw new Error('No such client') } return {} }, // Create a new secret for a client newSecret: async function (id) { - if (isNaN(id)) return { error: 'Invalid client ID' } + if (isNaN(id)) throw new Error('Invalid client ID') let secret = Users.Hash(16) try { await Models.OAuth2Client.query().patchAndFetchById(id, {secret: secret}) } catch (e) { - return { error: 'No such client' } + throw new Error('No such client') } return {} @@ -174,7 +174,7 @@ const API = { ] data = dataFilter(data, fields, ['scope']) - if (!data) return { error: 'Missing fields' } + if (!data) throw new Error('Missing fields') let obj = Object.assign({ secret: Users.Hash(16), @@ -187,7 +187,7 @@ const API = { }, // Remove a client and all associated data removeClient: async function (id) { - if (isNaN(id)) return {error: 'Invalid ID number'} + if (isNaN(id)) throw new Error('Invalid ID number') await Models.OAuth2Client.query().delete().where('id', id) await Models.OAuth2AuthorizedClient.query().delete().where('client_id', id) await Models.OAuth2AccessToken.query().delete().where('client_id', id) @@ -198,7 +198,7 @@ const API = { getAllBans: async function (page) { let count = await Models.Ban.query().count('id as ids') if (!count.length || !count[0]['ids'] || isNaN(page)) { - return {error: 'No bans on record'} + return { error: 'No bans on record' } } count = count[0].ids @@ -225,12 +225,12 @@ const API = { addBan: async function (data, adminId) { let user = await Users.User.get(parseInt(data.user_id)) - if (!user) return {error: 'No such user.'} - if (user.id === adminId) return {error: 'Cannot ban yourself!'} + if (!user) throw new Error('No such user.') + if (user.id === adminId) throw new Error('Cannot ban yourself!') let admin = await Users.User.get(adminId) - if (user.nw_privilege > admin.nw_privilege) return {error: 'Cannot ban user.'} + if (user.nw_privilege > admin.nw_privilege) throw new Error('Cannot ban user.') let banAdd = { reason: data.reason || 'Unspecified ban', diff --git a/server/api/emailer.js b/server/api/emailer.js index 1779a1b..0135742 100644 --- a/server/api/emailer.js +++ b/server/api/emailer.js @@ -1,6 +1,7 @@ import {EmailTemplate} from 'email-templates' import path from 'path' import nodemailer from 'nodemailer' + import config from '../../scripts/load-config' const templateDir = path.join(__dirname, '../../', 'templates') diff --git a/server/api/external.js b/server/api/external.js index 4ef9c5c..74c44dd 100644 --- a/server/api/external.js +++ b/server/api/external.js @@ -1,20 +1,27 @@ +import qs from 'querystring' +import oauth from 'oauth-libre' +import uuidV1 from 'uuid/v1' +import crypto from 'crypto' + import config from '../../scripts/load-config' import http from '../../scripts/http' import models from './models' +import Image from './image' import UAPI from './index' -import qs from 'querystring' -import oauth from 'oauth-libre' -import path from 'path' -import url from 'url' -import uuidV1 from 'uuid/v1' -const imgdir = path.join(__dirname, '../../', 'usercontent', 'images') +const userFields = ['username', 'email', 'avatar_file', 'display_name', 'ip_address'] let twitterApp let discordApp const API = { Common: { + // Generate a hash based on the current session + stateGenerator: (req) => { + let sessionCrypto = req.session.id + ':' + config.server.session_secret + return crypto.createHash('sha256').update(sessionCrypto).digest('hex') + }, + // Find an user with an external ID getExternal: async (service, identifier) => { let extr = await models.External.query().where('service', service).andWhere('identifier', identifier) if (!extr || !extr.length) return null @@ -30,10 +37,12 @@ const API = { return extr }, + // Get user ban status getBan: async (user, ipAddress) => { let banList = await UAPI.User.getBanStatus(ipAddress || user.id, ipAddress != null) return banList }, + // Create a new `external` instance for a user new: async (service, identifier, user) => { let data = { user_id: user.id, @@ -45,6 +54,7 @@ const API = { await await models.External.query().insert(data) return true }, + // Create a new user newUser: async (service, identifier, data) => { let udataLimited = Object.assign({ activated: 1, @@ -53,27 +63,38 @@ const API = { uuid: uuidV1() }, data) + // Some data cleanups + + // Limit display name length + udataLimited.display_name = udataLimited.display_name.substring(0, 32) + + // Remove illegal characters from the username + udataLimited.username = udataLimited.username.replace(/\W+/gi, '') + + // Limit user name length + udataLimited.username = udataLimited.username.substring(0, 26) + // Check if the username is already taken - if (await UAPI.User.get(udataLimited.username) != null) { + if (await UAPI.User.get(udataLimited.username) != null || udataLimited.username.length < 4) { udataLimited.username = udataLimited.username + UAPI.Hash(4) } // Check if the email given to us is already registered, if so, - // associate an external node with the user bearing the email + // tell them to log in first. if (udataLimited.email && udataLimited.email !== '') { let getByEmail = await UAPI.User.get(udataLimited.email) if (getByEmail) { - await API.Common.new(service, identifier, getByEmail) - return {error: null, user: getByEmail} + throw new Error('An user with this email address is already registered, but this external account is are not linked. If you wish to link the account, please log in first.') } } - // Create a new user based on the information we got from Facebook + // Create a new user based on the information we got from an external service let newUser = await models.User.query().insert(udataLimited) await API.Common.new(service, identifier, newUser) return newUser }, + // Remove an `external` object (thus unlinking from a service) remove: async (user, service) => { user = await UAPI.User.ensureObject(user, ['password']) let userExterns = await models.External.query().orderBy('created_at', 'asc').where('user_id', user.id) @@ -89,30 +110,85 @@ const API = { return models.External.query().delete().where('user_id', user.id).andWhere('service', service) }, - saveAvatar: async (avatarUrl) => { - if (!avatarUrl) return null - let imageName = 'download-' + UAPI.Hash(12) - let uridata = url.parse(avatarUrl) - let pathdata = path.parse(uridata.path) + // Common code for all auth callbacks + callback: async (identifier, uid, user, ipAddress, remoteData, avatarFunc) => { + let exists = await API.Common.getExternal(identifier, uid) - imageName += pathdata.ext || '.png' + if (user) { + // Get bans for user + let bans = await API.Common.getBan(user) + if (bans.length) return { banned: bans, ip: false } - try { - await http.Download(avatarUrl, path.join(imgdir, imageName)) - } catch (e) { - return null + if (exists) return {error: null, user: user} + + await API.Common.new(identifier, uid, user) + return {error: null, user: user} } - return {fileName: imageName} + // Callback succeeded with user id and the external table exists, we log in the user + if (exists) { + // Get bans for user + let bans = await API.Common.getBan(exists.user) + + if (bans.length) return { banned: bans, ip: false } + return {error: null, user: exists.user} + } + + // Get bans for IP address + let bans = await API.Common.getBan(null, ipAddress) + if (bans.length) return { banned: bans, ip: true } + + // Run the function for avatar fetching + let avatar = null + if (avatarFunc) { + avatar = await avatarFunc(remoteData) + } + + // Assign the data + let newUData = Object.assign({ + email: remoteData.email || '', + avatar_file: avatar, + ip_address: ipAddress + }, remoteData) + + // Remove unnecessary fields + for (let i in newUData) { + if (userFields.indexOf(i) === -1) { + delete newUData[i] + } + } + + let newUser + try { + newUser = await API.Common.newUser(identifier, uid, newUData) + } catch (e) { + return {error: e.message} + } + + return {error: null, user: newUser} } }, Facebook: { - callback: async (user, data) => { - if (!data.authResponse || data.status !== 'connected') { + getAvatar: async (rawData) => { + let profilepic = null + + if (rawData.picture) { + if (rawData.picture.is_silhouette === false && rawData.picture.url) { + let imgdata = await Image.downloadImage(rawData.picture.url) + if (imgdata && imgdata.fileName) { + profilepic = imgdata.fileName + } + } + } + + return profilepic + }, + callback: async (user, authResponse, ipAddress) => { + if (!authResponse) { return {error: 'No Authorization'} } - let uid = data.authResponse.userID + let uid = authResponse.userID if (!uid) { return {error: 'No Authorization'} } @@ -120,7 +196,7 @@ const API = { // Get facebook user information in order to create a new user or verify let fbdata let intel = { - access_token: data.authResponse.accessToken, + access_token: authResponse.accessToken, fields: 'name,email,picture,short_name' } @@ -135,57 +211,28 @@ const API = { return {error: fbdata.error.message} } - let exists = await API.Common.getExternal('fb', uid) - - if (user) { - // Get bans for user - let bans = await API.Common.getBan(user) - if (bans.length) return { banned: bans, ip: false } - - if (exists) return {error: null, user: user} - - await API.Common.new('fb', uid, user) - return {error: null, user: user} - } - - // Callback succeeded with user id and the external table exists, we log in the user - if (exists) { - // Get bans for user - let bans = await API.Common.getBan(exists.user) - if (bans.length) return { banned: bans, ip: false } - return {error: null, user: exists.user} - } - - // Get bans for IP address - let bans = await API.Common.getBan(null, data.ip_address) - if (bans.length) return { banned: bans, ip: true } - - // Determine profile picture - let profilepic = null - if (fbdata.picture) { - if (fbdata.picture.is_silhouette === false && fbdata.picture.url) { - let imgdata = await API.Common.saveAvatar(fbdata.picture.url) - if (imgdata && imgdata.fileName) { - profilepic = imgdata.fileName - } - } - } - - let newUData = { + let cleanedData = Object.assign(fbdata, { username: fbdata.short_name || 'FB' + UAPI.Hash(4), display_name: fbdata.name, - email: fbdata.email || '', - avatar_file: profilepic, - ip_address: data.ip_address - } + email: fbdata.email || '' + }) - let newUser = await API.Common.newUser('fb', uid, newUData) - if (!newUser) return {error: 'Failed to create user.'} - - return {error: null, user: newUser} + return API.Common.callback('facebook', uid, user, ipAddress, cleanedData, API.Facebook.getAvatar) } }, Twitter: { + getAvatar: async function (rawData) { + let profilepic = null + + if (rawData.profile_image_url_https) { + let imgdata = await Image.downloadImage(rawData.profile_image_url_https) + if (imgdata && imgdata.fileName) { + profilepic = imgdata.fileName + } + } + + return profilepic + }, oauthApp: function () { if (!twitterApp) { let redirectUri = config.server.domain + '/api/external/twitter/callback' @@ -243,56 +290,28 @@ const API = { } let uid = twdata.id_str - let exists = await API.Common.getExternal('twitter', uid) - if (user) { - // Get bans for user - let bans = await API.Common.getBan(user) - if (bans.length) return { banned: bans, ip: false } + let cleanedData = Object.assign(twdata, { + username: twdata.screen_name, + display_name: twdata.name, + email: twdata.email || '' + }) - if (exists) return {error: null, user: user} - - await API.Common.new('twitter', uid, user) - return {error: null, user: user} - } - - // Callback succeeded with user id and the external table exists, we log in the user - if (exists) { - // Get bans for user - let bans = await API.Common.getBan(exists.user) - if (bans.length) return { banned: bans, ip: false } - return {error: null, user: exists.user} - } - - // Get bans for IP - let bans = await API.Common.getBan(null, ipAddress) - if (bans.length) return { banned: bans, ip: true } - - // Determine profile picture + return API.Common.callback('twitter', uid, user, ipAddress, cleanedData, API.Twitter.getAvatar) + } + }, + Google: { + getAvatar: async (rawData) => { let profilepic = null - if (twdata.profile_image_url_https) { - let imgdata = await API.Common.saveAvatar(twdata.profile_image_url_https) + if (rawData.image) { + let imgdata = await Image.downloadImage(rawData.image) if (imgdata && imgdata.fileName) { profilepic = imgdata.fileName } } - // Create a new user - let newUData = { - username: twdata.screen_name, - display_name: twdata.name, - email: twdata.email || '', - avatar_file: profilepic, - ip_address: ipAddress - } - - let newUser = await API.Common.newUser('twitter', uid, newUData) - if (!newUser) return {error: 'Failed to create user.'} - - return {error: null, user: newUser} - } - }, - Google: { + return profilepic + }, callback: async (user, data, ipAddress) => { let uid @@ -312,56 +331,32 @@ const API = { return {error: e.message} } - let exists = await API.Common.getExternal('google', uid) - - if (user) { - // Get bans for user - let bans = await API.Common.getBan(user) - if (bans.length) return { banned: bans, ip: false } - - if (exists) return {error: null, user: user} - - await API.Common.new('google', uid, user) - return {error: null, user: user} - } - - // Callback succeeded with user id and the external table exists, we log in the user - if (exists) { - // Get bans for user - let bans = await API.Common.getBan(exists.user) - if (bans.length) return { banned: bans, ip: false } - return {error: null, user: exists.user} - } - - // Get bans for IP - let bans = await API.Common.getBan(null, ipAddress) - if (bans.length) return { banned: bans, ip: true } - - // Determine profile picture - let profilepic = null - if (data.image) { - let imgdata = await API.Common.saveAvatar(data.image) - if (imgdata && imgdata.fileName) { - profilepic = imgdata.fileName - } - } - - // Create a new user - let newUData = { - username: data.name.replace(/\W+/gi, ''), + let cleanedData = Object.assign(data, { + username: data.name, display_name: data.name, - email: data.email || '', - avatar_file: profilepic, - ip_address: ipAddress - } + email: data.email || '' + }) - let newUser = await API.Common.newUser('google', uid, newUData) - if (!newUser) return {error: 'Failed to create user.'} - - return {error: null, user: newUser} + return API.Common.callback('google', uid, user, ipAddress, cleanedData, API.Google.getAvatar) } }, Discord: { + getAvatar: async (rawData) => { + let profilepic = null + let aviSnowflake = rawData.avatar + if (aviSnowflake) { + try { + let avpt = await Image.downloadImage('https://cdn.discordapp.com/avatars/' + rawData.id + '/' + aviSnowflake + '.png') + if (avpt && avpt.fileName) { + profilepic = avpt.fileName + } + } catch (e) { + profilepic = null + } + } + + return profilepic + }, oauth2App: function () { if (discordApp) return discordApp = new oauth.PromiseOAuth2( @@ -374,9 +369,9 @@ const API = { discordApp.useAuthorizationHeaderforGET(true) }, - getAuthorizeURL: function () { + getAuthorizeURL: function (req) { if (!discordApp) API.Discord.oauth2App() - let state = UAPI.Hash(6) + let state = API.Common.stateGenerator(req) let redirectUri = config.server.domain + '/api/external/discord/callback' const params = { @@ -421,58 +416,14 @@ const API = { } let uid = ddata.id - let exists = await API.Common.getExternal('discord', uid) - - if (user) { - // Get bans for user - let bans = await API.Common.getBan(user) - if (bans.length) return { banned: bans, ip: false } - - if (exists) return {error: null, user: user} - - await API.Common.new('discord', uid, user) - return {error: null, user: user} - } - - // Callback succeeded with user id and the external table exists, we log in the user - if (exists) { - // Get bans for user - let bans = await API.Common.getBan(exists.user) - if (bans.length) return { banned: bans, ip: false } - return {error: null, user: exists.user} - } - - // Get bans for IP - let bans = await API.Common.getBan(null, ipAddress) - if (bans.length) return { banned: bans, ip: true } - - // Download profile picture - let profilepic = null - let aviSnowflake = ddata.avatar - if (aviSnowflake) { - try { - let avpt = await API.Common.saveAvatar('https://cdn.discordapp.com/avatars/' + ddata.id + '/' + aviSnowflake + '.png') - if (avpt && avpt.fileName) { - profilepic = avpt.fileName - } - } catch (e) { - profilepic = null - } - } // Create a new user - let newUData = { - username: ddata.username.replace(/\W+/gi, '_'), + let cleanedData = Object.assign(ddata, { display_name: ddata.username, - email: ddata.email || '', - avatar_file: profilepic, - ip_address: ipAddress - } + email: ddata.email || '' + }) - let newUser = await API.Common.newUser('discord', uid, newUData) - if (!newUser) return {error: 'Failed to create user.'} - - return {error: null, user: newUser} + return API.Common.callback('discord', uid, user, ipAddress, cleanedData, API.Discord.getAvatar) } } } diff --git a/server/api/image.js b/server/api/image.js index 6d6706c..3e6fb11 100644 --- a/server/api/image.js +++ b/server/api/image.js @@ -1,9 +1,11 @@ import gm from 'gm' +import url from 'url' import path from 'path' -import crypto from 'crypto' -import Promise from 'bluebird' +import uuid from 'uuid/v4' -const fs = Promise.promisifyAll(require('fs')) +import http from '../../scripts/http' + +const fs = require('fs-extra') const uploads = path.join(__dirname, '../../', 'usercontent') const images = path.join(uploads, 'images') @@ -42,8 +44,8 @@ function saneFields (fields) { } async function bailOut (file, error) { - await fs.unlinkAsync(file) - return { error: error } + await fs.unlink(file) + throw new Error(error) } async function imageBase64 (baseObj) { @@ -53,7 +55,7 @@ async function imageBase64 (baseObj) { if (!imgData) return null if (!imageTypes[imgData.type]) return null - let imageName = 'base64-' + crypto.randomBytes(12).toString('hex') + let imageName = 'base64-' + uuid() let ext = imageTypes[imgData.type] || '.png' imageName += ext @@ -61,7 +63,7 @@ async function imageBase64 (baseObj) { let fpath = path.join(images, imageName) try { - await fs.writeFileAsync(fpath, imgData.data) + await fs.writeFile(fpath, imgData.data) } catch (e) { console.error(e) return null @@ -70,8 +72,27 @@ async function imageBase64 (baseObj) { return {file: fpath} } +async function downloadImage (imgUrl, designation) { + if (!imgUrl) return null + if (!designation) designation = 'download' + + let imageName = designation + '-' + uuid() + let uridata = url.parse(imgUrl) + let pathdata = path.parse(uridata.path) + + imageName += pathdata.ext || '.png' + + try { + await http.Download(imgUrl, path.join(images, imageName)) + } catch (e) { + return null + } + + return imageName +} + async function uploadImage (identifier, fields, files) { - if (!files.image) return {error: 'No image file'} + if (!files.image) throw new Error('No image file') let file = files.image[0] if (file.size > maxFileSize) return bailOut(file.path, 'Image is too large! 1 MB max') @@ -132,7 +153,7 @@ async function uploadImage (identifier, fields, files) { }) }) - await fs.unlinkAsync(file) + await fs.unlink(file) } catch (e) { console.error(e) return bailOut(file, 'An error occured while cropping.') @@ -142,6 +163,7 @@ async function uploadImage (identifier, fields, files) { } module.exports = { + downloadImage: downloadImage, uploadImage: uploadImage, imageBase64: imageBase64, types: imageTypes diff --git a/server/api/index.js b/server/api/index.js index 028531d..d7df691 100644 --- a/server/api/index.js +++ b/server/api/index.js @@ -1,17 +1,15 @@ import path from 'path' import cprog from 'child_process' -import config from '../../scripts/load-config' -import http from '../../scripts/http' -import exists from '../../scripts/existsSync' -import models from './models' import crypto from 'crypto' import notp from 'notp' import base32 from 'thirty-two' -import emailer from './emailer' import uuidV1 from 'uuid/v1' +import fs from 'fs-extra' -import Promise from 'bluebird' -const fs = Promise.promisifyAll(require('fs')) +import config from '../../scripts/load-config' +import http from '../../scripts/http' +import models from './models' +import emailer 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,}))$/ @@ -178,7 +176,7 @@ const API = { }, update: async function (user, data) { user = await API.User.ensureObject(user) - if (!user) return {error: 'No such user.'} + if (!user) throw new Error('No such user.') data = Object.assign({ updated_at: new Date() @@ -191,20 +189,20 @@ const API = { let uploadsDir = path.join(__dirname, '../../', 'usercontent', 'images') let pathOf = path.join(uploadsDir, fileName) - if (!await exists(pathOf)) { - return {error: 'No such file'} + if (!await fs.exists(pathOf)) { + throw new Error('No such file') } // Delete previous upload if (user.avatar_file != null) { let file = path.join(uploadsDir, user.avatar_file) - if (await exists(file)) { - await fs.unlinkAsync(file) + if (await fs.exists(file)) { + await fs.unlink(file) } } await API.User.update(user, {avatar_file: fileName}) - return { file: fileName } + return fileName }, removeAvatar: async function (user) { user = await API.User.ensureObject(user, ['avatar_file']) @@ -212,8 +210,8 @@ const API = { if (!user.avatar_file) return {} let file = path.join(uploadsDir, user.avatar_file) - if (await exists(file)) { - await fs.unlinkAsync(file) + if (await fs.exists(file)) { + await fs.unlink(file) } return API.User.update(user, {avatar_file: null}) @@ -252,14 +250,18 @@ const API = { return bcryptTask({task: 'compare', password: password, hash: user.password}) }, activationToken: async function (token) { - let getToken = await models.Token.query().where('token', token) + let getToken = await models.Token.query().where('token', token).andWhere('type', 1) if (!getToken || !getToken.length) return false - let user = await API.User.get(getToken[0].user_id) + getToken = getToken[0] + + if (getToken.expires_at && new Date(getToken.expires_at).getTime() < Date.now()) return false + + let user = await API.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[0].id) + await models.Token.query().delete().where('id', getToken.id) return true }, totpTokenRequired: async function (user) { @@ -359,12 +361,12 @@ const API = { let userTest = await API.User.get(regdata.username) if (userTest) { - return {error: 'This username is already taken!'} + throw new Error('This username is already taken!') } let emailTest = await API.User.get(regdata.email) if (emailTest) { - return {error: 'This email address is already registered!'} + throw new Error('This email address is already registered!') } // Create user @@ -393,11 +395,75 @@ const API = { } catch (e) { console.error(e) await models.User.query().delete().where('id', user.id) - return {error: 'Invalid email address!'} + throw new Error('Invalid email address!') } } - return {error: null, user: user} + return user + } + }, + Reset: { + reset: async function (email, passRequired = true) { + let emailEnabled = config.email && config.email.enabled + + if (!emailEnabled) throw new Error('Cannot reset password.') + + let user = await API.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.') + + let 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.') + } + + let resetToken = API.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 { + let em = await emailer.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 + }, + resetToken: async function (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 + + let user = await API.User.get(getToken.user_id) + if (!user) return null + + return user + }, + changePassword: async function (user, password, token) { + let hashed = await API.User.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 } }, OAuth2: { @@ -468,7 +534,6 @@ const API = { console.debug('IPN Verified Notification:', body) } - // TODO: add database field for this if (body.txn_id) { if (txnStore.indexOf(body.txn_id) !== -1) return true txnStore.push(body.txn_id) diff --git a/server/api/minecraft.js b/server/api/minecraft.js index 4c72685..e7c6936 100644 --- a/server/api/minecraft.js +++ b/server/api/minecraft.js @@ -1,6 +1,7 @@ +import crypto from 'crypto' + import API from './index' import Model from './models' -import crypto from 'crypto' const mAPI = { getToken: async function (user) { diff --git a/server/api/news.js b/server/api/news.js index 4814d1d..3ccb650 100644 --- a/server/api/news.js +++ b/server/api/news.js @@ -53,7 +53,7 @@ const News = { if (page < 1) page = 1 if (!count.length || !count[0]['ids'] || isNaN(page)) { - return {error: 'No articles found'} + return { error: 'No news' } } count = count[0].ids @@ -100,7 +100,7 @@ const News = { } let result = await Models.News.query().patchAndFetchById(id, patch) - if (!result) return {error: 'Something went wrong.'} + if (!result) throw new Error('Something went wrong.') return {} } } diff --git a/server/index.js b/server/index.js index 060bdf1..1f72ef9 100644 --- a/server/index.js +++ b/server/index.js @@ -15,7 +15,7 @@ const args = { function spawnWorkers () { let workerCount = config.server.workers === 0 ? cpuCount : config.server.workers - console.log('Spinning up ' + workerCount + ' worker process' + (workerCount !== 1 ? 'es' : '')) + console.log('Spinning up %d worker process%s', workerCount, (workerCount !== 1 ? 'es' : '')) for (let i = 0; i < workerCount; i++) { spawnWorker() @@ -68,14 +68,14 @@ function spawnWorker (oldWorker) { w.process.stderr.on('data', (data) => { console.log(w.process.pid, data.toString().trim()) }) - args.verbose && console.log('Starting worker process ' + w.process.pid + '...') + args.verbose && console.log('Starting worker process %d...', w.process.pid) w.on('message', (message) => { if (message === 'started') { workers.push(w) - args.verbose && console.log('Started worker process ' + w.process.pid) + args.verbose && console.log('Started worker process', w.process.pid) if (oldWorker) { - args.verbose && console.log('Stopping worker process ' + oldWorker.process.pid) + args.verbose && console.log('Stopping worker process', oldWorker.process.pid) oldWorker.send('stop') } } else { @@ -99,7 +99,7 @@ cluster.setupMaster({ cluster.on('exit', (worker, code, signal) => { let extra = ((code || '') + ' ' + (signal || '')).trim() - console.error('Worker process ' + worker.process.pid + ' exited ' + (extra ? '(' + extra + ')' : '')) + console.error('Worker process %d exited %s', worker.process.pid, (extra ? '(' + extra + ')' : '')) let index = workers.indexOf(worker) diff --git a/server/routes/admin.js b/server/routes/admin.js index 088a80f..4ab3a46 100644 --- a/server/routes/admin.js +++ b/server/routes/admin.js @@ -1,8 +1,9 @@ import express from 'express' + import ensureLogin from '../../scripts/ensureLogin' import wrap from '../../scripts/asyncRoute' -import {User} from '../api' import API from '../api/admin' +import {User} from '../api' const router = express.Router() const apiRouter = express.Router() @@ -128,10 +129,7 @@ apiRouter.post('/client/new', wrap(async (req, res) => { return res.status(400).jsonp({error: 'Invalid session'}) } - let update = await API.createClient(req.body, req.session.user) - if (update.error) { - return res.status(400).jsonp({error: update.error}) - } + await API.createClient(req.body, req.session.user) res.status(204).end() })) @@ -145,10 +143,7 @@ apiRouter.post('/client/update', wrap(async (req, res) => { return res.status(400).jsonp({error: 'Invalid session'}) } - let update = await API.updateClient(id, req.body) - if (update.error) { - return res.status(400).jsonp({error: update.error}) - } + await API.updateClient(id, req.body) res.status(204).end() })) @@ -160,9 +155,6 @@ apiRouter.post('/client/new_secret/:id', wrap(async (req, res) => { } let client = await API.newSecret(id) - if (client.error) { - return res.status(400).jsonp({error: client.error}) - } res.jsonp(client) })) @@ -174,9 +166,6 @@ apiRouter.post('/client/delete/:id', wrap(async (req, res) => { } let client = await API.removeClient(id) - if (client.error) { - return res.status(400).jsonp({error: client.error}) - } res.jsonp(client) })) @@ -203,9 +192,6 @@ apiRouter.post('/ban/pardon/:id', wrap(async (req, res) => { } let ban = await API.removeBan(id) - if (ban.error) { - return res.status(400).jsonp({error: ban.error}) - } res.jsonp(ban) })) @@ -217,16 +203,13 @@ apiRouter.post('/ban', wrap(async (req, res) => { } let result = await API.addBan(req.body, req.session.user.id) - if (result.error) { - return res.status(400).jsonp({error: result.error}) - } res.jsonp(result) })) apiRouter.use((err, req, res, next) => { console.error(err) - return res.status(500).jsonp({error: 'Internal server error'}) + return res.status(400).jsonp({error: err.message}) }) router.use('/api', apiRouter) diff --git a/server/routes/api.js b/server/routes/api.js index e260b37..70a0330 100644 --- a/server/routes/api.js +++ b/server/routes/api.js @@ -1,12 +1,13 @@ import express from 'express' import RateLimit from 'express-rate-limit' import multiparty from 'multiparty' + import config from '../../scripts/load-config' import wrap from '../../scripts/asyncRoute' -import API from '../api' -import News from '../api/news' -import Image from '../api/image' import APIExtern from '../api/external' +import Image from '../api/image' +import News from '../api/news' +import API from '../api' let router = express.Router() let dev = process.env.NODE_ENV !== 'production' @@ -79,16 +80,34 @@ function JsonData (req, res, error, redirect = '/') { res.jsonp({error: error, redirect: redirect}) } +// Common middleware for all external account unlinks +function removeAuthMiddleware (identifier) { + return wrap(async (req, res) => { + if (!req.session.user) return res.redirect('/login') + let done = await APIExtern.Common.remove(req.session.user, identifier) + + if (!done) { + req.flash('message', {error: true, text: 'Unable to unlink social media account'}) + } + + res.redirect('/user/manage') + }) +} + /** FACEBOOK LOGIN * Ajax POST only * No tokens saved in configs, everything works out-of-the-box */ router.post('/external/facebook/callback', wrap(async (req, res, next) => { if (!config.facebook || !config.facebook.client) return next() - let sane = objectAssembler(req.body) - sane.ip_address = req.realIP - let response = await APIExtern.Facebook.callback(req.session.user, sane) + // Fix up the retarded object Facebook sends us + let sane = objectAssembler(req.body) + if (!sane || !sane.authResponse) { + return next() + } + + let response = await APIExtern.Facebook.callback(req.session.user, sane.authResponse, req.realIP) if (response.banned) { return JsonData(req, res, 'You are banned.') @@ -107,16 +126,7 @@ router.post('/external/facebook/callback', wrap(async (req, res, next) => { JsonData(req, res, null, '/login') })) -router.get('/external/facebook/remove', wrap(async (req, res) => { - if (!req.session.user) return res.redirect('/login') - let done = await APIExtern.Common.remove(req.session.user, 'fb') - - if (!done) { - req.flash('message', {error: true, text: 'Unable to unlink social media account'}) - } - - res.redirect('/user/manage') -})) +router.get('/external/facebook/remove', removeAuthMiddleware('facebook')) /** TWITTER LOGIN * OAuth1.0a flows @@ -172,17 +182,7 @@ router.get('/external/twitter/callback', wrap(async (req, res) => { res.render('redirect', {url: uri}) })) -router.get('/external/twitter/remove', wrap(async (req, res) => { - if (!req.session.user) return res.redirect('/login') - - let done = await APIExtern.Common.remove(req.session.user, 'twitter') - - if (!done) { - req.flash('message', {error: true, text: 'Unable to unlink social media account'}) - } - - res.redirect('/user/manage') -})) +router.get('/external/twitter/remove', removeAuthMiddleware('twitter')) /** DISCORD LOGIN * OAuth2 flows @@ -191,22 +191,16 @@ router.get('/external/twitter/remove', wrap(async (req, res) => { router.get('/external/discord/login', wrap(async (req, res) => { if (!config.discord || !config.discord.api) return res.redirect('/') - let infos = APIExtern.Discord.getAuthorizeURL() - - req.session.discord_auth = { - state: infos.state - } + let infos = APIExtern.Discord.getAuthorizeURL(req) res.redirect(infos.url) })) router.get('/external/discord/callback', wrap(async (req, res) => { if (!config.discord || !config.discord.api) return res.redirect('/login') - if (!req.session.discord_auth) return res.redirect('/login') let code = req.query.code let state = req.query.state - let da = req.session.discord_auth let uri = '/login' if (!code) { @@ -214,7 +208,7 @@ router.get('/external/discord/callback', wrap(async (req, res) => { return res.redirect(uri) } - if (!state || state !== da.state) { + if (!state || state !== APIExtern.Common.stateGenerator(req)) { req.flash('message', {error: true, text: 'Request got intercepted, try again.'}) return res.redirect(uri) } @@ -245,29 +239,7 @@ router.get('/external/discord/callback', wrap(async (req, res) => { res.render('redirect', {url: uri}) })) -router.get('/external/discord/remove', wrap(async (req, res) => { - if (!req.session.user) return res.redirect('/login') - - let done = await APIExtern.Common.remove(req.session.user, 'discord') - - if (!done) { - req.flash('message', {error: true, text: 'Unable to unlink social media account'}) - } - - res.redirect('/user/manage') -})) - -router.get('/external/discord/remove', wrap(async (req, res) => { - if (!req.session.user) return res.redirect('/login') - - let done = await APIExtern.Common.remove(req.session.user, 'discord') - - if (!done) { - req.flash('message', {error: true, text: 'Unable to unlink social media account'}) - } - - res.redirect('/user/manage') -})) +router.get('/external/discord/remove', removeAuthMiddleware('discord')) /** GOOGLE LOGIN * Google Token Verification @@ -303,17 +275,7 @@ router.post('/external/google/callback', wrap(async (req, res) => { JsonData(req, res, null, '/login') })) -router.get('/external/google/remove', wrap(async (req, res) => { - if (!req.session.user) return res.redirect('/login') - - let done = await APIExtern.Common.remove(req.session.user, 'google') - - if (!done) { - req.flash('message', {error: true, text: 'Unable to unlink social media account'}) - } - - res.redirect('/user/manage') -})) +router.get('/external/google/remove', removeAuthMiddleware('google')) /* ======== * NEWS @@ -356,9 +318,10 @@ router.post('/news/edit/:id', wrap(async (req, res, next) => { return res.status(400).jsonp({error: 'Content is required.'}) } - let result = await News.edit(id, req.body) - if (result.error) { - return res.status(400).jsonp({error: result.error}) + try { + await News.edit(id, req.body) + } catch (e) { + return res.status(400).jsonp({error: e.message}) } res.status(204).end() @@ -402,19 +365,19 @@ async function promiseForm (req) { router.post('/avatar', uploadLimiter, wrap(async (req, res, next) => { if (!req.session.user) return next() let data = await promiseForm(req) - let result = await Image.uploadImage(req.session.user.username, data.fields, data.files) - if (result.error) { - return res.status(400).jsonp({error: result.error}) + let avatarFile + + try { + let result = await Image.uploadImage(req.session.user.username, data.fields, data.files) + + avatarFile = await API.User.changeAvatar(req.session.user, result.file) + } catch (e) { + return res.status(400).jsonp({error: e.message}) } - let avatarUpdate = await API.User.changeAvatar(req.session.user, result.file) - if (avatarUpdate.error) { - return res.status(400).jsonp({error: avatarUpdate.error}) - } - - if (avatarUpdate.file) { - req.session.user.avatar_file = avatarUpdate.file + if (avatarFile) { + req.session.user.avatar_file = avatarFile } res.status(200).jsonp({}) diff --git a/server/routes/index.js b/server/routes/index.js index f01d609..93f1947 100644 --- a/server/routes/index.js +++ b/server/routes/index.js @@ -1,10 +1,10 @@ -import fs from 'fs' +import fs from 'fs-extra' import path from 'path' import express from 'express' import RateLimit from 'express-rate-limit' + import ensureLogin from '../../scripts/ensureLogin' import config from '../../scripts/load-config' -import exists from '../../scripts/existsSync' import wrap from '../../scripts/asyncRoute' import http from '../../scripts/http' import API from '../api' @@ -79,7 +79,7 @@ router.use(wrap(async (req, res, next) => { } // Update user session - let udata = await API.User.get(req.session.user.id) + let udata = await API.User.get(req.session.user) setSession(req, udata) // Update IP address @@ -135,8 +135,31 @@ function formKeep (req, res, next) { next() } +// Password reset request endpoint +router.get('/login/reset', extraButtons, (req, res) => { + if (req.session.user) return redirectLogin(req, res) + + res.render('user/reset_password', {sent: req.query.success != null}) +}) + +// Password reset endpoint (emailed link) +router.get('/reset/:token', wrap(async (req, res) => { + if (req.session.user) return res.redirect('/login') + let token = req.params.token + let success = await API.User.Reset.resetToken(token) + + if (!success) { + req.flash('message', {error: true, text: 'Invalid or expired reset token.'}) + res.redirect('/login') + return + } + + res.render('user/password_new', {token: true}) +})) + router.get('/login', extraButtons, (req, res) => { if (req.session.user) return redirectLogin(req, res) + if (req.query.returnTo) { req.session.redirectUri = req.query.returnTo } @@ -154,6 +177,21 @@ router.get('/register', extraButtons, formKeep, (req, res) => { res.render('user/register') }) +// User activation endpoint (emailed link) +router.get('/activate/:token', wrap(async (req, res) => { + if (req.session.user) return res.redirect('/login') + let token = req.params.token + let success = await API.User.Login.activationToken(token) + + if (!success) { + req.flash('message', {error: true, text: 'Invalid or expired activation token.'}) + } else { + req.flash('message', {error: false, text: 'Your account has been activated! You may now log in.'}) + } + + res.redirect('/login') +})) + // View for enabling Two-Factor Authentication router.get('/user/two-factor', ensureLogin, wrap(async (req, res) => { let twoFaEnabled = await API.User.Login.totpTokenRequired(req.session.user) @@ -205,9 +243,9 @@ router.get('/user/manage', ensureLogin, wrap(async (req, res) => { } if (config.facebook && config.facebook.client) { - if (!socialStatus.enabled.fb) { + if (!socialStatus.enabled.facebook) { res.locals.facebook_auth = config.facebook.client - } else if (socialStatus.source !== 'fb') { + } else if (socialStatus.source !== 'facebook') { res.locals.facebook_auth = false } } @@ -270,23 +308,31 @@ function formError (req, res, error, redirect) { // Make sure characters are UTF-8 function cleanString (input) { let output = '' + for (let i = 0; i < input.length; i++) { output += input.charCodeAt(i) <= 127 ? input.charAt(i) : '' } + return output } -// Enabling 2fa -router.post('/user/two-factor', wrap(async (req, res, next) => { - if (!req.session.user) return next() - if (!req.body.code) { - return formError(req, res, 'You need to enter the code.') - } - +// Make sure CSRF tokens are present and valid in every form +function csrfValidation (req, res, next) { if (req.body.csrf !== req.session.csrf) { return formError(req, res, 'Invalid session! Try reloading the page.') } + next() +} + +// Enabling 2fa +router.post('/user/two-factor', csrfValidation, wrap(async (req, res, next) => { + if (!req.session.user) return next() + + if (!req.body.code) { + return formError(req, res, 'You need to enter the code.') + } + let verified = await API.User.Login.totpCheck(req.session.user, req.body.code) if (!verified) { return formError(req, res, 'Something went wrong! Try scanning the code again.') @@ -296,11 +342,8 @@ router.post('/user/two-factor', wrap(async (req, res, next) => { })) // Disabling 2fa -router.post('/user/two-factor/disable', wrap(async (req, res, next) => { +router.post('/user/two-factor/disable', csrfValidation, wrap(async (req, res, next) => { if (!req.session.user) return next() - if (req.body.csrf !== req.session.csrf) { - return formError(req, res, 'Invalid session! Try reloading the page.') - } if (!req.body.password) { return formError(req, res, 'Please enter your password.') @@ -315,17 +358,15 @@ router.post('/user/two-factor/disable', wrap(async (req, res, next) => { })) // Verify 2FA for login -router.post('/login/verify', wrap(async (req, res, next) => { +router.post('/login/verify', csrfValidation, wrap(async (req, res, next) => { if (req.session.user) return next() + if (req.session.totp_check === null) return res.redirect('/login') + if (!req.body.code && !req.body.recovery) { return formError(req, res, 'You need to enter the code.') } - if (req.body.csrf !== req.session.csrf) { - return formError(req, res, 'Invalid session! Try reloading the page.') - } - let totpCheck = await API.User.Login.totpCheck(req.session.totp_check, req.body.code, req.body.recovery || false) if (!totpCheck) { return formError(req, res, 'Invalid code!') @@ -339,16 +380,13 @@ router.post('/login/verify', wrap(async (req, res, next) => { })) // Log the user in. Limited resource -router.post('/login', accountLimiter, wrap(async (req, res, next) => { +router.post('/login', accountLimiter, csrfValidation, wrap(async (req, res, next) => { if (req.session.user) return next() + if (!req.body.username || !req.body.password || req.body.username === '') { return res.redirect('/login') } - if (req.body.csrf !== req.session.csrf) { - return formError(req, res, 'Invalid session! Try reloading the page.') - } - let user = await API.User.get(req.body.username) if (!user) return formError(req, res, 'Invalid username or password.') @@ -358,6 +396,7 @@ router.post('/login', accountLimiter, wrap(async (req, res, next) => { if (!pwMatch) return formError(req, res, 'Invalid username or password.') if (user.activated === 0) return formError(req, res, 'Please activate your account first.') + if (user.locked === 1) return formError(req, res, 'This account has been locked.') // Check if the user is banned @@ -389,15 +428,72 @@ router.post('/login', accountLimiter, wrap(async (req, res, next) => { res.redirect(uri) })) -// Protected & Limited resource: Account registration -router.post('/register', accountLimiter, wrap(async (req, res, next) => { +// Password reset +router.post('/login/reset', accountLimiter, csrfValidation, wrap(async (req, res, next) => { if (req.session.user) return next() - if (!req.body.username || !req.body.display_name || !req.body.password || !req.body.email) { - return formError(req, res, 'Please fill in all the fields.') + + if (!req.body.email) { + return formError(req, res, 'You need to enter your email address.') } - if (req.body.csrf !== req.session.csrf) { - return formError(req, res, 'Invalid session! Try reloading the page.') + let email = req.body.email + let validEmail = await API.User.Register.validateEmail(email) + if (!validEmail) { + return formError(req, res, 'You need to enter a valid email address.') + } + + try { + await API.User.Reset.reset(email) + + req.flash('message', {error: false, text: 'We\'ve sent a link to your email address. Please check spam folders, too!'}) + res.redirect('/login/reset?success=true') + } catch (e) { + return formError(req, res, e.message) + } +})) + +// Password reset endpoint (emailed link) +router.post('/reset/:token', csrfValidation, wrap(async (req, res) => { + if (req.session.user) return res.redirect('/login') + let token = req.params.token + let user = await API.User.Reset.resetToken(token) + + if (!user) { + req.flash('message', {error: true, text: 'Invalid or expired reset token.'}) + res.redirect('/login') + return + } + + // 4th Check: Password length + let password = req.body.password + if (!password || password.length < 8) { + return formError(req, res, 'Invalid password! Please use at least 8 characters!') + } + + // 5th Check: Password match + let passwordAgain = req.body.password_repeat + if (!passwordAgain || password !== passwordAgain) { + return formError(req, res, 'Passwords do not match!') + } + + try { + await API.User.Reset.changePassword(user, password, token) + + console.warn('[SECURITY AUDIT] User \'%s\' password has been changed from %s', user.username, req.realIP) + + req.flash('message', {error: false, text: 'Your password has been changed successfully. You may now log in!'}) + res.redirect('/login') + } catch (e) { + return formError(req, res, e.message) + } +})) + +// Protected & Limited resource: Account registration +router.post('/register', accountLimiter, csrfValidation, wrap(async (req, res, next) => { + if (req.session.user) return next() + + if (!req.body.username || !req.body.display_name || !req.body.password || !req.body.email) { + return formError(req, res, 'Please fill in all the fields.') } // Ban check @@ -458,18 +554,19 @@ router.post('/register', accountLimiter, wrap(async (req, res, next) => { // Hash the password let hash = await API.User.Register.hashPassword(password) + let newUser // Attempt to create the user - let newUser = await API.User.Register.newAccount({ - username: username, - display_name: cleanString(displayName), - password: hash, - email: email, - ip_address: req.realIP - }) - - if (!newUser || newUser.error != null) { - return formError(req, res, newUser.error) + try { + newUser = await API.User.Register.newAccount({ + username: username, + display_name: cleanString(displayName), + password: hash, + email: email, + ip_address: req.realIP + }) + } catch (e) { + return formError(req, res, e.message) } // Do not include activation link message when the user is already activated @@ -483,20 +580,16 @@ router.post('/register', accountLimiter, wrap(async (req, res, next) => { })) // Change display name -router.post('/user/manage', wrap(async (req, res, next) => { +router.post('/user/manage', csrfValidation, wrap(async (req, res, next) => { if (!req.session.user) return next() - if (req.body.csrf !== req.session.csrf) { - return formError(req, res, 'Invalid session! Try reloading the page.') - } - if (!req.body.display_name) { return formError(req, res, 'Display Name cannot be blank.') } let displayName = req.body.display_name if (!displayName || !displayName.match(/^([^\\`]{3,32})$/i)) { - return formError(req, res, 'Invalid display name!') + return formError(req, res, 'Invalid Display Name!') } displayName = cleanString(displayName) @@ -506,12 +599,12 @@ router.post('/user/manage', wrap(async (req, res, next) => { return res.redirect('/user/manage') } - let success = await API.User.update(req.session.user, { - display_name: displayName - }) - - if (success.error) { - return formError(req, res, success.error) + try { + await API.User.update(req.session.user, { + display_name: displayName + }) + } catch (e) { + return formError(req, res, e.message) } req.session.user.display_name = displayName @@ -521,13 +614,9 @@ router.post('/user/manage', wrap(async (req, res, next) => { })) // Change user password -router.post('/user/manage/password', accountLimiter, wrap(async (req, res, next) => { +router.post('/user/manage/password', accountLimiter, csrfValidation, wrap(async (req, res, next) => { if (!req.session.user) return next() - if (req.body.csrf !== req.session.csrf) { - return formError(req, res, 'Invalid session! Try reloading the page.') - } - if (!req.body.password_old) { return formError(req, res, 'Please enter your current password.') } @@ -550,12 +639,12 @@ router.post('/user/manage/password', accountLimiter, wrap(async (req, res, next) password = await API.User.Register.hashPassword(password) - let success = await API.User.update(user, { - password: password - }) - - if (success.error) { - return formError(req, res, success.error) + try { + await API.User.update(user, { + password: password + }) + } catch (e) { + return formError(req, res, e.message) } console.warn('[SECURITY AUDIT] User \'%s\' password has been changed from %s', user.username, req.realIP) @@ -572,13 +661,9 @@ router.post('/user/manage/password', accountLimiter, wrap(async (req, res, next) })) // Change email address -router.post('/user/manage/email', accountLimiter, wrap(async (req, res, next) => { +router.post('/user/manage/email', accountLimiter, csrfValidation, wrap(async (req, res, next) => { if (!req.session.user) return next() - if (req.body.csrf !== req.session.csrf) { - return formError(req, res, 'Invalid session! Try reloading the page.') - } - let user = await API.User.get(req.session.user) let email = req.body.email let newEmail = req.body.email_new @@ -613,15 +698,14 @@ router.post('/user/manage/email', accountLimiter, wrap(async (req, res, next) => return formError(req, res, 'This email is already taken.') } - let success = await API.User.update(user, { - email: newEmail - }) - - if (success.error) { - return formError(req, res, success.error) + try { + await API.User.update(user, { + email: newEmail + }) + } catch (e) { + return formError(req, res, e.message) } - // TODO: Send necessary emails console.warn('[SECURITY AUDIT] User \'%s\' email has been changed from %s', user.username, req.realIP) req.session.user.email = newEmail @@ -640,7 +724,7 @@ router.post('/user/manage/email', accountLimiter, wrap(async (req, res, next) => const docsDir = path.join(__dirname, '../../documents') router.get('/docs/:name', wrap(async (req, res, next) => { let doc = path.join(docsDir, req.params.name + '.html') - if (!await exists(docsDir) || !await exists(doc)) { + if (!await fs.exists(docsDir) || !await fs.exists(doc)) { return next() } @@ -667,18 +751,16 @@ router.get('/news/compose', newsPrivilege, formKeep, (req, res) => { res.render('news/composer') }) -router.post('/news/compose', newsPrivilege, wrap(async (req, res) => { - if (req.body.csrf !== req.session.csrf) { - return formError(req, res, 'Invalid session! Try reloading the page.') - } - +router.post('/news/compose', newsPrivilege, csrfValidation, wrap(async (req, res) => { if (!req.body.title || !req.body.content) { return formError(req, res, 'Required fields missing!') } - let result = await News.compose(req.session.user, req.body) - if (result.error) { - return formError(req, res, result.error) + let result + try { + result = await News.compose(req.session.user, req.body) + } catch (e) { + return formError(req, res, e.message) } res.redirect('/news/' + result.id + '-' + result.slug) @@ -733,21 +815,6 @@ router.get('/logout', (req, res) => { res.redirect('/') }) -// User activation endpoint (emailed link) -router.get('/activate/:token', wrap(async (req, res) => { - if (req.session.user) return res.redirect('/login') - let token = req.params.token - let success = await API.User.Login.activationToken(token) - - if (!success) { - req.flash('message', {error: true, text: 'Invalid or expired activation token.'}) - } else { - req.flash('message', {error: false, text: 'Your account has been activated! You may now log in.'}) - } - - res.redirect('/login') -})) - router.use('/api', apiRouter) router.use('/admin', adminRouter) router.use('/mc', mcRouter) diff --git a/server/routes/minecraft.js b/server/routes/minecraft.js index d31aae5..6795589 100644 --- a/server/routes/minecraft.js +++ b/server/routes/minecraft.js @@ -1,4 +1,5 @@ import express from 'express' + import ensureLogin from '../../scripts/ensureLogin' import wrap from '../../scripts/asyncRoute' import Minecraft from '../api/minecraft' diff --git a/server/routes/oauth2.js b/server/routes/oauth2.js index 073d636..a29920f 100644 --- a/server/routes/oauth2.js +++ b/server/routes/oauth2.js @@ -1,5 +1,6 @@ import express from 'express' -import uapi from '../api' + +import UAPI from '../api' import OAuth2 from '../api/oauth2' import RateLimit from 'express-rate-limit' import wrap from '../../scripts/asyncRoute' @@ -31,7 +32,7 @@ router.post('/introspect', oauth.controller.introspection) // Protected user information resource router.get('/user', oauth.bearer, wrap(async (req, res) => { let accessToken = req.oauth2.accessToken - let user = await uapi.User.get(accessToken.user_id) + let user = await UAPI.User.get(accessToken.user_id) if (!user) { return res.status(404).jsonp({ diff --git a/server/server.js b/server/server.js index 215ffcd..82a8549 100644 --- a/server/server.js +++ b/server/server.js @@ -68,7 +68,7 @@ module.exports = (args) => { app.set('views', path.join(__dirname, '../views')) if (args.dev) { - console.log('Worker is in development mode') + console.warn('Worker is in development mode') // Dev logger const morgan = require('morgan') diff --git a/src/script/admin.js b/src/script/admin.js index 854eb1a..356c245 100644 --- a/src/script/admin.js +++ b/src/script/admin.js @@ -12,15 +12,20 @@ function buildTemplateScript (id, ctx) { function paginationButton (pages) { var html = '
' html += 'Page ' + pages.page + ' of ' + pages.pages + '' + if (pages.page > 1) { html += '
Previous
' } + for (var i = 0; i < pages.pages; i++) { - html += '
' + (i + 1) + '
' + html += '
' + (i + 1) + '
' } + if (pages.pages > pages.page) { html += '
Next
' } + + html += '(' + pages.total + ' Total Entries)' html += '
' return html } @@ -271,11 +276,9 @@ $(document).ready(function () { setInterval(function () { $.get({ - url: '/admin/access', - success: function (data) { - if (data && data.access) return - window.location.reload() - } + url: '/admin/access' + }).fail(function () { + window.location.reload() }) }, 30000) }) diff --git a/src/style/main.styl b/src/style/main.styl index b6c8af1..4b18b9d 100644 --- a/src/style/main.styl +++ b/src/style/main.styl @@ -206,6 +206,8 @@ input:not([type="submit"]) font-size: 120% margin: 10px 0 cursor: pointer + &.active + background-color: #ddd .button display: inline-block @@ -254,6 +256,9 @@ input:not([type="submit"]) .pagenum display: inline-block padding: 10px + &.total + color: #a9a9a9 + font-style: italic .dlbtn display: block diff --git a/templates/reset_password/html.pug b/templates/reset_password/html.pug new file mode 100644 index 0000000..8a8f29c --- /dev/null +++ b/templates/reset_password/html.pug @@ -0,0 +1,6 @@ +h1 Hello, #{display_name}! +p You've requested to reset your password on Icy Network. +p Click on or copy the following link into your URL bar in order to reset your Icy Network account password: +a.activate(href=domain + "/reset/" + reset_token, target="_blank", rel="nofollow")= domain + "/reset/" + reset_token +p If you did not request a password reset on Icy Network, please ignore this email. +small This email has been sent to you because of an action performed on the IcyNet.eu website. diff --git a/templates/reset_password/subject.pug b/templates/reset_password/subject.pug new file mode 100644 index 0000000..85b8d0b --- /dev/null +++ b/templates/reset_password/subject.pug @@ -0,0 +1 @@ +|Icy Network - Password reset request diff --git a/views/user/login.pug b/views/user/login.pug index fe6e6a8..5c9a099 100644 --- a/views/user/login.pug +++ b/views/user/login.pug @@ -23,5 +23,7 @@ block body input(type="password", name="password", id="password") input(type="submit", value="Log in") a#create(href="/register") Create an account + span.divider · + a#create(href="/login/reset") Forgot password? .right include ../includes/external.pug diff --git a/views/user/password_new.pug b/views/user/password_new.pug index bbc09a7..87d0c09 100644 --- a/views/user/password_new.pug +++ b/views/user/password_new.pug @@ -19,8 +19,6 @@ block body if !token label(for="password_old") Current Password input(type="password", name="password_old", id="password_old") - else - input(type="hidden", name="token", value=token) label(for="password") New Password input(type="password", name="password", id="password") label(for="password_repeat") Repeat New Password diff --git a/views/user/reset_password.pug b/views/user/reset_password.pug new file mode 100644 index 0000000..854a1d5 --- /dev/null +++ b/views/user/reset_password.pug @@ -0,0 +1,23 @@ +extends ../layout.pug +block title + |Icy Network - Reset Password + +block body + .wrapper + .boxcont + .box#totpcheck + h1 Reset your password + p Enter your email below in order to reset your password. + if message.text + if message.error + .message.error + span #{message.text} + else + .message + span #{message.text} + if !sent + form#loginForm(method="POST", action=post) + input(type="hidden", name="csrf", value=csrf) + label(for="email") Email Address + input(type="email", name="email", id="email") + input(type="submit", value="Continue")