node-red-contrib-room-tools 1.8.4 → 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.
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) {