Adding embedding functionality

pull/668/head
Eros Stein 1 month ago
parent 9bc624d550
commit 46838a2b79

@ -25,7 +25,8 @@ class AccountConfigsController < ApplicationController
AccountConfig::COMBINE_PDF_RESULT_KEY,
AccountConfig::REQUIRE_SIGNING_REASON_KEY,
AccountConfig::DOCUMENT_FILENAME_FORMAT_KEY,
AccountConfig::ENABLE_MCP_KEY
AccountConfig::ENABLE_MCP_KEY,
AccountConfig::EMBED_ALLOWED_ORIGINS_KEY
].freeze
InvalidKey = Class.new(StandardError)
@ -60,6 +61,22 @@ class AccountConfigsController < ApplicationController
def account_config_params
params.required(:account_config).permit(:key, :value, { value: {} }, { value: [] }).tap do |attrs|
attrs[:value] = attrs[:value] == '1' if attrs[:value].in?(%w[1 0])
attrs[:value] = normalize_origins(attrs[:value]) if attrs[:key] == AccountConfig::EMBED_ALLOWED_ORIGINS_KEY
end
end
def normalize_origins(value)
value.to_s.split(/[\s,]+/).filter_map do |origin|
uri = Addressable::URI.parse(origin.strip)
next unless uri.scheme.in?(%w[http https]) && uri.host.present?
uri.path = nil
uri.query = nil
uri.fragment = nil
uri.to_s.delete_suffix('/')
rescue Addressable::URI::InvalidURIError
nil
end.uniq
end
end

@ -2,6 +2,7 @@
module Api
class ApiBaseController < ActionController::API
include EmbedCors
include ActiveStorage::SetCurrent
include Pagy::Method
@ -13,6 +14,7 @@ module Api
wrap_parameters false
before_action :authenticate_user!
before_action :set_embed_cors_headers
check_authorization
rescue_from Params::BaseValidator::InvalidParameterError do |e|

@ -2,6 +2,7 @@
module Api
class AttachmentsController < ActionController::API
include EmbedCors
include ActionController::Cookies
include ActiveStorage::SetCurrent
@ -9,6 +10,9 @@ module Api
def create
submitter = Submitter.find_by!(slug: params[:submitter_slug])
@embed_cors_account = submitter.account
set_embed_cors_headers
unless can_upload?(submitter)
Rollbar.error("Can't upload: #{submitter.id}") if defined?(Rollbar)

@ -7,6 +7,9 @@ module Api
def create
@submitter = Submitter.find_by!(slug: params[:submitter_slug])
@embed_cors_account = @submitter.account
set_embed_cors_headers
if params[:t] == SubmissionEvents.build_tracking_param(@submitter, 'click_email')
SubmissionEvents.create_with_tracking_data(@submitter, 'click_email', request)

@ -7,6 +7,9 @@ module Api
def create
@submitter = Submitter.find_by!(slug: params[:submitter_slug])
@embed_cors_account = @submitter.account
set_embed_cors_headers
@submitter.opened_at = Time.current
@submitter.save

@ -0,0 +1,76 @@
# frozen_string_literal: true
module EmbedCors
extend ActiveSupport::Concern
private
def set_embed_cors_headers
allowed_origin = embed_cors_allowed_origin
headers.delete('Access-Control-Allow-Origin')
return if allowed_origin.blank?
headers['Access-Control-Allow-Origin'] = allowed_origin
headers['Access-Control-Allow-Methods'] = 'POST, GET, PUT, PATCH, DELETE, OPTIONS'
headers['Access-Control-Allow-Headers'] = '*'
headers['Access-Control-Max-Age'] = '1728000'
headers['Vary'] = [headers['Vary'], 'Origin'].compact_blank.join(', ') unless allowed_origin == '*'
end
def embed_cors_allowed_origin
origin = normalized_embed_origin(request.headers['Origin'])
return '*' if origin.blank?
account_origins = configured_embed_origins_for_account(embed_cors_account)
return origin if account_origins.include?(origin)
return if account_origins.present?
configured_origins = configured_embed_origins
return origin if configured_origins.include?(origin)
return if configured_origins.present?
'*'
end
def configured_embed_origins_for_account(account)
normalize_embed_origins(
account&.account_configs&.find_by(key: AccountConfig::EMBED_ALLOWED_ORIGINS_KEY)&.value
)
end
def configured_embed_origins
normalize_embed_origins(
AccountConfig.where(key: AccountConfig::EMBED_ALLOWED_ORIGINS_KEY).map(&:value).flatten
)
end
def normalize_embed_origins(value)
Array.wrap(value).filter_map { |origin| normalized_embed_origin(origin) }.uniq
end
def normalized_embed_origin(origin)
return if origin.blank?
uri = Addressable::URI.parse(origin.to_s.strip)
return unless uri.scheme.in?(%w[http https]) && uri.host.present?
uri.path = nil
uri.query = nil
uri.fragment = nil
uri.to_s.delete_suffix('/')
rescue Addressable::URI::InvalidURIError
nil
end
def embed_cors_account
return @embed_cors_account if defined?(@embed_cors_account)
current_account if respond_to?(:current_account, true)
end
end

@ -0,0 +1,11 @@
# frozen_string_literal: true
class CorsPreflightController < ActionController::API
include EmbedCors
before_action :set_embed_cors_headers
def show
head :ok
end
end

@ -0,0 +1,267 @@
# frozen_string_literal: true
module Embed
class FormsController < ActionController::API
include ActionController::Cookies
include ActiveStorage::SetCurrent
include EmbedCors
before_action :set_embed_cors_headers, only: :preflight
def create
slug = token_payload['slug'] || params[:slug].presence || params[:template_slug].presence
raise ActiveRecord::RecordNotFound if slug.blank?
submitter = Submitter.find_by(slug: slug)
payload =
if submitter
@embed_cors_account = submitter.account
form_json(submitter)
else
template = Template.find_by!(slug: slug)
@embed_cors_account = template.account
form_json_for_template(template)
end
set_embed_cors_headers
render json: payload, status: payload[:error].present? ? :unprocessable_content : :ok
end
def preflight
head :ok
end
private
def form_json_for_template(template)
raise ActiveRecord::RecordNotFound if template.archived_at? || template.account.archived_at?
raise ActiveRecord::RecordNotFound unless template.shared_link?
attrs = submitter_params
return { template: template_json(template), logo: logo_json(template.account) } if attrs.compact_blank.blank?
submitter = find_or_initialize_submitter(template, attrs)
if selected_template_submitter(template).blank? && filter_undefined_submitters(template).size > 1 && submitter.new_record?
return { error: I18n.t('this_template_has_multiple_parties_which_prevents_the_use_of_a_sharing_link') }
end
if submitter.new_record?
assign_submission_attributes(submitter, template)
Submissions::AssignDefinedSubmitters.call(submitter.submission)
else
submitter.assign_attributes(ip: request.remote_ip, ua: request.user_agent)
end
submitter.values = values_param.presence || submitter.values
submitter.metadata = metadata_param.presence || submitter.metadata
submitter.external_id = params[:external_id].presence || submitter.external_id
if template.preferences['shared_link_2fa'] == true &&
!Submitters::AuthorizedForForm.pass_link_2fa?(submitter, nil, request)
Submitters.send_shared_link_email_verification_code(submitter, request: request)
return { template: template_json(template), unverified_email: submitter.email, logo: logo_json(template.account) }
end
if submitter.errors.blank? && submitter.save
enqueue_new_submitter_jobs(submitter) if submitter.previous_changes.key?('id')
form_json(submitter)
else
{ error: submitter.errors.full_messages.to_sentence }
end
end
def form_json(submitter)
raise ActiveRecord::RecordNotFound if submitter.account.archived_at?
return { expired_submitter: submitter_json(submitter), submission: submission_json(submitter.submission, submitter),
template: template_json(submitter.template), logo: logo_json(submitter.account) } if submitter.submission.expired?
return { completed_submitter: submitter_json(submitter), submission: submission_json(submitter.submission, submitter),
template: template_json(submitter.template), logo: logo_json(submitter.account) } if submitter.completed_at?
return { expired_submitter: submitter_json(submitter), submission: submission_json(submitter.submission, submitter),
template: template_json(submitter.template), logo: logo_json(submitter.account) } if submitter.declined_at?
unless Submitters::AuthorizedForForm.pass_email_2fa?(submitter, request)
return { submitter_email_2fa: submitter_json(submitter),
submission: submission_json(submitter.submission, submitter),
template: template_json(submitter.template), logo: logo_json(submitter.account) }
end
unless Submitters::AuthorizedForForm.pass_link_2fa?(submitter, nil, request)
return { unverified_email: submitter.email, submission: submission_json(submitter.submission, submitter),
template: template_json(submitter.template), logo: logo_json(submitter.account) }
end
submission = submitter.submission
Submissions.preload_with_pages(submission)
Submitters::MaybeUpdateDefaultValues.call(submitter, nil)
{
template: template_json(submitter.template),
submission: submission_json(submission, submitter),
submitter: submitter_json(submitter),
documents: documents_json(submission),
attachments: attachments_json(submission),
values: values_param.presence || {},
logo: logo_json(submitter.account)
}
end
def template_json(template)
template.as_json(only: %i[id name slug preferences schema submitters archived_at account_id])
end
def submission_json(submission, submitter = nil)
submission.as_json(
only: %i[id slug name source submitters_order expire_at archived_at created_at updated_at template_id account_id],
methods: %i[expired?],
).merge(
'template_schema' => Submissions.filtered_conditions_schema(submission,
include_submitter_uuid: submitter&.uuid),
'template_fields' => submission.template_fields || submission.template.fields,
'template_submitters' => submission.template_submitters || submission.template.submitters,
'submitters' => submission.submitters.as_json(
only: %i[id uuid slug name email phone completed_at declined_at opened_at sent_at]
)
)
end
def submitter_json(submitter)
submitter.as_json(
only: %i[id uuid slug name email phone values metadata preferences completed_at declined_at opened_at sent_at]
)
end
def documents_json(submission)
submission.schema_documents.as_json(
methods: %i[metadata signed_key],
include: { preview_images: { methods: %i[url metadata filename] } }
)
end
def attachments_json(submission)
ActiveStorage::Attachment.where(record: submission.submitters, name: :attachments)
.preload(:blob)
.as_json(only: %i[uuid created_at], methods: %i[url filename content_type])
end
def logo_json(account)
return unless account.logo.attached?
{ url: account.logo.url }
end
def find_or_initialize_submitter(template, attrs)
required_fields = template.preferences.fetch('link_form_fields', ['email'])
required_params = required_fields.index_with { |key| attrs[key] }
find_params = required_params.except('name')
submitter = Submitter.new if find_params.compact_blank.blank?
relation =
Submitter
.where(submission: template.submissions.where(expire_at: Time.current..)
.or(template.submissions.where(expire_at: nil)).where(archived_at: nil))
.order(id: :desc)
.where(declined_at: nil)
.where(external_id: nil)
.where(template.preferences['shared_link_2fa'] == true ? {} : { ip: [nil, request.remote_ip] })
if (template_submitter = selected_template_submitter(template))
relation = relation.where(uuid: template_submitter['uuid'])
end
submitter ||= relation
.find_or_initialize_by(find_params)
submitter.name = required_params['name'] if submitter.new_record?
required_params.each do |key, value|
submitter.errors.add(key.to_sym, :blank) if value.blank?
end
submitter
end
def assign_submission_attributes(submitter, template)
submitter.assign_attributes(
uuid: (selected_template_submitter(template) || filter_undefined_submitters(template).first ||
template.submitters.first)['uuid'],
ip: request.remote_ip,
ua: request.user_agent,
values: {},
preferences: { 'send_email' => params[:send_email] },
metadata: {}
)
submitter.submission ||= Submission.new(template: template,
account_id: template.account_id,
template_submitters: template.submitters,
template_fields: template.fields,
template_schema: template.schema,
expire_at: Templates.build_default_expire_at(template),
submitters: [submitter],
source: :embed)
Submissions::CreateFromSubmitters.maybe_set_dynamic_documents(submitter.submission)
submitter.account_id = submitter.submission.account_id
end
def enqueue_new_submitter_jobs(submitter)
WebhookUrls.enqueue_events(submitter.submission, 'submission.created')
SearchEntries.enqueue_reindex(submitter)
return unless submitter.submission.expire_at?
ProcessSubmissionExpiredJob.perform_at(submitter.submission.expire_at, 'submission_id' => submitter.submission_id)
end
def filter_undefined_submitters(template)
Templates.filter_undefined_submitters(template.submitters)
end
def selected_template_submitter(template)
submitter_name = params[:submitter].presence || params[:role].presence
return if submitter_name.blank?
template.submitters.find { |item| item['uuid'] == submitter_name || item['name'] == submitter_name }
end
def submitter_params
{
'email' => Submissions.normalize_email(params[:email].presence),
'name' => params[:name].presence,
'phone' => params[:phone].presence
}.compact
end
def values_param
params[:values].respond_to?(:to_unsafe_h) ? params[:values].to_unsafe_h : params[:values]
end
def metadata_param
params[:metadata].respond_to?(:to_unsafe_h) ? params[:metadata].to_unsafe_h : params[:metadata]
end
def token_payload
return {} if params[:token].blank?
JSON.parse(Base64.urlsafe_decode64(params[:token].split('.')[1])).with_indifferent_access
rescue JSON::ParserError, ArgumentError
{}
end
end
end

@ -1,6 +1,8 @@
# frozen_string_literal: true
class SendSubmissionEmailController < ApplicationController
include EmbedCors
layout 'form'
skip_before_action :authenticate_user!
@ -27,6 +29,9 @@ class SendSubmissionEmailController < ApplicationController
@submitter = Submitter.completed.find_by!(slug: params[:submitter_slug])
end
@embed_cors_account = @submitter.account
set_embed_cors_headers
RateLimit.call("send-email-#{@submitter.id}", limit: 2, ttl: 5.minutes)
SubmitterMailer.documents_copy_email(@submitter, sig: true).deliver_later! if can_send?(@submitter)

@ -1,6 +1,8 @@
# frozen_string_literal: true
class SubmitFormCompletedDownloadController < ApplicationController
include EmbedCors
skip_before_action :authenticate_user!
skip_authorization_check
@ -18,6 +20,9 @@ class SubmitFormCompletedDownloadController < ApplicationController
end
@submitter ||= Submitter.find_by!(slug: submitter_slug)
@embed_cors_account = @submitter.account
set_embed_cors_headers
Submissions::EnsureResultGenerated.call(@submitter)

@ -1,13 +1,17 @@
# frozen_string_literal: true
class SubmitFormController < ApplicationController
include EmbedCors
layout 'form'
around_action :with_browser_locale, only: %i[show completed success delegated]
skip_before_action :authenticate_user!
skip_before_action :verify_authenticity_token, only: :update
skip_authorization_check
before_action :load_submitter, only: %i[show update completed]
before_action :set_embed_cors_headers, only: :update
before_action :maybe_redirect_delegated, only: %i[show completed]
before_action :maybe_render_locked_page, only: :show
before_action :maybe_require_link_2fa, only: %i[show]
@ -135,4 +139,8 @@ class SubmitFormController < ApplicationController
ActiveStorage::Attachment.where(record: submission.submitters, name: :attachments)
.preload(:blob).index_by(&:uuid)
end
def embed_cors_account
@submitter&.account || super
end
end

@ -1,6 +1,8 @@
# frozen_string_literal: true
class SubmitFormDownloadController < ApplicationController
include EmbedCors
skip_before_action :authenticate_user!
skip_authorization_check
@ -8,6 +10,9 @@ class SubmitFormDownloadController < ApplicationController
def index
@submitter = Submitter.find_by!(slug: params[:submit_form_slug])
@embed_cors_account = @submitter.account
set_embed_cors_headers
return redirect_to submit_form_documents_path(@submitter.slug) if @submitter.completed_at?

@ -1,11 +1,16 @@
# frozen_string_literal: true
class SubmitFormMetadataController < ApplicationController
include EmbedCors
skip_before_action :authenticate_user!
skip_authorization_check
def index
submitter = Submitter.find_by!(slug: params[:submit_form_slug])
@embed_cors_account = submitter.account
set_embed_cors_headers
return head :not_found if submitter.declined_at? ||
submitter.completed_at? ||
@ -17,7 +22,7 @@ class SubmitFormMetadataController < ApplicationController
submission = submitter.submission
values = submission.submitters.reduce({}) { |acc, sub| acc.merge(sub.values) }
schema = Submissions.filtered_conditions_schema(submission, values:, include_submitter_uuid: submitter.uuid)
schema = Submissions.filtered_conditions_schema(submission, values: values, include_submitter_uuid: submitter.uuid)
documents = schema.filter_map do |item|
submission.schema_documents.find { |a| a.uuid == item['attachment_uuid'] }
@ -32,6 +37,6 @@ class SubmitFormMetadataController < ApplicationController
]
end
render json: { text_runs: }
render json: { text_runs: text_runs }
end
end

@ -1,11 +1,16 @@
# frozen_string_literal: true
class SubmitFormValuesController < ApplicationController
include EmbedCors
skip_before_action :authenticate_user!
skip_authorization_check
def index
submitter = Submitter.find_by!(slug: params[:submit_form_slug])
@embed_cors_account = submitter.account
set_embed_cors_headers
return render json: {} if submitter.completed_at? ||
submitter.declined_at? ||
@ -18,7 +23,7 @@ class SubmitFormValuesController < ApplicationController
attachment = submitter.attachments.where(created_at: params[:after]..).find_by(uuid: value) if value.present?
render json: {
value:,
value: value,
attachment: attachment&.as_json(only: %i[uuid created_at], methods: %i[url filename content_type])
}, head: :ok
end

@ -59,6 +59,7 @@ class AccountConfig < ApplicationRecord
TEMPLATE_CUSTOM_FIELDS_KEY = 'template_custom_fields'
POLICY_LINKS_KEY = 'policy_links'
ENABLE_MCP_KEY = 'enable_mcp'
EMBED_ALLOWED_ORIGINS_KEY = 'embed_allowed_origins'
EMAIL_VARIABLES = {
SUBMITTER_INVITATION_EMAIL_KEY => %w[template.name submitter.link account.name].freeze,

@ -228,6 +228,27 @@
</div>
<% end %>
<% end %>
<% account_config = AccountConfig.find_or_initialize_by(account: current_account, key: AccountConfig::EMBED_ALLOWED_ORIGINS_KEY) %>
<% if can?(:manage, account_config) %>
<%= form_for account_config, url: account_configs_path, method: :post, html: { class: 'py-2.5' } do |f| %>
<%= f.hidden_field :key %>
<div class="space-y-2">
<div class="flex items-center space-x-1">
<span class="text-left"><%= t('allowed_embedding_origins') %></span>
<span class="tooltip tooltip-top flex cursor-pointer" data-tip="<%= t('allowed_embedding_origins_description') %>">
<%= svg_icon('info_circle', class: 'hidden md:inline-block w-4 h-4 shrink-0') %>
</span>
</div>
<%= text_area_tag 'account_config[value]', Array(account_config.value).join("\n"), class: 'base-input min-h-28', placeholder: "https://app.example.com\nhttp://localhost:5173", dir: 'ltr' %>
<div class="flex items-center justify-between gap-3">
<p class="text-sm text-gray-500">
<%= t('leave_blank_to_allow_embedding_from_any_origin') %>
</p>
<%= f.button button_title(title: t('save'), disabled_with: t('saving')), class: 'btn btn-sm btn-neutral text-white px-4' %>
</div>
</div>
<% end %>
<% end %>
<% account_config = AccountConfig.find_or_initialize_by(account: current_account, key: AccountConfig::COMBINE_PDF_RESULT_KEY) %>
<% if can?(:manage, account_config) %>
<%= form_for account_config, url: account_configs_path, method: :post do |f| %>

@ -46,6 +46,7 @@ en: &en
click_here_to_send_a_reset_password_email_html: '<label class="link" for="resend_password_button">Click here</label> to send a reset password email.'
edit_order: Edit Order
expirable_file_download_links: Expirable file download links
allowed_embedding_origins: Allowed embedding origins
sender_form_fields: Sender form fields
default_parties: Default parties
authenticate_embedded_form_preview_with_token: Authenticate embedded form preview with token
@ -209,6 +210,7 @@ en: &en
allow_to_delegate_documents: Allow to delegate documents
remember_and_pre_fill_signatures: Remember and pre-fill signatures
require_authentication_for_file_download_links: Require authentication for file download links
leave_blank_to_allow_embedding_from_any_origin: Leave blank to allow embedding from any origin.
combine_completed_documents_and_audit_log: Combine completed documents and Audit Log
salesforce_integration: Salesforce Integration
salesforce_has_been_connected: Salesforce has been connected.
@ -855,6 +857,7 @@ en: &en
save_a_users_signature_and_automatically_pre_fill_it_in_future_signing_sessions: "Save a user's signature and automatically pre-fill it in future signing sessions."
make_document_download_links_expire_after_40_minutes_to_prevent_long_term_access_and_enhance_security: Make document download links expire after 40 minutes to prevent long-term access and enhance security.
require_authentication_with_user_login_or_api_key_to_access_the_document_download_links: Require authentication with user login or API key to access the document download links.
allowed_embedding_origins_description: Enter allowed frontend origins for embedded signing, one per line or separated by commas. Include protocol and port, for example https://app.example.com or http://localhost:5173.
combine_signed_documents_and_the_audit_log_into_a_single_pdf_file_for_easier_recordkeeping_and_compliance: Combine signed documents and the Audit Log into a single PDF file for easier recordkeeping and compliance.
require_a_jwt_authorization_to_preview_embedded_forms_ensuring_only_authorized_users_can_view_them: Require a JWT authorization to preview embedded forms, ensuring only authorized users can view them.
make_all_newly_created_templates_private_to_their_creator_by_default: Make all newly created templates private to their creator by default.

@ -214,6 +214,14 @@ Rails.application.routes.draw do
match '/mcp', to: 'mcp#call', via: %i[get post]
post '/embed/forms', to: 'embed/forms#create'
match '/embed/forms', to: 'embed/forms#preflight', via: :options
match '/s/:slug', to: 'cors_preflight#show', via: :options
match '/s/:slug/*path', to: 'cors_preflight#show', via: :options
match '/submitters/:submitter_id/download', to: 'cors_preflight#show', via: :options
match '/send_submission_email', to: 'cors_preflight#show', via: :options
match '/api/*path', to: 'cors_preflight#show', via: :options
get '/js/:filename', to: 'embed_scripts#show', as: :embed_script
ActiveSupport.run_load_hooks(:routes, self)

@ -0,0 +1,50 @@
# frozen_string_literal: true
describe 'Embed forms' do
let(:account) { create(:account) }
let(:author) { create(:user, account: account) }
let(:template) { create(:template, account: account, author: author, shared_link: true) }
describe 'POST /embed/forms' do
it 'returns a shared template payload before the signer starts' do
post '/embed/forms', params: { slug: template.slug }.to_json, headers: { 'CONTENT_TYPE' => 'application/json' }
expect(response).to have_http_status(:ok)
expect(response.parsed_body.dig('template', 'id')).to eq(template.id)
expect(response.parsed_body['submitter']).to be_nil
expect(response.headers['Access-Control-Allow-Origin']).to eq('*')
end
it 'creates an embedded submitter and returns signing data' do
post '/embed/forms',
params: { slug: template.slug, email: 'signer@example.com', name: 'Jane Signer' }.to_json,
headers: { 'CONTENT_TYPE' => 'application/json' }
expect(response).to have_http_status(:ok)
expect(response.parsed_body.dig('submitter', 'email')).to eq('signer@example.com')
expect(response.parsed_body.dig('submission', 'source')).to eq('embed')
expect(response.parsed_body.dig('submission', 'template_fields')).to be_present
expect(response.parsed_body['documents']).to be_present
end
it 'loads an existing submitter by slug' do
submitter = create(:submission, :with_submitters, template: template).submitters.first
post '/embed/forms', params: { slug: submitter.slug }.to_json, headers: { 'CONTENT_TYPE' => 'application/json' }
expect(response).to have_http_status(:ok)
expect(response.parsed_body.dig('submitter', 'slug')).to eq(submitter.slug)
end
end
describe 'OPTIONS /embed/forms' do
it 'returns CORS preflight headers' do
process :options, '/embed/forms'
expect(response).to have_http_status(:ok)
expect(response.headers['Access-Control-Allow-Origin']).to eq('*')
expect(response.headers['Access-Control-Allow-Methods']).to include('POST')
expect(response.headers['Access-Control-Allow-Headers']).to eq('*')
end
end
end
Loading…
Cancel
Save