trix_embed 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 38ead3fd6cca0b10aca5f45b5602da75b8674b3c71a22ac5d9140c23ce7ceabe
4
+ data.tar.gz: 95691446e65997da2a828c6bf48e714c958fc0819f81e039ea7301f717f9b342
5
+ SHA512:
6
+ metadata.gz: 2d33ed333f7125d7cd932781b28b7c740859ea271d3dc00e2b5006ac12f9818408873518aa5d3c17b9be431f3c29b3103c32fb71275cf67b34af6961c2419236
7
+ data.tar.gz: a86192046fc34ba9b3b69f8851de2723307a3c9d2e43856ba20eb8a4c06a5efed24f07eaeb493eb6abf8a83dcc0d63c913635f4bae1ee9d7581e48552a8b301d
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright 2023 Nate Hopkins (hopsoft)
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,94 @@
1
+ # Trix Embed
2
+
3
+ A Stimulus controller to safely embed external media in the Trix editor.
4
+
5
+ ## Setup
6
+
7
+ ```sh
8
+ yarn add @hotwired/stimulus trix trix-embed
9
+ ```
10
+
11
+ ```js
12
+ import 'trix'
13
+ import { Application, Controller } from '@hotwired/stimulus'
14
+ import TrixEmbed from 'trix-embed'
15
+
16
+ const application = Application.start()
17
+ TrixEmbed.initialize({ application, Controller, Trix })
18
+ ```
19
+
20
+ ## Usage
21
+
22
+ ```html
23
+ <form>
24
+ <input id="content" name="content" type="hidden">
25
+ <trix-editor id="editor" input="content"
26
+ data-controller="trix-embed"
27
+ data-action="trix-paste->trix-embed#paste"
28
+ data-trix-embed-hosts-value='["example.com", "test.com"]'>
29
+ </trix-editor>
30
+ </form>
31
+ ```
32
+
33
+ ## Sponsors
34
+
35
+ <p align="center">
36
+ <em>Proudly sponsored by</em>
37
+ </p>
38
+ <p align="center">
39
+ <a href="https://www.clickfunnels.com?utm_source=hopsoft&utm_medium=open-source&utm_campaign=trix_embed">
40
+ <img src="https://images.clickfunnel.com/uploads/digital_asset/file/176632/clickfunnels-dark-logo.svg" width="575" />
41
+ </a>
42
+ </p>
43
+
44
+ ## Developing
45
+
46
+ ```sh
47
+ git clone https://github.com/hopsoft/trix_embed.git
48
+ cd trix_embed
49
+ yarn
50
+ yarn build
51
+ yarn dev
52
+ ```
53
+ ### Docker
54
+
55
+ This project supports a fully Dockerized development experience.
56
+
57
+ 1. Simply run the following commands to get started.
58
+
59
+ ```sh
60
+ git clone -o github https://github.com/hopsoft/trix_embed.git
61
+ cd trix_embed
62
+ ```
63
+
64
+ ```sh
65
+ docker compose up -d # start the envionment (will take a few minutes on 1st run)
66
+ open http://localhost:3000 # in a browser
67
+ ```
68
+
69
+ And, if you're using the [containers gem (WIP)](https://github.com/hopsoft/containers).
70
+
71
+ ```sh
72
+ containers up # start the envionment (will take a few minutes on 1st run)
73
+ open http://localhost:3000 # in a browser
74
+ ```
75
+
76
+ 1. Edit files using your preferred tools on the host machine.
77
+
78
+ 1. That's it!
79
+
80
+ ## Releasing
81
+
82
+ 1. Run `yarn` and `bundle` to pick up the latest
83
+ 1. Bump version number at `lib/trix_embed/version.rb`. Pre-release versions use `.preN`
84
+ 1. Run `yarn build`
85
+ 1. Commit and push changes to GitHub
86
+ 1. Run `rake release`
87
+ 1. Run `yarn publish --no-git-tag-version --access public`
88
+ 1. Yarn will prompt you for the new version. Pre-release versions use `-preN`
89
+ 1. Commit and push changes to GitHub
90
+ 1. Create a new release on GitHub ([here](https://github.com/hopsoft/trix_embed/releases)) and generate the changelog for the stable release for it
91
+
92
+ ## License
93
+
94
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -0,0 +1,22 @@
1
+ var G=Object.defineProperty,X=Object.defineProperties;var Q=Object.getOwnPropertyDescriptors;var P=Object.getOwnPropertySymbols;var Y=Object.prototype.hasOwnProperty,Z=Object.prototype.propertyIsEnumerable;var M=(n,e,t)=>e in n?G(n,e,{enumerable:!0,configurable:!0,writable:!0,value:t}):n[e]=t,U=(n,e)=>{for(var t in e||(e={}))Y.call(e,t)&&M(n,t,e[t]);if(P)for(var t of P(e))Z.call(e,t)&&M(n,t,e[t]);return n},N=(n,e)=>X(n,Q(e));var S=(n,e,t)=>(M(n,typeof e!="symbol"?e+"":e,t),t);var A={name:"AES-GCM",length:256},ee=!0,te=["encrypt","decrypt"];async function re(){let e=["encrypt","decrypt"];return await crypto.subtle.generateKey(A,!0,e)}async function ie(n){let e=await crypto.subtle.exportKey("jwk",n);return JSON.stringify(e)}async function O(n){let e=JSON.parse(n);return await crypto.subtle.importKey("jwk",e,A,ee,te)}async function ne(n,e){let t=new TextEncoder().encode(String(n)),i=crypto.getRandomValues(new Uint8Array(12)),r=await crypto.subtle.encrypt(N(U({},A),{iv:i}),e,t),o={ciphertext:btoa(String.fromCharCode(...new Uint8Array(r))),iv:btoa(String.fromCharCode(...i))};return btoa(JSON.stringify(o))}async function oe(n,e){let t=JSON.parse(atob(n)),i=new Uint8Array(atob(t.ciphertext).split("").map(s=>s.charCodeAt(0))),r=new Uint8Array(atob(t.iv).split("").map(s=>s.charCodeAt(0))),o=await crypto.subtle.decrypt(N(U({},A),{iv:r}),e,i);return new TextDecoder().decode(o)}async function v(){let n=await re(),e=await ie(n);return btoa(e)}async function b(n,e=[]){let t=await O(atob(n));return Promise.all(e.map(i=>ne(i,t)))}async function K(n,e=[]){let t=await O(atob(n));return Promise.all(e.map(i=>oe(i,t)))}async function $(n=[]){let e=await v(),t=await b(e,n);return console.log(`data-trix-embed-key-value="${e}"`),console.log(`data-trix-embed-hosts-value='${JSON.stringify(t)}'`),{key:e,encryptedValues:t}}function h(n,e=t=>{}){try{let t=new URL(String(n).trim());return e&&e(t),t}catch(t){console.info(`Failed to parse URL! value='${n}']`)}return null}function V(n,e=t=>{}){let t=null;return h(n,i=>t=i.host),t&&e&&e(t),t}function se(n){let e=[],t=document.createTreeWalker(n,NodeFilter.SHOW_TEXT,r=>r.nodeValue.includes("http")?NodeFilter.FILTER_ACCEPT:NodeFilter.FILTER_REJECT),i;for(;i=t.nextNode();)i.nodeValue.split(/\s+/).filter(r=>r.startsWith("http")).forEach(r=>h(r,o=>{e.includes(o.href)||e.push(o.href)}));return e}function ae(n){let e=[];return n.src&&h(n.src,i=>e.push(i.href)),n.href&&h(n.href,i=>{e.includes(i.href)||e.push(i.href)}),n.querySelectorAll("[src], [href]").forEach(i=>{h(i.src||i.href,r=>{e.includes(r.href)||e.push(r.href)})}),e}function R(n,e=[]){let t=!1;return V(n,i=>t=!!e.find(r=>i.includes(r))),t}function I(n){return n.reduce((e,t)=>(V(t,i=>{e.includes(i)||e.push(i)}),e),[])}function q(n){let e=ae(n),t=se(n);return[...new Set([...e,...t])]}var D={avif:"image/avif",bmp:"image/bmp",gif:"image/gif",heic:"image/heic",heif:"image/heif",ico:"image/x-icon",jp2:"image/jp2",jpeg:"image/jpeg",jpg:"image/jpeg",jxr:"image/vnd.ms-photo",png:"image/png",svg:"image/svg+xml",tif:"image/tiff",tiff:"image/tiff",webp:"image/webp"};var ce=D,le=["animate","animateMotion","animateTransform","area","audio","base","embed","feDisplacementMap","feImage","feTile","filter","font-face-uri","iframe","image","link","object","script","source","track","use","video"],de=["audio","embed","iframe","img","input","script","source","track","video","frame","frameset","object","picture","use"],F=le.concat(de);function J(n){return!!Object.values(D).find(e=>e===H(n))}function H(n){let e;if(h(n,r=>e=r),!e)return null;let t=e.pathname.lastIndexOf(".");if(!t)return null;let i=e.pathname.substring(t+1);return ce[i]}var T=class{constructor(e){S(this,"protectSubmit",e=>{let t=this.controller.formElement,i=e.target.closest("form");i&&i.action===t.action&&i.method===t.method&&i!==t&&e.preventDefault()});this.controller=e,e.element.addEventListener("trix-file-accept",t=>t.preventDefault())}protect(){if(!this.controller.formElement)return;let e=this.controller.formElement,t=this.controller.inputElement,i=`${e.method}${e.action}`;document.removeEventListener("submit",handlers[i],!0),handlers[i]=this.protectSubmit.bind(this),document.addEventListener("submit",handlers[i],!0),new MutationObserver((o,s)=>{o.forEach(a=>{var x;let{addedNodes:u,target:d,type:p}=a;switch(p){case"attributes":((x=d.closest("form"))==null?void 0:x.action)===e.action&&(d.id===t.id||d.name===t.name)&&d.remove();break;case"childList":u.forEach(l=>{var y;l.nodeType===Node.ELEMENT_NODE&&(l.tagName.match(/^form$/i)&&l.action===e.action&&l.remove(),((y=d.closest("form"))==null?void 0:y.action)===e.action&&(l.id===t.id||l.name===t.name)&&l.remove())});break}})}).observe(document.body,{attributeFilter:["id","name"],attributes:!0,childList:!0,subtree:!0})}cleanup(){let e=this.controller.element,t=this.controller.inputElement,i=this.controller.toolbarElement;t==null||t.remove(),i==null||i.remove(),e==null||e.remove()}};var E=class{constructor(e){var t;this.controller=e,this.base=this.obfuscate([location.pathname,(t=this.controller.element.closest("[id]"))==null?void 0:t.id].join("/"))}split(e){let t=Math.ceil(e.length/2);return[e.slice(0,t),e.slice(t)]}obfuscate(e){var r;let t=[...e].map(o=>o.charCodeAt(0));return[(r=this.split(t)[1])==null?void 0:r.reverse(),t[0]].flat().join("")}read(e){return sessionStorage.getItem(this.generateStorageKey(e))}write(e,t){return sessionStorage.setItem(this.generateStorageKey(e),t)}remove(e){return sessionStorage.removeItem(this.generateStorageKey(e))}generateStorageKey(e){let t=[...this.obfuscate(e)],[i,r]=this.split(t);return btoa(`${i}/${this.base}/${r}`)}};var me={header:"<h1></h1>",iframe:"<iframe></iframe>",image:"<img></img>",error:`
2
+ <div>
3
+ <h1>Copy/Paste Info</h1>
4
+ <h3>The pasted content includes media from unsupported hosts.</h3>
5
+
6
+ <h2>Prohibited Hosts / Domains</h2>
7
+ <ul data-list="prohibited-hosts">
8
+ <li>Media is only supported from allowed hosts.</li>
9
+ </ul>
10
+
11
+ <h2>Allowed Hosts / Domains</h2>
12
+ <ul data-list="allowed-hosts">
13
+ <li>Allowed hosts not configured.</li>
14
+ </ul>
15
+ </div>
16
+ `,exception:`
17
+ <div style='background-color:lightyellow; color:red; border:solid 1px red; padding:20px;'>
18
+ <h1>Unhandled Exception!</h1>
19
+ <p>Show a programmer the message below.</p>
20
+ <pre style="background-color:darkslategray; color:whitesmoke; padding:10px;"><code></code></pre>
21
+ </div>
22
+ `};function z(n){let e=document.createElement("template");return e.innerHTML=me[n],e}var w=class{constructor(e){this.controller=e,this.initializeTempates()}initializeTempates(){["error","exception","header","iframe","image"].forEach(t=>this.initializeTemplate(t))}initializeTemplate(e){let t;this.controller[`has${e.charAt(0).toUpperCase()+e.slice(1)}TemplateValue`]&&(t=document.getElementById(this.controller[`${e}TemplateValue`])),this[`${e}Template`]=t||z(e)}renderHeader(e){let t=this.headerTemplate.content.firstElementChild.cloneNode(!0),i=t.tagName.match(/h1/i)?t:t.querySelector("h1");return i.innerHTML=e,t.outerHTML}renderLinks(e=["https://example.com","https://test.com"]){return e=e.filter(i=>{let r=!1;return h(i,o=>r=!0),r}).sort(),e.length?`<ul>${e.map(i=>`<li><a href='${i}'>${i}</a></li>`).join("")}</ul><br>`:void 0}renderEmbed(e="https://example.com"){let t;if(J(e)){t=this.imageTemplate.content.firstElementChild.cloneNode(!0);let i=t.tagName.match(/img/i)?t:t.querySelector("img");i.src=e}else{t=this.iframeTemplate.content.firstElementChild.cloneNode(!0);let i=t.tagName.match(/iframe/i)?t:t.querySelector("iframe");i.src=e}return t.outerHTML}renderEmbeds(e=["https://example.com","https://test.com"]){if(e!=null&&e.length)return e.map(t=>this.renderEmbed(t))}renderErrors(e=["https://example.com","https://test.com"],t=[]){if(!(e!=null&&e.length))return;let i=this.errorTemplate.content.firstElementChild.cloneNode(!0),r=i.querySelector('[data-list="prohibited-hosts"]'),o=i.querySelector('[data-list="allowed-hosts"]');if(r){let s=I(e).sort();s.length&&(r.innerHTML=s.map(a=>`<li>${a}</li>`).join(""))}return o&&t.length&&(o.innerHTML=t.map(s=>`<li>${s}</li>`).join("")),i.outerHTML}renderException(e){let t=this.exceptionTemplate.content.firstElementChild.cloneNode(!0),i=t.querySelector("code");return i.innerHTML=e.message,t.outerHTML}};var ue={Controller:null,Trix:null};function B(n=ue){var i;let{Controller:e,Trix:t}=n;return i=class extends e{async connect(){var r;this.store=new E(this),this.guard=new T(this),await this.rememberConfig(),this.paranoid&&this.guard.protect(),(r=this.toolbarElement.querySelector('[data-trix-button-group="file-tools"]'))==null||r.remove(),window.addEventListener("beforeunload",()=>this.disconnect())}disconnect(){this.paranoid&&this.guard.cleanup(),this.forgetConfig()}async paste(r){let{html:o,string:s,range:a}=r.paste,u=o||s||"",d=this.buildPastedTemplate(u),p=d.content.firstElementChild,l=this.sanitizePastedElement(p).innerHTML.trim(),y=q(p);if(!y.length)return;r.preventDefault(),this.editor.setSelectedRange(a);let C=await this.hosts,f=new w(this);try{let g=y.filter(c=>H(c));Array.from(d.content.firstElementChild.querySelectorAll("iframe")).forEach(c=>{g.includes(c.src)||g.push(c.src)});let k=g.filter(c=>R(c,C)),W=g.filter(c=>!k.includes(c)),j=y.filter(c=>!g.includes(c)),L=j.filter(c=>R(c,C)),_=j.filter(c=>!L.includes(c)),m;if(m=W,m.length&&await this.insert(f.renderErrors(m,C.sort())),m=_,m.length&&(await this.insert(f.renderHeader("Pasted URLs")),await this.insert(f.renderLinks(m),{disposition:"inline"})),m=k,m.length&&(m.length>1&&await this.insert(f.renderHeader("Embedded Media")),await this.insert(f.renderEmbeds(m))),m=L,m.length&&await this.insert(f.renderEmbeds(L)),k[0]===l||L[0]===l)return this.editor.insertLineBreak();l.length&&(await this.insert(f.renderHeader("Pasted Content",l)),this.editor.insertLineBreak(),this.insert(l,{disposition:"inline"}))}catch(g){this.insert(f.renderException(g))}}buildPastedTemplate(r){let o=document.createElement("template");return o.innerHTML=`<div>${r.trim()}</div>`,o}sanitizePastedElement(r){r=r.cloneNode(!0),r.querySelectorAll(F.join(", ")).forEach(a=>a.remove());let o=r.querySelectorAll("*"),s=r.innerHTML.match(/\r\n|\n|\r/g)||[];return(s.length?o.length/s.length:0)<=.1&&(r.innerHTML=r.innerHTML.replaceAll(/\r\n|\n|\r/g,"<br>")),r}insertAttachment(r,o={delay:0}){let{delay:s}=o;return new Promise(a=>{setTimeout(()=>{let u=new t.Attachment({content:r,contentType:"application/vnd.trix-embed"});this.editor.insertAttachment(u),a()},s)})}insertHTML(r,o={delay:0}){let{delay:s}=o;return new Promise(a=>{setTimeout(()=>{this.editor.insertHTML(r),this.editor.moveCursorInDirection("forward"),this.editor.insertLineBreak(),this.editor.moveCursorInDirection("backward"),a()},s)})}insert(r,o={delay:0,disposition:"attachment"}){let{delay:s,disposition:a}=o;return r!=null&&r.length?new Promise(u=>{setTimeout(()=>{if(typeof r=="string")return a==="inline"?this.insertHTML(r,{delay:s}).then(u):this.insertAttachment(r,{delay:s}).then(u);if(Array.isArray(r))return a==="inline"?r.reduce((d,p,x)=>d.then(this.insertHTML(p,{delay:s})),Promise.resolve()).then(u):r.reduce((d,p,x)=>d.then(this.insertAttachment(p,{delay:s})),Promise.resolve()).then(u);u()})}):Promise.resolve()}get editor(){return this.element.editor}get toolbarElement(){let r=this.element.previousElementSibling;return r!=null&&r.tagName.match(/trix-toolbar/i)?r:null}get inputElement(){return document.getElementById(this.element.getAttribute("input"))}get formElement(){return this.element.closest("form")}get paranoid(){return!!this.store.read("paranoid")}get key(){try{return JSON.parse(this.store.read("key"))[2]}catch(r){}}get hosts(){try{return K(this.key,JSON.parse(this.store.read("hosts")))}catch(r){return[]}}get reservedDomains(){return["example.com","test.com","invalid.com","example.cat","nic.example","example.co.uk"]}async rememberConfig(){let r=await v(),o=await b(r,this.reservedDomains),s=await b(r,this.hostsValue);this.store.write("key",JSON.stringify([o[0],o[1],r,o[2]])),this.element.removeAttribute("data-trix-embed-key-value"),this.store.write("hosts",JSON.stringify(s)),this.element.removeAttribute("data-trix-embed-hosts-value"),this.paranoidValue!==!1&&(this.store.write("paranoid",JSON.stringify(o.slice(3))),this.element.removeAttribute("data-trix-embed-paranoid"))}forgetConfig(){this.store.remove("key"),this.store.remove("hosts"),this.store.remove("paranoid")}},S(i,"values",{validTemplate:String,errorTemplate:String,headerTemplate:String,iframeTemplate:String,imageTemplate:String,hosts:Array,paranoid:{type:Boolean,default:!0}}),i}var he={application:null,Controller:null,Trix:null};function pe(n=he){let{application:e,Controller:t,Trix:i}=n;e.register("trix-embed",B({Controller:t,Trix:i}))}self.TrixEmbed={initialize:pe,generateKey:v,encryptValues:b,generateKeyAndEncryptValues:$};var qe=self.TrixEmbed;export{qe as default};
@@ -0,0 +1 @@
1
+ {"inputs":{"app/javascript/encryption.js":{"bytes":4095,"imports":[{"path":"<runtime>","kind":"import-statement","external":true}],"format":"esm"},"app/javascript/urls.js":{"bytes":2818,"imports":[],"format":"esm"},"app/javascript/media.js":{"bytes":3331,"imports":[{"path":"app/javascript/urls.js","kind":"import-statement","original":"./urls"}],"format":"esm"},"app/javascript/guard.js":{"bytes":2028,"imports":[{"path":"<runtime>","kind":"import-statement","external":true}],"format":"esm"},"app/javascript/store.js":{"bytes":952,"imports":[],"format":"esm"},"app/javascript/templates.js":{"bytes":997,"imports":[],"format":"esm"},"app/javascript/renderer.js":{"bytes":4072,"imports":[{"path":"app/javascript/urls.js","kind":"import-statement","original":"./urls"},{"path":"app/javascript/media.js","kind":"import-statement","original":"./media"},{"path":"app/javascript/templates.js","kind":"import-statement","original":"./templates"}],"format":"esm"},"app/javascript/controller.js":{"bytes":9317,"imports":[{"path":"app/javascript/encryption.js","kind":"import-statement","original":"./encryption"},{"path":"app/javascript/urls.js","kind":"import-statement","original":"./urls"},{"path":"app/javascript/media.js","kind":"import-statement","original":"./media"},{"path":"app/javascript/guard.js","kind":"import-statement","original":"./guard"},{"path":"app/javascript/store.js","kind":"import-statement","original":"./store"},{"path":"app/javascript/renderer.js","kind":"import-statement","original":"./renderer"},{"path":"<runtime>","kind":"import-statement","external":true}],"format":"esm"},"app/javascript/index.js":{"bytes":548,"imports":[{"path":"app/javascript/encryption.js","kind":"import-statement","original":"./encryption"},{"path":"app/javascript/controller.js","kind":"import-statement","original":"./controller"}],"format":"esm"}},"outputs":{"app/assets/builds/trix-embed.js":{"imports":[],"exports":["default"],"entryPoint":"app/javascript/index.js","inputs":{"app/javascript/encryption.js":{"bytesInOutput":1346},"app/javascript/urls.js":{"bytesInOutput":973},"app/javascript/media.js":{"bytesInOutput":846},"app/javascript/guard.js":{"bytesInOutput":1285},"app/javascript/store.js":{"bytesInOutput":688},"app/javascript/templates.js":{"bytesInOutput":897},"app/javascript/renderer.js":{"bytesInOutput":1780},"app/javascript/controller.js":{"bytesInOutput":4300},"app/javascript/index.js":{"bytesInOutput":274}},"bytes":12893}}}
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TrixEmbed
4
+ module ApplicationHelper
5
+ def trix_embed_attachment(local_assigns = {})
6
+ return local_assigns[:attachable] if local_assigns[:attachable].is_a?(TrixEmbed::Attachment)
7
+ return local_assigns[:attachment].attachable if local_assigns[:attachment]&.attachable.is_a?(TrixEmbed::Attachment)
8
+ nil
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,254 @@
1
+ import { generateKey, encryptValues, decryptValues } from './encryption'
2
+ import { extractURLsFromElement, validateURL } from './urls'
3
+ import { getMediaType, mediaTags } from './media'
4
+ import Guard from './guard'
5
+ import Store from './store'
6
+ import Renderer from './renderer'
7
+
8
+ const defaultOptions = {
9
+ Controller: null,
10
+ Trix: null
11
+ }
12
+
13
+ export function getTrixEmbedControllerClass(options = defaultOptions) {
14
+ const { Controller, Trix } = options
15
+ return class extends Controller {
16
+ static values = {
17
+ // templates
18
+ validTemplate: String, // dom id of template to use for valid embeds
19
+ errorTemplate: String, // dom id of template to use for invalid embeds
20
+ headerTemplate: String, // dom id of template to use for embed headers
21
+ iframeTemplate: String, // dom id of template to use for iframe embeds
22
+ imageTemplate: String, // dom id of template to use for image embeds
23
+
24
+ // security related values
25
+ hosts: Array, // list of hosts/domains that embeds are allowed from
26
+ paranoid: { type: Boolean, default: true } // guard against attacks
27
+ }
28
+
29
+ async connect() {
30
+ this.store = new Store(this)
31
+ this.guard = new Guard(this)
32
+ await this.rememberConfig()
33
+ if (this.paranoid) this.guard.protect()
34
+ this.toolbarElement.querySelector('[data-trix-button-group="file-tools"]')?.remove()
35
+ window.addEventListener('beforeunload', () => this.disconnect()) // TODO: this may not be necessary
36
+ }
37
+
38
+ disconnect() {
39
+ if (this.paranoid) this.guard.cleanup()
40
+ this.forgetConfig()
41
+ }
42
+
43
+ async paste(event) {
44
+ const { html, string, range } = event.paste
45
+ let content = html || string || ''
46
+ const pastedTemplate = this.buildPastedTemplate(content)
47
+ const pastedElement = pastedTemplate.content.firstElementChild
48
+ const sanitizedPastedElement = this.sanitizePastedElement(pastedElement)
49
+ const sanitizedPastedContent = sanitizedPastedElement.innerHTML.trim()
50
+ const pastedURLs = extractURLsFromElement(pastedElement)
51
+
52
+ // no URLs were pasted, let Trix handle it ...............................................................
53
+ if (!pastedURLs.length) return
54
+
55
+ event.preventDefault()
56
+ this.editor.setSelectedRange(range)
57
+ const hosts = await this.hosts
58
+ const renderer = new Renderer(this)
59
+
60
+ try {
61
+ // Media URLs (images, videos, audio etc.)
62
+ const mediaURLs = pastedURLs.filter(url => getMediaType(url))
63
+ Array.from(pastedTemplate.content.firstElementChild.querySelectorAll('iframe')).forEach(frame => {
64
+ if (!mediaURLs.includes(frame.src)) mediaURLs.push(frame.src)
65
+ })
66
+ const validMediaURLs = mediaURLs.filter(url => validateURL(url, hosts))
67
+ const invalidMediaURLs = mediaURLs.filter(url => !validMediaURLs.includes(url))
68
+
69
+ // Standard URLs (non-media resources i.e. web pages etc.)
70
+ const standardURLs = pastedURLs.filter(url => !mediaURLs.includes(url))
71
+ const validStandardURLs = standardURLs.filter(url => validateURL(url, hosts))
72
+ const invalidStandardURLs = standardURLs.filter(url => !validStandardURLs.includes(url))
73
+
74
+ let urls
75
+
76
+ // 1. render invalid media urls ..........................................................................
77
+ urls = invalidMediaURLs
78
+ if (urls.length) await this.insert(renderer.renderErrors(urls, hosts.sort()))
79
+
80
+ // 2. render invalid standard urls .......................................................................
81
+ urls = invalidStandardURLs
82
+ if (urls.length) {
83
+ await this.insert(renderer.renderHeader('Pasted URLs'))
84
+ await this.insert(renderer.renderLinks(urls), { disposition: 'inline' })
85
+ }
86
+
87
+ // 3. render valid media urls ............................................................................
88
+ urls = validMediaURLs
89
+ if (urls.length) {
90
+ if (urls.length > 1) await this.insert(renderer.renderHeader('Embedded Media'))
91
+ await this.insert(renderer.renderEmbeds(urls))
92
+ }
93
+
94
+ // 4. render valid standard urls .........................................................................
95
+ urls = validStandardURLs
96
+ if (urls.length) await this.insert(renderer.renderEmbeds(validStandardURLs))
97
+
98
+ // exit early if there is only one valid URL and it is the same as the pasted content
99
+ if (validMediaURLs[0] === sanitizedPastedContent || validStandardURLs[0] === sanitizedPastedContent)
100
+ return this.editor.insertLineBreak()
101
+
102
+ // 5. render the pasted content as sanitized HTML ........................................................
103
+ if (sanitizedPastedContent.length) {
104
+ await this.insert(renderer.renderHeader('Pasted Content', sanitizedPastedContent))
105
+ this.editor.insertLineBreak()
106
+ this.insert(sanitizedPastedContent, { disposition: 'inline' })
107
+ }
108
+ } catch (ex) {
109
+ this.insert(renderer.renderException(ex))
110
+ }
111
+ }
112
+
113
+ buildPastedTemplate(content) {
114
+ const template = document.createElement('template')
115
+ template.innerHTML = `<div>${content.trim()}</div>`
116
+ return template
117
+ }
118
+
119
+ sanitizePastedElement(element) {
120
+ element = element.cloneNode(true)
121
+ element.querySelectorAll(mediaTags.join(', ')).forEach(tag => tag.remove())
122
+
123
+ const tags = element.querySelectorAll('*')
124
+ const newlines = element.innerHTML.match(/\r\n|\n|\r/g) || []
125
+
126
+ // replace newlines with <br> if there are <= 10% tags to newlines
127
+ if ((newlines.length ? tags.length / newlines.length : 0) <= 0.1)
128
+ element.innerHTML = element.innerHTML.replaceAll(/\r\n|\n|\r/g, '<br>')
129
+
130
+ return element
131
+ }
132
+
133
+ insertAttachment(content, options = { delay: 0 }) {
134
+ const { delay } = options
135
+ return new Promise(resolve => {
136
+ setTimeout(() => {
137
+ const attachment = new Trix.Attachment({ content, contentType: 'application/vnd.trix-embed' })
138
+ this.editor.insertAttachment(attachment)
139
+ resolve()
140
+ }, delay)
141
+ })
142
+ }
143
+
144
+ insertHTML(content, options = { delay: 0 }) {
145
+ const { delay } = options
146
+ return new Promise(resolve => {
147
+ setTimeout(() => {
148
+ this.editor.insertHTML(content)
149
+ // shenanigans to ensure that Trix considers this block of content closed
150
+ this.editor.moveCursorInDirection('forward')
151
+ this.editor.insertLineBreak()
152
+ this.editor.moveCursorInDirection('backward')
153
+ resolve()
154
+ }, delay)
155
+ })
156
+ }
157
+
158
+ insert(content, options = { delay: 0, disposition: 'attachment' }) {
159
+ const { delay, disposition } = options
160
+
161
+ if (content?.length) {
162
+ return new Promise(resolve => {
163
+ setTimeout(() => {
164
+ if (typeof content === 'string') {
165
+ if (disposition === 'inline') return this.insertHTML(content, { delay }).then(resolve)
166
+ else return this.insertAttachment(content, { delay }).then(resolve)
167
+ }
168
+
169
+ if (Array.isArray(content)) {
170
+ if (disposition === 'inline')
171
+ return content
172
+ .reduce((p, c, i) => p.then(this.insertHTML(c, { delay })), Promise.resolve())
173
+ .then(resolve)
174
+ else
175
+ return content
176
+ .reduce((p, c, i) => p.then(this.insertAttachment(c, { delay })), Promise.resolve())
177
+ .then(resolve)
178
+ }
179
+
180
+ resolve()
181
+ })
182
+ })
183
+ }
184
+
185
+ return Promise.resolve()
186
+ }
187
+
188
+ // Returns the Trix editor
189
+ //
190
+ // @returns {TrixEditor}
191
+ //
192
+ get editor() {
193
+ return this.element.editor
194
+ }
195
+
196
+ get toolbarElement() {
197
+ const sibling = this.element.previousElementSibling
198
+ return sibling?.tagName.match(/trix-toolbar/i) ? sibling : null
199
+ }
200
+
201
+ get inputElement() {
202
+ return document.getElementById(this.element.getAttribute('input'))
203
+ }
204
+
205
+ get formElement() {
206
+ return this.element.closest('form')
207
+ }
208
+
209
+ get paranoid() {
210
+ return !!this.store.read('paranoid')
211
+ }
212
+
213
+ get key() {
214
+ try {
215
+ return JSON.parse(this.store.read('key'))[2]
216
+ } catch {}
217
+ }
218
+
219
+ get hosts() {
220
+ try {
221
+ return decryptValues(this.key, JSON.parse(this.store.read('hosts')))
222
+ } catch {
223
+ return []
224
+ }
225
+ }
226
+
227
+ get reservedDomains() {
228
+ return ['example.com', 'test.com', 'invalid.com', 'example.cat', 'nic.example', 'example.co.uk']
229
+ }
230
+
231
+ async rememberConfig() {
232
+ const key = await generateKey()
233
+ const fakes = await encryptValues(key, this.reservedDomains)
234
+ const hosts = await encryptValues(key, this.hostsValue)
235
+
236
+ this.store.write('key', JSON.stringify([fakes[0], fakes[1], key, fakes[2]]))
237
+ this.element.removeAttribute('data-trix-embed-key-value')
238
+
239
+ this.store.write('hosts', JSON.stringify(hosts))
240
+ this.element.removeAttribute('data-trix-embed-hosts-value')
241
+
242
+ if (this.paranoidValue !== false) {
243
+ this.store.write('paranoid', JSON.stringify(fakes.slice(3)))
244
+ this.element.removeAttribute('data-trix-embed-paranoid')
245
+ }
246
+ }
247
+
248
+ forgetConfig() {
249
+ this.store.remove('key')
250
+ this.store.remove('hosts')
251
+ this.store.remove('paranoid')
252
+ }
253
+ }
254
+ }
@@ -0,0 +1,119 @@
1
+ const options = { name: 'AES-GCM', length: 256 } // encryption options
2
+ const extractable = true // makes it possible to export the key
3
+ const purposes = ['encrypt', 'decrypt']
4
+
5
+ // Generates a key for use with a symmetric encryption algorithm
6
+ //
7
+ // @returns {Promise<CryptoKey>} - The generated key
8
+ //
9
+ async function generateEncryptionKey() {
10
+ const extractable = true // makes it possible to export the key later
11
+ const purposes = ['encrypt', 'decrypt']
12
+ return await crypto.subtle.generateKey(options, extractable, purposes)
13
+ }
14
+
15
+ // Exports an encryption key
16
+ //
17
+ // @param {CryptoKey} key - The key to export
18
+ // @returns {Promise<String>} - The exported key as a JSON string
19
+ //
20
+ async function exportKey(key) {
21
+ const exported = await crypto.subtle.exportKey('jwk', key)
22
+ return JSON.stringify(exported)
23
+ }
24
+
25
+ // Imports an encryption key
26
+ //
27
+ // @param {String} key - The key to import as a string
28
+ // @returns {Promise<CryptoKey>} - The imported key
29
+ //
30
+ async function importKey(key) {
31
+ const parsed = JSON.parse(key)
32
+ return await crypto.subtle.importKey('jwk', parsed, options, extractable, purposes)
33
+ }
34
+
35
+ // Encrypts a value using a symmetric encryption algorithm
36
+ //
37
+ // @param {String} value - The value to encrypt
38
+ // @param {CryptoKey} key - The key to use for encryption
39
+ // @returns {Promise<String>} - Base64 encoded representation of the encrypted value
40
+ //
41
+ async function encrypt(value, key) {
42
+ const encoded = new TextEncoder().encode(String(value))
43
+ const iv = crypto.getRandomValues(new Uint8Array(12)) // initialization vector
44
+ const buffer = await crypto.subtle.encrypt({ ...options, iv }, key, encoded) // ciphertext as an ArrayBuffer
45
+ const data = {
46
+ ciphertext: btoa(String.fromCharCode(...new Uint8Array(buffer))),
47
+ iv: btoa(String.fromCharCode(...iv))
48
+ }
49
+ return btoa(JSON.stringify(data))
50
+ }
51
+
52
+ // Decrypts a value using a symmetric encryption algorithm
53
+ //
54
+ // @param {String} encrypted - The Base64 encoded encrypted value
55
+ // @param {CryptoKey} key - The key to use for decryption
56
+ // @returns {Promise<String>} - The decrypted value
57
+ //
58
+ async function decrypt(encrypted, key) {
59
+ const data = JSON.parse(atob(encrypted))
60
+ const ciphertextArray = new Uint8Array(
61
+ atob(data.ciphertext)
62
+ .split('')
63
+ .map(char => char.charCodeAt(0))
64
+ )
65
+ const iv = new Uint8Array(
66
+ atob(data.iv)
67
+ .split('')
68
+ .map(char => char.charCodeAt(0))
69
+ )
70
+
71
+ const buffer = await crypto.subtle.decrypt({ ...options, iv }, key, ciphertextArray)
72
+ return new TextDecoder().decode(buffer)
73
+ }
74
+
75
+ // Generates a new encryption key
76
+ //
77
+ // @returns {Promise<String>} - The base64 encoded key
78
+ //
79
+ export async function generateKey() {
80
+ const key = await generateEncryptionKey()
81
+ const jsonKey = await exportKey(key)
82
+ const base64Key = btoa(jsonKey)
83
+ return base64Key
84
+ }
85
+
86
+ // Encrypts a list of values
87
+ //
88
+ // @param {String} base64Key - The encryption key to use
89
+ // @param {String[]} values - The values to encrypt
90
+ // @returns {Promise<String>[]} - The encrypted values
91
+ //
92
+ export async function encryptValues(base64Key, values = []) {
93
+ const key = await importKey(atob(base64Key))
94
+ return Promise.all(values.map(value => encrypt(value, key)))
95
+ }
96
+
97
+ // Decrypts and logs a list of values
98
+ //
99
+ // @param {String} base64Key - The encryption key to use
100
+ // @param {String[]} values - The values to decrypt
101
+ // @returns {Promise<String>[]} - The decrypted values
102
+ //
103
+ export async function decryptValues(base64Key, encryptedValues = []) {
104
+ const key = await importKey(atob(base64Key))
105
+ return Promise.all(encryptedValues.map(encryptedValue => decrypt(encryptedValue, key)))
106
+ }
107
+
108
+ // Generates a new encryption key and encrypts a list of values
109
+ //
110
+ // @param {Array} values - The values to encrypt
111
+ // @returns {Promise<Object>} - The encryption key and encrypted values
112
+ //
113
+ export async function generateKeyAndEncryptValues(values = []) {
114
+ const key = await generateKey()
115
+ const encryptedValues = await encryptValues(key, values)
116
+ console.log(`data-trix-embed-key-value="${key}"`)
117
+ console.log(`data-trix-embed-hosts-value='${JSON.stringify(encryptedValues)}'`)
118
+ return { key, encryptedValues }
119
+ }
@@ -0,0 +1,64 @@
1
+ const submitGuards = {}
2
+
3
+ export default class Guard {
4
+ constructor(controller) {
5
+ this.controller = controller
6
+ controller.element.addEventListener('trix-file-accept', event => event.preventDefault())
7
+ }
8
+
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()
13
+ }
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}`
20
+
21
+ document.removeEventListener('submit', handlers[key], true)
22
+ handlers[key] = this.protectSubmit.bind(this)
23
+ document.addEventListener('submit', handlers[key], true)
24
+
25
+ const observer = new MutationObserver((mutations, observer) => {
26
+ mutations.forEach(mutation => {
27
+ const { addedNodes, target, type } = mutation
28
+
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
+ })
46
+
47
+ observer.observe(document.body, {
48
+ attributeFilter: ['id', 'name'],
49
+ attributes: true,
50
+ childList: true,
51
+ subtree: true
52
+ })
53
+ }
54
+
55
+ cleanup() {
56
+ const trix = this.controller.element
57
+ const input = this.controller.inputElement
58
+ const toolbar = this.controller.toolbarElement
59
+
60
+ input?.remove()
61
+ toolbar?.remove()
62
+ trix?.remove()
63
+ }
64
+ }
@@ -0,0 +1,22 @@
1
+ import { generateKey, encryptValues, generateKeyAndEncryptValues } from './encryption'
2
+ import { getTrixEmbedControllerClass } from './controller'
3
+
4
+ const defaultOptions = {
5
+ application: null,
6
+ Controller: null,
7
+ Trix: null
8
+ }
9
+
10
+ function initialize(options = defaultOptions) {
11
+ const { application, Controller, Trix } = options
12
+ application.register('trix-embed', getTrixEmbedControllerClass({ Controller, Trix }))
13
+ }
14
+
15
+ self.TrixEmbed = {
16
+ initialize,
17
+ generateKey,
18
+ encryptValues,
19
+ generateKeyAndEncryptValues
20
+ }
21
+
22
+ export default self.TrixEmbed