@flowfuse/nr-launcher 1.13.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +182 -0
- package/LICENSE +178 -0
- package/README.md +28 -0
- package/index.js +148 -0
- package/lib/admin.js +95 -0
- package/lib/auditLogger/index.js +41 -0
- package/lib/auth/adminAuth.js +77 -0
- package/lib/auth/httpAuthMiddleware.js +71 -0
- package/lib/auth/httpAuthPlugin.js +10 -0
- package/lib/auth/strategy.js +34 -0
- package/lib/context/FFContextStorage.js +422 -0
- package/lib/context/index.js +9 -0
- package/lib/context/memoryCache.js +156 -0
- package/lib/launcher.js +695 -0
- package/lib/logBuffer.js +57 -0
- package/lib/resources/resourcePlugin.js +20 -0
- package/lib/resources/sample.js +57 -0
- package/lib/resources/sampleBuffer.js +85 -0
- package/lib/runtimeSettings.js +320 -0
- package/lib/storage/index.js +92 -0
- package/lib/storage/libraryPlugin.js +90 -0
- package/lib/theme/LICENSE +178 -0
- package/lib/theme/README.md +24 -0
- package/lib/theme/common/forge-common.css +108 -0
- package/lib/theme/common/forge-common.js +75 -0
- package/lib/theme/forge-dark/forge-dark-custom.css +2 -0
- package/lib/theme/forge-dark/forge-dark-custom.js +1 -0
- package/lib/theme/forge-dark/forge-dark-monaco.json +213 -0
- package/lib/theme/forge-dark/forge-dark-theme.css +12 -0
- package/lib/theme/forge-dark/forge-dark.js +61 -0
- package/lib/theme/forge-light/forge-light-custom.css +2 -0
- package/lib/theme/forge-light/forge-light-custom.js +1 -0
- package/lib/theme/forge-light/forge-light-monaco.json +227 -0
- package/lib/theme/forge-light/forge-light-theme.css +12 -0
- package/lib/theme/forge-light/forge-light.js +62 -0
- package/package.json +72 -0
- package/resources/favicon-16x16.png +0 -0
- package/resources/favicon-32x32.png +0 -0
- package/resources/favicon.ico +0 -0
- package/resources/ff-nr.png +0 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
const { OAuth2 } = require('oauth')
|
|
2
|
+
const { Strategy } = require('./strategy')
|
|
3
|
+
|
|
4
|
+
module.exports = (options) => {
|
|
5
|
+
['clientID', 'clientSecret', 'forgeURL', 'baseURL'].forEach(prop => {
|
|
6
|
+
if (!options[prop]) {
|
|
7
|
+
throw new Error(`Missing configuration option ${prop}`)
|
|
8
|
+
}
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
const clientID = options.clientID
|
|
12
|
+
const clientSecret = options.clientSecret
|
|
13
|
+
const forgeURL = options.forgeURL
|
|
14
|
+
const baseURL = options.baseURL
|
|
15
|
+
|
|
16
|
+
const callbackURL = `${baseURL}/auth/strategy/callback`
|
|
17
|
+
const authorizationURL = `${forgeURL}/account/authorize`
|
|
18
|
+
const tokenURL = `${forgeURL}/account/token`
|
|
19
|
+
const userInfoURL = `${forgeURL}/api/v1/user`
|
|
20
|
+
|
|
21
|
+
const oa = new OAuth2(clientID, clientSecret, '', authorizationURL, tokenURL)
|
|
22
|
+
|
|
23
|
+
const version = require('../../package.json').version
|
|
24
|
+
|
|
25
|
+
const activeUsers = {}
|
|
26
|
+
|
|
27
|
+
function addUser (username, profile, refreshToken, expiresIn) {
|
|
28
|
+
if (activeUsers[username]) {
|
|
29
|
+
clearTimeout(activeUsers[username].refreshTimeout)
|
|
30
|
+
}
|
|
31
|
+
activeUsers[username] = {
|
|
32
|
+
profile,
|
|
33
|
+
refreshToken,
|
|
34
|
+
expiresIn
|
|
35
|
+
}
|
|
36
|
+
activeUsers[username].refreshTimeout = setTimeout(function () {
|
|
37
|
+
oa.getOAuthAccessToken(refreshToken, {
|
|
38
|
+
grant_type: 'refresh_token'
|
|
39
|
+
}, function (err, accessToken, refreshToken, results) {
|
|
40
|
+
if (err) {
|
|
41
|
+
delete activeUsers[username]
|
|
42
|
+
} else {
|
|
43
|
+
addUser(username, profile, refreshToken, results.expires_in)
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
}, expiresIn * 1000)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
type: 'strategy',
|
|
51
|
+
strategy: {
|
|
52
|
+
name: 'FlowFuse',
|
|
53
|
+
autoLogin: true,
|
|
54
|
+
label: 'Sign in',
|
|
55
|
+
strategy: Strategy,
|
|
56
|
+
options: {
|
|
57
|
+
authorizationURL,
|
|
58
|
+
tokenURL,
|
|
59
|
+
callbackURL,
|
|
60
|
+
userInfoURL,
|
|
61
|
+
scope: `editor-${version}`,
|
|
62
|
+
clientID,
|
|
63
|
+
clientSecret,
|
|
64
|
+
pkce: true,
|
|
65
|
+
state: true,
|
|
66
|
+
verify: function (accessToken, refreshToken, params, profile, done) {
|
|
67
|
+
profile.permissions = [params.scope || 'read']
|
|
68
|
+
addUser(profile.username, profile, refreshToken, params.expires_in)
|
|
69
|
+
done(null, profile)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
users: async function (username) {
|
|
74
|
+
return activeUsers[username] && activeUsers[username].profile
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
const { Passport } = require('passport')
|
|
2
|
+
const { Strategy } = require('./strategy')
|
|
3
|
+
|
|
4
|
+
let options
|
|
5
|
+
let passport
|
|
6
|
+
|
|
7
|
+
module.exports = {
|
|
8
|
+
init (_options) {
|
|
9
|
+
options = _options
|
|
10
|
+
return (req, res, next) => {
|
|
11
|
+
try {
|
|
12
|
+
if (req.session.ffSession) {
|
|
13
|
+
next()
|
|
14
|
+
} else {
|
|
15
|
+
req.session.redirectTo = req.originalUrl
|
|
16
|
+
passport.authenticate('FlowFuse', { session: false })(req, res, next)
|
|
17
|
+
}
|
|
18
|
+
} catch (err) {
|
|
19
|
+
console.log(err.stack)
|
|
20
|
+
throw err
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
|
|
25
|
+
setupAuthRoutes (app) {
|
|
26
|
+
if (!options) {
|
|
27
|
+
// If `init` has not been called, then the flowforge-user auth type
|
|
28
|
+
// has not been selected. No need to setup any further routes.
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
// 'app' is RED.httpNode - the express app that handles all http routes
|
|
32
|
+
// exposed by the flows.
|
|
33
|
+
|
|
34
|
+
passport = new Passport()
|
|
35
|
+
app.use(passport.initialize())
|
|
36
|
+
|
|
37
|
+
const callbackURL = `${options.baseURL}/_ffAuth/callback`
|
|
38
|
+
const authorizationURL = `${options.forgeURL}/account/authorize`
|
|
39
|
+
const tokenURL = `${options.forgeURL}/account/token`
|
|
40
|
+
const userInfoURL = `${options.forgeURL}/api/v1/user`
|
|
41
|
+
const version = require('../../package.json').version
|
|
42
|
+
|
|
43
|
+
passport.use('FlowFuse', new Strategy({
|
|
44
|
+
authorizationURL,
|
|
45
|
+
tokenURL,
|
|
46
|
+
callbackURL,
|
|
47
|
+
userInfoURL,
|
|
48
|
+
scope: `httpAuth-${version}`,
|
|
49
|
+
clientID: options.clientID,
|
|
50
|
+
clientSecret: options.clientSecret,
|
|
51
|
+
pkce: true,
|
|
52
|
+
state: true
|
|
53
|
+
}, function (accessToken, refreshToken, params, profile, done) {
|
|
54
|
+
done(null, profile)
|
|
55
|
+
}))
|
|
56
|
+
|
|
57
|
+
app.get('/_ffAuth/callback', passport.authenticate('FlowFuse', {
|
|
58
|
+
session: false
|
|
59
|
+
}), (req, res) => {
|
|
60
|
+
req.session.user = req.user
|
|
61
|
+
req.session.ffSession = true
|
|
62
|
+
if (req.session?.redirectTo) {
|
|
63
|
+
const redirectTo = req.session.redirectTo
|
|
64
|
+
delete req.session.redirectTo
|
|
65
|
+
res.redirect(redirectTo)
|
|
66
|
+
} else {
|
|
67
|
+
res.redirect('/')
|
|
68
|
+
}
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
const { setupAuthRoutes } = require('./httpAuthMiddleware')
|
|
2
|
+
|
|
3
|
+
module.exports = (RED) => {
|
|
4
|
+
RED.plugins.registerPlugin('ff-auth-plugin', {
|
|
5
|
+
onadd: () => {
|
|
6
|
+
RED.log.info('FlowFuse HTTP Authentication Plugin loaded')
|
|
7
|
+
setupAuthRoutes(RED.httpNode)
|
|
8
|
+
}
|
|
9
|
+
})
|
|
10
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
const util = require('util')
|
|
2
|
+
const OAuth2Strategy = require('passport-oauth2')
|
|
3
|
+
|
|
4
|
+
function Strategy (options, verify) {
|
|
5
|
+
this.options = options
|
|
6
|
+
this._base = Object.getPrototypeOf(Strategy.prototype)
|
|
7
|
+
this._base.constructor.call(this, this.options, verify)
|
|
8
|
+
this.name = 'FlowFuse'
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
util.inherits(Strategy, OAuth2Strategy)
|
|
12
|
+
|
|
13
|
+
Strategy.prototype.userProfile = function (accessToken, done) {
|
|
14
|
+
this._oauth2.useAuthorizationHeaderforGET(true)
|
|
15
|
+
this._oauth2.get(this.options.userInfoURL, accessToken, (err, body) => {
|
|
16
|
+
if (err) {
|
|
17
|
+
return done(err)
|
|
18
|
+
}
|
|
19
|
+
try {
|
|
20
|
+
const json = JSON.parse(body)
|
|
21
|
+
done(null, {
|
|
22
|
+
username: json.username,
|
|
23
|
+
email: json.email,
|
|
24
|
+
image: json.avatar,
|
|
25
|
+
name: json.name,
|
|
26
|
+
userId: json.id
|
|
27
|
+
})
|
|
28
|
+
} catch (e) {
|
|
29
|
+
done(e)
|
|
30
|
+
}
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
module.exports = { Strategy }
|
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
const MemoryCache = require('./memoryCache')
|
|
2
|
+
const got = require('got').default
|
|
3
|
+
const safeJSONStringify = require('json-stringify-safe')
|
|
4
|
+
const CONFIG_ERROR_MSG = 'Persistent context plugin cannot be used outside of FlowFuse EE environment'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @typedef {Object} FFContextStorageConfig - The configuration object for the FlowFuse Context Storage
|
|
8
|
+
* @property {string} projectID - The FlowFuse project ID
|
|
9
|
+
* @property {string} token - The FlowFuse project token
|
|
10
|
+
* @property {string} [url='http://127.0.0.1:3001'] - The FlowFuse File Store URL
|
|
11
|
+
* @property {number} [requestTimeout=3000] - The number of milliseconds to wait before timing out a request
|
|
12
|
+
* @property {number} [pageSize=20] - The number of context items/rows to fetch per page
|
|
13
|
+
* @property {number} [flushInterval=30] - The number of seconds to wait before flushing pending writes
|
|
14
|
+
* @property {boolean} [cache=true] - Whether to cache context items in memory (required for synchronous get/set)
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
class FFContextStorage {
|
|
18
|
+
constructor (/** @type {FFContextStorageConfig} */ config) {
|
|
19
|
+
// Ensure sane config
|
|
20
|
+
config = config || {}
|
|
21
|
+
config.pageSize = Math.max(1, config.pageSize || 20)
|
|
22
|
+
config.requestTimeout = Math.max(500, config.requestTimeout || 3000)
|
|
23
|
+
config.flushInterval = Math.max(0, config.flushInterval || 30) * 1000
|
|
24
|
+
config.cache = Object.hasOwn(config, 'cache') ? config.cache : true
|
|
25
|
+
config.projectID = config?.projectID || (process.env.FF_FS_TEST_CONFIG ? process.env.FLOWFORGE_PROJECT_ID : null)
|
|
26
|
+
config.token = config?.token || (process.env.FF_FS_TEST_CONFIG ? process.env.FLOWFORGE_PROJECT_TOKEN : null)
|
|
27
|
+
config.url = config?.url || 'http://127.0.0.1:3001'
|
|
28
|
+
|
|
29
|
+
// setup class vars
|
|
30
|
+
this.validSetup = false
|
|
31
|
+
/** @type {FFContextStorageConfig} */
|
|
32
|
+
this.config = config
|
|
33
|
+
if (config.cache) {
|
|
34
|
+
this.cache = MemoryCache({})
|
|
35
|
+
}
|
|
36
|
+
this.pendingWrites = {}
|
|
37
|
+
this.knownCircularRefs = {}
|
|
38
|
+
this.writePromise = Promise.resolve()
|
|
39
|
+
this._flushPendingWrites = function () { return Promise.resolve() } // place holder for later
|
|
40
|
+
this.validSetup = config.projectID && config.token && config.url?.startsWith('http')
|
|
41
|
+
if (!this.validSetup) {
|
|
42
|
+
console.warn(CONFIG_ERROR_MSG)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// create HTTP client instance for this project
|
|
46
|
+
/** @type {import('got').Got} */
|
|
47
|
+
this.client = got.extend({
|
|
48
|
+
prefixUrl: `${config.url}/v1/context/${config.projectID}`,
|
|
49
|
+
headers: {
|
|
50
|
+
'user-agent': 'FlowFuse Node-RED File Nodes for Storage Server',
|
|
51
|
+
authorization: 'Bearer ' + config.token
|
|
52
|
+
},
|
|
53
|
+
timeout: {
|
|
54
|
+
request: config.requestTimeout
|
|
55
|
+
},
|
|
56
|
+
retry: {
|
|
57
|
+
limit: 0
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async open () {
|
|
63
|
+
const self = this
|
|
64
|
+
if (!self.validSetup) {
|
|
65
|
+
return Promise.reject(new Error(CONFIG_ERROR_MSG))
|
|
66
|
+
}
|
|
67
|
+
if (!self.cache) {
|
|
68
|
+
self._flushPendingWrites = function () { return Promise.resolve() }
|
|
69
|
+
return Promise.resolve()
|
|
70
|
+
}
|
|
71
|
+
const opts = {
|
|
72
|
+
responseType: 'json'
|
|
73
|
+
}
|
|
74
|
+
self.nextActiveCursor = null
|
|
75
|
+
const limit = self.config.pageSize
|
|
76
|
+
let cursor = null
|
|
77
|
+
let result = await getNext(cursor, limit)
|
|
78
|
+
while (result) {
|
|
79
|
+
updateCache(result)
|
|
80
|
+
cursor = result.meta?.next_cursor
|
|
81
|
+
if (cursor) {
|
|
82
|
+
result = await getNext(cursor, limit)
|
|
83
|
+
} else {
|
|
84
|
+
result = null
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function getNext (cursor, limit) {
|
|
89
|
+
const path = paginateUrl('cache', cursor, limit)
|
|
90
|
+
const response = await self.client.get(path, opts)
|
|
91
|
+
return response?.body
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function updateCache (result) {
|
|
95
|
+
const rows = result?.data || []
|
|
96
|
+
for (let index = 0; index < rows.length; index++) {
|
|
97
|
+
const row = rows[index]
|
|
98
|
+
if (typeof row !== 'object') { continue }
|
|
99
|
+
const scope = row.scope
|
|
100
|
+
if (!scope) { continue }
|
|
101
|
+
const values = row?.values
|
|
102
|
+
if (typeof values !== 'object') { continue }
|
|
103
|
+
Object.keys(values).forEach(function (key) {
|
|
104
|
+
self.cache.set(scope, key, values[key])
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
// update _flushPendingWrites to a real function
|
|
109
|
+
self._flushPendingWrites = function () {
|
|
110
|
+
const scopes = Object.keys(self.pendingWrites)
|
|
111
|
+
self.pendingWrites = {}
|
|
112
|
+
const promises = []
|
|
113
|
+
const newContext = self.cache._export()
|
|
114
|
+
scopes.forEach(function (scope) {
|
|
115
|
+
const context = newContext[scope] || {}
|
|
116
|
+
const stringifiedContext = stringify(context)
|
|
117
|
+
if (stringifiedContext.circular && !self.knownCircularRefs[scope]) {
|
|
118
|
+
console.warn('context.flowforge.error-circular', scope)
|
|
119
|
+
self.knownCircularRefs[scope] = true
|
|
120
|
+
} else {
|
|
121
|
+
delete self.knownCircularRefs[scope]
|
|
122
|
+
}
|
|
123
|
+
promises.push(self._writeCache(scope, stringifiedContext.json))
|
|
124
|
+
})
|
|
125
|
+
delete self._pendingWriteTimeout
|
|
126
|
+
return Promise.all(promises)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
close () {
|
|
131
|
+
const self = this
|
|
132
|
+
if (this.cache && this._pendingWriteTimeout) {
|
|
133
|
+
clearTimeout(this._pendingWriteTimeout)
|
|
134
|
+
delete this._pendingWriteTimeout
|
|
135
|
+
this.config.flushInterval = 0
|
|
136
|
+
self.writePromise = self.writePromise.then(function () {
|
|
137
|
+
return self._flushPendingWrites().catch(function (err) {
|
|
138
|
+
console.error('Error flushing pending context writes:' + err.toString())
|
|
139
|
+
})
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
return this.writePromise
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Get one or more values from the context store
|
|
147
|
+
* @param {'context'|'flow'|'global'} scope - The scope of the context to get keys for
|
|
148
|
+
* @param {string|Array<string>} key - The key to get the value for
|
|
149
|
+
* @param {Function} callback - The callback to call when the value has been retrieved
|
|
150
|
+
* @example
|
|
151
|
+
* // get a single value
|
|
152
|
+
* http://localhost:3001/v1/context/project-id/flow?key=hello
|
|
153
|
+
* @example
|
|
154
|
+
* // get multiple values
|
|
155
|
+
* http://localhost:3001/v1/context/project-id/global?key=hello&key=nested.object.property
|
|
156
|
+
*/
|
|
157
|
+
get (scope, key, callback) {
|
|
158
|
+
if (!this.validSetup) {
|
|
159
|
+
return eeRejectOrCallback(!callback, callback)
|
|
160
|
+
}
|
|
161
|
+
if (this.cache) {
|
|
162
|
+
return this.cache.get(scope, key, callback)
|
|
163
|
+
} else if (typeof callback !== 'function') {
|
|
164
|
+
throw new Error('This context store must be called asynchronously')
|
|
165
|
+
} else {
|
|
166
|
+
const path = `${scope}`
|
|
167
|
+
const keys = Array.isArray(key) ? key : [key]
|
|
168
|
+
const opts = {
|
|
169
|
+
search: new URLSearchParams(keys.map(k => ['key', k])),
|
|
170
|
+
responseType: 'json'
|
|
171
|
+
}
|
|
172
|
+
this.client.get(path, opts).then(res => {
|
|
173
|
+
callback(null, ...reviver(keys, res.body))
|
|
174
|
+
}).catch(error => {
|
|
175
|
+
callback(normaliseError(error))
|
|
176
|
+
})
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Set one or more values in the context store
|
|
182
|
+
* @param {'context'|'flow'|'global'} scope - The scope of the context to set
|
|
183
|
+
* @param {string|Array<string>} key - The key(s) to set the value for
|
|
184
|
+
* @param {string|Array<string>} value - The value(s) to set for the given scope + key(s)
|
|
185
|
+
* @param {Function} callback - The callback to call when the value(s) have been set
|
|
186
|
+
* @example
|
|
187
|
+
* // set a single value
|
|
188
|
+
* http://localhost:3001/v1/context/project-id/flow
|
|
189
|
+
* // body
|
|
190
|
+
* [{ "key": "hello", "value": "world" }]
|
|
191
|
+
* @example
|
|
192
|
+
* // set multiple values
|
|
193
|
+
* http://localhost:3001/v1/context/project-id/flow
|
|
194
|
+
* // body
|
|
195
|
+
* [{ "key": "hello", "value": "world" }, { "key": "nested.object.property", "value": "value" }]
|
|
196
|
+
*/
|
|
197
|
+
set (scope, key, value, callback) {
|
|
198
|
+
const self = this
|
|
199
|
+
if (!self.validSetup) {
|
|
200
|
+
return eeRejectOrCallback(!callback, callback)
|
|
201
|
+
}
|
|
202
|
+
if (self.cache) {
|
|
203
|
+
self.cache.set(scope, key, value, callback)
|
|
204
|
+
self.pendingWrites[scope] = true
|
|
205
|
+
if (self._pendingWriteTimeout) {
|
|
206
|
+
// there's a pending write which will handle this
|
|
207
|
+
} else {
|
|
208
|
+
self._pendingWriteTimeout = setTimeout(function () {
|
|
209
|
+
self.writePromise = self.writePromise.then(function () {
|
|
210
|
+
return self._flushPendingWrites().catch(function (err) {
|
|
211
|
+
console.error('Error flushing pending context writes:' + err.toString())
|
|
212
|
+
})
|
|
213
|
+
})
|
|
214
|
+
}, self.flushInterval)
|
|
215
|
+
}
|
|
216
|
+
} else if (typeof callback !== 'function') {
|
|
217
|
+
throw new Error('This context store must be called asynchronously')
|
|
218
|
+
} else {
|
|
219
|
+
const path = `${scope}`
|
|
220
|
+
const data = Array.isArray(key) ? key.map((k, i) => ({ key: k, value: value[i] })) : [{ key, value }]
|
|
221
|
+
const stringifiedContext = stringify(data)
|
|
222
|
+
const opts = {
|
|
223
|
+
responseType: 'json',
|
|
224
|
+
body: stringifiedContext.json,
|
|
225
|
+
headers: { 'Content-Type': 'application/json;charset=utf-8' }
|
|
226
|
+
}
|
|
227
|
+
self.client.post(path, opts).then(res => {
|
|
228
|
+
callback(null)
|
|
229
|
+
}).catch(error => {
|
|
230
|
+
callback(normaliseError(error))
|
|
231
|
+
})
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Get a list of keys for a given scope
|
|
237
|
+
* @param {'context'|'flow'|'global'} scope - The scope of the context to get keys for
|
|
238
|
+
* @param {Function} callback - The callback to call when the keys have been retrieved
|
|
239
|
+
* @example
|
|
240
|
+
* http://localhost:3001/v1/context/project-id/global/keys
|
|
241
|
+
*/
|
|
242
|
+
keys (scope, callback) {
|
|
243
|
+
if (!this.validSetup) {
|
|
244
|
+
if (callback) {
|
|
245
|
+
callback(null, []) // quietly return empty key list
|
|
246
|
+
} else {
|
|
247
|
+
return Promise.resolve([]) // quietly return empty key list
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
if (this.cache) {
|
|
251
|
+
return this.cache.keys(scope, callback)
|
|
252
|
+
}
|
|
253
|
+
if (typeof callback !== 'function') {
|
|
254
|
+
throw new Error('This context store must be called asynchronously')
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const path = `${scope}/keys`
|
|
258
|
+
const opts = {
|
|
259
|
+
responseType: 'json',
|
|
260
|
+
headers: {
|
|
261
|
+
'Content-Type': 'application/json;charset=utf-8',
|
|
262
|
+
Accept: 'application/json'
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
this.client.get(path, opts).then(res => {
|
|
266
|
+
callback(null, res.body || [])
|
|
267
|
+
}).catch(error => {
|
|
268
|
+
callback(normaliseError(error))
|
|
269
|
+
})
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Delete the context of the given node/flow/global
|
|
274
|
+
* @param {String} scope - the scope to delete
|
|
275
|
+
*/
|
|
276
|
+
delete (scope) {
|
|
277
|
+
const self = this
|
|
278
|
+
if (!self.validSetup) {
|
|
279
|
+
return Promise.resolve() // quietly ignore
|
|
280
|
+
}
|
|
281
|
+
let cachePromise
|
|
282
|
+
if (self.cache) {
|
|
283
|
+
cachePromise = self.cache.delete(scope)
|
|
284
|
+
} else {
|
|
285
|
+
cachePromise = Promise.resolve()
|
|
286
|
+
}
|
|
287
|
+
delete self.pendingWrites[scope]
|
|
288
|
+
return cachePromise.then(function () {
|
|
289
|
+
return self.client.delete(scope)
|
|
290
|
+
})
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Delete any contexts that are no longer in use
|
|
295
|
+
* @param {Array<string>} _activeNodes - a list of nodes still active
|
|
296
|
+
*/
|
|
297
|
+
clean (_activeNodes) {
|
|
298
|
+
const self = this
|
|
299
|
+
if (!self.validSetup) {
|
|
300
|
+
return Promise.resolve() // quietly ignore
|
|
301
|
+
}
|
|
302
|
+
const activeNodes = _activeNodes || []
|
|
303
|
+
const opts = { json: activeNodes }
|
|
304
|
+
let cachePromise
|
|
305
|
+
if (self.cache) {
|
|
306
|
+
cachePromise = self.cache.clean(activeNodes)
|
|
307
|
+
} else {
|
|
308
|
+
cachePromise = Promise.resolve()
|
|
309
|
+
}
|
|
310
|
+
self.knownCircularRefs = {}
|
|
311
|
+
return cachePromise.then(function () {
|
|
312
|
+
return self.client.post('clean', opts)
|
|
313
|
+
}).then(() => {
|
|
314
|
+
// done
|
|
315
|
+
}).catch(error => {
|
|
316
|
+
error.code ||= 'unexpected_error'
|
|
317
|
+
// TODO: log error?
|
|
318
|
+
})
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
_export () {
|
|
322
|
+
// TODO: needed? I think not looking through @node-red/runtime/lib/nodes/context/index.js
|
|
323
|
+
return []
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async _writeCache (scope, jsonData) {
|
|
327
|
+
const self = this
|
|
328
|
+
const path = `cache/${scope}`
|
|
329
|
+
const opts = {
|
|
330
|
+
responseType: 'json',
|
|
331
|
+
body: jsonData,
|
|
332
|
+
headers: { 'Content-Type': 'application/json;charset=utf-8' }
|
|
333
|
+
}
|
|
334
|
+
await self.client.post(path, opts)
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// #region helpers
|
|
339
|
+
function stringify (value) {
|
|
340
|
+
let hasCircular
|
|
341
|
+
const result = safeJSONStringify(value, null, null, function (k, v) { hasCircular = true })
|
|
342
|
+
return { json: result, circular: hasCircular }
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const reviver = (keys, data) => {
|
|
346
|
+
const result = keys.map(key => {
|
|
347
|
+
const el = data.find(e => e.key === key)
|
|
348
|
+
return el?.value
|
|
349
|
+
})
|
|
350
|
+
return result
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function eeRejectOrCallback (reject, callback) {
|
|
354
|
+
if (callback) {
|
|
355
|
+
callback(new Error(CONFIG_ERROR_MSG))
|
|
356
|
+
} else if (reject) {
|
|
357
|
+
return Promise.reject(new Error(CONFIG_ERROR_MSG))
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function normaliseError (err) {
|
|
362
|
+
let niceError = new Error('Unexpected error.')
|
|
363
|
+
let statusCode = null
|
|
364
|
+
let childErr = {}
|
|
365
|
+
niceError.code ||= 'unexpected_error'
|
|
366
|
+
if (typeof err === 'string') {
|
|
367
|
+
niceError = new Error(err)
|
|
368
|
+
} else if (err?._normalised) {
|
|
369
|
+
return err // already normalised
|
|
370
|
+
}
|
|
371
|
+
if (err?.response) {
|
|
372
|
+
statusCode = err.response.statusCode
|
|
373
|
+
if (err.response.body) {
|
|
374
|
+
try {
|
|
375
|
+
if (err.response.body && typeof err.response.body === 'object') {
|
|
376
|
+
childErr = err.response.body
|
|
377
|
+
} else {
|
|
378
|
+
childErr = { ...JSON.parse(err.response.body.toString()) }
|
|
379
|
+
}
|
|
380
|
+
} catch (_error) { /* do nothing */ }
|
|
381
|
+
if (!childErr || typeof childErr !== 'object') {
|
|
382
|
+
childErr = {}
|
|
383
|
+
}
|
|
384
|
+
Object.assign(niceError, childErr)
|
|
385
|
+
niceError.message = childErr.error || childErr.message || niceError.message
|
|
386
|
+
niceError.code = childErr.code || niceError.code
|
|
387
|
+
niceError.stack = childErr.stack || niceError.stack
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
if (statusCode === 413) {
|
|
391
|
+
niceError.message = 'Quota exceeded.'
|
|
392
|
+
if (childErr && childErr.limit) {
|
|
393
|
+
niceError.message += ` The current limit is ${childErr.limit} bytes.`
|
|
394
|
+
}
|
|
395
|
+
niceError.code = 'quota_exceeded'
|
|
396
|
+
}
|
|
397
|
+
niceError.stack = niceError.stack || err.stack
|
|
398
|
+
niceError.code = niceError.code || err.code
|
|
399
|
+
niceError._normalised = true // prevent double processing
|
|
400
|
+
return niceError
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function paginateUrl (url, cursor, limit, query) {
|
|
404
|
+
const queryString = new URLSearchParams()
|
|
405
|
+
if (cursor) {
|
|
406
|
+
queryString.append('cursor', cursor)
|
|
407
|
+
}
|
|
408
|
+
if (limit) {
|
|
409
|
+
queryString.append('limit', limit)
|
|
410
|
+
}
|
|
411
|
+
if (query) {
|
|
412
|
+
queryString.append('query', query)
|
|
413
|
+
}
|
|
414
|
+
const qs = queryString.toString()
|
|
415
|
+
if (qs === '') {
|
|
416
|
+
return url
|
|
417
|
+
}
|
|
418
|
+
return `${url}?${qs}`
|
|
419
|
+
}
|
|
420
|
+
// #endregion helpers
|
|
421
|
+
|
|
422
|
+
exports.FFContextStorage = FFContextStorage
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
const { FFContextStorage } = require('./FFContextStorage')
|
|
2
|
+
/**
|
|
3
|
+
* Create a New FFContextStorage
|
|
4
|
+
* @param {FFContextStorageConfig} config Config options (see FFContextStorageConfig typedef)
|
|
5
|
+
* @returns {FFContextStorage} A new FFContextStorage instance
|
|
6
|
+
*/
|
|
7
|
+
module.exports = function (config) {
|
|
8
|
+
return new FFContextStorage(config)
|
|
9
|
+
}
|