trix_embed 0.0.2 → 0.0.4

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.
@@ -0,0 +1,134 @@
1
+ import { trixEditorTagName } from './media'
2
+ import { createURLObject } from './urls'
3
+
4
+ let _patch
5
+ let _observer
6
+
7
+ const protectedForms = new Set()
8
+ const trixEmbedSelector = `${trixEditorTagName}[data-controller~="trix-embed"]`
9
+
10
+ function makeKey(form) {
11
+ let { method, action } = form || {}
12
+ action = createURLObject(action)?.pathname || action
13
+ return `${method}:${action}`.trim().toLowerCase()
14
+ }
15
+
16
+ function protect(form, input) {
17
+ if (!form) return
18
+ const key = makeKey(form)
19
+ protectedForms.add({ key, form, input })
20
+ }
21
+
22
+ function shouldSubmit(form) {
23
+ const key = makeKey(form)
24
+ const list = [...protectedForms].filter(f => f.key === key)
25
+
26
+ // form is not protected
27
+ if (!list.length) return true
28
+
29
+ // form contains a trix-embed and it's currently pasting
30
+ if (form.trixEmbedPasting) return false
31
+
32
+ // form contains a trix-embed and it's not currently pasting
33
+ if (form.querySelector(trixEmbedSelector)) return true
34
+
35
+ // form is protected but does not contain a trix-embed
36
+ // prevent submit if any of the following conditions are true
37
+ //
38
+ // - the form data includes a protected key
39
+ // - the form action includes a protected querystring key
40
+ //
41
+ const data = new FormData(form)
42
+ const params = createURLObject(form.action)?.searchParams || new URLSearchParams()
43
+ const inputs = list.map(item => item.input)
44
+ const checks = inputs.map(input => {
45
+ if (input.name) {
46
+ if (data.has(input.name)) return false
47
+ if (params.has(input.name)) return false
48
+ }
49
+
50
+ if (input.id) {
51
+ if (data.has(input.id)) return false
52
+ if (params.has(input.id)) return false
53
+ }
54
+
55
+ return true
56
+ })
57
+ if (checks.includes(false)) return false
58
+ return true
59
+ }
60
+
61
+ function submit(event) {
62
+ if (shouldSubmit(event.target)) return
63
+ event.preventDefault()
64
+ }
65
+
66
+ function monitor(form) {
67
+ form.removeEventListener('submit', submit, true)
68
+ form.addEventListener('submit', submit, true)
69
+ }
70
+
71
+ function patch() {
72
+ if (_patch) return
73
+
74
+ const orig = Document.prototype.createElement
75
+
76
+ _patch = {
77
+ value: function () {
78
+ const element = orig.apply(this, arguments)
79
+
80
+ try {
81
+ const tagName = String(arguments[0]).toUpperCase()
82
+ if (tagName === 'FORM') monitor(element)
83
+ } catch {}
84
+
85
+ return element
86
+ },
87
+ configurable: false
88
+ }
89
+
90
+ Object.defineProperty(Document.prototype, 'createElement', _patch)
91
+ }
92
+
93
+ function observe(attempt = 0) {
94
+ if (!document.body && attempt < 10) return setTimeout(() => observe(attempt + 1), 50)
95
+
96
+ if (_observer) return
97
+
98
+ _observer = new MutationObserver(mutations =>
99
+ mutations.forEach(mutation =>
100
+ mutation.addedNodes.forEach(node => {
101
+ if (node instanceof HTMLFormElement) monitor(node)
102
+ })
103
+ )
104
+ )
105
+
106
+ _observer.observe(document.body, {
107
+ childList: true,
108
+ subtree: true
109
+ })
110
+ }
111
+
112
+ addEventListener('load', () => observe())
113
+ patch()
114
+ observe()
115
+
116
+ // monitor all forms
117
+ document.querySelectorAll('form').forEach(form => monitor(form))
118
+
119
+ // TODO: protect XHR POST requests
120
+ // addEventListener('readystatechange', event => {
121
+ // const xhr = event.target
122
+ // if (xhr instanceof XMLHttpRequest && xhr.readyState === 1) {
123
+ // // if (should not submit)
124
+ // // xhr.send = xhr.abort
125
+ // }
126
+ // })
127
+
128
+ // TODO: protect fetch POST requests
129
+ // addEventListener('fetch', event => {
130
+ // // if (should not submit)
131
+ // // event.preventDefault()
132
+ // })
133
+
134
+ export { protect as protectForm }
@@ -1,64 +1,46 @@
1
- const submitGuards = {}
1
+ import { protectForm } from './forms'
2
2
 
3
3
  export default class Guard {
4
4
  constructor(controller) {
5
5
  this.controller = controller
6
- controller.element.addEventListener('trix-file-accept', event => event.preventDefault())
7
6
  }
8
7
 
9
- protectSubmit = event => {
10
- const form = this.controller.formElement
11
- const f = event.target.closest('form')
12
- if (f && f.action === form.action && f.method === form.method && f !== form) event.preventDefault()
8
+ preventAttachments() {
9
+ this.editor?.removeAttribute('data-direct-upload-url')
10
+ this.editor?.removeAttribute('data-blob-url-template')
11
+ this.editor?.addEventListener('trix-file-accept', event => event.preventDefault(), true)
12
+ this.toolbar?.querySelector('[data-trix-button-group="file-tools"]')?.remove()
13
13
  }
14
14
 
15
- protect() {
16
- if (!this.controller.formElement) return
17
- const form = this.controller.formElement
18
- const input = this.controller.inputElement
19
- const key = `${form.method}${form.action}`
15
+ async preventLinks() {
16
+ const allowed = await this.controller.allowedLinkHosts
17
+ const blocked = await this.controller.blockedLinkHosts
18
+ if (!blocked.length && allowed.includes('*')) return
19
+ this.toolbar?.querySelector('[data-trix-action="link"]')?.remove()
20
+ }
20
21
 
21
- document.removeEventListener('submit', handlers[key], true)
22
- handlers[key] = this.protectSubmit.bind(this)
23
- document.addEventListener('submit', handlers[key], true)
22
+ protect(attempt = 0) {
23
+ if (!this.toolbar && attempt < 10) return setTimeout(() => this.protect(attempt + 1), 25)
24
24
 
25
- const observer = new MutationObserver((mutations, observer) => {
26
- mutations.forEach(mutation => {
27
- const { addedNodes, target, type } = mutation
25
+ this.preventAttachments()
26
+ this.preventLinks()
28
27
 
29
- switch (type) {
30
- case 'attributes':
31
- if (target.closest('form')?.action === form.action)
32
- if (target.id === input.id || target.name === input.name) target.remove()
33
- break
34
- case 'childList':
35
- addedNodes.forEach(node => {
36
- if (node.nodeType === Node.ELEMENT_NODE) {
37
- if (node.tagName.match(/^form$/i) && node.action === form.action) node.remove()
38
- if (target.closest('form')?.action === form.action)
39
- if (node.id === input.id || node.name === input.name) node.remove()
40
- }
41
- })
42
- break
43
- }
44
- })
45
- })
28
+ if (this.form) protectForm(this.form, this.input)
29
+ }
46
30
 
47
- observer.observe(document.body, {
48
- attributeFilter: ['id', 'name'],
49
- attributes: true,
50
- childList: true,
51
- subtree: true
52
- })
31
+ get editor() {
32
+ return this.controller.element
53
33
  }
54
34
 
55
- cleanup() {
56
- const trix = this.controller.element
57
- const input = this.controller.inputElement
58
- const toolbar = this.controller.toolbarElement
35
+ get toolbar() {
36
+ return this.controller.toolbarElement
37
+ }
38
+
39
+ get form() {
40
+ return this.controller.formElement
41
+ }
59
42
 
60
- input?.remove()
61
- toolbar?.remove()
62
- trix?.remove()
43
+ get input() {
44
+ return this.controller.inputElement
63
45
  }
64
46
  }
@@ -1,6 +1,9 @@
1
+ import metadata from './metadata'
1
2
  import { generateKey, encryptValues, generateKeyAndEncryptValues } from './encryption'
2
3
  import { getTrixEmbedControllerClass } from './controller'
3
4
 
5
+ let initialized = false
6
+
4
7
  const defaultOptions = {
5
8
  application: null,
6
9
  Controller: null,
@@ -8,15 +11,18 @@ const defaultOptions = {
8
11
  }
9
12
 
10
13
  function initialize(options = defaultOptions) {
14
+ if (initialized) return
11
15
  const { application, Controller, Trix } = options
12
16
  application.register('trix-embed', getTrixEmbedControllerClass({ Controller, Trix }))
17
+ initialized = true
13
18
  }
14
19
 
15
20
  self.TrixEmbed = {
16
- initialize,
17
- generateKey,
21
+ ...metadata,
18
22
  encryptValues,
19
- generateKeyAndEncryptValues
23
+ generateKey,
24
+ generateKeyAndEncryptValues,
25
+ initialize
20
26
  }
21
27
 
22
28
  export default self.TrixEmbed
@@ -1,4 +1,10 @@
1
- import { createURL } from './urls'
1
+ import { createURLObject } from './urls'
2
+
3
+ // Matches server side configuration
4
+ // SEE: lib/trix_embed/engine.rb
5
+ export const trixEmbedMediaTypes = {
6
+ attachment: 'trix-embed/attachment'
7
+ }
2
8
 
3
9
  const audioMediaTypes = {
4
10
  mp3: 'audio/mpeg', // MP3 audio format
@@ -74,6 +80,10 @@ const tagsWithSrcAttribute = [
74
80
  'use' // SVG: Reuse shapes from other documents
75
81
  ]
76
82
 
83
+ // TODO: move to schema.js
84
+ export const trixEditorTagName = 'trix-editor'
85
+ export const trixAttachmentTagName = 'action-text-attachment'
86
+
77
87
  export const mediaTags = tagsWithHrefAttribute.concat(tagsWithSrcAttribute)
78
88
 
79
89
  export function isAudio(url) {
@@ -91,7 +101,7 @@ export function isVideo(url) {
91
101
  export function getMediaType(value) {
92
102
  let url
93
103
 
94
- createURL(value, u => (url = u))
104
+ url = createURLObject(value)
95
105
  if (!url) return null
96
106
 
97
107
  const index = url.pathname.lastIndexOf('.')
@@ -0,0 +1,4 @@
1
+ // This file is auto-generated by bin/build.mjs
2
+ export default {
3
+ version: '0.0.4'
4
+ }
@@ -1,6 +1,95 @@
1
- import { createURL, extractURLHosts } from './urls'
2
- import { isImage, getMediaType } from './media'
3
- import { getTemplate } from './templates'
1
+ import { createURLObject, extractURLHosts } from './urls'
2
+ import { isImage, getMediaType, trixAttachmentTagName, trixEmbedMediaTypes } from './media'
3
+ import templates from './templates'
4
+
5
+ // Matches server side configuration
6
+ // SEE: TrixEmbed::Attachment::ALLOWED_TAGS (app/models/trix_embed/attachment.rb)
7
+ const ALLOWED_TAGS = [
8
+ trixAttachmentTagName,
9
+ 'a',
10
+ 'abbr',
11
+ 'acronym',
12
+ 'address',
13
+ 'b',
14
+ 'big',
15
+ 'blockquote',
16
+ 'br',
17
+ 'cite',
18
+ 'code',
19
+ 'dd',
20
+ 'del',
21
+ 'dfn',
22
+ 'div',
23
+ 'dl',
24
+ 'dt',
25
+ 'em',
26
+ 'figcaption',
27
+ 'figure',
28
+ 'h1',
29
+ 'h2',
30
+ 'h3',
31
+ 'h4',
32
+ 'h5',
33
+ 'h6',
34
+ 'hr',
35
+ 'i',
36
+ 'iframe',
37
+ 'img',
38
+ 'ins',
39
+ 'kbd',
40
+ 'li',
41
+ 'ol',
42
+ 'p',
43
+ 'pre',
44
+ 'samp',
45
+ 'small',
46
+ 'span',
47
+ 'strong',
48
+ 'sub',
49
+ 'sup',
50
+ 'time',
51
+ 'tt',
52
+ 'ul',
53
+ 'var'
54
+ ]
55
+
56
+ // Matches server side configuration
57
+ // SEE: TrixEmbed::Attachment::ALLOWED_ATTRIBUTES (app/models/trix_embed/attachment.rb)
58
+ const ALLOWED_ATTRIBUTES = [
59
+ 'abbr',
60
+ 'allow',
61
+ 'allowfullscreen',
62
+ 'allowpaymentrequest',
63
+ 'alt',
64
+ 'caption',
65
+ 'cite',
66
+ 'content-type',
67
+ 'credentialless',
68
+ 'csp',
69
+ 'data-trix-embed',
70
+ 'data-trix-embed-error',
71
+ 'data-trix-embed-prohibited',
72
+ 'data-trix-embed-warning',
73
+ 'datetime',
74
+ 'filename',
75
+ 'filesize',
76
+ 'height',
77
+ 'href',
78
+ 'lang',
79
+ 'loading',
80
+ 'name',
81
+ 'presentation',
82
+ 'previewable',
83
+ 'referrerpolicy',
84
+ 'sandbox',
85
+ 'sgid',
86
+ 'src',
87
+ 'srcdoc',
88
+ 'title',
89
+ 'url',
90
+ 'width',
91
+ 'xml:lang'
92
+ ]
4
93
 
5
94
  export default class Renderer {
6
95
  // Constructs a new Renderer instance
@@ -11,50 +100,40 @@ export default class Renderer {
11
100
  this.initializeTempates()
12
101
  }
13
102
 
14
- initializeTempates() {
15
- const templates = ['error', 'exception', 'header', 'iframe', 'image']
16
- templates.forEach(name => this.initializeTemplate(name))
103
+ sanitize(html) {
104
+ const template = document.createElement('template')
105
+ template.innerHTML = `<div>${html}</div>`
106
+ const element = template.content.firstElementChild
107
+ const all = [element].concat([...element.querySelectorAll('*')])
108
+ all.forEach(el => {
109
+ if (ALLOWED_TAGS.includes(el.tagName.toLowerCase())) {
110
+ ;[...el.attributes].forEach(attr => {
111
+ if (!ALLOWED_ATTRIBUTES.includes(attr.name.toLowerCase())) el.removeAttribute(attr.name)
112
+ })
113
+ } else {
114
+ el.remove()
115
+ }
116
+ })
117
+ return element.innerHTML
17
118
  }
18
119
 
19
- initializeTemplate(name) {
20
- let template
21
-
22
- if (this.controller[`has${name.charAt(0).toUpperCase() + name.slice(1)}TemplateValue`])
23
- template = document.getElementById(this.controller[`${name}TemplateValue`])
24
-
25
- this[`${name}Template`] = template || getTemplate(name)
120
+ initializeTempates() {
121
+ this.templates = templates
122
+ Object.keys(templates).forEach(name => this.initializeTemplate(name))
26
123
  }
27
124
 
28
- // Renders an embed header
29
- //
30
- // @param {String} html - HTML
31
- // @returns {String} HTML
32
- //
33
- renderHeader(html) {
34
- const header = this.headerTemplate.content.firstElementChild.cloneNode(true)
35
- const h1 = header.tagName.match(/h1/i) ? header : header.querySelector('h1')
36
- h1.innerHTML = html
37
- return header.outerHTML
125
+ initializeTemplate(name) {
126
+ const property = `${name}TemplateValue`
127
+ const id = this.controller[property]
128
+ const template = id ? document.getElementById(id)?.innerHTML?.trim() : null
129
+ this.controller[property] = null
130
+ if (template) this.templates[name] = template
131
+ return this.templates[name]
38
132
  }
39
133
 
40
- // TODO: Add templates for links
41
- // Renders a list of URLs as a list of HTML links i.e. anchor tags <a>
42
- //
43
- // @param {String[]} urls - list of URLs
44
- // @returns {String[]} list of individual HTML links
45
- //
46
- renderLinks(urls = ['https://example.com', 'https://test.com']) {
47
- urls = urls
48
- .filter(url => {
49
- let ok = false
50
- createURL(url, u => (ok = true))
51
- return ok
52
- })
53
- .sort()
54
-
55
- if (!urls.length) return
56
- const links = urls.map(url => `<li><a href='${url}'>${url}</a></li>`)
57
- return `<ul>${links.join('')}</ul><br>`
134
+ render(templateName, params = {}) {
135
+ const template = this.templates[templateName]
136
+ return template.replace(/{{(.*?)}}/g, (_, key) => key.split('.').reduce((obj, k) => obj[k], params))
58
137
  }
59
138
 
60
139
  // TOOO: add support for audio and video
@@ -64,19 +143,8 @@ export default class Renderer {
64
143
  // @returns {String} HTML
65
144
  //
66
145
  renderEmbed(url = 'https://example.com') {
67
- let embed
68
-
69
- if (isImage(url)) {
70
- embed = this.imageTemplate.content.firstElementChild.cloneNode(true)
71
- const img = embed.tagName.match(/img/i) ? embed : embed.querySelector('img')
72
- img.src = url
73
- } else {
74
- embed = this.iframeTemplate.content.firstElementChild.cloneNode(true)
75
- const iframe = embed.tagName.match(/iframe/i) ? embed : embed.querySelector('iframe')
76
- iframe.src = url
77
- }
78
-
79
- return embed.outerHTML
146
+ const html = isImage(url) ? this.render('image', { src: url }) : this.render('iframe', { src: url })
147
+ return this.sanitize(html)
80
148
  }
81
149
 
82
150
  // Renders a list of URLs as HTML embeds i.e. iframes or media tags (img, video, audio etc.)
@@ -95,33 +163,43 @@ export default class Renderer {
95
163
  // @param {String[]} allowedHosts - list of allowed hosts
96
164
  // @returns {String} HTML
97
165
  //
98
- renderErrors(urls = ['https://example.com', 'https://test.com'], allowedHosts = []) {
166
+ renderWarnings(urls = ['https://example.com', 'https://test.com'], allowedHosts = [], blockedHosts = []) {
99
167
  if (!urls?.length) return
100
168
 
101
- const element = this.errorTemplate.content.firstElementChild.cloneNode(true)
102
- const prohibitedHostsElement = element.querySelector('[data-list="prohibited-hosts"]')
103
- const allowedHostsElement = element.querySelector('[data-list="allowed-hosts"]')
104
-
105
- if (prohibitedHostsElement) {
106
- const hosts = extractURLHosts(urls).sort()
107
- if (hosts.length) prohibitedHostsElement.innerHTML = hosts.map(host => `<li>${host}</li>`).join('')
108
- }
109
-
110
- if (allowedHostsElement && allowedHosts.length)
111
- allowedHostsElement.innerHTML = allowedHosts.map(host => `<li>${host}</li>`).join('')
112
-
113
- return element.outerHTML
169
+ allowedHosts = [...allowedHosts].sort()
170
+ if (allowedHosts.includes('*')) allowedHosts = ['All']
171
+
172
+ blockedHosts = [...new Set([...blockedHosts, ...extractURLHosts(urls)])].sort()
173
+ if (blockedHosts.includes('*')) blockedHosts = ['All']
174
+
175
+ return this.render('warning', {
176
+ header: 'Copy/Paste Warning',
177
+ subheader: 'Content includes links or media from restricted protocols or prohibited hosts.',
178
+ prohibited: {
179
+ header: 'Prohibited Hosts',
180
+ hosts: blockedHosts.length
181
+ ? blockedHosts.map(host => `<li>${host}</li>`).join('')
182
+ : '<li>Not configured</li>'
183
+ },
184
+ allowed: {
185
+ header: 'Allowed Hosts',
186
+ hosts: allowedHosts.length
187
+ ? allowedHosts.map(host => `<li>${host}</li>`).join('')
188
+ : '<li>Not configured</li>'
189
+ }
190
+ })
114
191
  }
115
192
 
116
- // Renders an exception
193
+ // Renders a JavaScript error
117
194
  //
118
- // @param {String[]} ex - The exception
195
+ // @param {Error} error - The error or exception
119
196
  // @returns {String} HTML
120
197
  //
121
- renderException(ex) {
122
- const element = this.exceptionTemplate.content.firstElementChild.cloneNode(true)
123
- const code = element.querySelector('code')
124
- code.innerHTML = ex.message
125
- return element.outerHTML
198
+ renderError(error) {
199
+ return this.render('error', {
200
+ header: 'Unhandled Exception!',
201
+ subheader: 'Report this problem to a software engineer.',
202
+ error: error
203
+ })
126
204
  }
127
205
  }
@@ -1,7 +1,19 @@
1
+ import { createURLObject } from './urls'
2
+
1
3
  export default class Store {
2
4
  constructor(controller) {
5
+ const identifiers = [
6
+ location.pathname,
7
+ createURLObject(controller.formElement?.action)?.pathname,
8
+ controller.element.closest('[id]')?.id
9
+ ]
10
+
3
11
  this.controller = controller
4
- this.base = this.obfuscate([location.pathname, this.controller.element.closest('[id]')?.id].join('/'))
12
+ this.identifier = identifiers
13
+ .filter(i => i && i.length)
14
+ .join('/')
15
+ .replace(/\/{2,}/g, '/')
16
+ this.base = this.obfuscate(this.identifier)
5
17
  }
6
18
 
7
19
  split(list) {
@@ -1,36 +1,53 @@
1
- const defaults = {
2
- header: `<h1></h1>`,
3
- iframe: `<iframe></iframe>`,
4
- image: `<img></img>`,
1
+ export default {
2
+ // inline templates ........................................................................................
3
+ link: `<a href='{{url}}'>{{label}}</a>`,
5
4
 
5
+ embedded: `
6
+ <span>
7
+ <strong>{{label}}</strong>
8
+ <span>{{description}}</span>
9
+ <del>{{url}}</del>
10
+ </span>
11
+ `,
12
+
13
+ prohibited: `
14
+ <span>
15
+ <strong>{{label}}</strong>
16
+ <span>{{description}}</span>
17
+ <del>{{url}}</del>
18
+ </span>
19
+ `,
20
+
21
+ // attachment templates ....................................................................................
6
22
  error: `
7
- <div>
8
- <h1>Copy/Paste Info</h1>
9
- <h3>The pasted content includes media from unsupported hosts.</h3>
10
-
11
- <h2>Prohibited Hosts / Domains</h2>
12
- <ul data-list="prohibited-hosts">
13
- <li>Media is only supported from allowed hosts.</li>
14
- </ul>
15
-
16
- <h2>Allowed Hosts / Domains</h2>
17
- <ul data-list="allowed-hosts">
18
- <li>Allowed hosts not configured.</li>
19
- </ul>
23
+ <div data-trix-embed data-trix-embed-error>
24
+ <h1>{{header}}</h1>
25
+ <pre><code>{{error.stack}}</code></pre>
20
26
  </div>
21
27
  `,
22
28
 
23
- exception: `
24
- <div style='background-color:lightyellow; color:red; border:solid 1px red; padding:20px;'>
25
- <h1>Unhandled Exception!</h1>
26
- <p>Show a programmer the message below.</p>
27
- <pre style="background-color:darkslategray; color:whitesmoke; padding:10px;"><code></code></pre>
29
+ iframe: `
30
+ <div data-trix-embed>
31
+ <iframe src='{{src}}' loading='lazy' referrerpolicy='no-referrer' scrolling='no'></iframe>
28
32
  </div>
29
- `
30
- }
33
+ `,
31
34
 
32
- export function getTemplate(name) {
33
- const template = document.createElement('template')
34
- template.innerHTML = defaults[name]
35
- return template
35
+ image: `
36
+ <div data-trix-embed>
37
+ <img src='{{src}}' loading='lazy'></img>
38
+ </div>
39
+ `,
40
+
41
+ warning: `
42
+ <div data-trix-embed data-trix-embed-warning>
43
+ <h1>{{header}}</h1>
44
+ <h3>{{subheader}}</h3>
45
+
46
+ <h2>{{prohibited.header}}</h2>
47
+ <ul>{{prohibited.hosts}}</ul>
48
+
49
+ <h2>{{allowed.header}}</h2>
50
+ <ul>{{allowed.hosts}}</ul>
51
+ </div>
52
+ `
36
53
  }