@tetherto/mdk-core 0.0.0 → 0.12.1
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/LICENSE +202 -0
- package/README.md +572 -0
- package/index.js +519 -0
- package/lib/address-policy.js +124 -0
- package/lib/local-discovery.js +321 -0
- package/lib/services/actions.service.js +68 -0
- package/lib/services/alerts.service.js +207 -0
- package/lib/services/comments.service.js +118 -0
- package/lib/services/log-history.service.js +307 -0
- package/lib/services/logs.service.js +249 -0
- package/lib/services/pool-utils/constants.js +20 -0
- package/lib/services/pool-utils/time.js +49 -0
- package/lib/services/pool.service.js +397 -0
- package/lib/services/provisioning.service.js +420 -0
- package/lib/services/settings.service.js +26 -0
- package/lib/services/snaps.service.js +181 -0
- package/lib/services/stats.service.js +205 -0
- package/lib/stats/README.md +80 -0
- package/lib/stats/index.js +57 -0
- package/lib/stats/ops/alerts_aggr.js +40 -0
- package/lib/stats/ops/alerts_group_cnt.js +43 -0
- package/lib/stats/ops/arr_concat.js +27 -0
- package/lib/stats/ops/array_obj_calc.js +41 -0
- package/lib/stats/ops/avg.js +42 -0
- package/lib/stats/ops/cnt.js +19 -0
- package/lib/stats/ops/group.js +38 -0
- package/lib/stats/ops/group_avg.js +53 -0
- package/lib/stats/ops/group_cnt.js +38 -0
- package/lib/stats/ops/group_max.js +40 -0
- package/lib/stats/ops/group_multiple_stats.js +40 -0
- package/lib/stats/ops/group_sum.js +40 -0
- package/lib/stats/ops/index.js +35 -0
- package/lib/stats/ops/nested_obj_concat.js +54 -0
- package/lib/stats/ops/obj_concat.js +33 -0
- package/lib/stats/ops/sum.js +27 -0
- package/lib/stats/utils.js +99 -0
- package/lib/templates/alerts.js +128 -0
- package/lib/templates/stats.js +476 -0
- package/lib/things/constants.js +65 -0
- package/lib/things/container.js +84 -0
- package/lib/things/miner.js +190 -0
- package/lib/things/powermeter.js +30 -0
- package/lib/things/sensor.js +18 -0
- package/lib/things/thing.js +93 -0
- package/lib/utils.js +603 -0
- package/lib/worker-infra.js +221 -0
- package/package.json +64 -1
- package/services.js +246 -0
- package/utils/compose-yaml.js +56 -0
- package/utils/constants.js +30 -0
- package/utils/index.js +29 -0
- package/utils/initialize.js +148 -0
- package/utils/service-bootstrap.js +271 -0
- package/worker.js +9 -0
package/lib/utils.js
ADDED
|
@@ -0,0 +1,603 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto')
|
|
4
|
+
const mingo = require('mingo')
|
|
5
|
+
const gLibUtilBase = require('@bitfinex/lib-js-util-base')
|
|
6
|
+
|
|
7
|
+
const isValidSnap = (snap) => {
|
|
8
|
+
return snap.stats && snap.config
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const isOffline = (snap) => {
|
|
12
|
+
return !snap?.stats?.status || snap.stats.status === 'offline'
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function getRandomString (length) {
|
|
16
|
+
return crypto.randomBytes(length)
|
|
17
|
+
.toString('base64')
|
|
18
|
+
.replace(/[^a-z0-9]/gi, '')
|
|
19
|
+
.slice(0, length)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function hasErrorAndPositiveHashrate (entry) {
|
|
23
|
+
const { errors, hashrate_mhs: hashrateMHS } = entry?.last?.snap?.stats || {}
|
|
24
|
+
const hashrate = hashrateMHS?.t_5m
|
|
25
|
+
|
|
26
|
+
return errors?.length && hashrate > 0
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function sumPoolsShares (pools, key) {
|
|
30
|
+
if (!Array.isArray(pools)) return 0
|
|
31
|
+
|
|
32
|
+
return pools?.reduce((acc, pool) => {
|
|
33
|
+
const value = parseInt(pool[key])
|
|
34
|
+
return acc + (isNaN(value) ? 0 : value)
|
|
35
|
+
}, 0)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const TIME_PERIODS_MS = {
|
|
39
|
+
H: 60 * 60 * 1000,
|
|
40
|
+
D: 24 * 60 * 60 * 1000,
|
|
41
|
+
W: 7 * 24 * 60 * 60 * 1000,
|
|
42
|
+
M: 30 * 24 * 60 * 60 * 1000
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const getIntervalFromCron = (cron) => {
|
|
46
|
+
const [, minute, hour] = cron.split(' ')
|
|
47
|
+
|
|
48
|
+
if (minute.startsWith('*/')) {
|
|
49
|
+
return parseInt(minute.slice(2), 10) * 60 * 1000
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (hour.startsWith('*/')) {
|
|
53
|
+
return parseInt(hour.slice(2), 10) * 60 * 60 * 1000
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (hour === '0' && minute === '0') {
|
|
57
|
+
return 24 * 60 * 60 * 1000
|
|
58
|
+
}
|
|
59
|
+
throw new Error('ERR_CRON_UNSUPPORTED')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const getLogsCountForTimeRange = (start, end, key, statTimeframes) => {
|
|
63
|
+
end ||= Date.now()
|
|
64
|
+
start ??= 0
|
|
65
|
+
if (!statTimeframes) return 0
|
|
66
|
+
const currentLogCron = statTimeframes.find(
|
|
67
|
+
tf => tf[0] === key.replace('stat-', '')
|
|
68
|
+
)?.[1]
|
|
69
|
+
if (!currentLogCron) return 0
|
|
70
|
+
try {
|
|
71
|
+
const timeInterval = getIntervalFromCron(currentLogCron)
|
|
72
|
+
return Math.ceil((end - start) / timeInterval) + 1
|
|
73
|
+
} catch (error) {
|
|
74
|
+
return 0
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const isObject = obj => {
|
|
79
|
+
return obj && typeof obj === 'object' && !Array.isArray(obj)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const compareArrays = (prevArray, currArray) => {
|
|
83
|
+
const additions = currArray.filter(item => !prevArray.includes(item))
|
|
84
|
+
const deletions = prevArray.filter(item => !currArray.includes(item))
|
|
85
|
+
return { additions, deletions }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const compareObjects = (prev, curr, changes, path = '') => {
|
|
89
|
+
const allKeys = new Set([
|
|
90
|
+
...Object.keys(prev || {}),
|
|
91
|
+
...Object.keys(curr || {})
|
|
92
|
+
])
|
|
93
|
+
|
|
94
|
+
allKeys.forEach(key => {
|
|
95
|
+
const currentPath = path ? `${path}.${key}` : key
|
|
96
|
+
const prevValue = prev ? prev[key] : undefined
|
|
97
|
+
const currValue = curr ? curr[key] : undefined
|
|
98
|
+
|
|
99
|
+
if (isObject(prevValue) && isObject(currValue)) {
|
|
100
|
+
compareObjects(prevValue, currValue, changes, currentPath)
|
|
101
|
+
} else if (Array.isArray(prevValue) && Array.isArray(currValue)) {
|
|
102
|
+
const arrayChanges = compareArrays(prevValue, currValue)
|
|
103
|
+
if (
|
|
104
|
+
arrayChanges.additions.length > 0 ||
|
|
105
|
+
arrayChanges.deletions.length > 0
|
|
106
|
+
) {
|
|
107
|
+
changes[currentPath] = arrayChanges
|
|
108
|
+
}
|
|
109
|
+
} else if (prevValue !== currValue) {
|
|
110
|
+
changes[currentPath] = {
|
|
111
|
+
oldValue: prevValue,
|
|
112
|
+
newValue: currValue
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const getJsonChanges = (previousJson, currentJson) => {
|
|
119
|
+
const changes = {}
|
|
120
|
+
|
|
121
|
+
compareObjects(previousJson, currentJson, changes)
|
|
122
|
+
return changes
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const getLogMaxHeight = (logKeepCount = 3) => Math.ceil(logKeepCount * 1.5)
|
|
126
|
+
|
|
127
|
+
const aggregateLogs = (logs, groupRange, shouldCalculateAvg = false) => {
|
|
128
|
+
if (!logs || logs.length === 0) return []
|
|
129
|
+
|
|
130
|
+
const rangeMs = parseGroupRange(groupRange)
|
|
131
|
+
const buckets = groupByTimeBuckets(logs, rangeMs)
|
|
132
|
+
|
|
133
|
+
return buckets.map(bucket => aggregateBucket(bucket, shouldCalculateAvg ? 'avg' : 'sum'))
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const parseGroupRange = (range) => {
|
|
137
|
+
const match = /^(\d+)\s*([HDWM])s?$/i.exec(range.trim())
|
|
138
|
+
if (!match) {
|
|
139
|
+
throw new Error(
|
|
140
|
+
'ERR_INVALID_GROUP_RANGE_FORMAT. Use formats like "1D", "1H", "1W", "1M"'
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const value = Number(match[1])
|
|
145
|
+
const unit = match[2].toUpperCase()
|
|
146
|
+
return value * TIME_PERIODS_MS[unit]
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const groupByTimeBuckets = (logs, rangeMs) => {
|
|
150
|
+
if (logs.length === 0) return []
|
|
151
|
+
const sorted = [...logs].sort((a, b) => a.ts - b.ts)
|
|
152
|
+
|
|
153
|
+
const minTs = sorted[0].ts
|
|
154
|
+
const maxTs = sorted[sorted.length - 1].ts
|
|
155
|
+
|
|
156
|
+
const buckets = []
|
|
157
|
+
let bucketStart = Math.floor(minTs / rangeMs) * rangeMs
|
|
158
|
+
|
|
159
|
+
while (bucketStart <= maxTs) {
|
|
160
|
+
const bucketEnd = bucketStart + rangeMs
|
|
161
|
+
const logsInBucket = sorted.filter(log => log.ts >= bucketStart && log.ts < bucketEnd)
|
|
162
|
+
|
|
163
|
+
if (logsInBucket.length > 0) {
|
|
164
|
+
buckets.push({
|
|
165
|
+
start: bucketStart,
|
|
166
|
+
end: bucketEnd - 1,
|
|
167
|
+
logs: logsInBucket
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
bucketStart = bucketEnd
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return buckets
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const aggregateBucket = (bucket, operation) => {
|
|
178
|
+
const { start, end, logs } = bucket
|
|
179
|
+
|
|
180
|
+
if (logs.length === 0) return null
|
|
181
|
+
|
|
182
|
+
if (logs.length === 1) {
|
|
183
|
+
return {
|
|
184
|
+
...logs[0],
|
|
185
|
+
ts: `${start}-${end}`
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const result = {
|
|
190
|
+
ts: `${start}-${end}`
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const allKeys = new Set()
|
|
194
|
+
logs.forEach(log => {
|
|
195
|
+
Object.keys(log).forEach(key => {
|
|
196
|
+
if (key !== 'ts') allKeys.add(key)
|
|
197
|
+
})
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
for (const key of allKeys) {
|
|
201
|
+
result[key] = aggregateField(logs, key, operation)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return result
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const aggregateField = (logs, fieldName, operation) => {
|
|
208
|
+
const values = logs
|
|
209
|
+
.map(log => log[fieldName])
|
|
210
|
+
.filter(v => v !== undefined && v !== null)
|
|
211
|
+
|
|
212
|
+
if (values.length === 0) return null
|
|
213
|
+
|
|
214
|
+
const firstValue = values[0]
|
|
215
|
+
|
|
216
|
+
if (typeof firstValue === 'object' && !Array.isArray(firstValue)) {
|
|
217
|
+
return aggregateNestedObject(logs, fieldName, operation)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (typeof firstValue === 'number') {
|
|
221
|
+
return aggregateNumeric(values, operation)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return firstValue
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const aggregateNestedObject = (logs, fieldName, operation) => {
|
|
228
|
+
const allKeys = new Set()
|
|
229
|
+
logs.forEach(log => {
|
|
230
|
+
if (log[fieldName] && typeof log[fieldName] === 'object') {
|
|
231
|
+
Object.keys(log[fieldName]).forEach(k => allKeys.add(k))
|
|
232
|
+
}
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
const result = {}
|
|
236
|
+
|
|
237
|
+
for (const key of allKeys) {
|
|
238
|
+
const values = logs
|
|
239
|
+
.map(log => log[fieldName]?.[key])
|
|
240
|
+
.filter(v => v !== undefined && v !== null && typeof v === 'number')
|
|
241
|
+
|
|
242
|
+
if (values.length > 0) {
|
|
243
|
+
result[key] = aggregateNumeric(values, operation)
|
|
244
|
+
} else {
|
|
245
|
+
const firstValue = logs.find(log => log[fieldName]?.[key] !== undefined && log[fieldName]?.[key] !== null)
|
|
246
|
+
if (firstValue) {
|
|
247
|
+
result[key] = firstValue[fieldName][key]
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return Object.keys(result).length > 0 ? result : null
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const aggregateNumeric = (values, operation) => {
|
|
256
|
+
const numericValues = values.filter(v => typeof v === 'number' && !isNaN(v))
|
|
257
|
+
|
|
258
|
+
if (numericValues.length === 0) return 0
|
|
259
|
+
|
|
260
|
+
if (operation === 'sum') {
|
|
261
|
+
return numericValues.reduce((sum, val) => sum + val, 0)
|
|
262
|
+
} else if (operation === 'avg') {
|
|
263
|
+
const sum = numericValues.reduce((sum, val) => sum + val, 0)
|
|
264
|
+
return sum / numericValues.length
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
throw new Error(`ERR_UNKNOWN_OPERATION: ${operation}. Use 'avg' or 'sum'`)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function getValue (obj, path) {
|
|
271
|
+
const keys = path.split('.')
|
|
272
|
+
return keys.reduce((acc, key) => acc && acc[key], obj)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const getThingSorter = (a, b, sortBy) => {
|
|
276
|
+
const regex = /\d+|\D+/g
|
|
277
|
+
|
|
278
|
+
if (!sortBy || Object.keys(sortBy).length === 0) {
|
|
279
|
+
return 1
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
for (const [key, order] of Object.entries(sortBy)) {
|
|
283
|
+
const valA = getValue(a, key)
|
|
284
|
+
const valB = getValue(b, key)
|
|
285
|
+
|
|
286
|
+
if (valA === undefined || valB === undefined) {
|
|
287
|
+
return (valA === undefined) - (valB === undefined)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const parseValue = value => String(value).match(regex) || []
|
|
291
|
+
const partsA = parseValue(valA)
|
|
292
|
+
const partsB = parseValue(valB)
|
|
293
|
+
|
|
294
|
+
for (let i = 0; i < Math.min(partsA.length, partsB.length); i++) {
|
|
295
|
+
const [partA, partB] = [partsA[i], partsB[i]]
|
|
296
|
+
|
|
297
|
+
const diff =
|
|
298
|
+
!isNaN(partA) && !isNaN(partB)
|
|
299
|
+
? Number(partA) - Number(partB)
|
|
300
|
+
: partA.localeCompare(partB)
|
|
301
|
+
|
|
302
|
+
if (diff !== 0) {
|
|
303
|
+
return diff * order
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const lengthDiff = partsA.length - partsB.length
|
|
308
|
+
if (lengthDiff !== 0) {
|
|
309
|
+
return lengthDiff * order
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return 0
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ---------------------------------------------------------------------------
|
|
317
|
+
// Secret redaction + caller-query hardening
|
|
318
|
+
//
|
|
319
|
+
// Device records keep credentials in `opts` (password, apiKey, ...) and pool
|
|
320
|
+
// configs in `pools[]`. Anything handed back to a caller goes through
|
|
321
|
+
// redactSecrets(), and caller-supplied mingo queries are validated by
|
|
322
|
+
// sanitizeQuery() — an operator allowlist plus a path denylist — so a filter
|
|
323
|
+
// can neither read a secret nor be used as a character-by-character oracle
|
|
324
|
+
// against one ({'opts.password': {$regex: '^a'}}).
|
|
325
|
+
// ---------------------------------------------------------------------------
|
|
326
|
+
|
|
327
|
+
const REDACTED = '[REDACTED]'
|
|
328
|
+
|
|
329
|
+
// Exact (case-insensitive) secret key names, plus suffixes that mark a key as
|
|
330
|
+
// a secret wherever it appears (adminPassword, apiSecret, authToken, ...).
|
|
331
|
+
const SECRET_KEYS = new Set([
|
|
332
|
+
'password', 'pass', 'passwd', 'pwd', 'apikey', 'api_key', 'token', 'secret',
|
|
333
|
+
'privatekey', 'private_key', 'seed', 'mnemonic', 'apisecret', 'api_secret'
|
|
334
|
+
])
|
|
335
|
+
const SECRET_SUFFIX_RE = /(password|passwd|secret|token|apikey|api_key|privatekey|private_key|mnemonic)$/i
|
|
336
|
+
|
|
337
|
+
function isSecretKey (key) {
|
|
338
|
+
if (typeof key !== 'string') return false
|
|
339
|
+
return SECRET_KEYS.has(key.toLowerCase()) || SECRET_SUFFIX_RE.test(key)
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Deep copy of `value` with every secret-named key's value replaced by
|
|
344
|
+
* REDACTED. Non-plain values (Buffers, Dates) are returned as-is.
|
|
345
|
+
*/
|
|
346
|
+
function redactSecrets (value, depth = 0) {
|
|
347
|
+
if (depth > 32 || value === null || typeof value !== 'object') return value
|
|
348
|
+
if (Array.isArray(value)) return value.map((v) => redactSecrets(v, depth + 1))
|
|
349
|
+
const proto = Object.getPrototypeOf(value)
|
|
350
|
+
if (proto !== Object.prototype && proto !== null) return value
|
|
351
|
+
const out = {}
|
|
352
|
+
for (const [k, v] of Object.entries(value)) {
|
|
353
|
+
out[k] = isSecretKey(k) && v !== undefined && v !== null && v !== '' ? REDACTED : redactSecrets(v, depth + 1)
|
|
354
|
+
}
|
|
355
|
+
return out
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Removes REDACTED placeholders a caller echoed back (e.g. an edit form fed
|
|
360
|
+
* from a redacted getThingConf), so they never overwrite a stored secret.
|
|
361
|
+
*/
|
|
362
|
+
function stripRedacted (value, depth = 0) {
|
|
363
|
+
if (depth > 32 || value === null || typeof value !== 'object') return value
|
|
364
|
+
if (Array.isArray(value)) return value.map((v) => stripRedacted(v, depth + 1))
|
|
365
|
+
const out = {}
|
|
366
|
+
for (const [k, v] of Object.entries(value)) {
|
|
367
|
+
if (v === REDACTED) continue
|
|
368
|
+
out[k] = stripRedacted(v, depth + 1)
|
|
369
|
+
}
|
|
370
|
+
return out
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Operators a caller query may use. $where/$expr/$function/$accumulator and
|
|
374
|
+
// friends evaluate expressions or code and are never accepted. $regex is
|
|
375
|
+
// accepted (shipped UIs search with it) but only as a literal search — see
|
|
376
|
+
// _safeRegex.
|
|
377
|
+
const ALLOWED_QUERY_OPS = new Set([
|
|
378
|
+
'$eq', '$ne', '$in', '$nin', '$exists', '$gt', '$gte', '$lt', '$lte',
|
|
379
|
+
'$and', '$or', '$nor', '$not', '$elemMatch', '$all', '$size',
|
|
380
|
+
'$regex', '$options'
|
|
381
|
+
])
|
|
382
|
+
const MAX_QUERY_DEPTH = 8
|
|
383
|
+
const MAX_QUERY_NODES = 500
|
|
384
|
+
const MAX_REGEX_LENGTH = 256
|
|
385
|
+
const REGEX_META_RE = /[\\^$.*+?()[\]{}|/-]/g
|
|
386
|
+
const FORBIDDEN_PATH_SEGMENTS = new Set(['__proto__', 'constructor', 'prototype'])
|
|
387
|
+
// The non-secret connection fields under `opts` a caller may filter on (the UI's
|
|
388
|
+
// IP search sends `opts.address`). Every other `opts.*` path stays refused.
|
|
389
|
+
const QUERYABLE_OPTS = new Set(['address', 'port'])
|
|
390
|
+
|
|
391
|
+
function _checkPath (p) {
|
|
392
|
+
if (typeof p !== 'string' || !p.length || p.length > 256) throw new Error('ERR_QUERY_PATH_INVALID')
|
|
393
|
+
const segs = p.split('.')
|
|
394
|
+
if (segs[0] === 'opts' && !(segs.length === 2 && QUERYABLE_OPTS.has(segs[1]))) {
|
|
395
|
+
throw new Error(`ERR_QUERY_PATH_FORBIDDEN: ${p}`)
|
|
396
|
+
}
|
|
397
|
+
for (const seg of segs) {
|
|
398
|
+
if (FORBIDDEN_PATH_SEGMENTS.has(seg) || isSecretKey(seg)) {
|
|
399
|
+
throw new Error(`ERR_QUERY_PATH_FORBIDDEN: ${p}`)
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function escapeRegex (str) {
|
|
405
|
+
return str.replace(REGEX_META_RE, '\\$&')
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* A caller `$regex` is a literal search, never a pattern: every regex
|
|
410
|
+
* metacharacter is escaped, except an optional leading `^` (prefix match) and
|
|
411
|
+
* trailing `$` (suffix match). The only option is `i`. This keeps the
|
|
412
|
+
* shipped searches (`^miner-`, `^AA:BB:..$` with `i`, substring search) and
|
|
413
|
+
* makes catastrophic backtracking impossible. Returns { source, flags }.
|
|
414
|
+
*/
|
|
415
|
+
function _safeRegex (pattern, options) {
|
|
416
|
+
let src = pattern
|
|
417
|
+
let flags = options
|
|
418
|
+
if (pattern instanceof RegExp) {
|
|
419
|
+
src = pattern.source
|
|
420
|
+
if (flags === undefined) flags = pattern.flags
|
|
421
|
+
}
|
|
422
|
+
if (typeof src !== 'string' || src.length > MAX_REGEX_LENGTH) throw new Error('ERR_QUERY_REGEX_INVALID')
|
|
423
|
+
if (flags !== undefined && flags !== null && (typeof flags !== 'string' || !/^i?$/.test(flags))) {
|
|
424
|
+
throw new Error('ERR_QUERY_REGEX_INVALID')
|
|
425
|
+
}
|
|
426
|
+
const head = src.startsWith('^') ? '^' : ''
|
|
427
|
+
let body = head ? src.slice(1) : src
|
|
428
|
+
const tail = body.endsWith('$') ? '$' : ''
|
|
429
|
+
if (tail) body = body.slice(0, -1)
|
|
430
|
+
return { source: head + escapeRegex(body) + tail, flags: flags || '' }
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function _safeRegExp (pattern) {
|
|
434
|
+
const { source, flags } = _safeRegex(pattern)
|
|
435
|
+
return new RegExp(source, flags)
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Validates a caller-supplied mingo query in place of evaluating it blindly:
|
|
440
|
+
* only allowlisted operators, no paths under `opts.*` or naming a secret
|
|
441
|
+
* field, bounded depth/size. Every `$regex` (and any RegExp value) is
|
|
442
|
+
* rewritten to a literal search (see _safeRegex). Throws ERR_QUERY_* on the
|
|
443
|
+
* first violation; returns a sanitized copy otherwise (the input is not
|
|
444
|
+
* modified).
|
|
445
|
+
*/
|
|
446
|
+
function sanitizeQuery (query) {
|
|
447
|
+
let nodes = 0
|
|
448
|
+
const isPlain = (v) => v !== null && typeof v === 'object' && !Array.isArray(v) && !(v instanceof RegExp)
|
|
449
|
+
const isOpObject = (v) => isPlain(v) && Object.keys(v).some((k) => k.startsWith('$'))
|
|
450
|
+
|
|
451
|
+
const checkOp = (k) => {
|
|
452
|
+
if (!ALLOWED_QUERY_OPS.has(k)) throw new Error(`ERR_QUERY_OPERATOR_FORBIDDEN: ${k}`)
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// `doc` level: keys are field paths or logical operators.
|
|
456
|
+
const walkDoc = (node, depth) => {
|
|
457
|
+
if (++nodes > MAX_QUERY_NODES || depth > MAX_QUERY_DEPTH) throw new Error('ERR_QUERY_TOO_COMPLEX')
|
|
458
|
+
if (!isPlain(node)) throw new Error('ERR_QUERY_INVALID')
|
|
459
|
+
for (const [k, v] of Object.entries(node)) {
|
|
460
|
+
if (k === '$and' || k === '$or' || k === '$nor') {
|
|
461
|
+
if (!Array.isArray(v)) throw new Error('ERR_QUERY_INVALID')
|
|
462
|
+
for (const sub of v) walkDoc(sub, depth + 1)
|
|
463
|
+
continue
|
|
464
|
+
}
|
|
465
|
+
if (k.startsWith('$')) throw new Error(`ERR_QUERY_OPERATOR_FORBIDDEN: ${k}`)
|
|
466
|
+
_checkPath(k)
|
|
467
|
+
if (v instanceof RegExp) node[k] = _safeRegExp(v)
|
|
468
|
+
else if (isOpObject(v)) walkExpr(v, depth + 1)
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// `expr` level: keys are operators applied to one field's value; anything
|
|
473
|
+
// else in there is a literal value mingo compares, never evaluates.
|
|
474
|
+
const walkExpr = (node, depth) => {
|
|
475
|
+
if (++nodes > MAX_QUERY_NODES || depth > MAX_QUERY_DEPTH) throw new Error('ERR_QUERY_TOO_COMPLEX')
|
|
476
|
+
for (const [k, v] of Object.entries(node)) {
|
|
477
|
+
if (!k.startsWith('$')) continue
|
|
478
|
+
checkOp(k)
|
|
479
|
+
if (k === '$regex') {
|
|
480
|
+
const { source, flags } = _safeRegex(v, node.$options)
|
|
481
|
+
node.$regex = source
|
|
482
|
+
if (flags) node.$options = flags
|
|
483
|
+
else delete node.$options
|
|
484
|
+
} else if (k === '$not') {
|
|
485
|
+
if (v instanceof RegExp) node.$not = _safeRegExp(v)
|
|
486
|
+
else if (isPlain(v)) walkExpr(v, depth + 1)
|
|
487
|
+
} else if (k === '$elemMatch') {
|
|
488
|
+
if (isOpObject(v) && Object.keys(v).every((x) => x.startsWith('$') && !['$and', '$or', '$nor'].includes(x))) walkExpr(v, depth + 1)
|
|
489
|
+
else walkDoc(v, depth + 1)
|
|
490
|
+
} else if ((k === '$in' || k === '$nin' || k === '$all') && Array.isArray(v)) {
|
|
491
|
+
for (let i = 0; i < v.length; i++) {
|
|
492
|
+
if (v[i] instanceof RegExp) v[i] = _safeRegExp(v[i])
|
|
493
|
+
else if (k === '$all' && isOpObject(v[i])) walkExpr(v[i], depth + 1)
|
|
494
|
+
}
|
|
495
|
+
} else if (v instanceof RegExp) {
|
|
496
|
+
node[k] = _safeRegExp(v)
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
if (query === undefined || query === null) return query
|
|
502
|
+
if (!isPlain(query)) throw new Error('ERR_QUERY_INVALID')
|
|
503
|
+
// Rewritten in a copy so the caller's object (often reused by the caller,
|
|
504
|
+
// or a shared constant) is never modified.
|
|
505
|
+
let copy
|
|
506
|
+
try {
|
|
507
|
+
copy = structuredClone(query)
|
|
508
|
+
} catch (e) {
|
|
509
|
+
throw new Error('ERR_QUERY_INVALID')
|
|
510
|
+
}
|
|
511
|
+
walkDoc(copy, 0)
|
|
512
|
+
return copy
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function _sanitizeFields (fields) {
|
|
516
|
+
if (fields === undefined || fields === null) return fields
|
|
517
|
+
if (typeof fields !== 'object' || Array.isArray(fields)) throw new Error('ERR_QUERY_FIELDS_INVALID')
|
|
518
|
+
for (const [k, v] of Object.entries(fields)) {
|
|
519
|
+
if (k.startsWith('$')) throw new Error(`ERR_QUERY_OPERATOR_FORBIDDEN: ${k}`)
|
|
520
|
+
if (!(v === 0 || v === 1 || v === true || v === false)) throw new Error('ERR_QUERY_FIELDS_INVALID')
|
|
521
|
+
for (const seg of k.split('.')) {
|
|
522
|
+
if (FORBIDDEN_PATH_SEGMENTS.has(seg)) throw new Error(`ERR_QUERY_PATH_FORBIDDEN: ${k}`)
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
return fields
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function _sanitizeSort (sort) {
|
|
529
|
+
if (sort === undefined || sort === null) return sort
|
|
530
|
+
if (typeof sort !== 'object' || Array.isArray(sort)) throw new Error('ERR_QUERY_SORT_INVALID')
|
|
531
|
+
for (const [k, v] of Object.entries(sort)) {
|
|
532
|
+
_checkPath(k)
|
|
533
|
+
if (v !== 1 && v !== -1) throw new Error('ERR_QUERY_SORT_INVALID')
|
|
534
|
+
}
|
|
535
|
+
return sort
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function _toNonNegInt (v, name) {
|
|
539
|
+
if (v === undefined || v === null) return v
|
|
540
|
+
const n = Number(v)
|
|
541
|
+
if (!Number.isInteger(n) || n < 0) throw new Error(`ERR_QUERY_${name}_INVALID`)
|
|
542
|
+
return n
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Caller-facing filter/sort/page over `things`. `req.query`, `req.fields`,
|
|
547
|
+
* `req.sort`, `req.offset` and `req.limit` usually come straight from a
|
|
548
|
+
* remote caller and are validated here (see sanitizeQuery). Callers holding
|
|
549
|
+
* secret-bearing records must pass a redacted projection (redactSecrets).
|
|
550
|
+
*/
|
|
551
|
+
function applyFilters (things, req, returnObjects = false) {
|
|
552
|
+
const query = sanitizeQuery(req.query)
|
|
553
|
+
const fields = _sanitizeFields(req.fields)
|
|
554
|
+
const sort = _sanitizeSort(req.sort)
|
|
555
|
+
const offset = _toNonNegInt(req.offset, 'OFFSET')
|
|
556
|
+
const limit = _toNonNegInt(req.limit, 'LIMIT')
|
|
557
|
+
|
|
558
|
+
if (!gLibUtilBase.isNil(query) || !gLibUtilBase.isNil(fields)) {
|
|
559
|
+
const q = new mingo.Query(query || {}, { scriptEnabled: false })
|
|
560
|
+
things = q.find(things, fields || {}).all()
|
|
561
|
+
}
|
|
562
|
+
if (!gLibUtilBase.isNil(sort)) {
|
|
563
|
+
things = things.sort((a, b) => getThingSorter(a, b, sort))
|
|
564
|
+
}
|
|
565
|
+
if (offset) {
|
|
566
|
+
things = things.slice(offset)
|
|
567
|
+
}
|
|
568
|
+
if (!gLibUtilBase.isNil(limit)) {
|
|
569
|
+
things = things.slice(0, limit)
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
if (returnObjects) return things
|
|
573
|
+
|
|
574
|
+
return things.map(e => e.id)
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function projection (data, fields = {}) {
|
|
578
|
+
_sanitizeFields(fields)
|
|
579
|
+
const query = new mingo.Query({}, { scriptEnabled: false })
|
|
580
|
+
const cursor = query.find(data, fields)
|
|
581
|
+
return cursor.all()
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
module.exports = {
|
|
585
|
+
isValidSnap,
|
|
586
|
+
isOffline,
|
|
587
|
+
getRandomString,
|
|
588
|
+
hasErrorAndPositiveHashrate,
|
|
589
|
+
sumPoolsShares,
|
|
590
|
+
getLogsCountForTimeRange,
|
|
591
|
+
getLogMaxHeight,
|
|
592
|
+
getJsonChanges,
|
|
593
|
+
aggregateLogs,
|
|
594
|
+
getThingSorter,
|
|
595
|
+
applyFilters,
|
|
596
|
+
projection,
|
|
597
|
+
sanitizeQuery,
|
|
598
|
+
escapeRegex,
|
|
599
|
+
redactSecrets,
|
|
600
|
+
stripRedacted,
|
|
601
|
+
isSecretKey,
|
|
602
|
+
REDACTED
|
|
603
|
+
}
|