spyne 0.24.0 → 0.25.0
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/lib/spyne.esm.js +3 -3
- package/lib/spyne.esm.js.LICENSE.txt +2 -2
- package/lib/spyne.esm.js.map +1 -1
- package/lib/spyne.umd.js +2 -2
- package/lib/spyne.umd.js.LICENSE.txt +2 -2
- package/package.json +1 -1
- package/src/spyne/spyne-app.js +3 -2
- package/src/spyne/spyne.js +5 -2
- package/src/spyne/utils/channel-fetch-util.js +3 -2
- package/src/spyne/utils/sanitize-data.js +65 -6
- package/src/spyne/utils/sanitize-html.js +8 -2
- package/src/spyne/utils/spyne-warn.js +26 -0
- package/src/spyne/views/dom-element.js +2 -1
- package/src/spyne/views/view-stream-selector.js +52 -47
- package/src/tests/index.test.js +5 -0
- package/src/tests/utils/security.test.js +65 -1
- package/src/tests/views/dom-el-selectors.test.js +7 -7
- package/src/tests/views/view-stream-selector.test.js +178 -0
package/package.json
CHANGED
package/src/spyne/spyne-app.js
CHANGED
|
@@ -6,7 +6,7 @@ import { sanitizeHTMLConfigure } from './utils/sanitize-html.js'
|
|
|
6
6
|
import { sanitizeDataConfigure } from './utils/sanitize-data.js'
|
|
7
7
|
|
|
8
8
|
const _channels = new ChannelsMap()
|
|
9
|
-
const version = '0.
|
|
9
|
+
const version = '0.25.0'
|
|
10
10
|
|
|
11
11
|
class SpyneApplication {
|
|
12
12
|
/**
|
|
@@ -41,7 +41,7 @@ class SpyneApplication {
|
|
|
41
41
|
init(config = {}, testMode = false) {
|
|
42
42
|
// this.channels = new ChannelsMap();
|
|
43
43
|
/*!
|
|
44
|
-
* Spyne 0.
|
|
44
|
+
* Spyne 0.25.0
|
|
45
45
|
* https://spynejs.org
|
|
46
46
|
*
|
|
47
47
|
* @license
|
|
@@ -77,6 +77,7 @@ class SpyneApplication {
|
|
|
77
77
|
mode: 'app',
|
|
78
78
|
disableSanitize: false,
|
|
79
79
|
iframes: {},
|
|
80
|
+
customElements: [],
|
|
80
81
|
baseHref: undefined,
|
|
81
82
|
IMG_PATH: imgPath,
|
|
82
83
|
pluginMethods:{
|
package/src/spyne/spyne.js
CHANGED
|
@@ -17,7 +17,8 @@ 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
|
|
20
|
+
import { setSpyneWarningsDisabled } from './utils/spyne-warn.js'
|
|
21
|
+
import sanitizeData, { sanitizeEventTarget, allowCustomElements } from './utils/sanitize-data.js'
|
|
21
22
|
|
|
22
23
|
export {
|
|
23
24
|
ViewStreamElement,
|
|
@@ -40,5 +41,7 @@ export {
|
|
|
40
41
|
deepMerge,
|
|
41
42
|
safeClone,
|
|
42
43
|
sanitizeData,
|
|
43
|
-
sanitizeEventTarget
|
|
44
|
+
sanitizeEventTarget,
|
|
45
|
+
allowCustomElements,
|
|
46
|
+
setSpyneWarningsDisabled
|
|
44
47
|
}
|
|
@@ -2,6 +2,7 @@ import { from, of } from 'rxjs'
|
|
|
2
2
|
import { catchError, map, mergeMap, share, tap } from 'rxjs/operators'
|
|
3
3
|
import { compose, prop, defaultTo, pick, mergeDeepRight } from 'ramda'
|
|
4
4
|
import sanitizeData from './sanitize-data.js'
|
|
5
|
+
import { spyneWarn } from './spyne-warn.js'
|
|
5
6
|
|
|
6
7
|
export class ChannelFetchUtil {
|
|
7
8
|
/**
|
|
@@ -236,7 +237,7 @@ export class ChannelFetchUtil {
|
|
|
236
237
|
const url = prop('url', opts)
|
|
237
238
|
|
|
238
239
|
if (url === undefined) {
|
|
239
|
-
|
|
240
|
+
spyneWarn('SPYNE WARNING: URL is undefined for data channel')
|
|
240
241
|
}
|
|
241
242
|
|
|
242
243
|
return url
|
|
@@ -292,7 +293,7 @@ export class ChannelFetchUtil {
|
|
|
292
293
|
const method = opts?.method || 'GET'
|
|
293
294
|
|
|
294
295
|
if (hasBody && method.toUpperCase() === 'GET') {
|
|
295
|
-
|
|
296
|
+
spyneWarn(
|
|
296
297
|
'SPYNE WARNING: Fetch body was provided with method GET. Changing method to POST.',
|
|
297
298
|
opts
|
|
298
299
|
)
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// src/utils/sanitize-data.js
|
|
2
2
|
import DOMPurify from 'dompurify'
|
|
3
|
+
import { spyneWarn } from './spyne-warn.js'
|
|
3
4
|
import { SpyneAppProperties } from './spyne-app-properties.js'
|
|
4
5
|
|
|
5
6
|
let _sanitizeData
|
|
@@ -23,6 +24,56 @@ const normalizeUriForCheck = (val) => String(val).replace(/[\u0000-\u0020]/g, ''
|
|
|
23
24
|
|
|
24
25
|
const isSafeUri = (val) => SAFE_URI_REGEXP.test(normalizeUriForCheck(val))
|
|
25
26
|
|
|
27
|
+
/* ---------------------------------------------
|
|
28
|
+
* Custom elements
|
|
29
|
+
*
|
|
30
|
+
* The framework's own custom elements (spyne-*) are first-party output —
|
|
31
|
+
* the CMS proxy wraps editable content in <spyne-cms-item> etc. — and are
|
|
32
|
+
* admitted in every mode. They are inert without their defining plugin
|
|
33
|
+
* and the attribute check below only admits data-* on them.
|
|
34
|
+
*
|
|
35
|
+
* Additional elements register via allowCustomElements() (additive only;
|
|
36
|
+
* names must contain a hyphen per the custom-element spec, so built-in
|
|
37
|
+
* tags like script or iframe can never be admitted through this path)
|
|
38
|
+
* or via config.customElements at SpyneApp.init.
|
|
39
|
+
* ------------------------------------------- */
|
|
40
|
+
const SPYNE_CUSTOM_ELEMENT_RE = /^spyne-[a-z][a-z0-9-]*$/i
|
|
41
|
+
|
|
42
|
+
const allowedCustomElementPatterns = []
|
|
43
|
+
|
|
44
|
+
const allowCustomElements = (elements = []) => {
|
|
45
|
+
const arr = Array.isArray(elements) ? elements : [elements]
|
|
46
|
+
|
|
47
|
+
arr.forEach((entry) => {
|
|
48
|
+
if (entry instanceof RegExp) {
|
|
49
|
+
allowedCustomElementPatterns.push(entry)
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const name = String(entry).toLowerCase()
|
|
54
|
+
if (/^[a-z][a-z0-9]*-[a-z0-9-]+$/.test(name) !== true) {
|
|
55
|
+
spyneWarn(`SPYNE WARNING: "${entry}" is not a valid custom element name (a hyphen is required) and was not added to the sanitizer allowlist.`)
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
allowedCustomElementPatterns.push(name)
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const customElementTagCheck = (tagName) => {
|
|
64
|
+
if (SPYNE_CUSTOM_ELEMENT_RE.test(tagName)) return true
|
|
65
|
+
|
|
66
|
+
return allowedCustomElementPatterns.some(p =>
|
|
67
|
+
p instanceof RegExp ? p.test(tagName) : p === tagName
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const CUSTOM_ELEMENT_HANDLING = {
|
|
72
|
+
tagNameCheck: customElementTagCheck,
|
|
73
|
+
attributeNameCheck: /^data-[a-z0-9-]+$/i,
|
|
74
|
+
allowCustomizedBuiltInElements: false
|
|
75
|
+
}
|
|
76
|
+
|
|
26
77
|
/* ---------------------------------------------
|
|
27
78
|
* Mode configurations
|
|
28
79
|
* ------------------------------------------- */
|
|
@@ -42,14 +93,16 @@ const SAFE_FOR_RICH_TEXT = {
|
|
|
42
93
|
ALLOW_DATA_ATTR: true,
|
|
43
94
|
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus', 'onblur', 'onchange'],
|
|
44
95
|
FORBID_TAGS: ['script', 'object', 'embed', 'meta'],
|
|
45
|
-
ALLOWED_URI_REGEXP: SAFE_URI_REGEXP
|
|
96
|
+
ALLOWED_URI_REGEXP: SAFE_URI_REGEXP,
|
|
97
|
+
CUSTOM_ELEMENT_HANDLING
|
|
46
98
|
}
|
|
47
99
|
|
|
48
100
|
const SAFE_FOR_APP = {
|
|
49
101
|
FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'form', 'input', 'button', 'link', 'meta'],
|
|
50
102
|
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus', 'onblur', 'onchange'],
|
|
51
103
|
ALLOW_DATA_ATTR: false,
|
|
52
|
-
ALLOWED_URI_REGEXP: SAFE_URI_REGEXP
|
|
104
|
+
ALLOWED_URI_REGEXP: SAFE_URI_REGEXP,
|
|
105
|
+
CUSTOM_ELEMENT_HANDLING
|
|
53
106
|
}
|
|
54
107
|
|
|
55
108
|
/* ---------------------------------------------
|
|
@@ -136,7 +189,7 @@ const addIframeHardeningHook = () => {
|
|
|
136
189
|
|
|
137
190
|
const src = node.getAttribute('src') || ''
|
|
138
191
|
if (src !== '' && isAllowedIframeSrc(src) !== true) {
|
|
139
|
-
|
|
192
|
+
spyneWarn(`SPYNE WARNING: iframe src "${src}" was removed by the iframe policy (same-origin, https, or an allowed origin is required).`)
|
|
140
193
|
node.removeAttribute('src')
|
|
141
194
|
}
|
|
142
195
|
|
|
@@ -209,14 +262,18 @@ function makeSanitizeFn(mode = 'app') {
|
|
|
209
262
|
* ------------------------------------------- */
|
|
210
263
|
const sanitizeDataConfigure = (config = {}) => {
|
|
211
264
|
if (isConfigured) {
|
|
212
|
-
|
|
265
|
+
spyneWarn('sanitizeData is already configured. Reconfiguration is not allowed.')
|
|
213
266
|
return
|
|
214
267
|
}
|
|
215
268
|
|
|
216
|
-
const { strict = false, disableSanitize = false, iframes } = config
|
|
269
|
+
const { strict = false, disableSanitize = false, iframes, customElements } = config
|
|
217
270
|
|
|
218
271
|
globalDisable = disableSanitize === true
|
|
219
272
|
|
|
273
|
+
if (customElements !== undefined) {
|
|
274
|
+
allowCustomElements(customElements)
|
|
275
|
+
}
|
|
276
|
+
|
|
220
277
|
if (iframes && typeof iframes === 'object') {
|
|
221
278
|
configuredIframePolicy = resolveIframePolicy(iframes)
|
|
222
279
|
activeIframePolicy = configuredIframePolicy
|
|
@@ -376,7 +433,7 @@ const sanitizeEventTarget = (el, mode) => {
|
|
|
376
433
|
* config.mode at SpyneApp.init. Retained for backward compatibility.
|
|
377
434
|
* ------------------------------------------- */
|
|
378
435
|
const setSanitizeDataForceStrict = (bool = true) => {
|
|
379
|
-
|
|
436
|
+
spyneWarn('SPYNE DEPRECATION: setSanitizeDataForceStrict is deprecated. Pass { mode: "app" } to sanitizeData, or set config.mode in SpyneApp.init.')
|
|
380
437
|
forceStrict = !!bool
|
|
381
438
|
}
|
|
382
439
|
|
|
@@ -386,6 +443,8 @@ export {
|
|
|
386
443
|
sanitizeDataForce,
|
|
387
444
|
sanitizeAttribute,
|
|
388
445
|
applyIframeHardening,
|
|
446
|
+
allowCustomElements,
|
|
447
|
+
CUSTOM_ELEMENT_HANDLING as customElementHandling,
|
|
389
448
|
sanitizeEventTarget,
|
|
390
449
|
setSanitizeDataForceStrict
|
|
391
450
|
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import DOMPurify from 'dompurify'
|
|
2
|
+
import { spyneWarn } from './spyne-warn.js'
|
|
3
|
+
import { customElementHandling } from './sanitize-data.js'
|
|
2
4
|
|
|
3
5
|
let _sanitizeHTML
|
|
4
6
|
let isConfigured = false
|
|
@@ -17,7 +19,7 @@ let isConfigured = false
|
|
|
17
19
|
*/
|
|
18
20
|
const sanitizeHTMLConfigure = (config = {}) => {
|
|
19
21
|
if (isConfigured) {
|
|
20
|
-
|
|
22
|
+
spyneWarn('sanitizeHTML is already configured. Reconfiguration is not allowed.')
|
|
21
23
|
return
|
|
22
24
|
}
|
|
23
25
|
|
|
@@ -56,6 +58,10 @@ const sanitizeHTMLConfigure = (config = {}) => {
|
|
|
56
58
|
...(allowTargetAttr ? ['target'] : []),
|
|
57
59
|
...(allowIframe ? ['sandbox'] : [])
|
|
58
60
|
],
|
|
61
|
+
// Admits the framework's spyne-* elements (CMS proxy wrappers) and any
|
|
62
|
+
// elements registered via allowCustomElements/config.customElements.
|
|
63
|
+
// The tag check is a closure, so late plugin registration is honored.
|
|
64
|
+
CUSTOM_ELEMENT_HANDLING: customElementHandling,
|
|
59
65
|
...(allowIframe !== true && isRichtext ? { FORBID_TAGS: ['iframe'] } : {})
|
|
60
66
|
}
|
|
61
67
|
|
|
@@ -110,7 +116,7 @@ const sanitizeHTMLConfigure = (config = {}) => {
|
|
|
110
116
|
console.log('SPYNE: Trusted Types default policy registered. Note that browser enforcement additionally requires the CSP header: require-trusted-types-for \'script\'.')
|
|
111
117
|
}
|
|
112
118
|
} catch (err) {
|
|
113
|
-
|
|
119
|
+
spyneWarn('SPYNE WARNING: A Trusted Types "default" policy already exists on this page. Falling back to direct sanitization.', err)
|
|
114
120
|
}
|
|
115
121
|
}
|
|
116
122
|
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Central outlet for SpyneJS developer-hint warnings (invalid selectors,
|
|
3
|
+
* sanitized attributes, policy removals, deprecations).
|
|
4
|
+
*
|
|
5
|
+
* Warnings are suppressed when window.SPYNE_SUPPRESS_WARNINGS is true —
|
|
6
|
+
* primarily for test runs, where expected-failure cases would otherwise
|
|
7
|
+
* flood the reporter. The flag lives on window (not module state) so it
|
|
8
|
+
* spans separately bundled test files.
|
|
9
|
+
*
|
|
10
|
+
* Security-posture warnings (e.g. config.disableSanitize) do NOT route
|
|
11
|
+
* through here and are always printed.
|
|
12
|
+
*/
|
|
13
|
+
const spyneWarn = (...args) => {
|
|
14
|
+
const suppressed = typeof window !== 'undefined' && window.SPYNE_SUPPRESS_WARNINGS === true
|
|
15
|
+
if (suppressed !== true) {
|
|
16
|
+
console.warn(...args)
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const setSpyneWarningsDisabled = (bool = true) => {
|
|
21
|
+
if (typeof window !== 'undefined') {
|
|
22
|
+
window.SPYNE_SUPPRESS_WARNINGS = bool === true
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export { spyneWarn, setSpyneWarningsDisabled }
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { baseCoreMixins } from '../utils/mixins/base-core-mixins.js'
|
|
2
|
+
import { spyneWarn } from '../utils/spyne-warn.js'
|
|
2
3
|
import { DomElementTemplate } from './dom-element-template.js'
|
|
3
4
|
import { deepMerge } from '../utils/deep-merge.js'
|
|
4
5
|
import { sanitizeAttribute, applyIframeHardening } from '../utils/sanitize-data.js'
|
|
@@ -67,7 +68,7 @@ class DomElement {
|
|
|
67
68
|
if (testMode !== true) {
|
|
68
69
|
const { allowed, value } = sanitizeAttribute(key, val, el.tagName)
|
|
69
70
|
if (allowed !== true) {
|
|
70
|
-
|
|
71
|
+
spyneWarn(`SPYNE WARNING: The attribute "${key}" was removed during sanitization.`, el)
|
|
71
72
|
return
|
|
72
73
|
}
|
|
73
74
|
val = value
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { head, compose, reject, split, isEmpty, lte, defaultTo, prop } from 'ramda'
|
|
2
|
+
import { spyneWarn } from '../utils/spyne-warn.js'
|
|
2
3
|
import { SpyneAppProperties } from '../utils/spyne-app-properties.js'
|
|
3
4
|
|
|
4
5
|
function generateSpyneSelectorId(el) {
|
|
@@ -32,7 +33,7 @@ function testSelectors(cxt, sel, verboseBool) {
|
|
|
32
33
|
const elIsDomElement = compose(lte(0), defaultTo(-1), prop('nodeType'))
|
|
33
34
|
|
|
34
35
|
if (el !== null && elIsDomElement(el) === false) {
|
|
35
|
-
|
|
36
|
+
spyneWarn(`Spyne Warning: the el object is not a valid single element, ${el}`)
|
|
36
37
|
return
|
|
37
38
|
}
|
|
38
39
|
|
|
@@ -48,7 +49,7 @@ function testSelectors(cxt, sel, verboseBool) {
|
|
|
48
49
|
|
|
49
50
|
const isValidSelectorBool = isValidSelector(sel)
|
|
50
51
|
if (verboseBool === true && isDevMode() === true && isValidSelectorBool === false) {
|
|
51
|
-
|
|
52
|
+
spyneWarn(`Spyne Warning: the selector, ${sel} does not exist in this el, ${cxt}`)
|
|
52
53
|
}
|
|
53
54
|
}
|
|
54
55
|
}
|
|
@@ -127,8 +128,6 @@ function ViewStreamSelector(cxt, sel) {
|
|
|
127
128
|
*/
|
|
128
129
|
selector.forEach = (fn) => Array.from(getNodeListArray(cxt, sel)).map(fn)
|
|
129
130
|
|
|
130
|
-
selector.getNodeListArray = () => getNodeListArray(cxt, sel)
|
|
131
|
-
|
|
132
131
|
/**
|
|
133
132
|
*
|
|
134
133
|
* Adds the class to the Element or to the NodeList.
|
|
@@ -142,7 +141,7 @@ function ViewStreamSelector(cxt, sel) {
|
|
|
142
141
|
const arr = getNodeListArray(cxt, sel)
|
|
143
142
|
const addClass = item => item.classList.add(c)
|
|
144
143
|
arr.forEach(addClass)
|
|
145
|
-
return
|
|
144
|
+
return selector
|
|
146
145
|
}
|
|
147
146
|
|
|
148
147
|
/**
|
|
@@ -156,7 +155,7 @@ function ViewStreamSelector(cxt, sel) {
|
|
|
156
155
|
item.classList.remove(c)
|
|
157
156
|
}
|
|
158
157
|
arr.forEach(removeClass)
|
|
159
|
-
return
|
|
158
|
+
return selector
|
|
160
159
|
}
|
|
161
160
|
|
|
162
161
|
/**
|
|
@@ -181,7 +180,7 @@ function ViewStreamSelector(cxt, sel) {
|
|
|
181
180
|
classStrArr.forEach(adder)
|
|
182
181
|
}
|
|
183
182
|
arr.forEach(setTheClass)
|
|
184
|
-
return
|
|
183
|
+
return selector
|
|
185
184
|
}
|
|
186
185
|
|
|
187
186
|
selector.unmount = () => {
|
|
@@ -205,23 +204,25 @@ function ViewStreamSelector(cxt, sel) {
|
|
|
205
204
|
bool ? item.classList.add(c) : item.classList.remove(c)
|
|
206
205
|
}
|
|
207
206
|
arr.forEach(toggleClass)
|
|
208
|
-
return
|
|
207
|
+
return selector
|
|
209
208
|
}
|
|
210
209
|
|
|
211
210
|
selector.toggle = (c, bool) => {
|
|
212
211
|
selector.toggleClass(c, bool)
|
|
213
|
-
return
|
|
212
|
+
return selector
|
|
214
213
|
}
|
|
215
214
|
|
|
216
215
|
/**
|
|
217
|
-
* Attaches html to the Selector's element
|
|
216
|
+
* Attaches html to the Selector's element (the first match when the
|
|
217
|
+
* selector matches multiple elements).
|
|
218
218
|
* @param htmlElement
|
|
219
219
|
*/
|
|
220
220
|
selector.appendChild = (htmlElement) => {
|
|
221
|
-
|
|
222
|
-
|
|
221
|
+
const el = selector.el
|
|
222
|
+
if (el !== null) {
|
|
223
|
+
el.appendChild(htmlElement)
|
|
223
224
|
} else {
|
|
224
|
-
|
|
225
|
+
spyneWarn(`Spyne Warning: The selector, ${sel} does not appear to be valid!`)
|
|
225
226
|
}
|
|
226
227
|
|
|
227
228
|
return selector.el
|
|
@@ -244,7 +245,7 @@ function ViewStreamSelector(cxt, sel) {
|
|
|
244
245
|
arr.forEach(addClass)
|
|
245
246
|
}
|
|
246
247
|
requestAnimationFrame(delayAddClass)
|
|
247
|
-
|
|
248
|
+
return selector
|
|
248
249
|
}
|
|
249
250
|
|
|
250
251
|
/**
|
|
@@ -253,19 +254,6 @@ function ViewStreamSelector(cxt, sel) {
|
|
|
253
254
|
* @param {String|HTMLElement} elSel The selector for the element.
|
|
254
255
|
* @desc Sets the class active HTMLElement from a NodeList.
|
|
255
256
|
*/
|
|
256
|
-
selector.setActiveItem2 = (c, elSel) => {
|
|
257
|
-
const arr = getNodeListArray(cxt, sel)
|
|
258
|
-
const currentEl = typeof (elSel) === 'string' ? getElOrList(cxt, elSel) : elSel
|
|
259
|
-
const toggleBool = item => item.isEqualNode(currentEl) ? item.classList.add(c) : item.classList.remove(c)
|
|
260
|
-
if (isNodeElement(currentEl) === true) {
|
|
261
|
-
arr.forEach(toggleBool)
|
|
262
|
-
} else if (isDevMode() === true) {
|
|
263
|
-
// console.log("SEL IS ",elSel,c);
|
|
264
|
-
console.warn(`Spyne Warning: The selector, ${elSel}, does not appear to be a valid item in setActiveItem: ${c}`)
|
|
265
|
-
}
|
|
266
|
-
return this
|
|
267
|
-
}
|
|
268
|
-
|
|
269
257
|
selector.setActiveItem = (c, elSel) => {
|
|
270
258
|
const arr = getNodeListArray(cxt, sel)
|
|
271
259
|
const currentEl = typeof elSel === 'string'
|
|
@@ -280,17 +268,17 @@ function ViewStreamSelector(cxt, sel) {
|
|
|
280
268
|
if (matchingEl) {
|
|
281
269
|
matchingEl.classList.add(c)
|
|
282
270
|
} else if (isDevMode() === true) {
|
|
283
|
-
|
|
271
|
+
spyneWarn(
|
|
284
272
|
`Spyne Warning: The selector, ${elSel}, is valid but does not match any item in setActiveItem: ${c}`
|
|
285
273
|
)
|
|
286
274
|
}
|
|
287
275
|
} else if (isDevMode() === true) {
|
|
288
|
-
|
|
276
|
+
spyneWarn(
|
|
289
277
|
`Spyne Warning: The selector, ${elSel}, does not appear to be a valid item in setActiveItem: ${c}`
|
|
290
278
|
)
|
|
291
279
|
}
|
|
292
280
|
|
|
293
|
-
return
|
|
281
|
+
return selector
|
|
294
282
|
}
|
|
295
283
|
|
|
296
284
|
/**
|
|
@@ -298,10 +286,24 @@ function ViewStreamSelector(cxt, sel) {
|
|
|
298
286
|
* @function el
|
|
299
287
|
*
|
|
300
288
|
* @desc
|
|
301
|
-
* getter
|
|
289
|
+
* getter that always resolves to a single element: the matched element,
|
|
290
|
+
* or the first item when the selector matches multiple elements (a
|
|
291
|
+
* warning is logged in debug mode).
|
|
302
292
|
*
|
|
303
293
|
* @returns
|
|
304
|
-
*
|
|
294
|
+
* An HTMLElement, or null when the selector matches nothing
|
|
295
|
+
* (mirroring querySelector)
|
|
296
|
+
*/
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
*
|
|
300
|
+
* @function els
|
|
301
|
+
*
|
|
302
|
+
* @desc
|
|
303
|
+
* getter that always resolves to an Array of the matched elements.
|
|
304
|
+
*
|
|
305
|
+
* @returns
|
|
306
|
+
* An Array of HTMLElements; empty when the selector matches nothing
|
|
305
307
|
*/
|
|
306
308
|
|
|
307
309
|
/**
|
|
@@ -312,7 +314,7 @@ function ViewStreamSelector(cxt, sel) {
|
|
|
312
314
|
* Determines the length of the NodeList
|
|
313
315
|
*
|
|
314
316
|
* @returns
|
|
315
|
-
* The
|
|
317
|
+
* The number of elements matched by the selector
|
|
316
318
|
*/
|
|
317
319
|
|
|
318
320
|
/**
|
|
@@ -326,24 +328,27 @@ function ViewStreamSelector(cxt, sel) {
|
|
|
326
328
|
* Boolean
|
|
327
329
|
*/
|
|
328
330
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
Object.defineProperty(selector, '
|
|
332
|
-
Object.defineProperty(selector, 'exists', { get: () => getNodeListArray(cxt, sel, false).length >= 1 })
|
|
333
|
-
Object.defineProperty(selector, 'exist', { get: () => getNodeListArray(cxt, sel, false).length >= 1 })
|
|
334
|
-
Object.defineProperty(selector, 'nodeList', { get: () => getNodeListArray(cxt, sel) })
|
|
335
|
-
Object.defineProperty(selector, 'arr', {
|
|
331
|
+
const getElsArray = (verboseBool = true) => Array.from(getNodeListArray(cxt, sel, verboseBool))
|
|
332
|
+
|
|
333
|
+
Object.defineProperty(selector, 'el', {
|
|
336
334
|
get: () => {
|
|
337
|
-
const
|
|
338
|
-
if (
|
|
339
|
-
|
|
340
|
-
} else if (el.length === undefined) {
|
|
341
|
-
return [el]
|
|
342
|
-
} else {
|
|
343
|
-
return Array.from(el)
|
|
335
|
+
const list = getElsArray()
|
|
336
|
+
if (list.length > 1 && isDevMode() === true) {
|
|
337
|
+
spyneWarn(`Spyne Warning: el$("${sel !== undefined ? sel : cxt}").el matched ${list.length} elements; returning the first. Use .els for the full list.`)
|
|
344
338
|
}
|
|
339
|
+
return list.length >= 1 ? list[0] : null
|
|
345
340
|
}
|
|
346
341
|
})
|
|
342
|
+
Object.defineProperty(selector, 'els', { get: () => getElsArray() })
|
|
343
|
+
Object.defineProperty(selector, 'arr', { get: () => getElsArray() })
|
|
344
|
+
|
|
345
|
+
// Undocumented. Retains the pre-0.24 polymorphic el contract — a single
|
|
346
|
+
// element, a NodeList, or an empty NodeList depending on match count —
|
|
347
|
+
// to assist in debugging and migrating older SpyneJS apps.
|
|
348
|
+
Object.defineProperty(selector, 'elLegacy', { get: () => getElOrList(cxt, sel, true) })
|
|
349
|
+
Object.defineProperty(selector, 'len', { get: () => getNodeListArray(cxt, sel, false).length })
|
|
350
|
+
Object.defineProperty(selector, 'length', { get: () => getNodeListArray(cxt, sel, false).length })
|
|
351
|
+
Object.defineProperty(selector, 'exists', { get: () => getNodeListArray(cxt, sel, false).length >= 1 })
|
|
347
352
|
|
|
348
353
|
Object.defineProperty(selector, 'inline', { set: (val) => setInlineCss(val, cxt, sel), get: () => selector })
|
|
349
354
|
Object.defineProperty(selector, 'inlineCss', { set: (val) => setInlineCss(val, cxt, sel), get: () => selector })
|
package/src/tests/index.test.js
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
// Silence SpyneJS developer-hint warnings across all test bundles.
|
|
2
|
+
// Expected-failure cases (invalid selectors, blocked URIs, policy removals)
|
|
3
|
+
// would otherwise flood the karma reporter. Security warnings still print.
|
|
4
|
+
window.SPYNE_SUPPRESS_WARNINGS = true
|
|
5
|
+
|
|
1
6
|
import { SpyneApp } from '../spyne/spyne'
|
|
2
7
|
|
|
3
8
|
// import {AppView} from "../app/app-view";
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
const { expect } = require('chai')
|
|
2
2
|
import { SpyneApp, SpyneAppProperties } from '../../spyne/spyne'
|
|
3
|
-
import sanitizeData, { sanitizeAttribute } from '../../spyne/utils/sanitize-data'
|
|
3
|
+
import sanitizeData, { sanitizeAttribute, allowCustomElements } from '../../spyne/utils/sanitize-data'
|
|
4
4
|
import sanitizeHTML from '../../spyne/utils/sanitize-html'
|
|
5
5
|
import { DomElement } from '../../spyne/views/dom-element'
|
|
6
6
|
import { DomElementTemplate } from '../../spyne/views/dom-element-template'
|
|
@@ -268,6 +268,70 @@ describe('sanitization security', () => {
|
|
|
268
268
|
})
|
|
269
269
|
})
|
|
270
270
|
|
|
271
|
+
describe('CMS wrappers and custom elements', () => {
|
|
272
|
+
const cmsMarkup = '<spyne-cms-item data-cms-id="post.title" data-cms-key="title">' +
|
|
273
|
+
'<spyne-cms-item-hitbox></spyne-cms-item-hitbox>' +
|
|
274
|
+
'<spyne-cms-item-text>Hello</spyne-cms-item-text>' +
|
|
275
|
+
'</spyne-cms-item>'
|
|
276
|
+
|
|
277
|
+
it('should keep spyne-* CMS wrappers and data-cms attributes in app mode', () => {
|
|
278
|
+
const clean = sanitizeData(cmsMarkup, { mode: 'app' })
|
|
279
|
+
|
|
280
|
+
expect(clean).to.include('<spyne-cms-item')
|
|
281
|
+
expect(clean).to.include('data-cms-key="title"')
|
|
282
|
+
expect(clean).to.include('<spyne-cms-item-hitbox')
|
|
283
|
+
expect(clean).to.include('<spyne-cms-item-text>Hello</spyne-cms-item-text>')
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
it('should keep spyne-* CMS wrappers in richtext mode', () => {
|
|
287
|
+
const clean = sanitizeData(cmsMarkup, { mode: 'richtext' })
|
|
288
|
+
|
|
289
|
+
expect(clean).to.include('<spyne-cms-item')
|
|
290
|
+
expect(clean).to.include('data-cms-id="post.title"')
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
it('should keep spyne-* CMS wrappers at the template layer', () => {
|
|
294
|
+
const clean = String(sanitizeHTML(cmsMarkup))
|
|
295
|
+
|
|
296
|
+
expect(clean).to.include('<spyne-cms-item')
|
|
297
|
+
expect(clean).to.include('<spyne-cms-item-text>Hello</spyne-cms-item-text>')
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
it('should still sanitize content inside CMS wrappers', () => {
|
|
301
|
+
const dirty = '<spyne-cms-item data-cms-key="body">' +
|
|
302
|
+
'<spyne-cms-item-text><script>bad()</script><b>ok</b></spyne-cms-item-text>' +
|
|
303
|
+
'</spyne-cms-item>'
|
|
304
|
+
const clean = sanitizeData(dirty, { mode: 'app' })
|
|
305
|
+
|
|
306
|
+
expect(clean).to.include('<spyne-cms-item')
|
|
307
|
+
expect(clean).to.not.include('<script')
|
|
308
|
+
expect(clean).to.include('<b>ok</b>')
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
it('should not allow event-handler attributes on CMS wrappers', () => {
|
|
312
|
+
const dirty = '<spyne-cms-item onclick="evil()" data-cms-key="x">y</spyne-cms-item>'
|
|
313
|
+
const clean = sanitizeData(dirty, { mode: 'app' })
|
|
314
|
+
|
|
315
|
+
expect(clean).to.include('<spyne-cms-item')
|
|
316
|
+
expect(clean).to.not.include('onclick')
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
it('should admit elements registered via allowCustomElements', () => {
|
|
320
|
+
allowCustomElements(['my-widget'])
|
|
321
|
+
const clean = sanitizeData('<my-widget data-config="a">w</my-widget>', { mode: 'app' })
|
|
322
|
+
|
|
323
|
+
expect(clean).to.include('<my-widget')
|
|
324
|
+
expect(clean).to.include('data-config="a"')
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
it('should reject invalid custom element names', () => {
|
|
328
|
+
allowCustomElements(['script', 'iframe', 'div'])
|
|
329
|
+
const clean = sanitizeData('<script>bad()</script>', { mode: 'app' })
|
|
330
|
+
|
|
331
|
+
expect(clean).to.not.include('<script')
|
|
332
|
+
})
|
|
333
|
+
})
|
|
334
|
+
|
|
271
335
|
describe('DomElementTemplate output', () => {
|
|
272
336
|
it('should sanitize renderToString output', () => {
|
|
273
337
|
const tmpl = new DomElementTemplate('<b>{{word}}</b><script>bad()</script>', { word: 'hello' })
|
|
@@ -41,14 +41,14 @@ describe('Dom Item Selector', () => {
|
|
|
41
41
|
const el = document.querySelector('ul#my-list')
|
|
42
42
|
const el$ = ViewStreamSelector('ul#my-list')
|
|
43
43
|
const liList = el$('li')
|
|
44
|
-
expect(liList.
|
|
44
|
+
expect(liList.els.length).to.eq(5)
|
|
45
45
|
})
|
|
46
46
|
it('should add class to li', () => {
|
|
47
47
|
const el = document.querySelector('ul#my-list')
|
|
48
48
|
const el$ = ViewStreamSelector('ul#my-list')
|
|
49
49
|
const liList = el$('li')
|
|
50
50
|
liList.addClass('foo')
|
|
51
|
-
const hasFooClassBool = liList.
|
|
51
|
+
const hasFooClassBool = liList.els[0].classList.contains('foo')
|
|
52
52
|
expect(hasFooClassBool).to.eq(true)
|
|
53
53
|
})
|
|
54
54
|
|
|
@@ -57,7 +57,7 @@ describe('Dom Item Selector', () => {
|
|
|
57
57
|
const el$ = ViewStreamSelector('ul#my-list')
|
|
58
58
|
const liList = el$('li')
|
|
59
59
|
liList.removeClass('has-svg')
|
|
60
|
-
const hasSvgClassBool = liList.
|
|
60
|
+
const hasSvgClassBool = liList.els[0].classList.contains('has-svg')
|
|
61
61
|
expect(hasSvgClassBool).to.eq(false)
|
|
62
62
|
})
|
|
63
63
|
|
|
@@ -66,8 +66,8 @@ describe('Dom Item Selector', () => {
|
|
|
66
66
|
const el$ = ViewStreamSelector('ul#my-list')
|
|
67
67
|
const liList = el$('li')
|
|
68
68
|
liList.setClass('foo bar')
|
|
69
|
-
// console.log('liList ',liList.
|
|
70
|
-
const isFooBarClassBool = liList.
|
|
69
|
+
// console.log('liList ',liList.els[0].className)
|
|
70
|
+
const isFooBarClassBool = liList.els[0].className === 'foo bar'
|
|
71
71
|
expect(isFooBarClassBool).to.eq(true)
|
|
72
72
|
})
|
|
73
73
|
|
|
@@ -76,7 +76,7 @@ describe('Dom Item Selector', () => {
|
|
|
76
76
|
const el$ = ViewStreamSelector('ul#my-list')
|
|
77
77
|
const liList = el$('li')
|
|
78
78
|
liList.inlineCss = 'background:orange;'
|
|
79
|
-
const backgroundSetBool = liList.
|
|
79
|
+
const backgroundSetBool = liList.els[0].style.getPropertyValue('background') === 'orange'
|
|
80
80
|
expect(backgroundSetBool).to.eq(true)
|
|
81
81
|
})
|
|
82
82
|
|
|
@@ -85,7 +85,7 @@ describe('Dom Item Selector', () => {
|
|
|
85
85
|
const el$ = ViewStreamSelector('ul#my-list')
|
|
86
86
|
const liList = el$('li')
|
|
87
87
|
liList.toggleClass('foo', true)
|
|
88
|
-
const hasFooClassBool = liList.
|
|
88
|
+
const hasFooClassBool = liList.els[0].classList.contains('foo')
|
|
89
89
|
expect(hasFooClassBool).to.eq(true)
|
|
90
90
|
})
|
|
91
91
|
|