Initial commit

This commit is contained in:
John Lyon-Smith
2018-02-22 17:57:27 -08:00
commit e80f5490d5
196 changed files with 38982 additions and 0 deletions

13
server/src/api/.babelrc Normal file
View File

@@ -0,0 +1,13 @@
{
"presets": [
[ "env", {
"targets": {
"node": 8
}
}]
],
"plugins": [
"transform-class-properties",
"transform-object-rest-spread"
]
}

7
server/src/api/.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
.DS_STORE
*.log
node_modules
dist
coverage
**/local*.json*
.idea/

101
server/src/api/MQ.js Normal file
View File

@@ -0,0 +1,101 @@
import amqp from 'amqplib'
import uuidv4 from 'uuid/v4'
import autoBind from 'auto-bind2'
export class MQ {
constructor(container) {
autoBind(this)
this.container = container
this.log = container.log
this.replyQueueName = `reply-${uuidv4()}`
this.appId = 'dar-api'
}
connect(amqpUri) {
return amqp.connect(amqpUri).then((conn) => {
this.connection = conn
this.connection.on('error', () => {
this.log.error(`RabbitMQ has gone, shutting down service`)
process.exit(-1)
})
return conn.createChannel()
}).then((ch) => {
this.replyChannel = ch
return ch.assertQueue(this.replyQueueName, {exclusive: true})
}).then((q) => {
if (!q) {
return Promise.reject(new Error(`Could not create reply queue ${replyQueueName}`))
}
this.replyChannel.consume(q.queue, this.handleReply, {noAck: true})
return Promise.resolve(this)
})
}
handleReply(rawMsg) {
const { type, correlationId } = rawMsg.properties
const content = JSON.parse(rawMsg.content.toString())
const { error, data } = content
let { passback } = content
if (passback && passback.routerName && passback.funcName) {
const router = this.container[passback.routerName]
if (router) {
const func = router[passback.funcName]
if (func) {
// TODO: Try setting these to unknown instead as deleting fields break optimizations
delete passback.routerName
delete passback.funcName
passback.correlationId = correlationId
passback.type = type
func(passback, error, data)
} else {
this.log.error(`Router method '${passback.funcName}' not found`)
}
} else {
this.log.error(`Router '${passback.routerName}' not found`)
}
}
}
close() {
return this.replyChannel.close().then(() => {
this.connection.close()
})
}
request(exchangeName, msgType, msgs) {
if (!Array.isArray(msgs)) {
msgs = [msgs]
}
let channel = null
const correlationId = uuidv4()
return this.connection.createChannel().then((ch) => {
channel = ch
return channel.checkExchange(exchangeName)
}).then((arr) => {
return Promise.all(msgs.map((msg) => (
channel.publish(exchangeName, '', new Buffer(JSON.stringify(msg)), {
type: msgType,
contentType: 'application/json',
timestamp: Date.now(),
correlationId,
appId: this.appId,
replyTo: this.replyQueueName
})
)))
}).then(() => {
return channel.close()
}).then(() => {
return Promise.resolve(correlationId)
})
}
}

19
server/src/api/RS.js Normal file
View File

@@ -0,0 +1,19 @@
import redis from 'redis'
import util from 'util'
export class RS {
connect(redisUri) {
let client = redis.createClient(redisUri, { detect_buffers: true })
this.setAsync = util.promisify(client.set.bind(client))
this.getAsync = util.promisify(client.get.bind(client))
this.setrangeAsync = util.promisify(client.setrange.bind(client))
this.incrAsync = util.promisify(client.incr.bind(client))
this.expireAsync = util.promisify(client.expire.bind(client))
this.del = client.del.bind(client)
this.client = client
// The createClient call doesn't return a promise so we fake it
return Promise.resolve(this)
}
}

74
server/src/api/WS.js Normal file
View File

@@ -0,0 +1,74 @@
import IOServer from 'socket.io'
import autoBind from 'auto-bind2'
export class WS {
constructor(container) {
this.log = container.log
this.io = new IOServer(container.server, { path: '/socketio' })
this.socketMap = {}
this.db = container.db
autoBind(this)
}
listen() {
// Starting listing handling connections
this.io.on('connection', this.handleConnection)
return Promise.resolve(this)
}
handleConnection(socket) {
this.db.lookupToken(socket.handshake.query.auth_token, (err, user) => {
if (err || !user) {
this.log.warn(`socket.io client failed authentication and was disconnected`)
socket.disconnect()
} else {
this.socketMap[user._id] = socket.id
this.log.info(`socket.io client '${socket.id}' is connected`)
}
})
}
notify(roomNames, eventName, eventData) {
if (roomNames.length <= 0) {
return
}
let namespace = this.io.sockets
roomNames.forEach((roomName) => {
if (/[a-f0-9]{24}/.test(roomName)) {
const socketId = this.socketMap[roomName]
if (!socketId) {
return
} else {
roomName = socketId
}
}
namespace = namespace.in(roomName)
})
namespace.emit('notify', { eventName, eventData })
}
enterRoom(userId, newRoom) {
const socketId = this.socketMap[userId]
if (socketId) {
const socket = this.io.sockets.connected[socketId]
if (socket) {
const rooms = Object.keys(socket.rooms)
// We want to leave any rooms we are still in except the socket id room
rooms.filter((room) => (room !== socket.id)).forEach((room) => (socket.leave(room)))
if (newRoom) {
socket.join(newRoom)
}
}
}
}
}

105
server/src/api/index.js Normal file
View File

@@ -0,0 +1,105 @@
import config from 'config'
import express from 'express'
import pino from 'pino'
import * as pinoExpress from 'pino-pretty-express'
import http from 'http'
import { DB } from '../database'
import { MQ } from './MQ'
import { RS } from './RS'
import { WS } from './WS'
import bodyParser from 'body-parser'
import cors from 'cors'
import passport from 'passport'
import { Strategy as BearerStrategy } from 'passport-http-bearer'
import path from 'path'
import fs from 'fs'
import createError from 'http-errors'
import * as Routes from './routes'
let app = express()
let server = http.createServer(app)
let container = { app, server }
const serviceName = 'dar-api'
const isProduction = (process.env.NODE_ENV == 'production')
let log = null
if (isProduction) {
log = pino( { name: serviceName },
fs.createWriteStream(path.join(config.get('logDir'), serviceName + '.log'))
)
} else {
const pretty = pinoExpress.pretty({})
pretty.pipe(process.stdout)
log = pino({ name: serviceName }, pretty)
}
container.log = log
app.use(pinoExpress.config({ log }))
app.set('etag', false) // Not wanted for _all_ routes. See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag.
app.options('*', cors()) // Enable all pre-flight CORS requests
app.use(cors())
app.use(bodyParser.urlencoded({ extended: true }))
app.use(bodyParser.json())
app.use(bodyParser.raw({ type: 'application/octet-stream'})) // TODO: Support gzip, etc.. here
app.use(passport.initialize())
const rs = new RS(container)
container.rs = rs
const db = new DB(container)
container.db = db
const ws = new WS(container)
container.ws = ws
const mq = new MQ(container)
container.mq = mq
passport.use(new BearerStrategy(db.lookupToken))
let mongoUri = config.get('uri.mongo')
let amqpUri = config.get('uri.amqp')
let redisUri = config.get('uri.redis')
Promise.all([
db.connect(mongoUri, isProduction),
mq.connect(amqpUri),
rs.connect(redisUri),
ws.listen()
]).then(() => {
log.info(`Connected to MongoDB at ${mongoUri}`)
log.info(`Connected to RabbitMQ at ${amqpUri}`)
log.info(`Connected to Redis at ${redisUri}`)
try {
container.authRoutes = new Routes.AuthRoutes(container)
container.userRoutes = new Routes.UserRoutes(container)
container.assetRoutes = new Routes.AssetRoutes(container)
app.use(function(req, res, next) {
res.status(404).json({
message: 'Not found'
})
})
app.use(function(err, req, res, next) {
if (!isProduction) {
log.error(err)
}
if (!err.status) {
err = createError.InternalServerError(err.message)
}
res.status(err.status).json({
message: err.message
})
})
} catch(error) {
console.error(error)
process.exit(-1)
}
let port = config.get('api.port')
server.listen(port)
log.info(`Deight AR API started on port ${port}`)
}).catch((err) => {
log.error(err.message)
process.exit(1)
})

View File

@@ -0,0 +1,199 @@
import passport from 'passport'
import redis from 'redis'
import redisReadStream from 'redis-rstream'
import createError from 'http-errors'
import path from 'path'
import util from 'util'
import config from 'config'
import autoBind from 'auto-bind2'
function pipeToGridFS(readable, gfsWriteable) {
const promise = new Promise((resolve, reject) => {
readable.on('error', (error) => {
reject(error)
})
gfsWriteable.on('error', (error) => {
reject(error)
})
gfsWriteable.on('close', (file) => {
resolve(file)
})
})
readable.pipe(gfsWriteable)
return promise
}
export class AssetRoutes {
static rangeRegex = /^byte (\d+)/
constructor(container) {
const app = container.app
this.db = container.db
this.rs = container.rs
this.uploadTimeout = config.get('api.uploadTimout')
autoBind(this)
app.route('/assets/:_id')
.get(passport.authenticate('bearer', { session: false }), this.getAsset)
.delete(passport.authenticate('bearer', { session: false }), this.deleteAsset)
app.route('/assets/upload')
.post(passport.authenticate('bearer', { session: false }), this.beginAssetUpload)
app.route('/assets/upload/:_id')
.post(passport.authenticate('bearer', { session: false }), this.continueAssetUpload)
}
getAsset(req, res, next) {
const assetId = req.params._id
this.db.gridfs.findOneAsync({ _id: assetId }).then((file) => {
if (!file) {
return next(createError.NotFound(`Asset ${assetId} was not found`))
}
const ifNoneMatch = req.get('If-None-Match')
if (ifNoneMatch && ifNoneMatch === file.md5) {
res.status(304).set({
'ETag': file.md5,
'Cache-Control': 'private,max-age=86400'
}).end()
return
}
res.status(200).set({
'Content-Type': file.contentType,
'Content-Length': file.length,
'ETag': file.md5})
this.db.gridfs.createReadStream({ _id: file._id }).pipe(res)
}).catch((err) => {
next(createError.BadRequest(`Error returning asset '${assetId}'. ${err.message}`))
})
}
deleteAsset(req, res, next) {
const assetId = req.params._id
this.db.gridfs.removeAsync({ _id: assetId }).then(() => {
res.json({})
}).catch((err) => {
next(createError.BadRequest(`Unable to delete asset '${assetId}'. ${err.message}`))
})
}
beginAssetUpload(req, res, next) {
const uploadId = this.db.newObjectId()
let { fileName, fileSize, numberOfChunks, contentType } = req.body
if (!fileName || !fileSize || !numberOfChunks || !contentType) {
return next(createError.BadRequest('Must specify fileName, fileSize, numberOfChunks and Content-Type header'))
}
fileName = uploadId + '-' + path.basename(fileName)
this.rs.setAsync(
uploadId, JSON.stringify({
fileName, fileSize, numberOfChunks, contentType
}), 'EX', this.uploadTimeout).then(() => {
res.json({ uploadId })
}).catch((error) => {
next(createError.InternalServerError(error.message))
})
}
continueAssetUpload(req, res, next) {
if (!(req.body instanceof Buffer)) {
return next(createError.BadRequest('Body must be of type application/octet-stream'))
}
const range = req.get('Range')
const contentLength = req.get('Content-Length')
let match = range.match(AssetRoutes.rangeRegex)
let offset = null
if (!match || match.length < 2 || (offset = parseInt(match[1])) === NaN) {
return next(createError.BadRequest('Range header must be supplied and of form \'byte <offset>\''))
}
if (parseInt(contentLength, 10) !== req.body.length) {
return next(createError.BadRequest('Must supply Content-Length header matching length of request body'))
}
const uploadId = req.params._id
const uploadCountId = uploadId + '$#'
const uploadDataId = uploadId + '$@'
this.rs.getAsync(uploadId).then((content) => {
let uploadData = null
try {
uploadData = JSON.parse(content)
} catch (error){
return Promise.reject(new Error('Could not parse upload data'))
}
if (offset < 0 || offset + req.body.length > uploadData.fileSize) {
return Promise.reject(new Error(`Illegal range offset ${offset} given`))
}
Promise.all([
this.rs.setrangeAsync(uploadDataId, offset, req.body),
this.rs.incrAsync(uploadCountId)
]).then((arr) => {
const uploadedChunks = arr[1]
let chunkInfo = {
numberOfChunks: uploadData.numberOfChunks,
uploadedChunks
}
if (uploadedChunks >= uploadData.numberOfChunks) {
let readable = redisReadStream(this.rs.client, Buffer(uploadDataId))
let writeable = this.db.gridfs.createWriteStream({
_id: uploadId,
filename: uploadData.fileName,
content_type: uploadData.contentType
})
let promise = pipeToGridFS(readable, writeable).then((file) => {
return Promise.all([
Promise.resolve(file),
this.rs.del(uploadId),
this.rs.del(uploadCountId),
this.rs.del(uploadDataId)
])
}).then((arr) => {
const [file] = arr
res.json({
assetId: file._id,
fileName: file.filename,
contentType: file.contentType,
uploadDate: file.uploadDate,
md5: file.md5,
...chunkInfo
})
}) // TODO: Test that this will be caught...
return promise
} else {
return Promise.all([
this.rs.expireAsync(uploadId, this.uploadTimeout),
this.rs.expireAsync(uploadCountId, this.uploadTimeout),
this.rs.expireAsync(uploadDataId, this.uploadTimeout)
]).then(() => {
res.json(chunkInfo)
})
}
}).catch((error) => {
this.rs.del(uploadId)
this.rs.del(uploadCountId)
this.rs.del(uploadDataId)
console.error(error) // TODO: This should go into log file
next(createError.BadRequest('Unable to upload data chunk'))
})
}).catch((error) => {
console.error(error) // TODO: This should go into log file
next(createError.BadRequest(error.message))
})
}
}

View File

@@ -0,0 +1,396 @@
import passport from 'passport'
import credential from 'credential'
import createError from 'http-errors'
import config from 'config'
import crypto from 'crypto'
import urlSafeBase64 from 'urlsafe-base64'
import util from 'util'
import * as loginToken from './loginToken'
import autoBind from 'auto-bind2'
import url from 'url'
export class AuthRoutes {
constructor(container) {
const app = container.app
this.mq = container.mq
this.db = container.db
this.maxEmailTokenAgeInHours = config.get('email.maxEmailTokenAgeInHours')
this.maxPasswordTokenAgeInHours = config.get('email.maxPasswordTokenAgeInHours')
this.sendEmailDelayInSeconds = config.get('email.sendEmailDelayInSeconds')
this.supportEmail = config.get('email.supportEmail')
this.sendEmail = config.get('email.sendEmail')
autoBind(this)
app.route('/auth/login')
// Used to login. Email must be confirmed.
.post(this.login)
// Used to logout
.delete(passport.authenticate('bearer', { session: false }), this.logout)
// Send change email confirmation email
app.route('/auth/email/send')
.post(passport.authenticate('bearer', { session: false }), this.sendChangeEmailEmail)
// Confirm email address
app.route('/auth/email/confirm')
.post(this.confirmEmail)
// Change the logged in users password, leaving user still logged in
app.route('/auth/password/change')
.post(passport.authenticate('bearer', { session: false }), this.changePassword)
// Send a password reset email
app.route('/auth/password/send')
.post(this.sendPasswordResetEmail)
// Finish a password reset
app.route('/auth/password/reset')
.post(this.resetPassword)
// Indicate who the currently logged in user is
app.route('/auth/who')
.get(passport.authenticate('bearer', { session: false }), this.whoAmI)
}
login(req, res, next) {
const email = req.body.email
const password = req.body.password
if (!email || !password) {
return next(new createError.BadRequest('Must supply user name and password'))
}
let User = this.db.User
// Lookup the user
User.findOne({ email }).then((user) => {
if (!user) {
// NOTE: Don't return NotFound as that gives too much information away to hackers
return Promise.reject(createError.BadRequest("Email or password incorrect"))
} else if (user.emailToken || !user.passwordHash) {
return Promise.reject(createError.Forbidden("Must confirm email and set password"))
} else {
let cr = credential()
return Promise.all([
Promise.resolve(user),
cr.verify(JSON.stringify(user.passwordHash), req.body.password)
])
}
}).then((arr) => {
const [user, isValid] = arr
if (isValid) {
user.loginToken = loginToken.pack(user._id.toString(), user.email)
} else {
user.loginToken = null // A bad login removes existing token for this user...
}
return user.save()
}).then((savedUser) => {
if (savedUser.loginToken) {
res.set('Authorization', `Bearer ${savedUser.loginToken}`)
res.json(savedUser.toClient())
} else {
return Promise.reject(createError.BadRequest('Email or password incorrect'))
}
}).catch((err) => {
if (err instanceof createError.HttpError) {
next(err)
} else {
next(createError.InternalServerError(`Unable to login. ${err ? err.message : ''}`))
}
})
}
logout(req, res, next) {
let User = this.db.User
User.findById({ _id: req.user._id }).then((user) => {
if (!user) {
return next(createError.BadRequest())
}
user.loginToken = null
user.save().then((savedUser) => {
res.json({})
})
}).catch((err) => {
next(createError.InternalServerError(`Unable to login. ${err ? err.message : ''}`))
})
}
whoAmI(req, res, next) {
res.json(req.user.toClient())
}
sendChangeEmailEmail(req, res, next) {
let existingEmail = req.body.existingEmail
const newEmail = req.body.newEmail
let User = this.db.User
const role = req.user.role
const isAdminOrExec = (role === 'executive' || role === 'administrator')
if (existingEmail) {
if (!isAdminOrExec) {
return next(createError.Forbidden('Only admins can resend change email to any user'))
}
} else {
existingEmail = req.user.email
}
let promiseArray = [User.findOne({ email: existingEmail })]
if (newEmail) {
promiseArray.push(User.findOne({ email: newEmail }))
}
Promise.all(promiseArray).then((arr) => {
const [user, conflictingUser] = arr
if (!user) {
return Promise.reject(createError.NotFound(`User with email '${existingEmail}' was not found`))
} else if (conflictingUser) {
return Promise.reject(createError.BadRequest(`A user with '${newEmail}' already exists`))
} else if (!isAdminOrExec && user.emailToken && (new Date() - user.emailToken.created) < this.sendEmailDelayInSeconds) {
return Promise.reject(createError.BadRequest('Cannot request email confirmation again so soon'))
}
return Promise.all([Promise.resolve(user), util.promisify(crypto.randomBytes)(32)])
}).then((arr) => {
let [ user, buf ] = arr
user.emailToken = {
value: urlSafeBase64.encode(buf),
created: new Date()
}
if (newEmail) {
user.newEmail = newEmail
}
return user.save()
}).then((savedUser) => {
const userFullName = `${savedUser.firstName} ${savedUser.lastName}`
const siteUrl = url.parse(req.headers.referer)
let msgs = []
if (savedUser.newEmail) {
msgs.push({
toEmail: savedUser.email,
templateName: 'changeEmailOld',
templateData: {
recipientFullName: userFullName,
recipientNewEmail: savedUser.newEmail,
supportEmail: this.supportEmail
}
})
}
msgs.push({
toEmail: savedUser.newEmail || savedUser.email,
templateName: 'changeEmailNew',
templateData: {
recipientFullName: userFullName,
confirmEmailLink: `${siteUrl.protocol}//${siteUrl.host}/confirm-email?email-token%3D${savedUser.emailToken.value}`,
supportEmail: this.supportEmail
}
})
return this.sendEmail ? this.mq.request('dar-email', 'sendEmail', msgs) : Promise.resolve()
}).then(() => {
res.json({})
}).catch((err) => {
if (err instanceof createError.HttpError) {
next(err)
} else {
next(createError.InternalServerError(`Unable to send change email email. ${err.message}`))
}
})
}
confirmEmail(req, res, next) {
const token = req.body.emailToken
let User = this.db.User
if (!token) {
return next(createError.BadRequest('Invalid request parameters'))
}
User.findOne({ 'emailToken.value': token }).then((user) => {
if (!user) {
return Promise.reject(createError.BadRequest(`The token was not found`))
}
// Token must not be too old
const ageInHours = (new Date() - user.emailToken.created) / 3600000
if (ageInHours > this.maxEmailTokenAgeInHours) {
return Promise.reject(createError.BadRequest(`Token has expired`))
}
// Remove the email token & any login token as it will become invalid
user.emailToken = undefined
user.loginToken = undefined
// Switch in any new email now
if (user.newEmail) {
user.email = user.newEmail
user.newEmail = undefined
}
let promiseArray = [ Promise.resolve(user) ]
if (!user.passwordHash) {
// User has no password, create reset token for them
promiseArray.push(util.promisify(crypto.randomBytes)(32))
}
return Promise.all(promiseArray)
}).then((arr) => {
let [ user, buf ] = arr
if (buf) {
user.passwordToken = {
value: urlSafeBase64.encode(buf),
created: new Date()
}
}
return user.save()
}).then((savedUser) => {
let obj = {}
// Only because the user has sent us a valid email reset token can we respond with an password reset token
if (savedUser.passwordToken) {
obj.passwordToken = savedUser.passwordToken.value
}
res.json(obj)
}).catch((err) => {
if (err instanceof createError.HttpError) {
next(err)
} else {
next(createError.InternalServerError(`Unable to confirm set email token. ${err.message}`))
}
})
}
resetPassword(req, res, next) {
const token = req.body.passwordToken
const newPassword = req.body.newPassword
let User = this.db.User
let cr = credential()
if (!token || !newPassword) {
return next(createError.BadRequest('Invalid request parameters'))
}
User.findOne({ 'passwordToken.value': token }).then((user) => {
if (!user) {
return Promise.reject(createError.BadRequest(`The token was not found`))
}
// Token must not be too old
const ageInHours = (new Date() - user.passwordToken.created) / (3600 * 1000)
if (ageInHours > this.maxPasswordTokenAgeInHours) {
return Promise.reject(createError.BadRequest(`Token has expired`))
}
// Remove the password token & any login token
user.passwordToken = undefined
user.loginToken = undefined
return Promise.all([
Promise.resolve(user),
cr.hash(newPassword)
])
}).then((arr) => {
const [user, json] = arr
user.passwordHash = JSON.parse(json)
return user.save()
}).then((savedUser) => {
res.json({})
}).catch((err) => {
if (err instanceof createError.HttpError) {
next(err)
} else {
next(createError.InternalServerError(`Unable to confirm password reset token. ${err.message}`))
}
})
}
changePassword(req, res, next) {
let User = this.db.User
let cr = credential()
User.findById({ _id: req.user._id }).then((user) => {
if (!user) {
return next(createError.NotFound(`User ${req.user._id} not found`))
}
return Promise.all([
Promise.resolve(user),
cr.verify(JSON.stringify(user.passwordHash), req.body.oldPassword)
])
}).then((arr) => {
const [user, ok] = arr
return Promise.all([Promise.resolve(user), cr.hash(req.body.newPassword)])
}).then((arr) => {
const [user, obj] = arr
user.passwordHash = JSON.parse(obj)
return user.save()
}).then((savedUser) => {
res.json({})
}).catch((err) => {
return next(createError.InternalServerError(err.message))
})
}
sendPasswordResetEmail(req, res, next){
const email = req.body.email
let User = this.db.User
if (!email) {
return next(createError.BadRequest('Invalid request parameters'))
}
User.findOne({ email }).then((user) => {
// User must exist their email must be confirmed
if (!user || user.emailToken) {
// Don't give away any information about why we rejected the request
return Promise.reject(createError.BadRequest('Not a valid request'))
} else if (user.passwordToken && (new Date() - user.emailToken.created) < this.sendEmailDelayInSeconds) {
return Promise.reject(createError.BadRequest('Cannot request password reset so soon'))
}
return Promise.all([Promise.resolve(user), util.promisify(crypto.randomBytes)(32)])
}).then((arr) => {
let [ user, buf ] = arr
user.passwordToken = {
value: urlSafeBase64.encode(buf),
created: new Date()
}
return user.save()
}).then((savedUser) => {
const userFullName = `${savedUser.firstName} ${savedUser.lastName}`
const siteUrl = url.parse(req.headers.referer)
const msg = {
toEmail: savedUser.email,
templateName: 'forgotPassword',
templateData: {
recipientFullName: userFullName,
resetPasswordLink: `${siteUrl.protocol}//${siteUrl.host}/reset-password?password-token%3D${savedUser.passwordToken.value}`,
supportEmail: this.supportEmail
}
}
return this.sendEmail ? this.mq.request('dar-email', 'sendEmail', msg) : Promise.resolve()
}).then(() => {
res.json({})
}).catch((err) => {
if (err instanceof createError.HttpError) {
next(err)
} else {
next(createError.InternalServerError(`Unable to send password reset email. ${err.message}`))
}
})
}
}

View File

@@ -0,0 +1,493 @@
import passport from 'passport'
import createError from 'http-errors'
import { makeFingerprint } from '../makeFingerprint'
import autoBind from 'auto-bind2'
export class ProjectRoutes {
constructor(container) {
const app = container.app
this.log = container.log
this.db = container.db
this.mq = container.mq
this.ws = container.ws
autoBind(this)
app.route('/projects')
.get(passport.authenticate('bearer', { session: false }), this.listProjects)
.post(passport.authenticate('bearer', { session: false }), this.createProject)
.put(passport.authenticate('bearer', { session: false }), this.updateProject)
app.route('/projects/:_id([a-f0-9]{24})/broker/:brokerId([a-f0-9]{24})')
.get(passport.authenticate('bearer', { session: false }), this.getProjectBrokerClientData)
app.route('/projects/:_id([a-f0-9]{24})/broker/:brokerId([a-f0-9]{24})/sign-off')
.post(passport.authenticate('bearer', { session: false }), this.signOffProjectBrokerClientData)
app.route('/projects/:projectId([a-f0-9]{24})/create-packages')
.post(passport.authenticate('bearer', { session: false }), this.createProjectPackages)
app.route('/projects/:projectId([a-f0-9]{24})/reset-packages')
.post(passport.authenticate('bearer', { session: false }), this.resetProjectPackages)
app.route('/projects/:projectId([a-f0-9]{24})/build-pdfs')
.post(passport.authenticate('bearer', { session: false }), this.buildProjectPDFs)
app.route('/projects/:_id([a-f0-9]{24})')
.get(passport.authenticate('bearer', { session: false }), this.getProject)
.delete(passport.authenticate('bearer', { session: false }), this.deleteProject)
app.route('/projects/import-client-data')
.post(passport.authenticate('bearer', { session: false }), this.importProjectClientData)
app.route('/projects/:_id([a-f0-9]{24})/populated')
.get(passport.authenticate('bearer', { session: false }), this.getPopulatedProject)
app.route('/projects/dashboard')
.get(passport.authenticate('bearer', { session: false }), this.listDashboardProjects)
app.route('/projects/broker/:_id([a-f0-9]{24})')
.get(passport.authenticate('bearer', { session: false }), this.listBrokerProjects)
}
listProjects(req, res, next) {
const Project = this.db.Project
let limit = req.params.limit || 20
let skip = req.params.skip || 0
let partial = !!req.params.partial
let branch = req.params.branch
let query = {}
if (branch) {
query.branch = branch
}
Project.count({}).then((total) => {
let projects = []
let cursor = Project.find(query).limit(limit).skip(skip).cursor().map((doc) => {
return doc.toClient(partial)
})
cursor.on('data', (doc) => {
projects.push(doc)
})
cursor.on('end', () => {
res.json({
total: total,
offset: skip,
count: projects.length,
items: projects
})
})
cursor.on('error', (err) => {
next(createError.InternalServerError(err.message))
})
}).catch((err) => {
next(createError.InternalServerError(err.message))
})
}
listDashboardProjects(req, res, next) {
const Project = this.db.Project
let projects = []
let cursor = Project.find({}).select('_id name fingerprint branch').populate({
path: 'branch', select: '_id name fingerprint corporation', populate: {
path: 'corporation', select: '_id name imageId fingerprint'
}
}).cursor()
cursor.on('data', (project) => {
projects.push(project)
})
cursor.on('end', () => {
res.json({
total: projects.length,
offset: 0,
count: projects.length,
items: projects
})
})
cursor.on('error', (err) => {
next(createError.InternalServerError(err.message))
})
}
listBrokerProjects(req, res, next) {
const brokerId = req.params._id
const Project = this.db.Project
let projects = []
let cursor = Project.find({ brokers: brokerId }).select('_id name clientData').cursor()
cursor.on('data', (project) => {
projects.push(project)
})
cursor.on('end', () => {
res.json({
total: projects.length,
offset: 0,
count: projects.length,
items: projects
})
})
cursor.on('error', (err) => {
next(createError.InternalServerError(err.message))
})
}
createProject(req, res, next) {
const role = req.user.role
// If user's role is not Executive or Administrator, return an error
if (role !== 'executive' && role !== 'administrator') {
return next(new createError.Forbidden())
}
// Create a new Project template then assign it to a value in the req.body
const Project = this.db.Project
let project = new Project(req.body)
project.fingerprint = makeFingerprint(project.name)
// Save the project (with promise) - If it doesnt, catch and throw error
project.save().then((newProject) => {
res.json(newProject.toClient())
}).catch((err) => {
next(createError.InternalServerError(err.message))
})
}
updateProject(req, res, next) {
const role = req.user.role
// If user's role is not Executive or Administrator, return an error
if (role !== 'executive' && role !== 'administrator') {
return new createError.Forbidden()
}
// Do this here because Mongoose will add it automatically otherwise
if (!req.body._id) {
return next(createError.BadRequest('No _id given in body'))
}
let Project = this.db.Project
let projectUpdates = null
try {
projectUpdates = new Project(req.body)
} catch (err) {
return next(createError.BadRequest('Invalid data'))
}
if (projectUpdates.name) {
projectUpdates.fingerprint = makeFingerprint(projectUpdates.name)
}
Project.findById(projectUpdates._id).then((foundProject) => {
if (!foundProject) {
return next(createError.NotFound(`Project with _id ${_id} was not found`))
}
foundProject.merge(projectUpdates)
return foundProject.save()
}).then((savedProject) => {
res.json(savedProject.toClient())
}).catch((err) => {
next(createError.InternalServerError(err.message))
})
}
importProjectClientData(req, res, next) {
const role = req.user.role
if (role !== 'broker' && role !== 'executive' && role !== 'administrator') {
return next(new createError.Forbidden())
}
const { _id, brokerId, assetId } = req.body
if (!_id || !brokerId || !assetId) {
return next(createError.BadRequest('Must specify _id, brokerId and assetId'))
}
this.mq.request('dar-import', 'importClientData', {
projectId: _id,
brokerId: brokerId,
assetId: assetId,
passback: {
rooms: [ req.user._id ],
projectId: _id,
brokerId: brokerId,
routerName: 'projectRoutes',
funcName: 'completeImportClientData'
}
}).then((correlationId) => {
res.json({ correlationId })
}).catch((err) => {
// TODO: Should delete the asset
next(createError.InternalServerError('Unable to import uploaded file to project'))
})
}
completeImportClientData(passback, error, data) {
if (!passback.rooms) {
return
}
let obj = {
brokerId: passback.brokerId,
projectId: passback.projectId,
}
if (error) {
obj.problems = error.problems
obj.error = error.message
}
this.ws.notify(passback.rooms, 'clientDataImportComplete', obj)
}
getProject(req, res, next) {
const Project = this.db.Project
const _id = req.params._id
Project.findById(_id).then((project) => {
if (!project) {
return next(createError.NotFound(`Project with _id ${_id} not found`))
}
res.json(project.toClient())
}).catch((err) => {
next(createError.InternalServerError(err.message))
})
}
getPopulatedProject(req, res, next) {
const Project = this.db.Project
const _id = req.params._id
Project.findById(_id).then((project) => {
if (!project) {
return next(createError.NotFound(`Project with _id ${_id} not found`))
}
return project.populate({ path: 'brokers', select: '_id firstName lastName email thumbnailImageId t12 aum numHouseholds homePhone cellPhone' }).execPopulate()
}).then((project) => {
res.json(project.toClient())
}).catch((err) => {
next(createError.InternalServerError(err.message))
})
}
getProjectBrokerClientData(req, res, next) {
const Project = this.db.Project
const _id = req.params._id
const brokerId = req.params.brokerId
Project.findById(_id).select('clientData').then((project) => {
if (!project) {
return next(createError.NotFound(`Project with _id ${_id} not found`))
}
if (!project.clientData) {
return res.json({})
}
const clientData = project.clientData.find(data => data.brokerId.toString() === brokerId)
if (clientData) {
res.json(clientData)
} else {
res.json({})
}
}).catch((error) => {
next(createError.InternalServerError(err.message))
})
}
signOffProjectBrokerClientData(req, res, next) {
const Project = this.db.Project
const _id = req.params._id
const brokerId = req.params.brokerId
Project.findById(_id).then((project) => {
if (!project) {
return next(createError.NotFound(`Project with _id ${_id} not found`))
}
for (let clientData of project.clientData) {
if (clientData.brokerId && clientData.brokerId.toString() === brokerId) {
if (clientData.submitted || clientData.numProblems !== 0 || clientData.problems.length !== 0 || clientData.error) {
return next(createError.BadRequest(`Project ${_id}, broker ${brokerId} cannot be signed off at this time`))
}
clientData.submitted = new Date()
return project.save(project)
}
}
return next(createError.NotFound(`Client data for broker ${brokerId} was not found`))
}).then((savedProject) => {
res.json({})
}).catch((error) => {
next(createError.InternalServerError(error.message))
})
}
createProjectPackages(req, res, next) {
const role = req.user.role
if (role !== 'executive' && role !== 'administrator') {
return next(new createError.Forbidden())
}
const { projectId } = req.params
this.mq.request('dar-import', 'createPackages', {
projectId,
passback: {
rooms: [ req.user._id ], // TODO: Add a room for the specific project
projectId,
routerName: 'projectRoutes',
funcName: 'completeCreatePackageData'
}
}).then((correlationId) => {
res.json({ correlationId })
}).catch((err) => {
next(createError.InternalServerError('Unable to create package data'))
})
}
resetProjectPackages(req, res, next) {
const role = req.user.role
if (role !== 'executive' && role !== 'administrator') {
return next(new createError.Forbidden())
}
const { projectId } = req.params
const Package = this.db.Package
const cursor = Package.find({ project: projectId }).cursor()
let totalPDFs = 0
let totalPackages = 0
new Promise((resolve, reject) => {
cursor.on('data', (pkg) => {
pkg.remove().then((pkg) => {
if (pkg.assetId) {
totalPDFs += 1
return this.db.gridfs.remove({ _id: pkg.assetId })
}
}).catch((error) => {
reject(error)
})
totalPackages += 1
})
cursor.on('end', () => {
resolve()
})
cursor.on('error', (error) => {
reject(error)
})
}).then(() => {
res.json({ totalPackages, totalPDFs })
}).catch((error) => {
next(createError.InternalServerError('Unable to delete all project packages'))
})
}
completeCreatePackageData(passback, error, data) {
if (!passback.rooms) {
return
}
let obj = {
projectId: passback.projectId,
}
if (error) {
obj.problems = error.problems
obj.error = error.message
}
this.ws.notify(passback.rooms, 'packageGenerationComplete', obj)
}
buildProjectPDFs(req, res, next) {
const role = req.user.role
if (role !== 'executive' && role !== 'administrator') {
return next(new createError.Forbidden())
}
const projectId = req.params.projectId
const Package = this.db.Package
let total = 0
let cursor = Package.find({ project: projectId }).cursor()
cursor.on('data', (pkg) => {
const packageId = pkg._id
this.mq.request('dar-pdf', 'createPackagePDF', {
packageId,
passback: {
rooms: [ req.user._id ], // TODO: Add a room for the specific project
packageId,
routerName: 'projectRoutes',
funcName: 'completeCreatePackagePDF'
}
}).then((correlationId) => {
res.json({ correlationId })
}).catch((err) => {
// TODO: Collect errors and return to user
})
total += 1
})
cursor.on('end', () => {
res.json({ total })
})
cursor.on('error', (err) => {
next(createError.InternalServerError(err.message))
})
}
completeCreatePackagePDF(passback, error, data) {
if (!passback.rooms) {
return
}
let obj = {
packageId: passback.packageId,
}
if (error) {
obj.problems = error.problems
obj.error = error.message
} else {
obj.assetId = data.assetId
}
this.ws.notify(passback.rooms, 'createPackagePDFComplete', obj)
}
deleteProject(req, res, next) {
const role = req.user.role
// If user's role is not Executive or Administrator, return an error
if (role !== 'executive' && role !== 'administrator') {
return new createError.Forbidden()
}
const Project = this.db.Project
const _id = req.params._id
Project.remove({ _id }).then((project) => {
if (!project) {
return next(createError.NotFound(`Project with _id ${_id} not found`))
}
res.json({})
}).catch((err) => {
next(createError.InternalServerError(err.message))
})
}
}

View File

@@ -0,0 +1,339 @@
import passport from 'passport'
import createError from 'http-errors'
import crypto from 'crypto'
import urlSafeBase64 from 'urlsafe-base64'
import url from 'url'
import util from 'util'
import autoBind from 'auto-bind2'
import config from 'config'
export class UserRoutes {
constructor(container) {
const app = container.app
this.log = container.log
this.db = container.db
this.mq = container.mq
this.ws = container.ws
this.maxEmailTokenAgeInHours = config.get('email.maxEmailTokenAgeInHours')
this.sendEmail = config.get('email.sendEmail')
autoBind(this)
app.route('/users')
.get(passport.authenticate('bearer', { session: false }), this.listUsers)
// Add a new user, send email confirmation email
.post(passport.authenticate('bearer', { session: false }), this.createUser)
.put(passport.authenticate('bearer', { session: false }), this.updateUser)
app.route('/users/brokers')
.get(passport.authenticate('bearer', { session: false }), this.listBrokerUsers)
app.route('/users/:_id([a-f0-9]{24})')
.get(passport.authenticate('bearer', { session: false }), this.getUser)
.delete(passport.authenticate('bearer', { session: false }), this.deleteUser)
app.route('/users/set-image')
.put(passport.authenticate('bearer', { session: false }), this.setImage)
app.route('/users/enter-room/:roomName')
.put(passport.authenticate('bearer', { session: false }), this.enterRoom)
app.route('/users/leave-room')
.put(passport.authenticate('bearer', { session: false }), this.leaveRoom)
}
listUsers(req, res, next) {
const User = this.db.User
const limit = req.params.limit || 20
const skip = req.params.skip || 0
const role = req.user.role
if (role !== 'executive' && role !== 'administrator') {
return next(new createError.Forbidden())
}
User.count({}).then((total) => {
let users = []
let cursor = User.find({}).limit(limit).skip(skip).cursor().map((doc) => {
return doc.toClient(req.user)
})
cursor.on('data', (doc) => {
users.push(doc)
})
cursor.on('end', () => {
res.json({
total: total,
offset: skip,
count: users.length,
items: users
})
})
cursor.on('error', (err) => {
next(createError.InternalServerError(err.message))
})
}).catch((err) => {
next(createError.InternalServerError(err.message))
})
}
listBrokerUsers(req, res, next) {
let User = this.db.User
const role = req.user.role
if (role !== 'executive' && role !== 'administrator') {
return next(new createError.Forbidden())
}
let users = []
let cursor = User.find({ role: 'broker' })
.select('_id firstName lastName thumbnailImageId t12 aum numHouseholds cellPhone').cursor()
cursor.on('data', (doc) => {
users.push(doc)
})
cursor.on('end', () => {
res.json({
total: users.length,
offset: 0,
count: users.length,
items: users
})
})
cursor.on('error', (err) => {
next(createError.InternalServerError(err.message))
})
}
getUser(req, res, next) {
let User = this.db.User
const _id = req.params._id
const isSelf = (_id === req.user._id)
// User can see themselves, otherwise must be super user
if (!isSelf && role !== 'executive' && role !== 'administrator') {
return next(new createError.Forbidden())
}
User.findById(_id).then((user) => {
if (!user) {
return Promise.reject(createError.NotFound(`User with _id ${_id} was not found`))
}
res.json(user.toClient(req.user))
}).catch((err) => {
if (err instanceof createError.HttpError) {
next(err)
} else {
next(createError.InternalServerError(err.message))
}
})
}
createUser(req, res, next) {
const role = req.user.role
if (role !== 'executive' && role !== 'administrator') {
return next(new createError.Forbidden())
}
let User = this.db.User
let user = new User(req.body)
// Add email confirmation required token
util.promisify(crypto.randomBytes)(32).then((buf) => {
user.emailToken = {
value: urlSafeBase64.encode(buf),
created: new Date()
}
return user.save()
}).then((savedUser) => {
const userFullName = `${savedUser.firstName} ${savedUser.lastName}`
const senderFullName = `${req.user.firstName} ${req.user.lastName}`
const siteUrl = url.parse(req.headers.referer)
const msg = {
toEmail: savedUser.email,
templateName: 'welcome',
templateData: {
recipientFullName: userFullName,
senderFullName: senderFullName,
confirmEmailLink: `${siteUrl.protocol}//${siteUrl.host}/confirm-email?email-token%3D${savedUser.emailToken.value}`,
confirmEmailLinkExpirationHours: this.maxEmailTokenAgeInHours,
supportEmail: this.supportEmail
}
}
res.json(savedUser.toClient())
return this.sendEmail ? this.mq.request('dar-email', 'sendEmail', msg) : Promise.resolve()
}).catch((err) => {
next(createError.InternalServerError(err.message))
})
}
updateUser(req, res, next) {
const role = req.user.role
// Do this here because Mongoose will add it automatically otherwise
if (!req.body._id) {
return next(createError.BadRequest('No user _id given in body'))
}
const isSelf = (req.body._id === req.user._id.toString())
// User can change themselves, otherwise must be super user
if (!isSelf && role !== 'executive' && role !== 'administrator') {
return next(new createError.Forbidden())
}
const User = this.db.User
let userUpdates = null
try {
userUpdates = new User(req.body)
} catch (err) {
return next(createError.BadRequest('Invalid data'))
}
if (isSelf && userUpdates.role && userUpdates.role !== req.user.role) {
return next(createError.BadRequest('Cannot modify own role'))
}
User.findById(userUpdates._id).then((foundUser) => {
if (!foundUser) {
return Promise.reject(createError.NotFound(`User with _id ${user._id} was not found`))
}
// We don't allow direct updates to the email field so remove it if present
delete userUpdates.email
foundUser.merge(userUpdates)
return foundUser.save()
}).then((savedUser) => {
res.json(savedUser.toClient(req.user))
}).catch((err) => {
next(createError.InternalServerError(err.message))
})
}
setImage(req, res, next) {
const role = req.user.role
const { _id, imageId } = req.body
if (!_id || !imageId) {
return next(createError.BadRequest('Must specify _id and imageId'))
}
const isSelf = (_id === req.user._id.toString())
// User can change themselves, otherwise must be super user
if (!isSelf && role !== 'executive' && role !== 'administrator') {
return next(new createError.Forbidden())
}
const { bigSize = {}, smallSize = {} } = req.body
Promise.all([
this.mq.request('dar-image', 'scaleImage', {
newWidth: bigSize.width || 200,
newHeight: bigSize.height || 200,
scaleMode: 'aspectFill',
inputAssetId: imageId,
passback: {
userId: _id,
rooms: [ req.user._id ],
routerName: 'userRoutes',
funcName: 'completeSetBigImage'
}
}),
this.mq.request('dar-image', 'scaleImage', {
newWidth: smallSize.width || 25,
newHeight: smallSize.height || 25,
scaleMode: 'aspectFill',
inputAssetId: imageId,
passback: {
userId: _id,
rooms: [ req.user._id, 'users' ],
routerName: 'userRoutes',
funcName: 'completeSetSmallImage'
}
})
]).then((correlationIds) => {
res.json({ correlationIds })
}).catch((err) => {
next(createError.InternalServerError('Unable to scale user images'))
})
}
completeSetBigImage(passback, error, data) {
if (error || !passback.userId || !passback.rooms) {
return
}
const User = this.db.User
User.findByIdAndUpdate(passback.userId, { imageId: data.outputAssetId }).then((foundUser) => {
if (foundUser) {
this.ws.notify(passback.rooms, 'newProfileImage', { imageId: data.outputAssetId })
}
}).catch((err) => {
this.log.error(`Unable to notify [${passback.rooms.join(', ')}] of new image'`)
})
}
completeSetSmallImage(passback, error, data) {
if (error || !passback.userId || !passback.rooms) {
return
}
const User = this.db.User
User.findByIdAndUpdate(passback.userId, { thumbnailImageId: data.outputAssetId }).then((foundUser) => {
if (foundUser) {
this.ws.notify(passback.rooms, 'newThumbnailImage', { imageId: data.outputAssetId })
}
}).catch((err) => {
this.log.error(`Unable to notify [${passback.rooms.join(', ')}] of new thumbnail image'`)
})
}
enterRoom(req, res, next) {
this.ws.enterRoom(req.user._id, req.params.roomName)
res.json({})
}
leaveRoom(req, res, next) {
this.ws.enterRoom(req.user._id)
res.json({})
}
deleteUser(req, res, next) {
const role = req.user.role
if (role !== 'executive' && role !== 'administrator') {
return new createError.Forbidden()
}
let User = this.db.User
const _id = req.params._id
User.remove({ _id }).then((deletedUser) => {
if (!deletedUser) {
return next(createError.NotFound(`User with _id ${_id} was not found`))
}
const userFullName = `${deletedUser.firstName} ${deletedUser.lastName}`
const senderFullName = `${req.user.firstName} ${req.user.lastName}`
const msg = {
toEmail: deletedUser.newEmail,
templateName: 'accountDeleted',
templateData: {
recipientFullName: userFullName,
senderFullName: senderFullName,
supportEmail: this.supportEmail
}
}
return this.sendEmail ? this.mq.request('dar-email', 'sendEmail', msg) : Promise.resolve()
}).then(() => {
res.json({})
}).catch((err) => {
next(createError.InternalServerError(err.message))
})
}
}

View File

@@ -0,0 +1,3 @@
export { AuthRoutes } from './AuthRoutes'
export { AssetRoutes } from './AssetRoutes'
export { UserRoutes } from './UserRoutes'

View File

@@ -0,0 +1,37 @@
import jwt from 'jsonwebtoken'
import config from 'config'
import crypto from 'crypto'
let key = config.get('api.loginKey')
if (key == null || key == '*') {
key = crypto.randomBytes(32).toString('hex')
}
export function pack(id, email) {
let payload = {
prn: email,
jti: id.toString()
}
if (process.env.NODE_ENV == 'production') {
payload.exp = Math.floor(Date.now() / 1000) + (60 * 60)
}
// TODO: For performance this should return a promise and sign async
return jwt.sign(payload, key)
}
export function unpack(token) {
// TODO: For performance return promise and verify async
try {
let decoded = jwt.verify(token, key)
} catch (err) {
return null
}
return {
id: decoded.prn,
email: decoded.jti
}
}

50
server/src/database/DB.js Normal file
View File

@@ -0,0 +1,50 @@
import mongoose from 'mongoose'
import mongodb from 'mongodb'
import Grid from 'gridfs-stream'
import merge from 'mongoose-merge-plugin'
import autoBind from 'auto-bind2'
import * as Schemas from './schemas'
import util from 'util'
Grid.mongo = mongoose.mongo
export class DB {
constructor() {
mongoose.Promise = Promise
mongoose.plugin(merge)
autoBind(this)
}
connect(mongoUri, isProduction) {
return mongoose.connect(mongoUri, { useMongoClient: true, config: { autoIndex: !isProduction } }).then((connection) => {
this.connection = connection
this.gridfs = Grid(connection.db)
this.gridfs.findOneAsync = util.promisify(this.gridfs.findOne)
this.gridfs.removeAsync = util.promisify(this.gridfs.remove)
this.User = connection.model('User', Schemas.userSchema)
this.WorkItem = connection.model('WorkItem', Schemas.workItemSchema)
return Promise.resolve(this)
})
}
newObjectId(s) {
// If s is undefined, then a new ObjectID is created, else s is assumed to be a parsable ObjectID
return new mongodb.ObjectID(s).toString()
}
lookupToken(token, done) {
this.User.findOne({ 'loginToken': token }).then((user) => {
if (!user) {
done(null, false)
} else {
done(null, user)
}
}).catch((err) => {
done(err)
})
}
}

View File

@@ -0,0 +1 @@
export { DB } from './DB'

View File

@@ -0,0 +1,3 @@
export { workItemSchema } from './workItem'
export { userSchema } from './user'
export { teamSchema } from './team'

View File

@@ -0,0 +1,6 @@
import { Schema } from 'mongoose'
export let team = new Schema({
name: { type: String },
members: { type: Schema.Types.ObjectId, required: true },
}, { timestamps: true, id: false })

View File

@@ -0,0 +1,73 @@
import { Schema } from 'mongoose'
import { regExpPattern } from 'regexp-pattern'
export let userSchema = new Schema({
_id: { type: Schema.Types.ObjectId, required: true, auto: true },
loginToken: { type: String, index: true, unique: true, sparse: true },
passwordHash: {
type: {
hash: String,
salt: String,
keyLength: Number,
hashMethod: String,
iterations: Number
}
},
email: { type: String, match: regExpPattern.email, required: true, index: true, unique: true },
newEmail: { type: String, match: regExpPattern.email },
imageId: { type: Schema.Types.ObjectId },
thumbnailImageId: { type: Schema.Types.ObjectId },
emailToken: {
type: {
value: { type: String, index: true, unique: true, sparse: true },
created: Date
}
},
passwordToken: {
type: {
value: { type: String, index: true, unique: true, sparse: true },
created: Date
}
},
firstName: { type: String, required: true },
lastName: { type: String, required: true },
role: { type: String, required: true, enum: {
values: [ 'administrator', 'normal'],
message: 'enum validator failed for path `{PATH}` with value `{VALUE}`'
}},
}, { timestamps: true, id: false })
userSchema.methods.toClient = function(authUser) {
if (authUser === undefined) {
authUser = this
}
let user = {
_id: this._id,
email: this.email,
emailValidated: (!!this.emailToken !== true),
imageId: this.imageId,
thumbnailImageId: this.thumbnailImageId,
firstName: this.firstName,
lastName: this.lastName,
role: this.role
}
if ((authUser.role === 'administrator' || authUser.role === 'executive') || authUser._id.equals(this._id)) {
user.zip = this.zip
user.state = this.state
user.city = this.city
user.address1 = this.address1
user.address2 = this.address2
user.homePhone = this.homePhone
user.cellPhone = this.cellPhone
user.ssn = this.ssn
user.dateOfBirth = this.dateOfBirth
user.dateOfHire = this.dateOfHire
user.numHouseholds = this.numHouseholds
user.t12 = this.t12
user.aum = this.aum
}
return user
}

View File

@@ -0,0 +1,17 @@
import { Schema } from 'mongoose'
import { regExpPattern } from 'regexp-pattern'
export let workItemSchema = new Schema({
_id: { type: Schema.Types.ObjectId, required: true, auto: true },
workItemType: { type: String, required: true, enum: {
values: [ 'order', 'inspection', 'complaint'],
message: 'enum validator failed for path `{PATH}` with value `{VALUE}`'
}},
}, { timestamps: true, id: false })
workItemSchema.methods.toClient = function() {
return {
_id: this._id,
workItemType: this.workItemType
}
}

View File

@@ -0,0 +1,88 @@
import fs from 'fs'
import util from 'util'
import path from 'path'
import createError from 'http-errors'
import nodemailer from 'nodemailer'
import handlebars from 'handlebars'
import appRoot from 'app-root-path'
import JSON5 from 'json5'
import aws from 'aws-sdk'
import config from 'config'
import autoBind from 'auto-bind2'
export class EmailHandlers {
constructor(container) {
this.log = container.log
const templatesDir = path.join(appRoot.toString(), '/config/templates')
const templatesFile = path.join(templatesDir, 'templates.json5')
const defs = JSON5.parse(fs.readFileSync(templatesFile))
this.templates = {}
for (let name in defs) {
const def = defs[name]
const textFilename = path.join(templatesDir, def.text)
if (!fs.existsSync(textFilename)) {
this.log.error(`File '${textFilename}' specified in '${templatesFile}' does not exist`)
process.exit(-1)
}
this.templates[name] = {
heading: def.heading,
text: handlebars.compile(fs.readFileSync(textFilename).toString()),
html: def.html ? handlebars.compile(fs.readFileSync(def.html).toString()) : null
}
}
autoBind(this)
}
sendEmail(options) {
const {
toEmail,
templateName,
templateData
} = options
if (!toEmail || !templateName || !templateData) {
return Promise.reject(createError.BadRequest(`Must specify toEmail, templateName and templateData`))
}
// configure AWS SDK
aws.config = new aws.Config(config.get('awsConfig'));
// create Nodemailer SES transporter
let transporter = nodemailer.createTransport({
SES: new aws.SES({
apiVersion: '2010-12-01'
})
});
const senderEmail = config.get('senderEmail')
const template = this.templates[templateName]
if (!template) {
return Promise.reject(createError.BadRequest(`Template '${templateName}' was not found`))
}
let mailObj = {
from: senderEmail,
to: toEmail,
subject: template.heading,
text: template.text(templateData)
}
if (template.html) {
mailObj.html = template.html(templateData)
}
return transporter.sendMail(mailObj).then((info) => {
return Promise.resolve({
envelope: info.envelope,
messageId: info.messageId
})
})
}
}

40
server/src/email/index.js Normal file
View File

@@ -0,0 +1,40 @@
import config from 'config'
import pino from 'pino'
import * as pinoExpress from 'pino-pretty-express'
import createError from 'http-errors'
import { MS } from '../message-service'
import { EmailHandlers } from './EmailHandlers'
import path from 'path'
import fs from 'fs'
const serviceName = 'dar-email'
const isProduction = (process.env.NODE_ENV == 'production')
let log = null
if (isProduction) {
log = pino( { name: serviceName },
fs.createWriteStream(path.join(config.get('logDir'), serviceName + '.log'))
)
} else {
const pretty = pinoExpress.pretty({})
pretty.pipe(process.stdout)
log = pino({ name: serviceName }, pretty)
}
const ms = new MS(serviceName, { durable: true }, log)
let container = { ms, log }
const amqpUri = config.get('uri.amqp')
ms.connect(amqpUri).then(() => {
log.info(`Connected to RabbitMQ at ${amqpUri}`)
container = {
...container,
handlers: new EmailHandlers(container)
}
ms.listen(container.handlers)
}).catch((err) => {
log.error(isProduction ? err.message : err)
})

View File

@@ -0,0 +1,298 @@
import Canvas from 'canvas'
import fs from 'fs'
import util from 'util'
import createError from 'http-errors'
import autoBind from 'auto-bind2'
import stream from 'stream'
function streamToBuffer(readable) {
return new Promise((resolve, reject) => {
var chunks = []
var writeable = new stream.Writable()
writeable._write = function (chunk, enc, done) {
chunks.push(chunk)
done()
}
readable.on('end', function () {
resolve(Buffer.concat(chunks))
})
readable.on('error', (err) => {
reject(err)
})
readable.pipe(writeable);
})
}
function pipeToGridFS(readable, gfsWriteable) {
const promise = new Promise((resolve, reject) => {
readable.on('error', (error) => {
reject(error)
})
gfsWriteable.on('error', (error) => {
reject(error)
})
gfsWriteable.on('close', (file) => {
resolve(file)
})
})
readable.pipe(gfsWriteable)
return promise
}
function loadImage(buf) {
return new Promise((resolve, reject) => {
const image = new Canvas.Image()
function cleanup () {
image.onload = null
image.onerror = null
}
image.onload = () => {
cleanup();
resolve(image)
}
image.onerror = () => {
cleanup();
reject(new Error(`Failed to load the image "${buf}"`))
}
image.src = buf
})
}
// Derived from https://stackoverflow.com/questions/20600800/js-client-side-exif-orientation-rotate-and-mirror-jpeg-images
function getExifOrientation(buf) {
if (buf.length < 2 || buf.readUInt16BE(0) != 0xFFD8) {
return -2
}
let length = buf.byteLength
let offset = 2
while (offset < length) {
let marker = buf.readUInt16BE(offset)
offset += 2
if (marker == 0xFFE1) {
if (buf.readUInt32BE(offset += 2) != 0x45786966) {
return -1
}
let little = (buf.readUInt16BE(offset += 6) == 0x4949)
offset += (little ? buf.readUInt32LE(offset + 4) : buf.readUInt32BE(offset + 4))
const numTags = (little ? buf.readUInt16LE(offset) : buf.readUInt16BE(offset))
offset += 2
for (let i = 0; i < numTags; i++) {
let val = (little ? buf.readUInt16LE(offset + (i * 12)) : buf.readUInt16BE(offset + (i * 12)))
if (val === 0x0112) {
return (little ? buf.readUInt16LE(offset + (i * 12) + 8) : buf.readUInt16BE(offset + (i * 12) + 8))
}
}
} else if ((marker & 0xFF00) != 0xFF00) {
break
} else {
offset += buf.readUInt16BE(offset)
}
}
}
function normalizeOrientation(image, orientation) {
let width = image.width
let height = image.height
let canvas = new Canvas(width, height)
let ctx = canvas.getContext("2d")
if (4 < orientation && orientation < 9) {
canvas.width = height
canvas.height = width
} else {
canvas.width = width
canvas.height = height
}
switch (orientation) {
case 2: ctx.transform(-1, 0, 0, 1, width, 0); break
case 3: ctx.transform(-1, 0, 0, -1, width, height ); break
case 4: ctx.transform(1, 0, 0, -1, 0, height ); break
case 5: ctx.transform(0, 1, 1, 0, 0, 0); break
case 6: ctx.transform(0, 1, -1, 0, height , 0); break
case 7: ctx.transform(0, -1, -1, 0, height , width); break
case 8: ctx.transform(0, -1, 1, 0, 0, width); break
default: return Promise.resolve(image)
}
ctx.drawImage(image, 0, 0)
return loadImage(canvas.toBuffer())
}
export class ImageHandlers {
constructor(container) {
this.db = container.db
this.log = container.log
autoBind(this)
}
scaleImage(options) {
const {
newWidth = 400,
newHeight = 100,
scaleMode = 'scaleToFill',
inputFile,
inputAssetId,
outputFile
} = options
if (!inputFile && !inputAssetId) {
return Promise.reject(createError.BadRequest(`No inputAssetId or inputFile given`))
}
let canvas = new Canvas(newWidth, newHeight)
let ctx = canvas.getContext("2d")
ctx.imageSmoothingEnabled = true
let loadPromise = null
if (inputFile) {
loadPromise = util.promisify(fs.readFile)(inputFile)
} else {
loadPromise = streamToBuffer(this.db.gridfs.createReadStream({ _id: inputAssetId }))
}
let orientation
return loadPromise.then((buf) => {
orientation = getExifOrientation(buf)
return loadImage(buf)
}).then((img) => {
return normalizeOrientation(img, orientation)
}).then((img) => {
let x = 0
let y = 0
let scale = 1
switch (scaleMode) {
case 'aspectFill':
if (img.width - newWidth > img.height - newHeight) {
scale = newHeight / img.height
x = -(img.width * scale - newWidth) / 2
} else {
scale = newWidth / img.width
y = -(img.height * scale - newHeight) / 2
}
ctx.drawImage(img, x, y, img.width * scale, img.height * scale)
break
case 'aspectFit':
if (img.width - newWidth > img.height - newHeight) {
scale = newWidth / img.width
y = (newHeight - img.height * scale) / 2
} else {
scale = newHeight / img.height
x = (newWidth - img.width * scale) / 2
}
ctx.drawImage(img, x, y, img.width * scale, img.height * scale)
break
case 'scaleToFill':
default:
ctx.drawImage(img, 0, 0, newWidth, newHeight)
break
}
let savePromise = null
let readable = canvas.createPNGStream()
let writeable = null
if (outputFile) {
writeable = fs.createWriteStream(outputFile)
} else {
const _id = this.db.newObjectId()
writeable = this.db.gridfs.createWriteStream({
_id,
filename: _id + '.png',
content_type: 'image/png',
metadata: {
scaledFrom: this.db.newObjectId(inputAssetId),
width: newWidth,
height: newHeight
}
})
}
return pipeToGridFS(readable, writeable).then((file) => {
let res = {}
if (outputFile) {
res.outputFile = outputFile
} else if (file) {
res.outputAssetId = file._id
}
return Promise.resolve(res)
})
})
}
createPlaceholder(options) {
const {
width = 400,
height = 100,
fontSize = 36,
fontName = 'helvetica neue, arial black, sans serif',
fontWeight = 'bold',
background = '#1B1B1B',
foreground = '#333333',
outputFile
} = options
let canvas = new Canvas(width, height)
let ctx = canvas.getContext("2d")
const text = `${width}x${height}`
ctx.fillStyle = background
ctx.fillRect(0, 0, width, height)
ctx.font = `${fontWeight} ${fontSize}px ${fontName}`
ctx.fillStyle = foreground
const tm = ctx.measureText(text)
if (tm.width <= width / 2 && fontSize <= height / 2) {
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText(text, width / 2, height / 2, width)
}
let promise = null
let readable = canvas.createPNGStream()
let writeable = null
if (outputFile) {
writeable = fs.createWriteStream(outputFile)
} else {
const _id = this.db.newObjectId()
writeable = this.db.gridfs.createWriteStream({
_id,
filename: `${width}x${height}.png`,
content_type: 'image/png',
metadata: {
width: width,
height: height,
}
})
}
return pipeToGridFS(readable, writeable).then((file) => {
let res = {}
if (outputFile) {
res.outputFile = outputFile
} else if (file) {
res.outputAssetId = file._id
}
return Promise.resolve(res)
})
}
}

45
server/src/image/index.js Normal file
View File

@@ -0,0 +1,45 @@
import config from 'config'
import pino from 'pino'
import * as pinoExpress from 'pino-pretty-express'
import { DB } from '../database'
import { MS } from '../message-service'
import { ImageHandlers } from './ImageHandlers'
import path from 'path'
import fs from 'fs'
const serviceName = 'dar-image'
const isProduction = (process.env.NODE_ENV == 'production')
let log = null
if (isProduction) {
log = pino( { name: serviceName },
fs.createWriteStream(path.join(config.get('logDir'), serviceName + '.log'))
)
} else {
const pretty = pinoExpress.pretty({})
pretty.pipe(process.stdout)
log = pino({ name: serviceName }, pretty)
}
const ms = new MS(serviceName, { durable: false }, log)
const db = new DB()
let container = { db, ms, log }
const mongoUri = config.get('uri.mongo')
const amqpUri = config.get('uri.amqp')
Promise.all([
db.connect(mongoUri),
ms.connect(amqpUri)
]).then(() => {
log.info(`Connected to MongoDB at ${mongoUri}`)
log.info(`Connected to RabbitMQ at ${amqpUri}`)
container = {
...container,
handlers: new ImageHandlers(container)
}
ms.listen(container.handlers)
}).catch((err) => {
log.error(isProduction ? err.message : err)
})

View File

@@ -0,0 +1,114 @@
import amqp from 'amqplib'
import autoBind from 'auto-bind2'
import createError from 'http-errors'
export class MS {
constructor(exchangeName, options, log) {
this.exchangeName = exchangeName
this.options = options || {}
this.isProduction = (process.env.NODE_ENV === 'production')
this.log = log
autoBind(this)
}
async connect(amqpUri) {
this.connection = await amqp.connect(amqpUri)
this.connection.on('error', () => {
this.log.error(`RabbitMQ has gone, shutting down service`)
process.exit(-1)
})
this.channel = await this.connection.createChannel()
this.channel.prefetch(1) // Only process one message at a time
return this
}
async listen(obj) {
let handlers = {}
let typeNames = ''
for (const key of Object.getOwnPropertyNames(obj.constructor.prototype)) {
const val = obj[key]
if (key !== 'constructor' && typeof val === 'function') {
handlers[key] = val
if (!typeNames) {
typeNames = `'${key}'`
} else {
typeNames += ', ' + `'${key}'`
}
}
}
this.handlers = handlers
let ok = await this.channel.assertExchange(this.exchangeName, 'fanout', { durable: !!this.options.durable })
const q = await this.channel.assertQueue('', {exclusive: true})
this.log.info(`Waiting for '${this.exchangeName}' exchange ${typeNames} messages in queue '${q.queue}'`)
await this.channel.bindQueue(q.queue, this.exchangeName, '')
this.channel.consume(q.queue, this.consumeMessage)
}
consumeMessage(msg) {
const { type, appId, replyTo, correlationId } = msg.properties
const s = msg.content.toString()
const content = JSON.parse(s)
this.log.info(`Received '${type}' from '${appId}', ${s}`)
const sendReply = (replyContent) => {
if (content.passback) {
replyContent = { ...replyContent, passback: content.passback }
}
this.channel.sendToQueue(replyTo, new Buffer(JSON.stringify(replyContent)), {
correlationId,
appId,
contentType: 'application/json',
timestamp: Date.now(),
type: `replyTo.${type}`
})
}
this.dispatchMessage(type, content).then((res) => {
sendReply({ data: res })
this.channel.ack(msg)
this.log.info(`Processed '${type}' (correlation id '${correlationId}')`)
}).catch((err) => {
this.log.error(`Failed to process '${type}' (correlation id '${correlationId}')`)
if (!this.isProduction) {
// So we can see what happened
console.error(err)
}
sendReply({ error: { name: err.name, message: err.message, problems: err.problems } })
this.channel.ack(msg)
})
}
dispatchMessage(type, content) {
const handler = this.handlers[type]
if (handler) {
return handler(content)
} else {
return Promise.reject(createError.BadRequest(`Unknown message type '${type}'`))
}
}
// Used for intra-service requests, such as when generating packages
async request(exchangeName, msgType, msg, correlationId) {
const channel = await this.connection.createChannel()
await channel.checkExchange(exchangeName)
await channel.publish(exchangeName, '', new Buffer(JSON.stringify(msg)), {
type: msgType,
contentType: 'application/json',
timestamp: Date.now(),
correlationId,
appId: this.appId,
replyTo: this.replyQueueName
})
await channel.close()
return correlationId
}
}

View File

@@ -0,0 +1 @@
export { MS } from './MS'

9
server/src/server.js Normal file
View File

@@ -0,0 +1,9 @@
import childProcess from 'child_process'
const actors = [ 'api', 'email', 'image' ]
// TODO: spawn index.js in each of these sub-directories
// TODO: If any child exits, wait for a back-off period and then restart
// TODO: Gradually increase back-off period if process fails again too quickly