trix_embed 0.0.2

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.
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