trix_embed 0.0.2 → 0.0.3

Sign up to get free protection for your applications and to get access to all the features.
@@ -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.3'
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,45 @@ 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.splice(allowedHosts.indexOf('*'), 1)
171
+
172
+ blockedHosts = [...blockedHosts]
173
+ if (blockedHosts.includes('*')) blockedHosts.splice(blockedHosts.indexOf('*'), 1)
174
+
175
+ const hosts = [...new Set([...blockedHosts, ...extractURLHosts(urls)])].sort()
176
+
177
+ return this.render('warning', {
178
+ header: 'Copy/Paste Warning',
179
+ subheader: 'Content includes URLs or media from prohibited hosts or restricted protocols.',
180
+ prohibited: {
181
+ header: 'Prohibited Hosts',
182
+ hosts: hosts.length
183
+ ? hosts.map(host => `<li>${host}</li>`).join('')
184
+ : '<li>URLs and media are restricted to allowed hosts and standard protocols.</li>'
185
+ },
186
+ allowed: {
187
+ header: 'Allowed Hosts',
188
+ hosts: allowedHosts.length
189
+ ? allowedHosts.map(host => `<li>${host}</li>`).join('')
190
+ : '<li>Allowed hosts not configured.</li>'
191
+ }
192
+ })
114
193
  }
115
194
 
116
- // Renders an exception
195
+ // Renders a JavaScript error
117
196
  //
118
- // @param {String[]} ex - The exception
197
+ // @param {Error} error - The error or exception
119
198
  // @returns {String} HTML
120
199
  //
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
200
+ renderError(error) {
201
+ return this.render('error', {
202
+ header: 'Unhandled Exception!',
203
+ subheader: 'Report this problem to a software engineer.',
204
+ error: error
205
+ })
126
206
  }
127
207
  }
@@ -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
  }