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/.babelrc Normal file
View File

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

7
server/.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
node_modules/
coverage/
dist/
.DS_Store
npm-debug.log*
yarn-debug.log*
yarn-error.log*

View File

@@ -0,0 +1,21 @@
{
logDir: '',
uri: {
mongo: 'mongodb://localhost/dar-v1',
amqp: 'amqp://localhost',
redis: 'redis://localhost',
},
api: {
port: '3001',
loginKey: '6508b427b3cc486498671cfd7967218bd6b95fbb42f5e17a112f4c9e9900057c',
uploadTimout: 3600,
},
email: {
senderEmail: 'support@kingstonsoftware.solutions',
maxEmailTokenAgeInHours: 36,
maxPasswordTokenAgeInHours: 3,
sendEmailDelayInSeconds: 3,
supportEmail: 'support@kingstonsoftware.solutions',
sendEmail: true
}
}

View File

@@ -0,0 +1,7 @@
{
awsConfig: {
accessKeyId: 'AKIAJUP6XRVYDAXNTUNA',
secretAccessKey: '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',
region: 'us-west-2'
}
}

View File

@@ -0,0 +1,10 @@
{
logDir: '/var/log/deighton-ar',
api: {
port: '3001',
loginKey: '*',
uploadTimout: 120,
maxEmailTokenAgeInHours: 36,
sendEmailDelayInSeconds: 3
},
}

View File

@@ -0,0 +1,27 @@
# Email Templates
This directory contains the email templates used for various communications with the user. The list of templates is as follows:
| File Name | Description
|:------------ |:-----------
`welcome.txt/.html` | Sent to users when they are first added to the system. Should include welcome message, link to email confirmation page and notification of link expiration and support email.
`forgotPassword.txt/.html` | Sent to users when they click on the forgot password link. Should include the link to the reset and support email. Rate limited in production.
`changeEmailNew.txt/.html` | Sent to the old email of existing users when they are changing their email. Should include support email and the new email.
`changeEmailOld.txt/.html` | Sent to existing users when they are changing their email. Should include the link to email confirmation page and support email.
`accountDeleted.txt/.html` | Notification that the users account has been deleted from the system. Must include a support email.
Each template must have a text (`.txt`) version and can also have an HTML (`.html`) version. Both will be sent and it is up to the users email reader to decide which to use.
## List of Supported Data Fields
This is the the definitive list of supported data fields in emails.
Name | Value
---- | -----
`recipientFullName` | Full name of the user receiving the email
`confirmEmailLink` | URL that will take the user to confirming their email, and if necessary setting their password (for first time account setup.)
`confirmEmailLinkExpirationHours` | The number of hours before the given link expires
`senderFullName` | The full name of the user that initiated the action
`supportEmail` | The support email for the system
`recipientNewEmail` | The new email that is being set for the user
`resetPasswordLink` | A link that allows the user to reset their password

View File

@@ -0,0 +1,9 @@
Hello {{recipientFullName}}.
This email is for your records to indicated that your account for the Deighton AR system has been deleted.
Please contact {{supportEmail}} if you have any questions.
Regards,
{{senderFullName}}

View File

@@ -0,0 +1,13 @@
Hello {{recipientFullName}},
This message allows you to complete the process of changing your email. If you did not make this request please do not worry. Just ignore this email and your account will remain unchanged.
If you did make this request, please click on the following link to confirm your new email:
{{confirmEmailLink}}
If you have any questions, please contact us at {{supportEmail}}.
Regards,
Deighton

View File

@@ -0,0 +1,11 @@
Hello {{recipientFullName}},
This message is to inform you that a request was made to change your email to {{recipientNewEmail}}. If you did not make this request please do not worry. Just ignore this email and your account will remain unchanged.
If you did make this request, please see the message sent to your new email account for further instructions.
If you have any questions, please contact us at {{supportEmail}}.
Regards,
Deighton

View File

@@ -0,0 +1,11 @@
Hello {{recipientFullName}},
The following link will allow you to reset your password. Please paste it into your browser and you will be redirected to the Deighton AR site to set your new password:
{{resetPasswordLink}}
Please contact {{supportEmail}} if you have any questions or problems.
Regards,
Deighton

View File

@@ -0,0 +1,22 @@
{
accountDeleted: {
heading: 'Deighton AR Account Deleted Notification',
text: 'accountDeleted.txt'
},
changeEmailNew: {
heading: 'Deighton AR Change of Email Confirmation',
text: 'changeEmailNew.txt'
},
changeEmailOld: {
heading: 'Deighton AR Change of Email Request',
text: 'changeEmailOld.txt'
},
forgotPassword: {
heading: 'Deighton AR Password Reset Request',
text: 'forgotPassword.txt'
},
welcome: {
heading: 'Welcome to the Deighton AR System!',
text: 'welcome.txt'
}
}

View File

@@ -0,0 +1,15 @@
Hello {{recipientFullName}},
Thank you for joining the Deighton AR system!
Please paste this link into your browser to go to the Deighton AR system and set your login password:
{{confirmEmailLink}}
This invitation expires in {{confirmEmailLinkExpirationHours}} hours. If it has expired or you have any problems creating a password or logging into our system, please contact {{supportEmail}}.
Thank you and we look forward to working with you!
Sincerely,
{{senderFullName}}

View File

@@ -0,0 +1,15 @@
[Unit]
Description=Deighton Service
After=rabbitmq-server.service mongod.service redis-server.service
[Service]
Type=simple
User=ubuntu
Group=ubuntu
WorkingDirectory=/home/ubuntu/deighton-ar/server
Environment='NODE_ENV=production'
ExecStart=/usr/bin/node server/index.js
Restart=on-abort
[Install]
WantedBy=multi-user.target

6622
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

77
server/package.json Normal file
View File

@@ -0,0 +1,77 @@
{
"name": "dar-server",
"version": "1.0.0",
"description": "Deighton AR Server",
"main": "src/server.js",
"scripts": {
"start": "babel-node src/index.js",
"start:prod": "NODE_ENV=production npm start",
"build": "babel src -d dist -s",
"serve": "NODE_ENV=production node dist/server.js",
"test": "jest",
"actor:api": "monzilla 'src/api/**/*.js:src/database/**/*.js' -- babel-node src/api/index.js",
"actor:api:debug": "babel-node --inspect-brk src/api/index.js",
"actor:image": "monzilla 'src/image/**/*.js:src/(message-service|database)/**/*.js' -- babel-node src/image/index.js",
"actor:image:debug": "babel-node --inspect-brk src/image/index.js",
"actor:email": "monzilla 'src/email/**/*.js:src/(message-service|database)/**/*.js' -- babel-node src/email/index.js",
"actor:email:debug": "babel-node --inspect-brk src/email/index.js"
},
"author": "John Lyon-Smith",
"license": "ISC",
"dependencies": {
"amqplib": "^0.5.1",
"app-root-path": "^2.0.1",
"auto-bind2": "^1.0.3",
"aws-sdk": "^2.98.0",
"body-parser": "^1.17.1",
"canvas": "^1.6.7",
"config": "^1.25.1",
"cors": "^2.8.3",
"credential": "^2.0.0",
"eventemitter3": "^2.0.3",
"express": "^4.15.2",
"gridfs-stream": "^1.1.1",
"handlebars": "^4.0.10",
"http-errors": "^1.6.1",
"json5": "^0.5.1",
"jsonwebtoken": "^7.4.0",
"mongodb": "^2.2.31",
"mongoose": "^4.11.7",
"mongoose-merge-plugin": "0.0.5",
"nodemailer": "^4.0.1",
"passport": "^0.3.2",
"passport-http-bearer": "^1.0.1",
"pino": "^4.10.1",
"pino-pretty-express": "^1.0.4",
"redis": "^2.7.1",
"redis-rstream": "^0.1.3",
"regexp-pattern": "^1.0.4",
"replace-ext": "^1.0.0",
"socket.io": "^2.0.3",
"urlsafe-base64": "^1.0.0",
"uuid": "^3.1.0"
},
"devDependencies": {
"babel-cli": "^6.24.1",
"babel-plugin-transform-class-properties": "^6.24.1",
"babel-plugin-transform-object-rest-spread": "^6.23.0",
"babel-preset-env": "^1.5.2",
"istanbul": "^0.4.5",
"jest": "^21.1.0",
"monzilla": "^1.1.0"
},
"private": true,
"keywords": {
"0": "rest",
"1": "api",
"2": "deighton"
},
"repository": {
"type": "git",
"url": "git+ssh://git@github.com/KingstonSoftware/deighton-ar.git"
},
"bugs": {
"url": "https://github.com/KingstonSoftware/deighton-ar/issues"
},
"homepage": "https://github.com/KingstonSoftware/deighton-ar#readme"
}

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