spyne 0.20.9 → 0.20.12

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,5 +1,5 @@
1
1
  /*!
2
- * Spyne 0.20.9
2
+ * Spyne 0.20.12
3
3
  * https://spynejs.org
4
4
  *
5
5
  * @license
@@ -15,7 +15,7 @@
15
15
  */
16
16
 
17
17
  /*!
18
- * spynejs 0.20.9
18
+ * spynejs 0.20.12
19
19
  * https://spynejs.org
20
20
  * (c) 2017-present Frank Batista
21
21
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spyne",
3
- "version": "0.20.9",
3
+ "version": "0.20.12",
4
4
  "description": "Reactive Real-DOM Framework for Advanced Javascript applications",
5
5
  "sideEffects": true,
6
6
  "main": "./lib/spyne.esm.js",
@@ -412,6 +412,12 @@ export class Channel {
412
412
  `channel name ${c} is not within ${registeredStreamNames}`)
413
413
  const startSubscribe = (c) => {
414
414
  const obs$ = this.streamsController.getStream(c).observer
415
+ if (obs$.source) {
416
+ obs$.source.channelName = channelName
417
+ } else {
418
+ obs$.channelName = channelName
419
+ }
420
+
415
421
  if (payloadFilter !== undefined) {
416
422
  return obs$.pipe(filter(payloadFilter))
417
423
  }
@@ -453,11 +459,14 @@ export class Channel {
453
459
  }
454
460
 
455
461
  // 3) Map array → keyed object
462
+ // return combined$;
456
463
  return combined$.pipe(
457
464
  map(resultsArr => {
458
465
  const obj = {}
459
466
  channelsArr.forEach((item, idx) => {
460
- const key = typeof item === 'string' ? item : `channel_${idx}`
467
+ const iterKey = typeof item === 'string' ? item : `channel_${idx}`
468
+ const cnKey = item?.source?.channelName || item?.source?.source?.channelName
469
+ const key = cnKey || iterKey
461
470
  obj[key] = resultsArr[idx]
462
471
  })
463
472
  return obj
@@ -2,6 +2,7 @@ import { Channel } from './channel.js'
2
2
  import { Observable } from 'rxjs'
3
3
  import { map } from 'rxjs/operators'
4
4
  import { equals, path, compose, prop, filter, replace, lensProp, over, omit, test, keys, either, toUpper } from 'ramda'
5
+ import { sanitizeEventTarget } from '../utils/sanitize-data.js'
5
6
 
6
7
  export class SpyneChannelUI extends Channel {
7
8
  /**
@@ -334,6 +335,7 @@ export class SpyneChannelUI extends Channel {
334
335
  const { payload, srcElement } = obs
335
336
 
336
337
  const event = obs.event
338
+ sanitizeEventTarget(event?.target)
337
339
  // const payload = domStringMapToObject(srcElement?.el?.dataset) ?? obs.payload
338
340
  this.sendChannelPayload(action, payload, srcElement, event)
339
341
  }
@@ -3,9 +3,10 @@ import { ViewStream } from './views/view-stream.js'
3
3
  import { SpyneUtilsChannelRoute } from './utils/spyne-utils-channel-route.js'
4
4
  import { SpyneAppProperties } from './utils/spyne-app-properties.js'
5
5
  import { sanitizeHTMLConfigure } from './utils/sanitize-html.js'
6
+ import { sanitizeDataConfigure } from './utils/sanitize-data.js'
6
7
 
7
8
  const _channels = new ChannelsMap()
8
- const version = '0.20.9'
9
+ const version = '0.20.12'
9
10
 
10
11
  class SpyneApplication {
11
12
  /**
@@ -42,7 +43,7 @@ class SpyneApplication {
42
43
  init(config = {}, testMode = false) {
43
44
  // this.channels = new ChannelsMap();
44
45
  /*!
45
- * Spyne 0.20.9
46
+ * Spyne 0.20.12
46
47
  * https://spynejs.org
47
48
  *
48
49
  * @license
@@ -141,6 +142,7 @@ class SpyneApplication {
141
142
  }
142
143
 
143
144
  sanitizeHTMLConfigure(SpyneAppProperties.config);
145
+ sanitizeDataConfigure(SpyneAppProperties.config)
144
146
 
145
147
  }
146
148
 
@@ -17,6 +17,7 @@ import { deepMerge } from './utils/deep-merge.js'
17
17
  import { safeClone } from './utils/safe-clone.js'
18
18
  import { SpyneAppProperties } from './utils/spyne-app-properties.js'
19
19
  import { SpyneApp } from './spyne-app.js'
20
+ import sanitizeData, { sanitizeEventTarget } from './utils/sanitize-data.js'
20
21
 
21
22
  export {
22
23
  ViewStreamElement,
@@ -37,5 +38,7 @@ export {
37
38
  SpyneAppProperties,
38
39
  SpynePlugin,
39
40
  deepMerge,
40
- safeClone
41
+ safeClone,
42
+ sanitizeData,
43
+ sanitizeEventTarget
41
44
  }
@@ -1,6 +1,8 @@
1
1
  import { from } from 'rxjs'
2
2
  import { flatMap, map, publish, tap } from 'rxjs/operators'
3
3
  import { compose, prop, defaultTo, over, lensProp, has, propEq, when, propIs, allPass, assoc, pick, mergeDeepRight } from 'ramda'
4
+ import sanitizeData, { setSanitizeDataForceStrict } from './sanitize-data.js'
5
+
4
6
  export class ChannelFetchUtil {
5
7
  /**
6
8
  * @module ChannelFetchUtil
@@ -47,6 +49,8 @@ export class ChannelFetchUtil {
47
49
  */
48
50
 
49
51
  constructor(options, subscriber, testMode, CHANNEL_NAME) {
52
+ setSanitizeDataForceStrict(true)
53
+
50
54
  const testSubscriber = (p) => console.log('FETCH RETURNED ', p)
51
55
 
52
56
  this._mapFn = ChannelFetchUtil.setMapFn(options)
@@ -78,7 +82,27 @@ export class ChannelFetchUtil {
78
82
  const metadata = { channelName, url, responseType, serverOptions }
79
83
 
80
84
  return (data) => {
81
- return mapMethod(data, metadata)
85
+ const disableSanitize = props?.disableSanitize === true
86
+
87
+ if (disableSanitize) {
88
+ const env = (typeof process !== 'undefined' && process.env && process.env.NODE_ENV)
89
+ ? process.env.NODE_ENV
90
+ : 'development'
91
+
92
+ const msg = `SPYNE SECURITY WARNING — Sanitization disabled for fetch from ${url}. Data will enter the reactive stream unsanitized. Only use disableSanitize=true for trusted or controlled sources.`
93
+
94
+ if (env === 'production') {
95
+ throw new Error(`SpyneJS security policy violation: disableSanitize=true is not allowed in production (${url}).`)
96
+ } else {
97
+ console.warn(
98
+ `%c${msg}`,
99
+ 'color:#f33;font-weight:bold;'
100
+ )
101
+ }
102
+ }
103
+
104
+ const sanitizedData = disableSanitize ? data : sanitizeData(data)
105
+ return mapMethod(sanitizedData, metadata)
82
106
  }
83
107
  }
84
108
 
@@ -0,0 +1,160 @@
1
+ // src/utils/sanitize-data.js
2
+ import DOMPurify from 'dompurify'
3
+ import { SpyneAppProperties } from './spyne-app-properties.js'
4
+
5
+ let _sanitizeData
6
+ let isConfigured = false
7
+ let forceStrict = false // legacy compatibility toggle
8
+
9
+ /* ---------------------------------------------
10
+ * Mode configurations
11
+ * ------------------------------------------- */
12
+ const SAFE_FOR_RICH_TEXT = {
13
+ ALLOWED_TAGS: [
14
+ 'a', 'b', 'i', 'em', 'strong', 'u', 'p', 'br', 'ul', 'ol', 'li',
15
+ 'blockquote', 'pre', 'code', 'span', 'div', 'section', 'article', 'aside',
16
+ 'header', 'footer', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
17
+ 'table', 'thead', 'tbody', 'tr', 'td', 'th', 'img', 'figure', 'figcaption',
18
+ 'video', 'source'
19
+ ],
20
+ ALLOWED_ATTR: [
21
+ 'href', 'src', 'alt', 'title', 'width', 'height', 'style', 'target', 'rel',
22
+ 'controls', 'poster', 'type', 'rows', 'cols'
23
+ ],
24
+ ALLOW_DATA_ATTR: true,
25
+ FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus', 'onblur', 'onchange'],
26
+ FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'form', 'input', 'button', 'meta', 'link'],
27
+ SAFE_URL_PATTERN: /^(https?:|mailto:|tel:|data:image\/)/i
28
+ }
29
+
30
+ const SAFE_FOR_APP = {
31
+ FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'form', 'input', 'button', 'link', 'meta'],
32
+ FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus', 'onblur', 'onchange'],
33
+ ALLOW_DATA_ATTR: false
34
+ }
35
+
36
+ /* ---------------------------------------------
37
+ * Determine sanitization mode from SpyneAppProperties
38
+ * ------------------------------------------- */
39
+ const resolveDefaultMode = () => {
40
+ try {
41
+ const mode = SpyneAppProperties?.mode
42
+ if (mode === 'authoring' || mode === 'development') return 'richtext'
43
+ } catch (e) {
44
+ // Fallback: assume production strict mode
45
+ }
46
+ return 'app'
47
+ }
48
+
49
+ /* --------------------------------------------- */
50
+ function makeSanitizeFn(mode = 'app') {
51
+ const cfg = mode === 'richtext' ? SAFE_FOR_RICH_TEXT : SAFE_FOR_APP
52
+
53
+ return (html) => {
54
+ let clean = DOMPurify.sanitize(html, cfg)
55
+
56
+ // In richtext mode, enforce safe URL schemes for href/src
57
+ if (mode === 'richtext') {
58
+ clean = clean.replace(/<(a|img)\b([^>]+?)>/gi, (match) => {
59
+ return match.replace(/\b(href|src)="([^"]*)"/gi, (m, attr, val) => {
60
+ return SAFE_FOR_RICH_TEXT.SAFE_URL_PATTERN.test(val) ? m : `${attr}=""`
61
+ })
62
+ })
63
+ }
64
+ return clean
65
+ }
66
+ }
67
+
68
+ /* ---------------------------------------------
69
+ * Configure once (kept for compatibility)
70
+ * ------------------------------------------- */
71
+ const sanitizeDataConfigure = (config = {}) => {
72
+ if (isConfigured) {
73
+ console.warn('sanitizeData is already configured. Reconfiguration is not allowed.')
74
+ return
75
+ }
76
+
77
+ const { strict = false } = config
78
+ const processFactory = (mode) => {
79
+ const sanitizeStr = makeSanitizeFn(mode)
80
+ const process = (val) => {
81
+ if (Array.isArray(val)) return val.map(process)
82
+ if (val && typeof val === 'object') {
83
+ const out = {}
84
+ for (const [k, v] of Object.entries(val)) {
85
+ out[k] = (typeof v === 'string') ? sanitizeStr(v) : process(v)
86
+ }
87
+ return out
88
+ }
89
+ return (typeof val === 'string') ? sanitizeStr(val) : val
90
+ }
91
+ return process
92
+ }
93
+
94
+ _sanitizeData = (data, opts = {}) => {
95
+ const { disableSanitize = false, mode } = opts
96
+ const legacyStrict = forceStrict || strict
97
+ const effectiveMode = mode || (legacyStrict ? 'app' : resolveDefaultMode())
98
+ if (disableSanitize) return data
99
+ return processFactory(effectiveMode)(data)
100
+ }
101
+
102
+ sanitizeDataConfigure.sanitizeDataForce = (data, mode) => {
103
+ const effective = mode || resolveDefaultMode()
104
+ return processFactory(effective)(data)
105
+ }
106
+
107
+ isConfigured = true
108
+ }
109
+
110
+ /* ---------------------------------------------
111
+ * Configurable runtime sanitizer
112
+ * ------------------------------------------- */
113
+ const sanitizeData = (data, opts = {}) => {
114
+ if (!isConfigured) {
115
+ throw new Error('sanitizeData is not configured. Call sanitizeDataConfigure() first.')
116
+ }
117
+ return _sanitizeData(data, opts)
118
+ }
119
+
120
+ /* ---------------------------------------------
121
+ * Always-on sanitizer with environment default
122
+ * ------------------------------------------- */
123
+ const sanitizeDataForce = (data, mode) => {
124
+ if (!isConfigured || !sanitizeDataConfigure.sanitizeDataForce) {
125
+ const sanitizeStr = makeSanitizeFn(mode || resolveDefaultMode())
126
+ return (typeof data === 'string') ? sanitizeStr(data) : data
127
+ }
128
+ return sanitizeDataConfigure.sanitizeDataForce(data, mode)
129
+ }
130
+
131
+ /* ---------------------------------------------
132
+ * Event-target sanitizer
133
+ * ------------------------------------------- */
134
+ const sanitizeEventTarget = (el, mode) => {
135
+ if (!el) return
136
+ const effectiveMode = mode || resolveDefaultMode()
137
+ const sanitizeStr = makeSanitizeFn(effectiveMode)
138
+ const UNSAFE_RE = /<(?:script|iframe|object|embed|form|input|button|link|meta)[^>]*>|on\w+=|javascript:/i
139
+
140
+ if (typeof el.value === 'string' && UNSAFE_RE.test(el.value)) {
141
+ const clean = sanitizeStr(el.value)
142
+ if (clean !== el.value) el.value = clean
143
+ }
144
+ if (el.isContentEditable && typeof el.innerHTML === 'string' && UNSAFE_RE.test(el.innerHTML)) {
145
+ const clean = sanitizeStr(el.innerHTML)
146
+ if (clean !== el.innerHTML) el.innerHTML = clean
147
+ }
148
+ }
149
+
150
+ /* --------------------------------------------- */
151
+ const setSanitizeDataForceStrict = (bool = true) => { forceStrict = !!bool }
152
+
153
+ export {
154
+ sanitizeDataConfigure,
155
+ sanitizeData,
156
+ sanitizeDataForce,
157
+ sanitizeEventTarget,
158
+ setSanitizeDataForceStrict
159
+ }
160
+ export default sanitizeData
@@ -7,6 +7,7 @@ let _channels
7
7
  let _channelsMap
8
8
  let _initialized
9
9
  let _debug = true
10
+ let _enableCMSProxies = false
10
11
  const _excludeChannelsFromConsole = []
11
12
  /* eslint-disable */
12
13
  let _linksData
@@ -267,6 +268,72 @@ class SpyneAppPropertiesClass {
267
268
  _navLinks = arr
268
269
  }
269
270
 
271
+ get enableCMSProxies(){
272
+ return _enableCMSProxies
273
+ }
274
+
275
+ set enableCMSProxies(bool=true){
276
+ _enableCMSProxies = Boolean(bool);
277
+ }
278
+
279
+ setCMSProxyMethod(fn){
280
+ // this.formatTemplateForProxyData = fn;
281
+ this.enableCMSProxies = true;
282
+ }
283
+
284
+ // SpyneJS Enterprise Code Start
285
+ formatTemplateForProxyData(tmpl) {
286
+ const isPrimitiveTag = str => /({{\.\*?}})/.test(str);
287
+
288
+ const reLines2 = /(({{(?!loopNum|loopIndex))(.*?)(}}))/gm
289
+ const reStr = '(?<=>)(.*?)(({{)(.*)(}}))'
290
+
291
+ const reLines1 = new RegExp(reStr, 'gm')
292
+
293
+ const formatCmsParam = (str) => {
294
+ const isArrPrimitive = isPrimitiveTag(str)
295
+
296
+ const getKeyAndDataId = (s) => {
297
+ const re = /({{)([\w_]+(\.))*?(\w+)(}})/gm // this is old, will be deprecated.
298
+ const reNestedData = /^\{\{([^}]+?)\.[^.}]+}}$/gm
299
+
300
+ let dataId = '__cms__dataId'
301
+ const key = isArrPrimitive ? '{{loopIndex}}' : String(s).replace(re, '$4')
302
+ if (/(\w\.)/.test(s)) {
303
+ dataId = String(s).replace(reNestedData, '$1.') + dataId
304
+ }
305
+ return { key, dataId }
306
+ }
307
+
308
+ const { key, dataId } = getKeyAndDataId(str)
309
+
310
+ let prefix = `<spyne-cms-item data-cms-id="{{${dataId}}}" data-cms-key="${key}"`
311
+
312
+ // IF AN ARRAY PRIMITIVE CHECK FOR ORIGINAL ARRAY POSITION
313
+ if (isArrPrimitive) {
314
+ prefix += ' data-cms-orig-key="{{origKey}}"'
315
+ }
316
+
317
+ // CREATE PROXY TAG
318
+ const suffix = '</spyne-cms-item>'
319
+ const param = isArrPrimitive ? '{{spyneLoopKey}}' : str
320
+ return `${prefix}><spyne-cms-item-hitbox></spyne-cms-item-hitbox><spyne-cms-item-text>${param}</spyne-cms-item-text>${suffix}`
321
+ }
322
+
323
+ const reSwapTags = (str, m1, m2, m3) => {
324
+ return formatCmsParam(str)
325
+ }
326
+
327
+ const reMethod = (str, m1, m2, m3, m4) => {
328
+ const m2Updated = String(m2).replace(reLines2, reSwapTags)
329
+ return `${m1}${m2Updated}`
330
+ }
331
+
332
+ return String(tmpl).replace(reLines1, reMethod)
333
+ }
334
+ // SpyneJS Enterprise Code End
335
+
336
+
270
337
  tempGetChannelsInstance() {
271
338
 
272
339
  }
@@ -93,6 +93,20 @@ export class SpyneUtilsChannelRoute {
93
93
  static addRouteDatasets(channelRouteObj) {
94
94
  // channelRouteObj.type='query';
95
95
 
96
+ function removeUndefined(arr) {
97
+ return arr.map(obj => {
98
+ for (const key in obj) {
99
+ if (key === undefined || key === 'undefined' || obj[key] === undefined) {
100
+ delete obj[key]
101
+ } else if (obj[key] && typeof obj[key] === 'object' && !Array.isArray(obj[key])) {
102
+ // recurse if nested object
103
+ obj[key] = removeUndefined([obj[key]])[0]
104
+ }
105
+ }
106
+ return obj
107
+ })
108
+ }
109
+
96
110
  const { type, isHash } = channelRouteObj
97
111
 
98
112
  // create href and check to see if need to convert to hash href links
@@ -163,10 +177,11 @@ export class SpyneUtilsChannelRoute {
163
177
  }
164
178
 
165
179
  const reducedArr = createInitialValFn([], channelRouteObj.routes)
166
- const routeDatasetsArr = flatten(reducedArr)
180
+ let routeDatasetsArr = flatten(reducedArr)
181
+ routeDatasetsArr = removeUndefined(routeDatasetsArr)
167
182
 
168
183
  const getNavProps = (datasetsArr) => {
169
- const exclude = reject(includes(__, ['title', 'href', 'navLevel']))
184
+ const exclude = reject(includes(__, ['title', 'href', 'navLevel', 'undefined']))
170
185
  const getMainKeys = compose(uniq, exclude, flatten, map(keys))
171
186
  return getMainKeys(datasetsArr)
172
187
  }
@@ -140,6 +140,17 @@ export class DomElementTemplate {
140
140
  this.isProxyData = data.__cms__isProxy === true
141
141
  this.testMode = opts?.testMode
142
142
 
143
+ /*
144
+ if (this.isProxyData === true) {
145
+ console.log('PROXY IS ', data.__cms__rootData)
146
+ this.template = DomElementTemplate.formatTemplateForProxyData(this.template)
147
+ }
148
+ */
149
+
150
+ if (SpyneAppProperties.enableCMSProxies && this.isProxyData === true) {
151
+ this.template = SpyneAppProperties.formatTemplateForProxyData(this.template)
152
+ }
153
+
143
154
  const checkForArrayData = () => {
144
155
  if (is(Array, data) === true) {
145
156
  data = { spyneData:data }