Initial commit
This commit is contained in:
13
server/src/api/.babelrc
Normal file
13
server/src/api/.babelrc
Normal 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
7
server/src/api/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
.DS_STORE
|
||||
*.log
|
||||
node_modules
|
||||
dist
|
||||
coverage
|
||||
**/local*.json*
|
||||
.idea/
|
||||
101
server/src/api/MQ.js
Normal file
101
server/src/api/MQ.js
Normal 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
19
server/src/api/RS.js
Normal 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
74
server/src/api/WS.js
Normal 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
105
server/src/api/index.js
Normal 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)
|
||||
})
|
||||
199
server/src/api/routes/AssetRoutes.js
Normal file
199
server/src/api/routes/AssetRoutes.js
Normal 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))
|
||||
})
|
||||
}
|
||||
}
|
||||
396
server/src/api/routes/AuthRoutes.js
Normal file
396
server/src/api/routes/AuthRoutes.js
Normal 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}`))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
493
server/src/api/routes/ProjectRoutes.js
Normal file
493
server/src/api/routes/ProjectRoutes.js
Normal 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))
|
||||
})
|
||||
}
|
||||
}
|
||||
339
server/src/api/routes/UserRoutes.js
Normal file
339
server/src/api/routes/UserRoutes.js
Normal 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))
|
||||
})
|
||||
}
|
||||
}
|
||||
3
server/src/api/routes/index.js
Normal file
3
server/src/api/routes/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
export { AuthRoutes } from './AuthRoutes'
|
||||
export { AssetRoutes } from './AssetRoutes'
|
||||
export { UserRoutes } from './UserRoutes'
|
||||
37
server/src/api/routes/loginToken.js
Normal file
37
server/src/api/routes/loginToken.js
Normal 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
50
server/src/database/DB.js
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
1
server/src/database/index.js
Normal file
1
server/src/database/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { DB } from './DB'
|
||||
3
server/src/database/schemas/index.js
Normal file
3
server/src/database/schemas/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
export { workItemSchema } from './workItem'
|
||||
export { userSchema } from './user'
|
||||
export { teamSchema } from './team'
|
||||
6
server/src/database/schemas/team.js
Normal file
6
server/src/database/schemas/team.js
Normal 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 })
|
||||
73
server/src/database/schemas/user.js
Normal file
73
server/src/database/schemas/user.js
Normal 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
|
||||
}
|
||||
17
server/src/database/schemas/workItem.js
Normal file
17
server/src/database/schemas/workItem.js
Normal 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
|
||||
}
|
||||
}
|
||||
88
server/src/email/EmailHandlers.js
Normal file
88
server/src/email/EmailHandlers.js
Normal 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
40
server/src/email/index.js
Normal 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)
|
||||
})
|
||||
298
server/src/image/ImageHandlers.js
Normal file
298
server/src/image/ImageHandlers.js
Normal 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
45
server/src/image/index.js
Normal 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)
|
||||
})
|
||||
114
server/src/message-service/MS.js
Normal file
114
server/src/message-service/MS.js
Normal 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
|
||||
}
|
||||
}
|
||||
1
server/src/message-service/index.js
Normal file
1
server/src/message-service/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { MS } from './MS'
|
||||
9
server/src/server.js
Normal file
9
server/src/server.js
Normal 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
|
||||
Reference in New Issue
Block a user