prisme-flow
Version:
prisme platform flow engine
540 lines (520 loc) • 19.2 kB
JavaScript
/**
* Created by prisme.io on 09/06/2017.
*/
module.exports = function (RED) {
'use strict'
var crypto = require('crypto')
var fs = require('fs')
var request = require('request')
var url = require('url')
var minimatch = require('minimatch')
function BoxNode (n) {
RED.nodes.createNode(this, n)
}
RED.nodes.registerType('box-credentials', BoxNode, {
credentials: {
displayName: {type: 'text'},
clientId: {type: 'text'},
clientSecret: {type: 'password'},
accessToken: {type: 'password'},
refreshToken: {type: 'password'},
expireTime: {type: 'password'}
}
})
BoxNode.prototype.refreshToken = function (cb) {
var credentials = this.credentials
var node = this
// console.log("refreshing token: " + credentials.refreshToken);
if (!credentials.refreshToken) {
// TODO: add a timeout to make sure we make a request
// every so often (if no flows trigger one) to ensure the
// refresh token does not expire
node.error(RED._('refresh token error: __message__'))
return cb(RED._('refresh token error: __message__'))
}
request.post({
url: 'https://api.box.com/oauth2/token',
json: true,
form: {
grant_type: 'refresh_token',
client_id: credentials.clientId,
client_secret: credentials.clientSecret,
refresh_token: credentials.refreshToken
}
}, function (err, result, data) {
if (err) {
node.error(RED._('refresh token request error: __err__', {err: err}))
return
}
if (data.error) {
node.error(RED._('refresh token error: __message__', {message: data.error.message}))
return
}
// console.log("refreshed: " + require('util').inspect(data));
credentials.accessToken = data.access_token
if (data.refresh_token) {
credentials.refreshToken = data.refresh_token
}
credentials.expiresIn = data.expires_in
credentials.expireTime =
data.expires_in + (new Date().getTime() / 1000)
credentials.tokenType = data.token_type
RED.nodes.addCredentials(node.id, credentials)
if (typeof cb !== undefined) {
cb()
}
})
}
BoxNode.prototype.request = function (req, retries, cb) {
var node = this
if (typeof retries === 'function') {
cb = retries
retries = 1
}
if (typeof req !== 'object') {
req = { url: req }
}
req.method = req.method || 'GET'
if (!req.hasOwnProperty('json')) {
req.json = true
}
// always set access token to the latest ignoring any already present
req.auth = { bearer: this.credentials.accessToken }
if (!this.credentials.expireTime ||
this.credentials.expireTime < (new Date().getTime() / 1000)) {
if (retries === 0) {
node.error(RED._('too many refresh attempts, giving up'))
cb(RED._('too many refresh attempts, giving up'))
return
}
node.warn(RED._('trying to refresh token due to expiry'))
node.refreshToken(function (err) {
if (err) {
return
}
node.request(req, 0, cb)
})
return
}
return request(req, function (err, result, data) {
if (err) {
// handled in callback
return cb(err, data)
}
if (result.statusCode === 401 && retries > 0) {
retries--
node.warn(RED._('refreshing access token after 401 error'))
node.refreshToken(function (err) {
if (err) {
return cb(err, null)
}
return node.request(req, retries, cb)
})
}
if (result.statusCode >= 400) {
return cb(result.statusCode + ': ' + data.message, data)
}
return cb(err, data)
})
}
BoxNode.prototype.folderInfo = function (parent_id, cb) {
this.request('https://api.box.com/2.0/folders/' + parent_id, cb)
}
BoxNode.prototype.resolvePath = function (path, parent_id, cb) {
var node = this
if (typeof parent_id === 'function') {
cb = parent_id
parent_id = 0
}
if (typeof path === 'string') {
// split path and remove empty string components
path = path.split('/').filter(function (e) { return e !== '' })
// TODO: could also handle '/blah/../' and '/./' perhaps
} else {
path = path.filter(function (e) { return e !== '' })
}
if (path.length === 0) {
return cb(null, parent_id)
}
var folder = path.shift()
node.folderInfo(parent_id, function (err, data) {
if (err) {
return cb(err, -1)
}
var entries = data.item_collection.entries
for (var i = 0; i < entries.length; i++) {
if (entries[i].type === 'folder' &&
entries[i].name === folder) {
// found
return node.resolvePath(path, entries[i].id, cb)
}
}
return cb(RED._('not found'), -1)
})
}
BoxNode.prototype.resolveFile = function (path, parent_id, cb) {
var node = this
if (typeof parent_id === 'function') {
cb = parent_id
parent_id = 0
}
if (typeof path === 'string') {
// split path and remove empty string components
path = path.split('/').filter(function (e) { return e !== '' })
// TODO: could also handle '/blah/../' and '/./' perhaps
} else {
path = path.filter(function (e) { return e !== '' })
}
if (path.length === 0) {
return cb(RED._('missing filename?'), -1)
}
var file = path.pop()
node.resolvePath(path, function (err, parent_id) {
if (err) {
return cb(err, parent_id)
}
node.folderInfo(parent_id, function (err, data) {
if (err) {
return cb(err, -1)
}
var entries = data.item_collection.entries
for (var i = 0; i < entries.length; i++) {
if (entries[i].type === 'file' &&
entries[i].name === file) {
// found
return cb(null, entries[i].id)
}
}
return cb(RED._('not found'), -1)
})
})
}
function constructFullPath (entry) {
var parentPath = entry.path_collection.entries
.filter(function (e) { return e.id !== '0' })
.map(function (e) { return e.name })
.join('/')
return (parentPath !== '' ? parentPath + '/' : '') + entry.name
}
RED.httpAdmin.get('/box-credentials/auth', function (req, res) {
if (!req.query.clientId || !req.query.clientSecret ||
!req.query.id || !req.query.callback) {
res.send(400)
return
}
var node_id = req.query.id
var callback = req.query.callback
var credentials = {
clientId: req.query.clientId,
clientSecret: req.query.clientSecret
}
var csrfToken = crypto.randomBytes(18).toString('base64').replace(/\//g, '-').replace(/\+/g, '_')
credentials.csrfToken = csrfToken
credentials.callback = callback
res.cookie('csrf', csrfToken)
res.redirect(url.format({
protocol: 'https',
hostname: 'app.box.com',
pathname: '/api/oauth2/authorize',
query: {
response_type: 'code',
client_id: credentials.clientId,
state: node_id + ':' + csrfToken,
redirect_uri: callback
}
}))
RED.nodes.addCredentials(node_id, credentials)
})
RED.httpAdmin.get('/box-credentials/auth/callback', function (req, res) {
if (req.query.error) {
return res.send('ERROR: ' + req.query.error + ': ' + req.query.error_description)
}
var state = req.query.state.split(':')
var node_id = state[0]
var credentials = RED.nodes.getCredentials(node_id)
if (!credentials || !credentials.clientId || !credentials.clientSecret) {
return res.send(RED._('ERROR: no credentials - should never happen'))
}
if (state[1] !== credentials.csrfToken) {
return res.status(401).send(
RED._('CSRF token mismatch, possible cross-site request forgery attempt.')
)
}
request.post({
url: 'https://app.box.com/api/oauth2/token',
json: true,
form: {
grant_type: 'authorization_code',
code: req.query.code,
client_id: credentials.clientId,
client_secret: credentials.clientSecret,
redirect_uri: credentials.callback
}
}, function (err, result, data) {
if (err) {
console.log('request error:' + err)
return res.send(RED._('Something went wrong in the authentication process. Please try again.'))
}
if (data.error) {
console.log('oauth error: ' + data.error)
return res.send(RED._('Something went wrong in the authentication process. Please try again.'))
}
// console.log("data: " + require('util').inspect(data));
credentials.accessToken = data.access_token
credentials.refreshToken = data.refresh_token
credentials.expiresIn = data.expires_in
credentials.expireTime = data.expires_in + (new Date().getTime() / 1000)
credentials.tokenType = data.token_type
delete credentials.csrfToken
delete credentials.callback
RED.nodes.addCredentials(node_id, credentials)
request.get({
url: 'https://api.box.com/2.0/users/me',
json: true,
auth: { bearer: credentials.accessToken }
}, function (err, result, data) {
if (err) {
console.log('fetching box profile failed: ' + err)
return res.send(RED._('auth worked but profile fetching failed'))
}
if (result.statusCode >= 400) {
console.log('fetching box profile failed: ' +
result.statusCode + ': ' + data.message)
return res.send(RED._('auth worked but profile fetching failed'))
}
if (!data.name) {
console.log('fetching box profile failed: no name found')
return res.send(RED._('auth worked but profile fetching failed'))
}
credentials.displayName = data.name
RED.nodes.addCredentials(node_id, credentials)
res.send(RED._('<html><head></head><body>Authorised - you can close this window and return to prisme-flow</body></html>'))
})
})
})
function BoxInNode (n) {
RED.nodes.createNode(this, n)
this.filepattern = n.filepattern || ''
this.box = RED.nodes.getNode(n.box)
var node = this
if (!this.box || !this.box.credentials.accessToken) {
this.warn(RED._('Missing box credentialss'))
return
}
node.status({fill: 'blue', shape: 'dot', text: 'initializing'})
this.box.request({
url: 'https://api.box.com/2.0/events?stream_position=now&stream_type=changes'
}, function (err, data) {
if (err) {
node.error(RED._('box.error.event-stream-initialize-failed', {err: err.toString()}))
node.status({fill: 'red', shape: 'ring', text: 'failed'})
return
}
node.state = data.next_stream_position
node.status({})
node.on('input', function (msg) {
node.status({fill: 'blue', shape: 'dot', text: 'checking for events'})
node.box.request({
url: 'https://api.box.com/2.0/events?stream_position=' + node.state + '&stream_type=changes'
}, function (err, data) {
if (err) {
node.error(RED._('failed to fetch events: __err__', {err: err.toString()}), msg)
node.status({})
return
}
node.status({})
node.state = data.next_stream_position
for (var i = 0; i < data.entries.length; i++) {
// TODO: support other event types
// TODO: suppress duplicate events
// for both of the above see:
// https://developers.box.com/docs/#events
var event
if (data.entries[i].event_type === 'ITEM_CREATE') {
event = 'add'
} else if (data.entries[i].event_type === 'ITEM_UPLOAD') {
event = 'add'
} else if (data.entries[i].event_type === 'ITEM_RENAME') {
event = 'add'
// TODO: emit delete event?
} else if (data.entries[i].event_type === 'ITEM_TRASH') {
// need to find old path
node.lookupOldPath({}, data.entries[i], 'delete')
/* strictly speaking the {} argument above should
* be clone(msg) but:
* - it must be {}
* - if there was any possibility of a different
* msg then it should be cloned using the
* node-red/red/nodes/Node.js cloning function
*/
continue
} else {
event = 'unknown'
}
// console.log(JSON.stringify(data.entries[i], null, 2));
node.sendEvent(msg, data.entries[i], event)
}
})
})
var interval = setInterval(function () {
node.emit('input', {})
}, 600000) // 10 minutes
node.on('close', function () {
if (interval !== null) { clearInterval(interval) }
})
})
}
RED.nodes.registerType('box in', BoxInNode)
BoxInNode.prototype.sendEvent = function (msg, entry, event, path) {
var source = entry.source
if (typeof path === 'undefined') {
path = constructFullPath(source)
}
if (this.filepattern && !minimatch(path, this.filepattern)) {
return
}
msg.file = source.name
msg.payload = path
msg.event = event
msg.data = entry
this.send(msg)
}
BoxInNode.prototype.lookupOldPath = function (msg, entry, event) {
var source = entry.source
this.status({fill: 'blue', shape: 'dot', text: 'resolving path'})
var node = this
node.box.folderInfo(source.parent.id, function (err, folder) {
if (err) {
node.warn(RED._('failed to resolve old path: __err__', {err: err.toString()}))
node.status({fill: 'red', shape: 'ring', text: 'failed'})
return
}
node.status({})
// TODO: add folder path_collection to entry.parent?
var parentPath = constructFullPath(folder)
node.sendEvent(msg, entry, event,
(parentPath !== '' ? parentPath + '/' : '') + source.name)
})
}
function BoxQueryNode (n) {
RED.nodes.createNode(this, n)
this.filename = n.filename || ''
this.box = RED.nodes.getNode(n.box)
var node = this
if (!this.box || !this.box.credentials.accessToken) {
this.warn(RED._('Missing box credentials'))
return
}
node.on('input', function (msg) {
var filename = node.filename || msg.filename
if (filename === '') {
node.error(RED._('No filename specified'))
return
}
msg.filename = filename
node.status({fill: 'blue', shape: 'dot', text: 'resolving path'})
node.box.resolveFile(filename, function (err, file_id) {
if (err) {
node.error(RED._('failed to resolve path: __err__', {err: err.toString()}), msg)
node.status({fill: 'red', shape: 'ring', text: 'failed'})
return
}
node.status({fill: 'blue', shape: 'dot', text: 'downloading'})
node.box.request({
url: 'https://api.box.com/2.0/files/' + file_id + '/content',
json: false,
followRedirect: true,
maxRedirects: 1,
encoding: null
}, function (err, data) {
if (err) {
node.error(RED._('download failed: __err__', {err: err.toString()}), msg)
node.status({fill: 'red', shape: 'ring', text: 'failed'})
} else {
msg.payload = data
delete msg.error
node.status({})
node.send(msg)
}
})
})
})
}
RED.nodes.registerType('box', BoxQueryNode)
function BoxOutNode (n) {
RED.nodes.createNode(this, n)
this.filename = n.filename || ''
this.localFilename = n.localFilename || ''
this.box = RED.nodes.getNode(n.box)
var node = this
if (!this.box || !this.box.credentials.accessToken) {
this.warn(RED._('Missing box credentials'))
return
}
node.on('input', function (msg) {
var filename = node.filename || msg.filename
if (filename === '') {
node.error(RED._('No filename specified'))
return
}
var path = filename.split('/')
var basename = path.pop()
node.status({fill: 'blue', shape: 'dot', text: 'resolving path'})
var localFilename = node.localFilename || msg.localFilename
if (!localFilename && typeof msg.payload === 'undefined') {
return
}
node.box.resolvePath(path, function (err, parent_id) {
if (err) {
node.error(RED._('failed to resolve path: __err__', {err: err.toString()}), msg)
node.status({fill: 'red', shape: 'ring', text: 'failed'})
return
}
node.status({fill: 'blue', shape: 'dot', text: 'uploading'})
var r = node.box.request({
method: 'POST',
url: 'https://upload.box.com/api/2.0/files/content'
}, function (err, data) {
if (err) {
if (data && data.status === 409 &&
data.context_info && data.context_info.conflicts) {
// existing file, attempt to overwrite it
node.status({fill: 'blue', shape: 'dot', text: 'overwriting'})
var r = node.box.request({
method: 'POST',
url: 'https://upload.box.com/api/2.0/files/' +
data.context_info.conflicts.id + '/content'
}, function (err, data) {
if (err) {
node.error(RED._('failed upload: __err__', {err: err.toString()}), msg)
node.status({fill: 'red', shape: 'ring', text: 'failed'})
return
}
node.status({})
})
var form = r.form()
if (localFilename) {
form.append('filename', fs.createReadStream(localFilename), { filename: basename })
} else {
form.append('filename', RED.util.ensureBuffer(msg.payload), { filename: basename })
}
} else {
node.error(RED._('failed upload: __err__', {err: err.toString()}), msg)
node.status({fill: 'red', shape: 'ring', text: 'failed'})
}
return
}
node.status({})
})
var form = r.form()
if (localFilename) {
form.append('filename', fs.createReadStream(localFilename), { filename: basename })
} else {
form.append('filename', RED.util.ensureBuffer(msg.payload), { filename: basename })
}
form.append('parent_id', parent_id)
})
})
}
RED.nodes.registerType('box out', BoxOutNode)
}