node-red-contrib-room-tools 1.8.3 → 1.8.8

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.
@@ -1,4 +1,5 @@
1
1
  const crypto = require('node:crypto')
2
+ const { readJsonBody, replyJson: reply, sameOrigin } = require('./panel-http')
2
3
 
3
4
  function createPanelEditor ({ panelId, username, password, secureCookie, getConfig, getDevices, getTargets, apply, restoredSessions }) {
4
5
  if (!username || !password) return null
@@ -20,58 +21,14 @@ function createPanelEditor ({ panelId, username, password, secureCookie, getConf
20
21
  const ttl = 30 * 60 * 1000
21
22
  let closed = false
22
23
 
23
- function reply (res, status, data, headers = {}) {
24
- res.writeHead(status, {
25
- 'Content-Type': 'application/json; charset=utf-8',
26
- 'Cache-Control': 'no-store',
27
- 'X-Content-Type-Options': 'nosniff',
28
- ...headers
29
- })
30
- res.end(JSON.stringify(data))
31
- }
32
-
33
- function cookie (value, age) {
34
- return `${cookieName}=${value}; Path=${prefix}; HttpOnly; SameSite=Strict; Max-Age=${age}${secureCookie ? '; Secure' : ''}`
35
- }
36
-
37
- function sameOrigin (req) {
38
- const origin = req.headers.origin
39
- if (!origin) return false
40
- try {
41
- const parsed = new URL(origin)
42
- return parsed.host === req.headers.host &&
43
- parsed.protocol === (req.socket.encrypted ? 'https:' : 'http:')
44
- } catch (_) { return false }
45
- }
46
-
47
- function localOrSecure (req) {
48
- const address = String(req.socket.remoteAddress || '')
49
- return ['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(address) ||
50
- req.socket.encrypted === true
51
- }
52
-
53
- async function jsonBody (req) {
54
- if (!String(req.headers['content-type'] || '').startsWith('application/json')) {
55
- throw new Error('JSON body required')
56
- }
57
- if (req.body !== undefined) {
58
- if (Buffer.isBuffer(req.body)) return JSON.parse(req.body.toString('utf8'))
59
- if (typeof req.body === 'string') return JSON.parse(req.body)
60
- if (typeof req.body === 'object' && req.body !== null && Object.keys(req.body).length) return req.body
61
- }
62
- let raw = ''
63
- for await (const chunk of req) {
64
- raw += chunk
65
- if (Buffer.byteLength(raw) > 65536) throw new Error('Request too large')
66
- }
67
- return JSON.parse(raw)
24
+ function cookie (value, age, req) {
25
+ return `${cookieName}=${value}; Path=${prefix}; HttpOnly; SameSite=Strict; Max-Age=${age}${secureCookie && req.socket.encrypted ? '; Secure' : ''}`
68
26
  }
69
27
 
70
28
  async function handler (req, res) {
71
29
  const path = new URL(req.url, 'http://localhost').pathname
72
30
  if (!path.startsWith(prefix + '/')) return false
73
31
  if (closed) { reply(res, 503, { error: 'Editor unavailable' }); return true }
74
- if (!localOrSecure(req)) { reply(res, 403, { error: 'HTTPS required' }); return true }
75
32
  if (req.method === 'POST' && !sameOrigin(req)) {
76
33
  reply(res, 403, { error: 'Origin rejected' })
77
34
  return true
@@ -89,7 +46,7 @@ function createPanelEditor ({ panelId, username, password, secureCookie, getConf
89
46
  }
90
47
  attempt.count++
91
48
  attempts.set(ip, attempt)
92
- const body = await jsonBody(req)
49
+ const body = await readJsonBody(req, 65536)
93
50
  const supplied = String(body.password || '')
94
51
  if (supplied.length > 256) { reply(res, 401, { error: 'Invalid credentials' }); return true }
95
52
  const test = await new Promise((resolve, reject) => crypto.scrypt(supplied, salt, 32,
@@ -103,7 +60,7 @@ function createPanelEditor ({ panelId, username, password, secureCookie, getConf
103
60
  const token = crypto.randomBytes(32).toString('hex')
104
61
  const csrf = crypto.randomBytes(24).toString('hex')
105
62
  sessions.set(token, { csrf, expiry: now + ttl })
106
- reply(res, 200, { csrf }, { 'Set-Cookie': cookie(token, ttl / 1000) })
63
+ reply(res, 200, { csrf }, { 'Set-Cookie': cookie(token, ttl / 1000, req) })
107
64
  return true
108
65
  }
109
66
  const token = String(req.headers.cookie || '').split(';').map(part => part.trim())
@@ -118,7 +75,7 @@ function createPanelEditor ({ panelId, username, password, secureCookie, getConf
118
75
  reply(res, 200, { csrf: session.csrf })
119
76
  } else if (path === prefix + '/logout' && req.method === 'POST') {
120
77
  sessions.delete(token)
121
- reply(res, 200, { ok: true }, { 'Set-Cookie': cookie('', 0) })
78
+ reply(res, 200, { ok: true }, { 'Set-Cookie': cookie('', 0, req) })
122
79
  } else if (path === prefix + '/config' && req.method === 'GET') {
123
80
  reply(res, 200, await getConfig(req))
124
81
  } else if (path === prefix + '/devices' && req.method === 'GET') {
@@ -127,7 +84,7 @@ function createPanelEditor ({ panelId, username, password, secureCookie, getConf
127
84
  const device = new URL(req.url, 'http://localhost').searchParams.get('device')
128
85
  reply(res, 200, { targets: await getTargets(req, device) })
129
86
  } else if (path === prefix + '/apply' && req.method === 'POST') {
130
- reply(res, 200, await apply(req, await jsonBody(req)))
87
+ reply(res, 200, await apply(req, await readJsonBody(req, 65536)))
131
88
  } else {
132
89
  reply(res, 404, { error: 'Not found' })
133
90
  }
package/panel-http.js ADDED
@@ -0,0 +1,69 @@
1
+ function sameOrigin (req) {
2
+ if (!req.headers.origin) return false
3
+ try {
4
+ const origin = new URL(req.headers.origin)
5
+ return origin.host === req.headers.host &&
6
+ origin.protocol === (req.socket.encrypted ? 'https:' : 'http:')
7
+ } catch (_) { return false }
8
+ }
9
+
10
+ function replyJson (res, status, data, headers = {}) {
11
+ res.writeHead(status, {
12
+ 'Content-Type': 'application/json; charset=utf-8',
13
+ 'Cache-Control': 'no-store',
14
+ 'X-Content-Type-Options': 'nosniff',
15
+ ...headers
16
+ })
17
+ res.end(JSON.stringify(data))
18
+ }
19
+
20
+ async function readJsonBody (req, maxBytes) {
21
+ if (String(req.headers['content-type'] || '').split(';')[0].trim().toLowerCase() !== 'application/json') {
22
+ throw new Error('JSON body required')
23
+ }
24
+ let raw
25
+ if (req.body !== undefined) {
26
+ // MUSE's middleware can expose an empty placeholder object while the raw
27
+ // JSON bytes are still on the request stream. Keep the legacy fallback so
28
+ // login and Apply receive the actual payload on that runtime.
29
+ const hasParsedObject = req.body && typeof req.body === 'object' &&
30
+ !Buffer.isBuffer(req.body) && !Array.isArray(req.body) &&
31
+ Object.keys(req.body).length > 0
32
+ if (Buffer.isBuffer(req.body) || typeof req.body === 'string' || hasParsedObject) {
33
+ raw = Buffer.isBuffer(req.body) ? req.body
34
+ : typeof req.body === 'string' ? Buffer.from(req.body) : Buffer.from(JSON.stringify(req.body))
35
+ }
36
+ }
37
+ if (raw === undefined && typeof req[Symbol.asyncIterator] === 'function') {
38
+ const chunks = []
39
+ let size = 0
40
+ for await (const chunk of req) {
41
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
42
+ size += bytes.length
43
+ if (size > maxBytes) throw new Error('Request too large')
44
+ chunks.push(bytes)
45
+ }
46
+ raw = Buffer.concat(chunks)
47
+ }
48
+ if (raw === undefined) {
49
+ const value = req.body === undefined ? {} : req.body
50
+ raw = Buffer.from(JSON.stringify(value))
51
+ }
52
+ if (raw.length > maxBytes) throw new Error('Request too large')
53
+ const value = JSON.parse(raw.toString('utf8'))
54
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('JSON object required')
55
+ return value
56
+ }
57
+
58
+ // Serialized into the Vue component, so keep this function browser-only and self-contained.
59
+ async function fetchWithTimeout (url, options = {}, timeoutMs = 12000) {
60
+ const controller = new AbortController()
61
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
62
+ try {
63
+ return await fetch(url, { ...options, signal: controller.signal })
64
+ } finally {
65
+ clearTimeout(timer)
66
+ }
67
+ }
68
+
69
+ module.exports = { sameOrigin, replyJson, readJsonBody, fetchWithTimeout }
@@ -4,7 +4,7 @@ const { validatedVisualBindings } = require('./panel-visual-bindings')
4
4
  const { listCatalogDevices, listCatalogTargets } = require('./control-catalog')
5
5
  const { configuredRoutes } = require('./control-bound-protocol')
6
6
  const { provisionManagedBridges } = require('./managed-native-bridge')
7
- const { backupFlow, flowLocation, localDirectory } = require('./managed-bridge-admin')
7
+ const { backupFlow, flowLocation, runtimeDirectory } = require('./managed-bridge-admin')
8
8
 
9
9
  function conflict (message) {
10
10
  const error = new Error(message)
@@ -12,15 +12,20 @@ function conflict (message) {
12
12
  return error
13
13
  }
14
14
 
15
- function adminUrl (localPort) {
15
+ function adminUrl (localPort, adminRoot = '/red') {
16
16
  if (!Number.isInteger(localPort) || localPort < 1 || localPort > 65535) {
17
- throw new Error('Local Automator port unavailable')
17
+ throw new Error('Node-RED runtime port unavailable')
18
18
  }
19
- return `http://127.0.0.1:${localPort}/red/flows`
19
+ if (typeof adminRoot !== 'string' || !adminRoot.startsWith('/') || /[?#]/.test(adminRoot)) {
20
+ throw new Error('Node-RED admin root unavailable')
21
+ }
22
+ const url = new URL(`http://127.0.0.1:${localPort}`)
23
+ url.pathname = adminRoot.replace(/\/$/, '') + '/flows'
24
+ return url.href
20
25
  }
21
26
 
22
- async function getSnapshot (localPort, fetcher = fetch) {
23
- const response = await fetcher(adminUrl(localPort), {
27
+ async function getSnapshot (localPort, fetcher = fetch, adminRoot = '/red') {
28
+ const response = await fetcher(adminUrl(localPort, adminRoot), {
24
29
  headers: { 'Node-RED-API-Version': 'v2' },
25
30
  signal: AbortSignal.timeout(10000)
26
31
  })
@@ -59,18 +64,19 @@ function editorConfig (snapshot, panelId) {
59
64
  }
60
65
 
61
66
  async function applyVisualBindings ({
62
- panelId, rev, templateHash, entries, userDir, remoteAddress, localPort,
67
+ panelId, rev, templateHash, entries, userDir, localPort, adminRoot = '/red',
63
68
  catalogBase, fetcher = fetch
64
69
  }) {
65
- localDirectory(userDir, remoteAddress)
70
+ // The editor authenticates the browser; Flow access stays on the runtime's loopback interface.
71
+ const directory = runtimeDirectory(userDir)
66
72
  if (!catalogBase) throw conflict('Device catalog is unavailable')
67
- const snapshot = await getSnapshot(localPort, fetcher)
73
+ const snapshot = await getSnapshot(localPort, fetcher, adminRoot)
68
74
  if (snapshot.rev !== rev) throw conflict('Flow changed; refresh the visual editor')
69
75
  const panel = findPanel(snapshot, panelId)
70
76
  if (inspectTemplate(panel.format).templateHash !== templateHash) {
71
77
  throw conflict('Template changed; refresh the visual editor')
72
78
  }
73
- const location = flowLocation(fs.realpathSync(userDir))
79
+ const location = flowLocation(directory)
74
80
  if (JSON.stringify(JSON.parse(fs.readFileSync(location.flowFile, 'utf8'))) !== JSON.stringify(snapshot.flows)) {
75
81
  throw conflict('Deployed Flow differs from the active project file')
76
82
  }
@@ -92,13 +98,13 @@ async function applyVisualBindings ({
92
98
  nextPanel.nativeBridge = configuredRoutes(nextPanel).size > 0
93
99
  const provisioned = provisionManagedBridges(next, { panelId })
94
100
 
95
- const latest = await getSnapshot(localPort, fetcher)
101
+ const latest = await getSnapshot(localPort, fetcher, adminRoot)
96
102
  if (latest.rev !== snapshot.rev) throw conflict('Flow changed during validation; refresh and retry')
97
103
  if (JSON.stringify(JSON.parse(fs.readFileSync(location.flowFile, 'utf8'))) !== JSON.stringify(snapshot.flows)) {
98
104
  throw conflict('Active project changed during validation')
99
105
  }
100
106
  const backupDir = backupFlow(location, snapshot.rev, snapshot.flows)
101
- const response = await fetcher(adminUrl(localPort), {
107
+ const response = await fetcher(adminUrl(localPort, adminRoot), {
102
108
  method: 'POST',
103
109
  headers: {
104
110
  'Content-Type': 'application/json',
@@ -112,7 +118,7 @@ async function applyVisualBindings ({
112
118
  const applied = await response.json().catch(() => ({}))
113
119
  let confirmed
114
120
  for (let attempt = 0; attempt < 5; attempt++) {
115
- confirmed = await getSnapshot(localPort, fetcher)
121
+ confirmed = await getSnapshot(localPort, fetcher, adminRoot)
116
122
  const confirmedPanel = confirmed.flows.find(node => node.id === panelId)
117
123
  if (confirmedPanel?.visualBindings === nextPanel.visualBindings &&
118
124
  confirmedPanel.bridgeRevisionApplied === provisioned.flows.find(node => node.id === panelId)?.bridgeRevisionApplied) {