node-red-dashboard-2-hmi 0.1.10 → 0.1.11

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/nodes/access.js CHANGED
@@ -1,69 +1,69 @@
1
- 'use strict'
2
-
3
- // Who may do what on a canvas. Each list is typed in the node:
4
- // empty -> everybody
5
- // "-" -> nobody (view only)
6
- // names -> only these users, separated by commas (user name, login or e-mail)
7
- // The user comes from the dashboard login (FlowFuse / multi-user plugins or basic auth),
8
- // so these checks run in Node-RED and a page cannot get around them.
9
- const ROLES = ['cmd', 'ack', 'shelve']
10
-
11
- function parseList (text) {
12
- const t = String(text || '').trim()
13
- if (!t) return null
14
- if (t === '-') return []
15
- return t.split(/[,;\n]/).map(s => s.trim().toLowerCase()).filter(Boolean)
16
- }
17
-
18
- function namesOf (user) {
19
- if (!user) return []
20
- if (typeof user === 'string') return [user.toLowerCase()]
21
- return ['name', 'username', 'email', 'userId', 'id', 'login']
22
- .map(k => user[k]).filter(v => typeof v === 'string' && v).map(v => v.toLowerCase())
23
- }
24
-
25
- function createAccess (config) {
26
- const lists = {
27
- cmd: parseList(config.cmdUsers),
28
- ack: parseList(config.ackUsers),
29
- // shelving needs the same people as acknowledging when it has no list of its own
30
- shelve: parseList(config.shelveUsers !== undefined && String(config.shelveUsers).trim() !== '' ? config.shelveUsers : config.ackUsers)
31
- }
32
- const can = (role, client) => {
33
- const list = lists[role]
34
- if (list === null || list === undefined) return true
35
- const names = namesOf(client && client._client ? client._client.user : client && client.user)
36
- return names.some(n => list.includes(n))
37
- }
38
- return {
39
- can,
40
- // what a page may show as enabled for this client
41
- of (client) {
42
- const out = {}
43
- for (const r of ROLES) out[r] = can(r, client)
44
- return out
45
- }
46
- }
47
- }
48
-
49
- // Wrong passwords: after 5 tries in 5 minutes the address waits 1 minute.
50
- function createPinGuard ({ tries = 5, windowMs = 5 * 60000, lockMs = 60000 } = {}) {
51
- const fails = new Map()
52
- return {
53
- locked (who, now) {
54
- const f = fails.get(who)
55
- return !!(f && f.until && f.until > now)
56
- },
57
- fail (who, now) {
58
- const f = fails.get(who) || { list: [], until: 0 }
59
- f.list = f.list.filter(t => t > now - windowMs)
60
- f.list.push(now)
61
- if (f.list.length >= tries) { f.until = now + lockMs; f.list = [] }
62
- fails.set(who, f)
63
- return f.until > now
64
- },
65
- ok (who) { fails.delete(who) }
66
- }
67
- }
68
-
69
- module.exports = { createAccess, createPinGuard, parseList }
1
+ 'use strict'
2
+
3
+ // Who may do what on a canvas. Each list is typed in the node:
4
+ // empty -> everybody
5
+ // "-" -> nobody (view only)
6
+ // names -> only these users, separated by commas (user name, login or e-mail)
7
+ // The user comes from the dashboard login (FlowFuse / multi-user plugins or basic auth),
8
+ // so these checks run in Node-RED and a page cannot get around them.
9
+ const ROLES = ['cmd', 'ack', 'shelve']
10
+
11
+ function parseList (text) {
12
+ const t = String(text || '').trim()
13
+ if (!t) return null
14
+ if (t === '-') return []
15
+ return t.split(/[,;\n]/).map(s => s.trim().toLowerCase()).filter(Boolean)
16
+ }
17
+
18
+ function namesOf (user) {
19
+ if (!user) return []
20
+ if (typeof user === 'string') return [user.toLowerCase()]
21
+ return ['name', 'username', 'email', 'userId', 'id', 'login']
22
+ .map(k => user[k]).filter(v => typeof v === 'string' && v).map(v => v.toLowerCase())
23
+ }
24
+
25
+ function createAccess (config) {
26
+ const lists = {
27
+ cmd: parseList(config.cmdUsers),
28
+ ack: parseList(config.ackUsers),
29
+ // shelving needs the same people as acknowledging when it has no list of its own
30
+ shelve: parseList(config.shelveUsers !== undefined && String(config.shelveUsers).trim() !== '' ? config.shelveUsers : config.ackUsers)
31
+ }
32
+ const can = (role, client) => {
33
+ const list = lists[role]
34
+ if (list === null || list === undefined) return true
35
+ const names = namesOf(client && client._client ? client._client.user : client && client.user)
36
+ return names.some(n => list.includes(n))
37
+ }
38
+ return {
39
+ can,
40
+ // what a page may show as enabled for this client
41
+ of (client) {
42
+ const out = {}
43
+ for (const r of ROLES) out[r] = can(r, client)
44
+ return out
45
+ }
46
+ }
47
+ }
48
+
49
+ // Wrong passwords: after 5 tries in 5 minutes the address waits 1 minute.
50
+ function createPinGuard ({ tries = 5, windowMs = 5 * 60000, lockMs = 60000 } = {}) {
51
+ const fails = new Map()
52
+ return {
53
+ locked (who, now) {
54
+ const f = fails.get(who)
55
+ return !!(f && f.until && f.until > now)
56
+ },
57
+ fail (who, now) {
58
+ const f = fails.get(who) || { list: [], until: 0 }
59
+ f.list = f.list.filter(t => t > now - windowMs)
60
+ f.list.push(now)
61
+ if (f.list.length >= tries) { f.until = now + lockMs; f.list = [] }
62
+ fails.set(who, f)
63
+ return f.until > now
64
+ },
65
+ ok (who) { fails.delete(who) }
66
+ }
67
+ }
68
+
69
+ module.exports = { createAccess, createPinGuard, parseList }
package/nodes/admin.js CHANGED
@@ -1,166 +1,170 @@
1
- 'use strict'
2
-
3
- const crypto = require('crypto')
4
- const http = require('http')
5
- const https = require('https')
6
- const net = require('net')
7
-
8
- // Edit sessions opened from the Node-RED editor: canvas id -> { key, until }
9
- const editKeys = new Map()
10
- const EDIT_TTL = 4 * 3600 * 1000
11
-
12
- const RULE_OPS = ['on', 'off', 'eq', 'ne', 'gt', 'lt']
13
- const RULE_EFFECTS = ['alarm', 'warn', 'info', 'ok', 'dim', 'blink', 'hide']
14
- const ACTIONS = ['value', 'toggle', 'pulse', 'input', 'page', 'screen', 'faceplate']
15
- const FACEPLATE_KEYS = ['modeKey', 'modeTexts', 'hoursKey', 'startsKey', 'valueKey', 'valueUnit', 'onTopic', 'onValue', 'offTopic', 'offValue', 'resetTopic', 'resetValue',
16
- // PID / regulator: process value, setpoint, output and auto/manual
17
- 'pvKey', 'spKey', 'opKey', 'pidUnit', 'spTopic', 'spMin', 'spMax', 'opTopic', 'opMin', 'opMax', 'modeTopic', 'autoValue', 'manValue']
18
-
19
- // screen names of a canvas: unique strings, at most 20
20
- function cleanScreens (list) {
21
- const out = []
22
- for (const n of Array.isArray(list) ? list : []) {
23
- const t = typeof n === 'string' ? n.trim().slice(0, 40) : ''
24
- if (t && !out.includes(t) && out.length < 20) out.push(t)
25
- }
26
- return out
27
- }
28
-
29
- function cleanSymbols (list) {
30
- if (!Array.isArray(list)) return null
31
- const out = []
32
- for (const s of list) {
33
- if (!s || typeof s !== 'object' || typeof s.id !== 'string' || typeof s.type !== 'string') return null
34
- const n = v => (isFinite(Number(v)) ? Number(v) : 0)
35
- const clean = {
36
- id: s.id.slice(0, 64),
37
- type: s.type.slice(0, 32),
38
- x: n(s.x),
39
- y: n(s.y),
40
- w: Math.max(10, n(s.w)),
41
- h: Math.max(10, n(s.h)),
42
- rot: n(s.rot),
43
- key: typeof s.key === 'string' ? s.key : '',
44
- props: s.props && typeof s.props === 'object' && !Array.isArray(s.props) ? s.props : {}
45
- }
46
- if (s.preview !== undefined && ['number', 'string', 'boolean'].includes(typeof s.preview)) clean.preview = s.preview
47
- const a = s.action
48
- if (typeof s.group === 'string' && s.group) clean.group = s.group.slice(0, 64)
49
- if (typeof s.name === 'string' && s.name) clean.name = s.name.slice(0, 80)
50
- if (typeof s.screen === 'string' && s.screen) clean.screen = s.screen.slice(0, 40)
51
- if (s.locked === true) clean.locked = true
52
- const sc = s.scale
53
- if (sc && typeof sc === 'object') clean.scale = { factor: String(sc.factor ?? ''), divisor: String(sc.divisor ?? ''), offset: String(sc.offset ?? '') }
54
- if (Array.isArray(s.rules)) {
55
- clean.rules = s.rules.slice(0, 20).filter(r => r && typeof r === 'object').map(r => ({
56
- key: String(r.key ?? '').slice(0, 200),
57
- op: RULE_OPS.includes(r.op) ? r.op : 'on',
58
- value: String(r.value ?? '').slice(0, 200),
59
- effect: RULE_EFFECTS.includes(r.effect) ? r.effect : 'alarm'
60
- }))
61
- }
62
- if (a && typeof a === 'object' && ACTIONS.includes(a.mode)) {
63
- clean.action = { mode: a.mode, value: String(a.value ?? ''), topic: String(a.topic ?? ''), confirm: a.confirm === 'yes' ? 'yes' : 'no', pin: a.pin === 'yes' ? 'yes' : 'no' }
64
- if (a.interlock) Object.assign(clean.action, { interlock: String(a.interlock).slice(0, 300), interlockText: String(a.interlockText ?? '').slice(0, 120) })
65
- if (a.mode === 'input') Object.assign(clean.action, { min: String(a.min ?? ''), max: String(a.max ?? ''), maxStep: String(a.maxStep ?? '') })
66
- if (a.mode === 'faceplate') for (const k of FACEPLATE_KEYS) clean.action[k] = String(a[k] ?? '').slice(0, 200)
67
- }
68
- out.push(clean)
69
- }
70
- return out
71
- }
72
-
73
- // Calls this Node-RED's own admin API (GET/POST /flows) with the caller's credentials.
74
- async function adminRequest (RED, req, method, body) {
75
- let tls = RED.settings.https
76
- if (typeof tls === 'function') tls = await tls()
77
- const lib = tls ? https : http
78
- // always this machine: an address of its own (uiHost) or loopback, never a host
79
- // name, so the user's credentials cannot be sent anywhere else
80
- const h = RED.settings.uiHost
81
- const host = (!h || h === '0.0.0.0' || h === '::' || !net.isIP(h)) ? '127.0.0.1' : h
82
- const root = typeof RED.settings.httpAdminRoot === 'string' ? RED.settings.httpAdminRoot : '/'
83
- const headers = { 'Node-RED-API-Version': 'v2', Accept: 'application/json' }
84
- if (req.headers.authorization) headers.authorization = req.headers.authorization
85
- if (req.headers.cookie) headers.cookie = req.headers.cookie
86
- let data
87
- if (body) {
88
- data = Buffer.from(JSON.stringify(body))
89
- headers['Content-Type'] = 'application/json'
90
- headers['Content-Length'] = data.length
91
- headers['Node-RED-Deployment-Type'] = 'nodes'
92
- }
93
- return new Promise((resolve, reject) => {
94
- const r = lib.request({
95
- host,
96
- port: RED.settings.uiPort || 1880,
97
- path: ('/' + root + '/flows').replace(/\/+/g, '/'),
98
- method,
99
- headers,
100
- // our own address: its certificate is not issued for it
101
- rejectUnauthorized: false,
102
- ...(tls && typeof tls === 'object' ? { ca: tls.ca, cert: tls.cert, key: tls.key } : {})
103
- }, res => {
104
- let text = ''
105
- res.on('data', c => { text += c })
106
- res.on('end', () => {
107
- let json = null
108
- try { json = text ? JSON.parse(text) : null } catch (e) {}
109
- resolve({ status: res.statusCode, json, text })
110
- })
111
- })
112
- r.on('error', reject)
113
- if (data) r.write(data)
114
- r.end()
115
- })
116
- }
117
-
118
- module.exports = function setupAdmin (RED) {
119
- const canWrite = RED.auth.needsPermission('flows.write')
120
-
121
- // Start editing a deployed canvas from the dashboard page
122
- RED.httpAdmin.patch('/hmi-canvas/:id/edit', canWrite, function (req, res) {
123
- const node = RED.nodes.getNode(req.params.id)
124
- if (!node || node.type !== 'ui-hmi-canvas') {
125
- return res.status(404).json({ error: 'Canvas not deployed', code: 'NOT_DEPLOYED' })
126
- }
127
- const key = crypto.randomBytes(16).toString('hex')
128
- editKeys.set(req.params.id, { key, until: Date.now() + EDIT_TTL })
129
- res.json({ key })
130
- })
131
-
132
- // Save the symbols edited on the dashboard page and deploy only this node
133
- RED.httpAdmin.post('/hmi-canvas/:id/symbols', canWrite, async function (req, res) {
134
- const id = req.params.id
135
- const body = req.body || {}
136
- const open = editKeys.get(id)
137
- if (open && open.until < Date.now()) editKeys.delete(id)
138
- if (!body.key || !open || open.key !== body.key || open.until < Date.now()) {
139
- return res.status(403).json({ error: 'Edit session expired, open the editor again from Node-RED', code: 'BAD_KEY' })
140
- }
141
- const symbols = cleanSymbols(body.symbols)
142
- if (!symbols) return res.status(400).json({ error: 'Invalid symbols', code: 'BAD_SYMBOLS' })
143
- try {
144
- const current = await adminRequest(RED, req, 'GET')
145
- if (current.status !== 200 || !current.json) {
146
- return res.status(current.status || 500).json({ error: 'Could not read flows', code: 'GET_FAILED' })
147
- }
148
- const flows = current.json.flows
149
- const target = flows.find(n => n.id === id && n.type === 'ui-hmi-canvas')
150
- if (!target) return res.status(404).json({ error: 'Canvas not found', code: 'NOT_FOUND' })
151
- target.symbols = symbols
152
- if (body.screens !== undefined) target.screens = cleanScreens(body.screens)
153
- const saved = await adminRequest(RED, req, 'POST', { flows, rev: current.json.rev })
154
- if (saved.status !== 200) {
155
- return res.status(saved.status).json({ error: (saved.json && saved.json.message) || 'Deploy failed', code: 'POST_FAILED' })
156
- }
157
- editKeys.delete(id)
158
- res.json({ ok: true, rev: saved.json && saved.json.rev })
159
- } catch (err) {
160
- res.status(500).json({ error: err.message, code: 'ERROR' })
161
- }
162
- })
163
- }
164
-
165
- module.exports.cleanSymbols = cleanSymbols
166
- module.exports.cleanScreens = cleanScreens
1
+ 'use strict'
2
+
3
+ const crypto = require('crypto')
4
+ const http = require('http')
5
+ const https = require('https')
6
+ const net = require('net')
7
+
8
+ // Edit sessions opened from the Node-RED editor: canvas id -> { key, until }
9
+ const editKeys = new Map()
10
+ const EDIT_TTL = 4 * 3600 * 1000
11
+
12
+ const RULE_OPS = ['on', 'off', 'eq', 'ne', 'gt', 'lt']
13
+ const RULE_EFFECTS = ['alarm', 'warn', 'info', 'ok', 'dim', 'blink', 'hide']
14
+ const ACTIONS = ['value', 'toggle', 'pulse', 'input', 'page', 'screen', 'faceplate']
15
+ const FACEPLATE_KEYS = ['modeKey', 'modeTexts', 'hoursKey', 'startsKey', 'valueKey', 'valueUnit', 'onTopic', 'onValue', 'offTopic', 'offValue', 'resetTopic', 'resetValue',
16
+ // PID / regulator: process value, setpoint, output and auto/manual
17
+ 'pvKey', 'spKey', 'opKey', 'pidUnit', 'spTopic', 'spMin', 'spMax', 'opTopic', 'opMin', 'opMax', 'modeTopic', 'autoValue', 'manValue']
18
+
19
+ // screen names of a canvas: unique strings, at most 100
20
+ function cleanScreens (list) {
21
+ const out = []
22
+ for (const n of Array.isArray(list) ? list : []) {
23
+ const t = typeof n === 'string' ? n.trim().slice(0, 40) : ''
24
+ if (t && !out.includes(t) && out.length < 100) out.push(t)
25
+ }
26
+ return out
27
+ }
28
+
29
+ function cleanSymbols (list) {
30
+ if (!Array.isArray(list)) return null
31
+ const out = []
32
+ for (const s of list) {
33
+ if (!s || typeof s !== 'object' || typeof s.id !== 'string' || typeof s.type !== 'string') return null
34
+ const n = v => (isFinite(Number(v)) ? Number(v) : 0)
35
+ const clean = {
36
+ id: s.id.slice(0, 64),
37
+ type: s.type.slice(0, 32),
38
+ x: n(s.x),
39
+ y: n(s.y),
40
+ w: Math.max(10, n(s.w)),
41
+ h: Math.max(10, n(s.h)),
42
+ rot: n(s.rot),
43
+ key: typeof s.key === 'string' ? s.key : '',
44
+ props: s.props && typeof s.props === 'object' && !Array.isArray(s.props) ? s.props : {}
45
+ }
46
+ if (s.preview !== undefined && ['number', 'string', 'boolean'].includes(typeof s.preview)) clean.preview = s.preview
47
+ const a = s.action
48
+ if (typeof s.group === 'string' && s.group) clean.group = s.group.slice(0, 64)
49
+ if (typeof s.name === 'string' && s.name) clean.name = s.name.slice(0, 80)
50
+ if (typeof s.screen === 'string' && s.screen) clean.screen = s.screen.slice(0, 40)
51
+ if (s.locked === true) clean.locked = true
52
+ const sc = s.scale
53
+ if (sc && typeof sc === 'object') clean.scale = { factor: String(sc.factor ?? ''), divisor: String(sc.divisor ?? ''), offset: String(sc.offset ?? '') }
54
+ if (Array.isArray(s.rules)) {
55
+ clean.rules = s.rules.slice(0, 20).filter(r => r && typeof r === 'object').map(r => ({
56
+ key: String(r.key ?? '').slice(0, 200),
57
+ op: RULE_OPS.includes(r.op) ? r.op : 'on',
58
+ value: String(r.value ?? '').slice(0, 200),
59
+ effect: RULE_EFFECTS.includes(r.effect) ? r.effect : 'alarm'
60
+ }))
61
+ }
62
+ if (a && typeof a === 'object' && ACTIONS.includes(a.mode)) {
63
+ clean.action = { mode: a.mode, value: String(a.value ?? ''), topic: String(a.topic ?? ''), confirm: a.confirm === 'yes' ? 'yes' : 'no', pin: a.pin === 'yes' ? 'yes' : 'no' }
64
+ if (a.interlock) Object.assign(clean.action, { interlock: String(a.interlock).slice(0, 300), interlockText: String(a.interlockText ?? '').slice(0, 120) })
65
+ if (a.mode === 'input') Object.assign(clean.action, { min: String(a.min ?? ''), max: String(a.max ?? ''), maxStep: String(a.maxStep ?? '') })
66
+ if (a.mode === 'faceplate') for (const k of FACEPLATE_KEYS) clean.action[k] = String(a[k] ?? '').slice(0, 200)
67
+ }
68
+ out.push(clean)
69
+ }
70
+ return out
71
+ }
72
+
73
+ // Calls this Node-RED's own admin API (GET/POST /flows) with the caller's credentials.
74
+ async function adminRequest (RED, req, method, body) {
75
+ let tls = RED.settings.https
76
+ if (typeof tls === 'function') tls = await tls()
77
+ const lib = tls ? https : http
78
+ // always this machine: an address of its own (uiHost) or loopback, never a host
79
+ // name, so the user's credentials cannot be sent anywhere else
80
+ const h = RED.settings.uiHost
81
+ const host = (!h || h === '0.0.0.0' || h === '::' || !net.isIP(h)) ? '127.0.0.1' : h
82
+ const root = typeof RED.settings.httpAdminRoot === 'string' ? RED.settings.httpAdminRoot : '/'
83
+ const headers = { 'Node-RED-API-Version': 'v2', Accept: 'application/json' }
84
+ if (req.headers.authorization) headers.authorization = req.headers.authorization
85
+ if (req.headers.cookie) headers.cookie = req.headers.cookie
86
+ let data
87
+ if (body) {
88
+ data = Buffer.from(JSON.stringify(body))
89
+ headers['Content-Type'] = 'application/json'
90
+ headers['Content-Length'] = data.length
91
+ headers['Node-RED-Deployment-Type'] = 'nodes'
92
+ }
93
+ return new Promise((resolve, reject) => {
94
+ const r = lib.request({
95
+ host,
96
+ port: RED.settings.uiPort || 1880,
97
+ path: ('/' + root + '/flows').replace(/\/+/g, '/'),
98
+ method,
99
+ headers,
100
+ // our own address: its certificate is not issued for it
101
+ rejectUnauthorized: false,
102
+ ...(tls && typeof tls === 'object' ? { ca: tls.ca, cert: tls.cert, key: tls.key } : {})
103
+ }, res => {
104
+ let text = ''
105
+ res.on('data', c => { text += c })
106
+ res.on('end', () => {
107
+ let json = null
108
+ try { json = text ? JSON.parse(text) : null } catch (e) {}
109
+ resolve({ status: res.statusCode, json, text })
110
+ })
111
+ })
112
+ r.on('error', reject)
113
+ if (data) r.write(data)
114
+ r.end()
115
+ })
116
+ }
117
+
118
+ module.exports = function setupAdmin (RED) {
119
+ const canWrite = RED.auth.needsPermission('flows.write')
120
+
121
+ // Start editing a deployed canvas from the dashboard page
122
+ RED.httpAdmin.patch('/hmi-canvas/:id/edit', canWrite, function (req, res) {
123
+ const node = RED.nodes.getNode(req.params.id)
124
+ if (!node || node.type !== 'ui-hmi-canvas') {
125
+ return res.status(404).json({ error: 'Canvas not deployed', code: 'NOT_DEPLOYED' })
126
+ }
127
+ const key = crypto.randomBytes(16).toString('hex')
128
+ editKeys.set(req.params.id, { key, until: Date.now() + EDIT_TTL })
129
+ res.json({ key })
130
+ })
131
+
132
+ // Save the symbols edited on the dashboard page and deploy only this node
133
+ RED.httpAdmin.post('/hmi-canvas/:id/symbols', canWrite, async function (req, res) {
134
+ const id = req.params.id
135
+ const body = req.body || {}
136
+ const open = editKeys.get(id)
137
+ if (open && open.until < Date.now()) editKeys.delete(id)
138
+ if (!body.key || !open || open.key !== body.key || open.until < Date.now()) {
139
+ return res.status(403).json({ error: 'Edit session expired, open the editor again from Node-RED', code: 'BAD_KEY' })
140
+ }
141
+ const symbols = cleanSymbols(body.symbols)
142
+ if (!symbols) return res.status(400).json({ error: 'Invalid symbols', code: 'BAD_SYMBOLS' })
143
+ try {
144
+ const current = await adminRequest(RED, req, 'GET')
145
+ if (current.status !== 200 || !current.json) {
146
+ return res.status(current.status || 500).json({ error: 'Could not read flows', code: 'GET_FAILED' })
147
+ }
148
+ const flows = current.json.flows
149
+ const target = flows.find(n => n.id === id && n.type === 'ui-hmi-canvas')
150
+ if (!target) return res.status(404).json({ error: 'Canvas not found', code: 'NOT_FOUND' })
151
+ target.symbols = symbols
152
+ if (body.screens !== undefined) {
153
+ // keep every screen a symbol names, so no symbol falls onto the first screen
154
+ const list = cleanScreens(body.screens)
155
+ target.screens = list.length ? cleanScreens([...list, ...symbols.map(m => m.screen).filter(Boolean)]) : list
156
+ }
157
+ const saved = await adminRequest(RED, req, 'POST', { flows, rev: current.json.rev })
158
+ if (saved.status !== 200) {
159
+ return res.status(saved.status).json({ error: (saved.json && saved.json.message) || 'Deploy failed', code: 'POST_FAILED' })
160
+ }
161
+ editKeys.delete(id)
162
+ res.json({ ok: true, rev: saved.json && saved.json.rev })
163
+ } catch (err) {
164
+ res.status(500).json({ error: err.message, code: 'ERROR' })
165
+ }
166
+ })
167
+ }
168
+
169
+ module.exports.cleanSymbols = cleanSymbols
170
+ module.exports.cleanScreens = cleanScreens