Initial commit
This commit is contained in:
13
server/.babelrc
Normal file
13
server/.babelrc
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"presets": [
|
||||
[ "env", {
|
||||
"targets": {
|
||||
"node": 8
|
||||
}
|
||||
}]
|
||||
],
|
||||
"plugins": [
|
||||
"transform-class-properties",
|
||||
"transform-object-rest-spread"
|
||||
]
|
||||
}
|
||||
7
server/.gitignore
vendored
Normal file
7
server/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
coverage/
|
||||
dist/
|
||||
.DS_Store
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
21
server/config/default.json5
Normal file
21
server/config/default.json5
Normal 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
|
||||
}
|
||||
}
|
||||
7
server/config/local-development.json5
Normal file
7
server/config/local-development.json5
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
awsConfig: {
|
||||
accessKeyId: 'AKIAJUP6XRVYDAXNTUNA',
|
||||
secretAccessKey: 'hbZpkr9QLMivVK5oIGlnSa18ivqAYBPTdoUFYDqt',
|
||||
region: 'us-west-2'
|
||||
}
|
||||
}
|
||||
10
server/config/production.json5
Normal file
10
server/config/production.json5
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
logDir: '/var/log/deighton-ar',
|
||||
api: {
|
||||
port: '3001',
|
||||
loginKey: '*',
|
||||
uploadTimout: 120,
|
||||
maxEmailTokenAgeInHours: 36,
|
||||
sendEmailDelayInSeconds: 3
|
||||
},
|
||||
}
|
||||
27
server/config/templates/README.md5
Normal file
27
server/config/templates/README.md5
Normal 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
|
||||
9
server/config/templates/accountDeleted.txt
Normal file
9
server/config/templates/accountDeleted.txt
Normal 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}}
|
||||
13
server/config/templates/changeEmailNew.txt
Normal file
13
server/config/templates/changeEmailNew.txt
Normal 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
|
||||
11
server/config/templates/changeEmailOld.txt
Normal file
11
server/config/templates/changeEmailOld.txt
Normal 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
|
||||
11
server/config/templates/forgotPassword.txt
Normal file
11
server/config/templates/forgotPassword.txt
Normal 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
|
||||
22
server/config/templates/templates.json5
Normal file
22
server/config/templates/templates.json5
Normal 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'
|
||||
}
|
||||
}
|
||||
15
server/config/templates/welcome.txt
Normal file
15
server/config/templates/welcome.txt
Normal 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}}
|
||||
15
server/deighton-ar.service
Normal file
15
server/deighton-ar.service
Normal 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
6622
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
77
server/package.json
Normal file
77
server/package.json
Normal 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
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