diff --git a/.env.example b/.env.example index 48c81b0f..49f9f3da 100644 --- a/.env.example +++ b/.env.example @@ -6,7 +6,17 @@ PORT=3030 # Postgres dev DB. Create with: createdb docuseal_dev DATABASE_URL=postgres:///docuseal_dev +# Allow framing from this origin (EHR dev URL). Drops X-Frame-Options and +# adds `Content-Security-Policy: frame-ancestors 'self' `. +# Vite serves HTTPS via mkcert locally, so use https://. +EMBED_ALLOWED_ORIGIN=https://localhost:4500 + # Webpacker / shakapacker dev-server port (default 3035; left alone). +# Trust mkcert CA so DocuSeal can fetch uploaded PDFs from Rails over +# https://localhost:3000. Ruby OpenSSL ignores macOS keychain — set this +# to the mkcert root CA path (output of `mkcert -CAROOT`/rootCA.pem). +# SSL_CERT_FILE=/Users/you/Library/Application Support/mkcert/rootCA.pem + # Override host base URL used in generated links if needed. -# HOST=http://localhost:3030 +# HOST=https://localhost:3030 diff --git a/Procfile.dev b/Procfile.dev index 359012c7..84677673 100644 --- a/Procfile.dev +++ b/Procfile.dev @@ -1,2 +1,2 @@ -web: PORT=${PORT:-3000} bundle exec rails s -p ${PORT:-3000} +web: PORT=${PORT:-3000} bundle exec puma -C config/puma.rb webpacker: bundle exec ./bin/shakapacker-dev-server diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 592c006d..f3e231d9 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -141,6 +141,11 @@ class ApplicationController < ActionController::Base policy.connect_src :self policy.directives['connect-src'] << 'ws:' if Rails.env.development? + + # Allow the embedding app (set via EMBED_ALLOWED_ORIGIN) to iframe + # this DocuSeal instance. Required by the self-hosted JWT shim in + # `embed_scripts_controller.rb`. + policy.frame_ancestors :self, ENV['EMBED_ALLOWED_ORIGIN'] if ENV['EMBED_ALLOWED_ORIGIN'].present? end end end diff --git a/app/controllers/templates_uploads_controller.rb b/app/controllers/templates_uploads_controller.rb index 7d8b1bae..cbc1531a 100644 --- a/app/controllers/templates_uploads_controller.rb +++ b/app/controllers/templates_uploads_controller.rb @@ -45,6 +45,10 @@ class TemplatesUploadsController < ApplicationController template.author = current_user template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:folder_name]) template.name = File.basename((url_params || params)[:files].first.original_filename, '.*') + # Persist the embedder-supplied external_id so the host app can later + # query `/api/templates?external_id=...` to reconcile its own record + # with this template (used by the consent-template fallback link path). + template.external_id = params[:external_id] if params[:external_id].present? Templates.maybe_assign_access(template) @@ -56,7 +60,10 @@ class TemplatesUploadsController < ApplicationController def create_file_params_from_url tempfile = Tempfile.new tempfile.binmode - tempfile.write(DownloadUtils.call(params[:url], validate: true).body) + # Validation rejects http, non-443 ports, and any localhost — which is + # exactly what the embedding EHR sends in dev (https://localhost:3000 + # Active Storage URL). Skip the SSRF guard locally; keep it on in prod. + tempfile.write(DownloadUtils.call(params[:url], validate: !Rails.env.local?).body) tempfile.rewind filename = URI.decode_www_form_component(params[:filename]) if params[:filename].present? diff --git a/app/javascript/template_builder/builder.vue b/app/javascript/template_builder/builder.vue index c998fcc1..4e499ec1 100644 --- a/app/javascript/template_builder/builder.vue +++ b/app/javascript/template_builder/builder.vue @@ -76,7 +76,7 @@
ref(), customDragFieldRef: () => ref(), selectedAreasRef: () => ref([]), + isEmbedded () { + return typeof window !== 'undefined' && window.parent !== window + }, attachmentUuidsIndex () { return this.template.schema.reduce((acc, e, index) => { acc[e.attachment_uuid] = index @@ -3142,6 +3145,9 @@ export default { const dynamicDocumentSaves = dynamicDocumentRefs.map((ref) => ref.saveBody()) Promise.all([this.save({ force: true, revision: this.withRevisions }), ...dynamicDocumentSaves]).then(() => { + if (window.parent !== window) { + window.parent.postMessage({ source: 'docuseal-embed', type: 'save', template_id: this.template.id }, '*') + } window.Turbo.visit(`/templates/${this.template.id}`) }).finally(() => { this.isSaving = false diff --git a/app/views/templates_uploads/show.html.erb b/app/views/templates_uploads/show.html.erb index 16ca71ab..e577dd7a 100644 --- a/app/views/templates_uploads/show.html.erb +++ b/app/views/templates_uploads/show.html.erb @@ -14,5 +14,6 @@ + <% end %> diff --git a/config/application.rb b/config/application.rb index a2bcddaf..f12740df 100644 --- a/config/application.rb +++ b/config/application.rb @@ -34,6 +34,15 @@ module DocuSeal config.content_security_policy_nonce_generator = ->(_) { SecureRandom.base64(16) } config.content_security_policy_nonce_directives = %w[script-src] + # When configured as an embed target, drop the default + # `X-Frame-Options: SAMEORIGIN` header so CSP `frame-ancestors` + # (set per-request in `ApplicationController#set_csp`) governs framing. + # Modern browsers prefer `frame-ancestors`, but X-Frame-Options still + # blocks if present alongside it. + if ENV['EMBED_ALLOWED_ORIGIN'].present? + config.action_dispatch.default_headers.delete('X-Frame-Options') + end + config.action_view.frozen_string_literal = true config.middleware.insert_before ActionDispatch::Static, Rack::Deflater diff --git a/config/puma.rb b/config/puma.rb index ed99f0c9..dd974385 100644 --- a/config/puma.rb +++ b/config/puma.rb @@ -17,9 +17,19 @@ threads min_threads_count, max_threads_count # worker_timeout 3600 if ENV.fetch('RAILS_ENV', 'development') == 'development' -# Specifies the `port` that Puma will listen on to receive requests; default is 3000. -# -port ENV.fetch('PORT', 3000) +# Local HTTPS via mkcert. Enable by pointing `LOCAL_HTTPS_CERT` / `LOCAL_HTTPS_KEY` +# at PEM files (see ../bin/docuseal-dev which wires them to ../.certs/lvh.me*). +# Embedding app (Vite) is served HTTPS, so an HTTP DocuSeal iframe is blocked +# as mixed content — running DocuSeal over TLS lets the parent iframe load it. +ssl_cert = ENV['LOCAL_HTTPS_CERT'].to_s +ssl_key = ENV['LOCAL_HTTPS_KEY'].to_s +if !ssl_cert.empty? && !ssl_key.empty? && File.exist?(ssl_cert) && File.exist?(ssl_key) + bind "ssl://0.0.0.0:#{ENV.fetch('PORT', 3000)}?cert=#{ssl_cert}&key=#{ssl_key}" +else + # Specifies the `port` that Puma will listen on to receive requests; default is 3000. + # + port ENV.fetch('PORT', 3000) +end # Specifies the `environment` that Puma will run in. # diff --git a/public/js/builder.js b/public/js/builder.js new file mode 100644 index 00000000..7e13c759 --- /dev/null +++ b/public/js/builder.js @@ -0,0 +1,122 @@ +// Self-hosted embed shim for the web component. +// Upstream `EmbedScriptsController#show` returns an "Upgrade to Pro" stub; +// the Rails static middleware serves this file ahead of that route, so the +// real shim wins without touching the controller. +// +// What this does: +// 1. Reads the JWT minted by the embedding app from `data-token`. +// 2. Decodes its payload to discover `document_urls`, `name`, +// `external_id`, and `template_id`. +// 3. Iframes either `/templates/:id/edit` (existing template) or +// `/new?url=...&filename=...&external_id=...` (fresh upload flow). +// 4. Relays the inner builder's `save` postMessage up as a DOM +// `CustomEvent('save')` on the outer element so `@docuseal/react`'s +// `onSave` callback fires. +// +// JWT signature is NOT verified here — host DocuSeal session cookies carry +// auth, and the calling app signs with the shared secret it controls. +(function () { + function decodeJwtPayload(token) { + try { + var part = token.split('.')[1] + if (!part) return null + var b64 = part.replace(/-/g, '+').replace(/_/g, '/') + var pad = b64.length % 4 + if (pad) b64 += '='.repeat(4 - pad) + return JSON.parse(decodeURIComponent(escape(atob(b64)))) + } catch (e) { + return null + } + } + + // Discover the DocuSeal host from this script's own src so embedders + // don't have to set data-host on every element. + var SCRIPT_HOST = (function () { + try { + var current = document.currentScript + var src = current ? current.src : '' + if (!src) { + var all = document.getElementsByTagName('script') + for (var i = 0; i < all.length; i++) { + if (/\/js\/(builder|form)\.js/.test(all[i].src)) { + src = all[i].src + break + } + } + } + return src ? new URL(src).host : '' + } catch (e) { + return '' + } + })() + + // Relay `save` postMessage from the iframe (templates_builder fires it + // after a successful manual SAVE) up as a DOM `save` CustomEvent on the + // outer element so React listeners can close the + // embedding sheet. + if (!window.__docusealEmbedListenerInstalled) { + window.__docusealEmbedListenerInstalled = true + window.addEventListener('message', function (ev) { + var d = ev && ev.data + if (!d || d.source !== 'docuseal-embed' || d.type !== 'save') return + document.querySelectorAll('docuseal-builder, docuseal-form').forEach(function (el) { + el.dispatchEvent(new CustomEvent('save', { detail: { template_id: d.template_id } })) + }) + }) + } + + var EmbedBuilder = class extends HTMLElement { + static get observedAttributes() { + return ['data-token', 'data-host'] + } + connectedCallback() { + this._maybeMount() + } + attributeChangedCallback() { + this._maybeMount() + } + _maybeMount() { + if (this._mounted) return + var token = this.getAttribute('data-token') || '' + if (!token) return + this._mounted = true + var host = this.getAttribute('data-host') || SCRIPT_HOST || window.location.host + var payload = decodeJwtPayload(token) || {} + var docUrl = (payload.document_urls && payload.document_urls[0]) || '' + var name = payload.name || 'Untitled' + var filename = name.replace(/[^A-Za-z0-9_\-]+/g, '_') + '.pdf' + + // Match parent page scheme. Both ends run TLS locally (mkcert), so + // https parent -> https iframe with no mixed-content blocking. + var origin = window.location.protocol + '//' + host + var src + if (payload.template_id) { + // Existing template: open the editor directly so prior field + // edits are preserved. Posting to /new would create another + // template and orphan the original. + src = origin + '/templates/' + encodeURIComponent(payload.template_id) + '/edit' + } else { + var qs = new URLSearchParams() + if (docUrl) qs.set('url', docUrl) + qs.set('filename', filename) + if (payload.external_id) qs.set('external_id', payload.external_id) + src = origin + '/new?' + qs.toString() + } + var iframe = document.createElement('iframe') + iframe.src = src + iframe.style.cssText = 'width:100%;height:100%;min-height:600px;border:0;display:block' + iframe.setAttribute('allow', 'clipboard-write') + this.style.display = 'block' + this.style.height = this.style.height || '100%' + this.appendChild(iframe) + } + } + + if (!window.customElements.get('docuseal-builder')) { + window.customElements.define('docuseal-builder', EmbedBuilder) + } + + if (!window.customElements.get('docuseal-form')) { + window.customElements.define('docuseal-form', class extends EmbedBuilder {}) + } +})()