Compare commits

...
49 Commits
Author SHA1 Message Date
Alex Turchyn c216e43d24 Merge from docusealco/wip 2026-08-31 11:35:27 +03:00
Pete Matsyburka d2373c1578 fix normalize email 2026-08-31 10:13:17 +03:00
Pete Matsyburka 474603e1c9 hide list form buttons 2026-08-30 18:51:35 +03:00
Pete Matsyburka 1fc1444b8f optimize load bmp 2026-08-30 15:49:54 +03:00
Pete Matsyburka 47361da26f adjust validate required 2026-08-30 15:36:49 +03:00
Pete Matsyburka 11dea042ad adjust test mode auth 2026-08-30 12:07:05 +03:00
Pete Matsyburka 8756931638 render locked page 2026-08-30 12:01:54 +03:00
Pete Matsyburka 180b201dcf fix mfa rate limit 2026-08-30 12:01:54 +03:00
Pete Matsyburka a1c30c2093 update tools verify 2026-08-30 11:04:45 +03:00
Pete Matsyburka 66bf6af7ba adjust verify pdf signature 2026-08-30 10:56:34 +03:00
Pete Matsyburka 1a65e7bcff adjust download route 2026-08-29 21:53:01 +03:00
Pete Matsyburka 59b5f03948 fix buttons order 2026-08-29 20:18:34 +03:00
Pete Matsyburka 4626518c83 fix search 2026-08-29 20:06:14 +03:00
Pete Matsyburka c18e263f34 adjust verify pdf signature 2026-08-29 20:06:13 +03:00
Pete Matsyburka c3dae1bfe4 add size checks 2026-08-29 16:29:52 +03:00
Pete Matsyburka b44152fd52 fix search index 2026-08-29 16:29:52 +03:00
Pete Matsyburka 2889a7c937 squish name 2026-08-29 16:29:52 +03:00
Pete Matsyburka 91e65e4a19 adjust prefill 2026-08-29 16:29:52 +03:00
Pete Matsyburka 2a1a2cbd82 fix generation 2026-08-28 19:15:48 +03:00
Pete Matsyburka 99b3247cf7 archive account 2026-08-25 18:07:48 +03:00
Pete Matsyburka fa91c6d6be fix mobile filters 2026-08-25 15:12:19 +03:00
Alex Turchyn 27a0d10525 Merge from docusealco/wip 2026-08-25 13:46:49 +03:00
Pete Matsyburka d25f0812ca reset decline values 2026-08-24 16:38:05 +03:00
Pete Matsyburka 9e2aa6f8c8 pass fetch options 2026-08-24 14:43:13 +03:00
Pete Matsyburka 11c76bf554 refactor 2026-08-24 09:47:34 +03:00
Pete Matsyburka e5733d88c0 remove uuids scope 2026-08-23 22:15:02 +03:00
Pete Matsyburka 6d38c9f375 adjust resubmit 2026-08-23 21:23:27 +03:00
Pete Matsyburka 91ec29ad2c add i18n 2026-08-23 19:23:05 +03:00
Pete Matsyburka d374c7fa10 fix log 2026-08-23 19:04:18 +03:00
Pete Matsyburka ca0daf6d0b normalize 2fa email 2026-08-23 18:52:12 +03:00
Pete Matsyburka 076c3d805a fix find_or_initialize_submitter 2026-08-23 18:49:32 +03:00
Pete Matsyburka 1f149ac67a new form if fields changed 2026-08-23 18:01:00 +03:00
Pete Matsyburka 616f7b4fd8 adjsut resubmit 2026-08-23 17:48:04 +03:00
Pete Matsyburka 6f4527038b fix redirect 2026-08-23 17:38:17 +03:00
Pete Matsyburka 8ed2883504 adjust email 2fa form 2026-08-23 17:34:17 +03:00
Pete Matsyburka 651b84a460 fix redirect status 2026-08-23 17:24:44 +03:00
Pete Matsyburka 73c4f158ff add self source 2026-08-23 17:19:50 +03:00
Pete Matsyburka 6f1cc34433 adjust log 2026-08-23 16:51:21 +03:00
Pete Matsyburka a77029ffcb start form remember session 2026-08-23 16:51:15 +03:00
Pete Matsyburka 0480572ef5 refactor start form 2026-08-23 15:45:43 +03:00
Pete Matsyburka 807463c465 optimize shared link 2026-08-23 15:45:43 +03:00
Pete Matsyburka 0f61053976 remove secrets yml 2026-08-23 11:50:12 +03:00
Pete Matsyburka b8cf525034 fix cycle safe 2026-08-21 18:56:22 +03:00
Pete Matsyburka fef203daed adjust notification settings 2026-08-21 17:45:19 +03:00
Pete Matsyburka 41b38cc594 fix js error on redirect 2026-08-21 12:00:29 +03:00
Pete Matsyburka 4e4354af99 fix RTL reverse 2026-08-20 17:26:54 +03:00
Pete Matsyburka 113b61588b process pdf from doc 2026-08-20 13:21:24 +03:00
Pete Matsyburka c1aac6b763 add stats 2026-08-19 12:58:24 +03:00
Alex Turchyn f85fd3cff7 add personalization tooltips 2026-08-18 14:24:55 +03:00
58 changed files with 725 additions and 364 deletions

No files matched your search

+2 -1
View File
@@ -47,7 +47,8 @@ class AccountsController < ApplicationController
authorize!(:manage, current_account)
true_user.skip_reconfirmation!
true_user.update!(locked_at: Time.current, email: true_user.email.sub('@', '+removed@'))
true_user.update!(locked_at: Time.current, archived_at: Time.current,
email: true_user.email.sub('@', '+removed@'))
true_user.account.update!(archived_at: Time.current)
# rubocop:disable Layout/LineLength
+6 -7
View File
@@ -17,24 +17,23 @@ module Api
def verify
file = Base64.decode64(params[:file])
pdf = HexaPDF::Document.new(io: StringIO.new(file))
trusted_certs = Accounts.load_trusted_certs(current_account)
is_checksum_found = CompletedDocument.exists?(sha256: Base64.urlsafe_encode64(Digest::SHA256.digest(file)))
render json: {
checksum_status: is_checksum_found ? 'verified' : 'not_found',
signatures: pdf.signatures.map do |sig|
signatures: VerifyPdfSignature.call(StringIO.new(file), trusted_certs).map do |sig|
{
verification_result: sig.verify(trusted_certs:).messages,
signer_name: sig.signer_name,
signing_reason: sig.signing_reason,
verification_result: sig.messages.map { |m| { type: m.status || :info, content: m.text } },
signer_name: sig.common_name,
signing_reason: sig.reason,
signing_time: sig.signing_time,
signature_type: sig.signature_type
signature_type: sig.type
}
end
}
rescue HexaPDF::MalformedPDFError
rescue Pdfium::PdfiumError
render json: { error: 'Malformed PDF' }, status: :unprocessable_content
end
end
+1 -1
View File
@@ -30,7 +30,7 @@ class ApplicationController < ActionController::Base
rescue_from RateLimit::LimitApproached do |e|
Rollbar.error(e) if defined?(Rollbar)
redirect_to request.referer, alert: 'Too many requests', status: :too_many_requests
redirect_back_or_to root_path, alert: 'Too many requests'
end
if Rails.env.production? || Rails.env.test?
+4 -4
View File
@@ -14,14 +14,14 @@ class MfaSetupController < ApplicationController
def edit; end
def create
RateLimit.call("mfa-setup-otp-#{current_user.id}", limit: 5, ttl: 5.minutes, enabled: true)
if current_user.validate_and_consume_otp!(params[:otp_attempt])
current_user.otp_required_for_login = true
current_user.save!
redirect_to settings_profile_index_path, notice: I18n.t('2fa_has_been_configured')
else
RateLimit.call("mfa-setup-otp-#{current_user.id}", limit: 5, ttl: 5.minutes, enabled: true)
@provision_url = current_user.otp_provisioning_uri(current_user.email, issuer: Docuseal.product_name)
@error_message = I18n.t('code_is_invalid')
@@ -31,13 +31,13 @@ class MfaSetupController < ApplicationController
end
def destroy
RateLimit.call("mfa-setup-otp-#{current_user.id}", limit: 5, ttl: 5.minutes, enabled: true)
if current_user.validate_and_consume_otp!(params[:otp_attempt])
current_user.update!(otp_required_for_login: false, otp_secret: nil)
redirect_to settings_profile_index_path, notice: I18n.t('2fa_has_been_removed')
else
RateLimit.call("mfa-setup-otp-#{current_user.id}", limit: 5, ttl: 5.minutes, enabled: true)
@error_message = I18n.t('code_is_invalid')
render turbo_stream: turbo_stream.replace(:modal, template: 'mfa_setup/edit'), status: :unprocessable_content
+44 -133
View File
@@ -8,13 +8,9 @@ class StartFormController < ApplicationController
around_action :with_browser_locale, only: %i[show update completed]
before_action :maybe_redirect_com, only: %i[show completed]
before_action :load_resubmit_submitter, only: :update
before_action :load_template
before_action :authorize_start!, only: :update
COOKIES_TTL = 12.hours
COOKIES_DEFAULTS = { httponly: true, secure: Rails.env.production? }.freeze
def show
if @template.preferences['require_phone_2fa'] || @template.preferences['require_email_2fa']
raise ActionController::RoutingError, I18n.t('not_found')
@@ -23,8 +19,7 @@ class StartFormController < ApplicationController
if @template.shared_link?
@submitter = @template.submissions.new(account_id: @template.account_id)
.submitters.new(account_id: @template.account_id,
uuid: (filter_undefined_submitters(@template).first ||
@template.submitters.first)['uuid'])
uuid: Submitters::StartForm.first_submitter_uuid(@template))
render :email_verification if params[:email_verification]
else
Rollbar.warning("Not shared template: #{@template.id}") if defined?(Rollbar)
@@ -41,24 +36,28 @@ class StartFormController < ApplicationController
if @submitter.completed_at?
redirect_to start_form_completed_path(@template.slug, submitter_params.compact_blank)
else
if filter_undefined_submitters(@template).size > 1 && @submitter.new_record?
if Templates.filter_undefined_submitters(@template.submitters).size > 1 && @submitter.new_record?
@error_message = multiple_submitters_error_message
return render :show, status: :unprocessable_content
end
if (is_new_record = @submitter.new_record?)
assign_submission_attributes(@submitter, @template)
Submitters::StartForm.assign_submission_attributes(
@submitter, @template, ip: request.remote_ip, user_agent: request.user_agent
)
Submissions::AssignDefinedSubmitters.call(@submitter.submission)
else
@submitter.assign_attributes(ip: request.remote_ip, ua: request.user_agent)
end
if @template.preferences['shared_link_2fa'] == true
handle_require_2fa(@submitter, is_new_record:)
if require_link_2fa?(@template, @submitter)
handle_require_2fa(@template, @submitter)
elsif @submitter.errors.blank? && @submitter.save
enqueue_new_submitter_jobs(@submitter) if is_new_record
Submitters::StartForm.enqueue_new_submitter_jobs(@submitter) if is_new_record
Submitters::StartForm.assign_start_form_cookie(@submitter, request)
redirect_to submit_form_path(@submitter.slug)
else
@@ -88,41 +87,40 @@ class StartFormController < ApplicationController
private
def enqueue_new_submitter_jobs(submitter)
WebhookUrls.enqueue_events(submitter.submission, 'submission.created')
def find_or_initialize_submitter(template, submitter_params)
required_fields = template.preferences.fetch('link_form_fields', ['email'])
SearchEntries.enqueue_reindex(submitter)
blank_fields = required_fields.select { |key| submitter_params[key].blank? }
expire_at = submitter.submission.expire_at
return unless expire_at
ProcessSubmissionExpiredJob.perform_at(expire_at, 'submission_id' => submitter.submission_id,
'expire_at' => expire_at.to_i)
end
def load_resubmit_submitter
@resubmit_submitter =
if params[:resubmit].present? && !params[:resubmit].in?([true, 'true'])
submitter = Submitter.find_by(slug: params[:resubmit])
submitter if submitter && can_resubmit?(submitter)
submitter =
if blank_fields.present?
Submitter.new(submitter_params)
else
Submitters::StartForm.find_or_initialize_submitter(
template, submitter_params, exclude_completed: params[:resubmit].present?,
request:, current_user:
)
end
blank_fields.each { |key| submitter.errors.add(key.to_sym, :blank) }
submitter
end
def can_resubmit?(submitter)
submitter.completed_at? && submitter.completed_at > 14.days.ago &&
%w[api embed mcp].exclude?(submitter.submission.source) &&
submitter.account.account_configs.find_or_initialize_by(key: AccountConfig::ALLOW_TO_RESUBMIT).value != false
def require_link_2fa?(template, submitter)
return true if template.preferences['shared_link_2fa'] == true
return false if cookies.encrypted[:start_form_slug] == submitter.slug
return false if current_user && submitter.email == current_user.email &&
current_user.account_id == submitter.account_id
!submitter.new_record?
end
def authorize_start!
is_archived = @template.archived_at? || @template.account.archived_at?
return redirect_to submit_form_path(@resubmit_submitter.slug) if @resubmit_submitter && is_archived
return redirect_to start_form_path(@template.slug) if is_archived
return if @resubmit_submitter
return if @template.shared_link? || (current_user && current_ability.can?(:read, @template))
Rollbar.warning("Not shared template: #{@template.id}") if defined?(Rollbar)
@@ -130,90 +128,14 @@ class StartFormController < ApplicationController
redirect_to start_form_path(@template.slug)
end
def find_or_initialize_submitter(template, submitter_params)
required_fields = template.preferences.fetch('link_form_fields', ['email'])
required_params = required_fields.index_with { |key| submitter_params[key] }
find_params = required_params.except('name')
submitter = Submitter.new if find_params.compact_blank.blank?
submitter ||=
Submitter
.where(submission: template.submissions.non_expired.active)
.order(id: :desc)
.where(declined_at: nil)
.where(external_id: nil)
.where(template.preferences['shared_link_2fa'] == true ? {} : { ip: [nil, request.remote_ip] })
.then { |rel| params[:resubmit].present? || params[:selfsign].present? ? rel.where(completed_at: nil) : rel }
.find_or_initialize_by(find_params)
submitter = Submitter.new(find_params) if submitter.submission&.completed_at? && submitter.viewer?
submitter.name = required_params['name'] if submitter.new_record?
unless @resubmit_submitter
required_params.each do |key, value|
submitter.errors.add(key.to_sym, :blank) if value.blank?
end
end
submitter
end
def assign_submission_attributes(submitter, template)
submitter.assign_attributes(
uuid: (filter_undefined_submitters(template).first || @template.submitters.first)['uuid'],
ip: request.remote_ip,
ua: request.user_agent,
values: @resubmit_submitter&.preferences&.fetch('default_values', nil) || {},
preferences: @resubmit_submitter&.preferences.presence || { 'send_email' => true },
metadata: @resubmit_submitter&.metadata.presence || {}
)
submitter.assign_attributes(@resubmit_submitter.slice(:name, :email, :phone)) if @resubmit_submitter
if submitter.values.present?
@resubmit_submitter.attachments.each do |attachment|
submitter.attachments << attachment.dup if submitter.values.value?(attachment.uuid)
end
end
submitter.submission ||= Submission.new(template:,
account_id: template.account_id,
template_submitters: template.submitters,
expire_at: Templates.build_default_expire_at(template),
submitters: [submitter],
source: :link)
Submissions::CreateFromSubmitters.maybe_set_dynamic_documents(submitter.submission)
submitter.account_id = submitter.submission.account_id
submitter
end
def filter_undefined_submitters(template)
Templates.filter_undefined_submitters(template.submitters)
end
def submitter_params
return { 'email' => current_user.email, 'name' => current_user.full_name } if params[:selfsign]
return @resubmit_submitter.slice(:name, :phone, :email) if @resubmit_submitter.present?
params.require(:submitter).permit(:email, :phone, :name).tap do |attrs|
attrs[:email] = Submissions.normalize_email(attrs[:email])
end
end
def load_template
@template =
if @resubmit_submitter
@resubmit_submitter.template
else
Template.find_by!(slug: params[:slug] || params[:start_form_slug])
end
@template = Template.find_by!(slug: params[:slug] || params[:start_form_slug])
end
def multiple_submitters_error_message
@@ -224,38 +146,27 @@ class StartFormController < ApplicationController
end
end
def handle_require_2fa(submitter, is_new_record:)
def handle_require_2fa(template, submitter)
return render :show, status: :unprocessable_content if submitter.errors.present?
is_otp_verified = Submitters.verify_link_otp!(params[:one_time_code], submitter)
if cookies.encrypted[:email_2fa_slug] == submitter.slug || is_otp_verified
if submitter.save
enqueue_new_submitter_jobs(submitter) if is_new_record
if is_otp_verified
SubmissionEvents.create_with_tracking_data(submitter, 'email_verified', request)
cookies.encrypted[:email_2fa_slug] =
{ value: submitter.slug, expires: COOKIES_TTL.from_now, **COOKIES_DEFAULTS }
end
redirect_to submit_form_path(submitter.slug)
else
render :show, status: :unprocessable_content
end
if Submitters::StartForm.verify_2fa_and_save_submitter(submitter, request, is_new_record: submitter.new_record?)
redirect_to submit_form_path(submitter.slug)
else
if defined?(Rollbar) && template.preferences['shared_link_2fa'] != true
Rollbar.info("2FA link requested: #{submitter.id}")
end
Submitters.send_shared_link_email_verification_code(submitter, request:)
render :email_verification
end
rescue Submitters::StartForm::NotSaved
render :show, status: :unprocessable_content
rescue Submitters::UnableToSendCode, Submitters::InvalidOtp => e
redirect_to start_form_path(submitter.submission.template.slug,
params: submitter_params.merge(email_verification: true)),
redirect_to start_form_path(template.slug, params: submitter_params.merge(email_verification: true)),
alert: e.message
rescue RateLimit::LimitApproached
redirect_to start_form_path(submitter.submission.template.slug,
params: submitter_params.merge(email_verification: true)),
redirect_to start_form_path(template.slug, params: submitter_params.merge(email_verification: true)),
alert: I18n.t(:too_many_attempts)
end
end
@@ -13,6 +13,10 @@ class StartFormEmail2faSendController < ApplicationController
@submitter = @template.submissions.new(account_id: @template.account_id)
.submitters.new(**submitter_params, account_id: @template.account_id)
if @submitter.email.blank?
return redirect_to start_form_path(@template.slug), alert: I18n.t(:provide_your_email_to_start)
end
Submitters.send_shared_link_email_verification_code(@submitter, request:)
redir_params = { notice: I18n.t(:code_has_been_resent) } if params[:resend]
@@ -36,14 +40,24 @@ class StartFormEmail2faSendController < ApplicationController
return redirect_to start_form_path(@template.slug) if is_archived
return if (@template.shared_link? || (current_user && current_ability.can?(:read, @template))) &&
@template.preferences['shared_link_2fa'] == true
(@template.preferences['shared_link_2fa'] == true || submitter_exists?(@template, params))
Rollbar.warning("Not shared template: #{@template.id}") if defined?(Rollbar)
Rollbar.warning("Not 2FA shared template: #{@template.id}") if defined?(Rollbar)
redirect_to start_form_path(@template.slug)
end
def submitter_exists?(template, params)
email = Submissions.normalize_email(params.dig(:submitter, :email))
return false if email.blank?
Submitters::StartForm.build_submitters_scope(template, exclude_completed: true).exists?(email:)
end
def submitter_params
params.require(:submitter).permit(:name, :email, :phone)
params.require(:submitter).permit(:name, :email, :phone).tap do |attrs|
attrs[:email] = Submissions.normalize_email(attrs[:email])
end
end
end
@@ -0,0 +1,99 @@
# frozen_string_literal: true
class StartFormResubmitController < ApplicationController
layout 'form'
skip_before_action :authenticate_user!
skip_authorization_check
around_action :with_browser_locale
before_action :load_resubmit_submitter
before_action :load_template
before_action :authorize_start!
def update
@submitter = find_or_initialize_submitter(@template, @resubmit_submitter)
if Templates.filter_undefined_submitters(@template.submitters).size > 1 && @submitter.new_record?
@error_message = multiple_submitters_error_message
return render 'start_form/show', status: :unprocessable_content
end
if (is_new_record = @submitter.new_record?)
Submitters::StartForm.assign_submission_attributes(
@submitter, @template,
ip: request.remote_ip, user_agent: request.user_agent, resubmit_submitter: @resubmit_submitter,
source: @resubmit_submitter.submission.source_self? ? :self : :link
)
Submissions::AssignDefinedSubmitters.call(@submitter.submission)
else
@submitter.assign_attributes(ip: request.remote_ip, ua: request.user_agent)
end
if @template.preferences['shared_link_2fa'] == true
handle_require_2fa(@submitter)
elsif @submitter.save
Submitters::StartForm.enqueue_new_submitter_jobs(@submitter) if is_new_record
Submitters::StartForm.assign_start_form_cookie(@submitter, request)
redirect_to submit_form_path(@submitter.slug)
else
render 'start_form/show', status: :unprocessable_content
end
end
private
def find_or_initialize_submitter(template, resubmit_submitter)
Submitters::StartForm.build_submitters_scope(template, exclude_completed: true)
.find_by(slug: cookies.encrypted[:start_form_slug],
email: resubmit_submitter.email) || Submitter.new
end
def load_resubmit_submitter
@resubmit_submitter = Submitter.find_by(slug: params[:resubmit])
raise ActiveRecord::RecordNotFound if @resubmit_submitter.blank? ||
!Submitters::StartForm.can_resubmit?(@resubmit_submitter)
end
def load_template
@template = @resubmit_submitter.template
end
def authorize_start!
redirect_to submit_form_path(@resubmit_submitter.slug) if @template.archived_at? || @template.account.archived_at?
end
def multiple_submitters_error_message
if current_user&.account_id == @template.account_id
helpers.t('this_submission_has_multiple_signers_which_prevents_the_use_of_a_sharing_link_html')
else
I18n.t('not_found')
end
end
def handle_require_2fa(submitter)
if Submitters::StartForm.verify_2fa_and_save_submitter(submitter, request,
is_new_record: submitter.new_record?)
redirect_to submit_form_path(submitter.slug)
else
Submitters.send_shared_link_email_verification_code(submitter, request:)
render 'start_form/email_verification'
end
rescue Submitters::StartForm::NotSaved
render 'start_form/show', status: :unprocessable_content
rescue Submitters::UnableToSendCode, Submitters::InvalidOtp => e
flash.now[:alert] = e.message
render 'start_form/email_verification'
rescue RateLimit::LimitApproached
flash.now[:alert] = I18n.t(:too_many_attempts)
render 'start_form/email_verification'
end
end
@@ -0,0 +1,50 @@
# frozen_string_literal: true
class StartFormSelfController < ApplicationController
load_resource :template, parent: false
around_action :with_browser_locale
before_action :authorize_start!
def update
@submitter = Submitters::StartForm.build_submitters_scope(@template, exclude_completed: true, source: :self)
.find_or_initialize_by(email: current_user.email)
@submitter = Submitter.new(email: current_user.email) if @submitter.persisted? &&
outdated_template_fields?(@submitter, @template)
if (is_new_record = @submitter.new_record?)
@submitter.name = current_user.full_name
Submitters::StartForm.assign_submission_attributes(
@submitter, @template, ip: request.remote_ip, user_agent: request.user_agent, source: :self
)
Submissions::AssignDefinedSubmitters.call(@submitter.submission)
else
@submitter.assign_attributes(ip: request.remote_ip, ua: request.user_agent)
end
@submitter.save!
Submitters::StartForm.enqueue_new_submitter_jobs(@submitter) if is_new_record
Submitters::StartForm.assign_start_form_cookie(@submitter, request)
redirect_to submit_form_path(@submitter.slug)
end
private
def outdated_template_fields?(submitter, template)
template_fields = submitter.submission.template_fields
template_fields.present? && template_fields != template.fields
end
def authorize_start!
authorize!(:read, @template)
redirect_to template_path(@template) if @template.archived_at?
end
end
@@ -48,7 +48,7 @@ class SubmitFormCompletedDownloadController < ApplicationController
private
def submitter_slug
params[:submit_form_slug] || params[:submitter_slug] || params[:submitter_id]
params[:submit_form_slug] || params[:submitter_slug]
end
def respond_with_combined(submitter)
@@ -107,6 +107,8 @@ class SubmitFormController < ApplicationController
submitter_version = SubmitterVersion.find_by!(slug: params[:slug] || params[:submit_form_slug])
@submitter = submitter_version.submitter
maybe_render_locked_page
end
private
@@ -19,7 +19,7 @@ class SubmitFormDeclineController < ApplicationController
request)
ApplicationRecord.transaction do
@submitter.update!(declined_at: Time.current)
@submitter.update!(declined_at: Time.current, values: @submitter.preferences['default_values'] || {})
SubmissionEvents.create_with_tracking_data(@submitter, 'decline_form', request, { reason: params[:reason] })
end
@@ -8,9 +8,6 @@ class SubmitFormEmail2fasController < ApplicationController
before_action :load_submitter
COOKIES_TTL = 12.hours
COOKIES_DEFAULTS = { httponly: true, secure: Rails.env.production? }.freeze
def create
RateLimit.call("verify-2fa-code-#{@submitter.id}", limit: 2, ttl: 45.seconds, enabled: true)
@@ -20,7 +17,8 @@ class SubmitFormEmail2fasController < ApplicationController
SubmissionEvents.create_with_tracking_data(@submitter, 'email_verified', request, { email: @submitter.email })
cookies.encrypted[:email_2fa_slug] =
{ value: @submitter.slug, expires: COOKIES_TTL.from_now, **COOKIES_DEFAULTS }
{ value: @submitter.slug, expires: Submitters::StartForm::COOKIES_TTL.from_now,
**Submitters::StartForm::COOKIES_DEFAULTS }
redirect_to submit_form_path(@submitter.slug)
else
@@ -6,6 +6,7 @@ class TestingAccountsController < ApplicationController
def create
authorize!(:manage, current_account)
authorize!(:manage, current_user)
authorize!(:manage, EncryptedConfig)
impersonate_user(Accounts.find_or_create_testing_user(true_user.account))
+1 -1
View File
@@ -230,7 +230,7 @@ export default {
download () {
this.isDownloading = true
fetch(this.baseUrl + `/submitters/${this.submitterSlug}/download`, {
fetch(this.baseUrl + `/s/${this.submitterSlug}/documents`, {
method: 'GET',
...this.fetchOptions
}).then(async (response) => {
+4 -1
View File
@@ -560,6 +560,7 @@
:fields="formulaFields"
:values="values"
:readonly-values="readonlyFieldValues"
:fetch-options="fetchOptions"
:provider="paymentProvider"
@attached="attachments.push($event)"
@focus="scrollIntoField(currentField)"
@@ -573,6 +574,7 @@
:empty-value-required-step="emptyValueRequiredStep"
:field="currentField"
:submitter-slug="submitterSlug"
:fetch-options="fetchOptions"
:values="values"
@submit="!isSubmitting && submitStep()"
/>
@@ -585,6 +587,7 @@
:empty-value-required-step="emptyValueRequiredStep"
:field="currentField"
:submitter-slug="submitterSlug"
:fetch-options="fetchOptions"
:values="values"
@focus="scrollIntoField(currentField)"
@submit="!isSubmitting && submitStep()"
@@ -1776,7 +1779,7 @@ export default {
window.location.href = sanitizeUrl(this.completedRedirectUrl)
} else {
this.$nextTick(() => {
const root = this.$root.$el.parentNode.getRootNode()
const root = this.$root.$el.parentNode?.getRootNode() || document
const completedEl = root.getElementById('form_completed')
if (completedEl) {
+10 -3
View File
@@ -312,6 +312,11 @@ export default {
values: {
type: Object,
required: true
},
fetchOptions: {
type: Object,
required: false,
default: () => ({})
}
},
emits: ['submit'],
@@ -494,8 +499,9 @@ export default {
const resp = await fetch(this.baseUrl + '/api/kba', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
body: JSON.stringify(payload),
...this.fetchOptions,
headers: { 'Content-Type': 'application/json', ...this.fetchOptions.headers }
})
const data = await resp.json()
@@ -550,7 +556,8 @@ export default {
reference: this.reference,
submitter_slug: this.submitterSlug
}),
headers: { 'Content-Type': 'application/json' }
...this.fetchOptions,
headers: { 'Content-Type': 'application/json', ...this.fetchOptions.headers }
})
const data = await resp.json()
@@ -121,6 +121,11 @@ export default {
type: String,
required: false,
default: ''
},
fetchOptions: {
type: Object,
required: false,
default: () => ({})
}
},
emits: ['focus', 'submit', 'update:model-value', 'attached'],
@@ -234,7 +239,8 @@ export default {
body: JSON.stringify({
submitter_slug: this.submitterSlug
}),
headers: { 'Content-Type': 'application/json' }
...this.fetchOptions,
headers: { 'Content-Type': 'application/json', ...this.fetchOptions.headers }
}).then(async (resp) => {
if (resp.status === 422 || resp.status === 500) {
this.isProcessing = false
@@ -274,7 +280,8 @@ export default {
check_status: checkStatus,
referer: this.normalizedPaymentUrl()
}),
headers: { 'Content-Type': 'application/json' }
...this.fetchOptions,
headers: { 'Content-Type': 'application/json', ...this.fetchOptions.headers }
}).then(async (resp) => {
if (resp.status === 422 || resp.status === 500) {
const data = await resp.json()
@@ -91,6 +91,11 @@ export default {
submitterSlug: {
type: String,
required: true
},
fetchOptions: {
type: Object,
required: false,
default: () => ({})
}
},
emits: ['focus', 'submit', 'update:model-value', 'attached'],
@@ -163,7 +168,8 @@ export default {
submitter_slug: this.submitterSlug,
redirect_url: document.location.href
}),
headers: { 'Content-Type': 'application/json' }
...this.fetchOptions,
headers: { 'Content-Type': 'application/json', ...this.fetchOptions.headers }
}).then(async (resp) => {
this.eidEasyData = await resp.json()
@@ -205,7 +211,8 @@ export default {
body: JSON.stringify({
submitter_slug: this.submitterSlug
}),
headers: { 'Content-Type': 'application/json' }
...this.fetchOptions,
headers: { 'Content-Type': 'application/json', ...this.fetchOptions.headers }
})
if (resp.status === 404) {
+1 -7
View File
@@ -110,7 +110,7 @@
data-turbo="false"
class="inline"
method="post"
:action="`/d/${template.slug}`"
:action="`/start_form_self/${template.id}`"
@submit="maybeShowErrorTemplateAlert"
>
<input
@@ -125,12 +125,6 @@
:value="authenticityToken"
autocomplete="off"
>
<input
type="hidden"
name="selfsign"
value="true"
autocomplete="off"
>
<button
class="btn btn-primary btn-ghost text-base hidden md:flex"
type="submit"
@@ -366,6 +366,9 @@ export default {
}
}
},
beforeUnmount () {
document.getElementById('list_form_buttons')?.classList?.add('hidden')
},
methods: {
t (key) {
return this.i18n[key] || key
-17
View File
@@ -1,17 +0,0 @@
# frozen_string_literal: true
class GeneratePreviewImagesJob
include Sidekiq::Job
sidekiq_options queue: :images
def perform(params = {})
attachment = ActiveStorage::Attachment.find(params['attachment_id'])
max_page = [attachment.metadata['pdf']['number_of_pages'].to_i - 1,
Templates::ProcessDocument::MAX_NUMBER_OF_PAGES_PROCESSED].min
Templates::ProcessDocument.generate_document_preview_images(attachment, attachment.download, 1..max_page,
concurrency: 1)
end
end
+2 -1
View File
@@ -112,7 +112,8 @@ class Submission < ApplicationRecord
api: 'api',
embed: 'embed',
mcp: 'mcp',
link: 'link'
link: 'link',
self: 'self'
}, scope: false, prefix: true
enum :submitters_order, {
@@ -21,13 +21,15 @@
<% end %>
</div>
<%= render 'bcc_form', config: @bcc_config %>
<div class="flex justify-between items-end mb-4 mt-8">
<h2 class="text-2xl md:text-3xl font-bold">
<%= t('sign_request_email_reminders') %>
</h2>
</div>
<%= render 'reminder_banner' %>
<%= render 'reminder_form', config: @reminder_config %>
<% unless can?(:manage, :personalization_restricted) %>
<div class="flex justify-between items-end mb-4 mt-8">
<h2 class="text-2xl md:text-3xl font-bold">
<%= t('sign_request_email_reminders') %>
</h2>
</div>
<%= render 'reminder_banner' %>
<%= render 'reminder_form', config: @reminder_config %>
<% end %>
</div>
<div class="w-0 md:w-52"></div>
</div>
@@ -10,7 +10,12 @@
<%= f.hidden_field :key %>
<%= f.fields_for :value, Struct.new(:subject, :body, :reply_to, :attach_audit_log, :attach_documents, :bcc_recipients, :enabled).new(*f.object.value.values_at('subject', 'body', 'reply_to', 'attach_audit_log', 'attach_documents', 'bcc_recipients', 'enabled')) do |ff| %>
<div class="form-control">
<%= ff.label :subject, t('subject'), class: 'label' %>
<div class="flex items-center">
<%= ff.label :subject, t('subject'), class: 'label' %>
<span class="tooltip tooltip-right" data-tip="<%= t('completed_documents_copy_email_sent_to_recipients_upon_completion_containing_their_documents') %>">
<%= svg_icon('info_circle', class: 'w-4 h-4') %>
</span>
</div>
<%= ff.text_field :subject, required: true, class: 'base-input', dir: 'auto' %>
</div>
<%= render 'personalization_settings/email_body_field', ff:, config: f.object %>
@@ -10,7 +10,12 @@
<%= f.hidden_field :key %>
<%= f.fields_for :value, Struct.new(:subject, :body, :reply_to).new(*f.object.value.values_at('subject', 'body', 'reply_to')) do |ff| %>
<div class="form-control">
<%= ff.label :subject, t('subject'), class: 'label' %>
<div class="flex items-center">
<%= ff.label :subject, t('subject'), class: 'label' %>
<span class="tooltip tooltip-right" data-tip="<%= t('signature_request_email_sent_to_recipients_with_a_link_to_review_and_sign') %>">
<%= svg_icon('info_circle', class: 'w-4 h-4') %>
</span>
</div>
<%= ff.text_field :subject, required: true, class: 'base-input', dir: 'auto' %>
</div>
<%= render 'personalization_settings/email_body_field', ff:, config: f.object %>
@@ -12,7 +12,7 @@
<div class="form-control">
<div class="flex items-center">
<%= ff.label :subject, t('subject'), class: 'label' %>
<span class="tooltip" data-tip="<%= t('use_following_placeholders_text_') %> <%= AccountConfig::EMAIL_VARIABLES[AccountConfig::SUBMITTER_COMPLETED_EMAIL_KEY].map { |v| "{#{v}}" }.join(', ') %>">
<span class="tooltip tooltip-right" data-tip="<%= t('notification_email_sent_to_the_sender_user_and_bcc_addresses_once_the_submission_is_completed_by_all_parties') %> <%= t('use_following_placeholders_text_') %> <%= AccountConfig::EMAIL_VARIABLES[AccountConfig::SUBMITTER_COMPLETED_EMAIL_KEY].map { |v| "{#{v}}" }.join(', ') %>">
<%= svg_icon('info_circle', class: 'w-4 h-4') %>
</span>
</div>
+2 -2
View File
@@ -2,7 +2,7 @@
<div class="space-y-6 mx-auto">
<div class="space-y-6">
<div class="flex items-center justify-center">
<%= render 'banner' %>
<%= render 'start_form/banner' %>
</div>
<div class="flex items-center bg-base-200 rounded-xl p-4 mb-4">
<div class="flex items-center">
@@ -26,7 +26,7 @@
<%= button_to button_title(title: t('send_copy_to_email'), disabled_with: t('sending'), icon: svg_icon('mail_forward', class: 'w-6 h-6')), send_submission_email_index_path, params: { template_slug: @template.slug, email: params[:email] }, class: 'base-button w-full' %>
</toggle-submit>
<% end %>
<% if Templates.filter_undefined_submitters(@template.submitters).size == 1 && %w[api embed mcp].exclude?(@submitter.submission.source) && @submitter.account.account_configs.find_or_initialize_by(key: AccountConfig::ALLOW_TO_RESUBMIT).value != false && @template.shared_link? %>
<% if @template.shared_link? && Templates.filter_undefined_submitters(@template.submitters).size == 1 && %w[api embed mcp].exclude?(@submitter.submission.source) && @submitter.account.account_configs.find_or_initialize_by(key: AccountConfig::ALLOW_TO_RESUBMIT).value != false %>
<toggle-submit class="block">
<%= button_to button_title(title: t('resubmit'), disabled_with: t('resubmit'), icon: svg_icon('reload', class: 'w-6 h-6')), start_form_path(@template.slug), params: { submitter: params.permit(:name, :email, :phone).compact_blank, resubmit: true }, method: :put, class: 'white-button w-full' %>
</toggle-submit>
@@ -23,12 +23,12 @@
<div>
<%= t('we_sent_a_one_time_verification_code_to_your_email_address_please_enter_the_code_below_to_continue') %>
</div>
<%= form_for '', url: start_form_path(@template.slug), method: :put, html: { class: 'space-y-4', id: 'code_form' } do |f| %>
<%= form_for '', url: @resubmit_submitter ? resubmit_form_path : start_form_path(@template.slug), method: :put, html: { class: 'space-y-4', id: 'code_form' } do |f| %>
<div dir="auto" class="form-control !mt-0">
<%= f.hidden_field 'resubmit', value: params[:resubmit] %>
<%= f.hidden_field 'submitter[name]', value: params[:name] || @submitter&.name %>
<%= f.hidden_field 'submitter[email]', value: params[:email] || @submitter&.email %>
<%= f.hidden_field 'submitter[phone]', value: params[:phone] || @submitter&.phone %>
<%= f.hidden_field 'submitter[name]', value: params[:name] || params.dig(:submitter, :name) %>
<%= f.hidden_field 'submitter[email]', value: params[:email] || params.dig(:submitter, :email) %>
<%= f.hidden_field 'submitter[phone]', value: params[:phone] || params.dig(:submitter, :phone) %>
<%= f.text_field :one_time_code, required: true, inputmode: 'numeric', class: 'base-input text-center', placeholder: 'XXXXXX' %>
<div class="flex justify-between items-center mt-1">
<span>
@@ -49,6 +49,10 @@
<%= f.button button_title(title: t('submit')), class: 'base-button' %>
</toggle-submit>
<% end %>
<%= button_to t(:re_send_email), start_form_email_2fa_send_index_path, params: { slug: @template.slug, resend: true, submitter: { name: params[:name] || @submitter&.name, email: params[:email] || @submitter&.email, phone: params[:phone] || @submitter&.phone } }.compact, method: :post, id: 'resend_code', class: 'hidden' %>
<% if @resubmit_submitter %>
<%= button_to t(:re_send_email), resubmit_form_path, params: { resubmit: @resubmit_submitter.slug }, method: :put, id: 'resend_code', class: 'hidden' %>
<% else %>
<%= button_to t(:re_send_email), start_form_email_2fa_send_index_path, params: { slug: @template.slug, resend: true, submitter: { name: params[:name] || params.dig(:submitter, :name), email: params[:email] || params.dig(:submitter, :email), phone: params[:phone] || params.dig(:submitter, :phone) } }.compact, method: :post, id: 'resend_code', class: 'hidden' %>
<% end %>
</div>
</main>
+1 -1
View File
@@ -4,7 +4,7 @@
<% end %>
<main class="max-w-md space-y-6 mx-auto px-2 mt-12 mb-4">
<div class="text-center w-full space-y-6">
<%= render 'banner' %>
<%= render 'start_form/banner' %>
<p class="text-xl font-semibold text-center">
<%= t('share_link_is_currently_disabled') %>
</p>
+3 -2
View File
@@ -6,7 +6,7 @@
<div class="space-y-6 mx-auto">
<div class="space-y-6">
<div class="text-center w-full space-y-6">
<%= render 'banner' %>
<%= render 'start_form/banner' %>
<% unless @template.archived_at? %>
<p class="text-xl font-semibold text-center"><%= t('you_have_been_invited_to_submit_a_form') %></p>
<% end %>
@@ -28,7 +28,8 @@
</div>
</div>
<% if !@template.archived_at? && !@template.account.archived_at? %>
<%= form_for @submitter, url: start_form_path(@template.slug), data: { turbo_frame: :_top }, method: :put, html: { class: 'space-y-4' } do |f| %>
<%= form_for @submitter, url: @resubmit_submitter ? resubmit_form_path : start_form_path(@template.slug), data: { turbo_frame: :_top }, method: :put, html: { class: 'space-y-4' } do |f| %>
<%= hidden_field_tag :resubmit, @resubmit_submitter.slug if @resubmit_submitter %>
<% if @error_message %>
<div class="alert items-start bg-base-100 border border-red-400 text-red-400">
<%= svg_icon('info_circle', class: 'stroke-current shrink-0 h-6 w-6 mt-1') %>
@@ -17,7 +17,7 @@
</div>
</div>
<div class="flex items-center md:items-end gap-2">
<%= render 'submissions_filters/applied_filters', filter_params:, with_status: true %>
<%= render 'submissions_filters/applied_filters', filter_params:, with_status: true, with_default_status: true %>
<%= render 'submissions_filters/filter_button', filter_params: %>
</div>
</div>
@@ -3,15 +3,16 @@
<% ordered_filters = request.query_parameters.keys.filter_map { |key| filter_names.find { |name| key.start_with?(name) } }.uniq %>
<% chip_order = ordered_filters.reverse.each_with_index.to_h { |name, index| [name, index + 1] } %>
<% chip_order.default = ordered_filters.size + 1 %>
<% status_icon = { 'declined' => 'x_circle', 'expired' => 'clock_cancel', 'partially_completed' => 'clock_edit', 'sent' => 'send', 'opened' => 'mail_opened' }[params[:status]] %>
<% status_icons = { 'all' => 'list', 'pending' => 'clock', 'completed' => 'circle_check' } %>
<% current_status = status_icons.key?(params[:status].to_s) ? params[:status].to_s : 'all' %>
<% status_icon = { 'declined' => 'x_circle', 'expired' => 'clock_cancel', 'partially_completed' => 'clock_edit', 'sent' => 'send', 'opened' => 'mail_opened' }[params[:status]] %>
<% default_status_icon = status_icons[params[:status]] if status_icon.blank? && local_assigns[:with_default_status] && current_status != 'all' %>
<% with_status_button = local_assigns[:with_status] && status_icon.blank? %>
<% chips_html = capture do %>
<% if status_icon %>
<div class="order-none flex h-10 px-2 py-1 text-base md:text-lg items-center justify-between border text-center text-neutral font-semibold rounded-xl shrink-0 max-w-[60vw] md:w-36 border-neutral-700">
<% status_chip_html = capture do %>
<% if status_icon || default_status_icon %>
<div class="order-none flex h-10 px-2 py-1 text-base md:text-lg items-center justify-between border text-center text-neutral font-semibold rounded-xl shrink-0 max-w-[60vw] md:w-36 border-neutral-700 <%= 'max-md:hidden' if default_status_icon %>">
<%= link_to submissions_filter_path('status', query_params.merge(path: url_for, with_remove: true)), data: { turbo_frame: 'modal' }, class: 'flex items-center space-x-1 flex-1 min-w-0 pr-1' do %>
<%= svg_icon(status_icon, class: 'w-5 h-5 shrink-0') %>
<%= svg_icon(status_icon || default_status_icon, class: 'w-5 h-5 shrink-0') %>
<span class="font-normal truncate"><%= t(params[:status]) %></span>
<% end %>
<%= link_to url_for(params: request.query_parameters.except('status')), class: 'rounded-lg ml-1 shrink-0 hover:bg-base-content hover:text-white' do %>
@@ -19,6 +20,8 @@
<% end %>
</div>
<% end %>
<% end %>
<% chips_html = capture do %>
<% if params[:folder].present? %>
<div class="order-<%= chip_order['folder'] %> md:order-none md:tooltip tooltip-no-touch tooltip-bottom flex md:flex h-10 px-2 py-1 text-base md:text-lg items-center justify-between border text-center text-neutral font-semibold rounded-xl shrink-0 max-w-[60vw] md:w-36 border-neutral-700" data-tip="<%= t('folder') %>">
<%= link_to submissions_filter_path('folder', query_params.merge(path: url_for, with_remove: true)), data: { turbo_frame: 'modal' }, class: 'flex items-center space-x-1 flex-1 min-w-0 pr-1' do %>
@@ -78,7 +81,8 @@
</div>
<% end %>
<% end %>
<% if with_status_button && chips_html.blank? %>
<% with_status_dropdown = with_status_button && chips_html.blank? %>
<% if with_status_dropdown %>
<div class="dropdown md:hidden shrink-0">
<label tabindex="0" class="cursor-pointer flex h-10 px-2 py-1 space-x-1 text-base items-center border text-neutral rounded-xl <%= current_status == 'all' ? 'border-neutral-300' : 'border-neutral-700' %>">
<%= svg_icon(status_icons[current_status], class: 'w-5 h-5 shrink-0') %>
@@ -122,15 +126,16 @@
</ul>
</div>
<% end %>
<% if chips_html.present? %>
<scroll-fade class="flex items-center gap-2 overflow-x-auto flex-1 min-w-0 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden [&::-webkit-scrollbar]:h-0 [&::-webkit-scrollbar]:w-0 md:contents">
<% if with_status_button %>
<% if chips_html.present? || status_chip_html.present? %>
<scroll-fade class="flex items-center gap-2 overflow-x-auto flex-1 min-w-0 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden [&::-webkit-scrollbar]:h-0 [&::-webkit-scrollbar]:w-0 md:contents <%= 'max-md:hidden' if with_status_dropdown %>">
<% if with_status_button && !with_status_dropdown %>
<a href="<%= submissions_filter_path('status', query_params.merge(path: url_for)) %>" data-turbo-frame="modal" class="order-none md:hidden flex h-10 px-2 py-1 space-x-1 text-base items-center border text-neutral rounded-xl shrink-0 <%= current_status == 'all' ? 'border-neutral-300' : 'border-neutral-700' %>">
<%= svg_icon(status_icons[current_status], class: 'w-5 h-5 shrink-0') %>
<span><%= t(current_status) %></span>
<%= svg_icon('chevron_down', class: 'w-4 h-4 shrink-0') %>
</a>
<% end %>
<%= status_chip_html %>
<%= chips_html %>
</scroll-fade>
<% end %>
+1 -1
View File
@@ -44,7 +44,7 @@
<% end %>
</div>
<% undefined_submitters = Templates.filter_undefined_submitters(@submitter.submission.template_submitters) %>
<% if undefined_submitters.size == 1 && undefined_submitters.first['uuid'] == @submitter.uuid && @submitter.completed_at? && @submitter.completed_at > 14.days.ago && %w[api embed mcp].exclude?(@submitter.submission.source) && @submitter.account.account_configs.find_or_initialize_by(key: AccountConfig::ALLOW_TO_RESUBMIT).value != false && @submitter.template && !@submitter.template.archived_at? %>
<% if undefined_submitters.size == 1 && undefined_submitters.first['uuid'] == @submitter.uuid && Submitters::StartForm.can_resubmit?(@submitter) && @submitter.template && !@submitter.template.archived_at? %>
<div class="divider uppercase"><%= t('or') %></div>
<toggle-submit class="block">
<%= button_to button_title(title: t('resubmit'), disabled_with: t('resubmit'), icon: svg_icon('reload', class: 'w-6 h-6')), resubmit_form_path, params: { resubmit: @submitter.slug }, method: :put, class: 'white-button w-full' %>
+6 -5
View File
@@ -64,8 +64,12 @@
<% end %>
</div>
<div class="flex flex-wrap gap-2 w-full md:w-fit md:justify-between md:flex-none md:pt-1">
<% if !template.archived_at? && can?(:destroy, template) %>
<%= button_to button_title(title: t('archive'), disabled_with: t('archiving')[..-4], title_class: 'inline', icon: svg_icon('archive', class: 'w-6 h-6')), template_path(template), class: 'btn btn-outline btn-sm w-full', form_class: 'flex-1', method: :delete %>
<% if can?(:destroy, template) %>
<% if template.archived_at? %>
<%= button_to button_title(title: t('restore'), disabled_with: t('restoring')[..-4], icon: svg_icon('rotate', class: 'w-6 h-6')), template_restore_index_path(template), class: 'btn btn-outline btn-sm w-full', form_class: 'flex-1' %>
<% else %>
<%= button_to button_title(title: t('archive'), disabled_with: t('archiving')[..-4], title_class: 'inline', icon: svg_icon('archive', class: 'w-6 h-6')), template_path(template), class: 'btn btn-outline btn-sm w-full', form_class: 'flex-1', method: :delete %>
<% end %>
<% end %>
<% if can?(:create, current_account.templates.new(author: current_user)) %>
<div class="flex-1">
@@ -99,9 +103,6 @@
<% end %>
<% end %>
<% if template.archived_at? %>
<% if can?(:destroy, template) %>
<%= button_to button_title(title: t('restore'), disabled_with: t('restoring')[..-4], icon: svg_icon('rotate', class: 'w-6 h-6')), template_restore_index_path(template), class: 'btn btn-outline btn-sm w-full', form_class: 'flex-1' %>
<% end %>
<div class="flex-1">
<%= link_to template_preview_path(template), class: 'btn btn-outline btn-sm w-full' do %>
<span class="flex items-center justify-center space-x-2">
+1 -1
View File
@@ -99,7 +99,7 @@
</open-modal-mobile>
<% end %>
<% if Templates.filter_undefined_submitters(@template.submitters).size == 1 && @template.variables_schema.blank? %>
<%= button_to start_form_path(@template.slug), params: { selfsign: true }, method: :put, class: 'white-button w-full', form: { style: 'display: inline', target: '_blank', data: { turbo: false } } do %>
<%= button_to start_form_self_path(@template), method: :put, class: 'white-button w-full', form: { style: 'display: inline', target: '_blank', data: { turbo: false } } do %>
<%= svg_icon('writing', class: 'w-6 h-6') %>
<span class="mr-1"><%= t('sign_it_yourself') %></span>
<% end %>
@@ -19,7 +19,7 @@
</div>
</div>
<div class="flex flex-nowrap md:flex-wrap md:justify-end items-center gap-2">
<%= render 'submissions_filters/applied_filters', filter_params:, with_status: true %>
<%= render 'submissions_filters/applied_filters', filter_params:, with_status: true, with_default_status: true %>
<div class="hidden md:block shrink-0 md:order-first">
<%= link_to new_template_submissions_export_path(@template, archived: true), class: 'btn btn-ghost text-base h-10 min-h-10', data: { turbo_frame: 'modal' } do %>
<%= svg_icon('download', class: 'w-6 h-6 stroke-2') %>
@@ -10,7 +10,12 @@
<%= f.fields_for :preferences, Struct.new(:completed_notification_email_subject, :completed_notification_email_body).new(@template.preferences['completed_notification_email_subject'].presence || configs['subject'], @template.preferences['completed_notification_email_body'].presence || configs['body']) do |ff| %>
<div class="form-control">
<div class="flex justify-between">
<%= ff.label :completed_notification_email_subject, t('email_subject'), class: 'label' %>
<div class="flex items-center">
<%= ff.label :completed_notification_email_subject, t('email_subject'), class: 'label' %>
<span class="tooltip tooltip-right" data-tip="<%= t('notification_email_sent_to_the_sender_user_and_bcc_addresses_once_the_submission_is_completed_by_all_parties') %>">
<%= svg_icon('info_circle', class: 'w-4 h-4') %>
</span>
</div>
<% if is_custom_template_email %>
<label for="submitter_completed_email_reset_link" class="label underline">
<%= t('reset_default') %>
@@ -10,7 +10,12 @@
<%= f.fields_for :preferences, Struct.new(:documents_copy_email_reply_to, :documents_copy_email_subject, :documents_copy_email_body).new(@template.preferences['documents_copy_email_reply_to'].presence || configs['reply_to'], @template.preferences['documents_copy_email_subject'].presence || configs['subject'], @template.preferences['documents_copy_email_body'].presence || configs['body']) do |ff| %>
<div class="form-control">
<div class="flex justify-between">
<%= ff.label :documents_copy_email_subject, t('email_subject'), class: 'label' %>
<div class="flex items-center">
<%= ff.label :documents_copy_email_subject, t('email_subject'), class: 'label' %>
<span class="tooltip tooltip-right" data-tip="<%= t('completed_documents_copy_email_sent_to_recipients_upon_completion_containing_their_documents') %>">
<%= svg_icon('info_circle', class: 'w-4 h-4') %>
</span>
</div>
<% if is_custom_template_email %>
<label for="submitter_documents_copy_email_reset_link" class="label underline">
<%= t('reset_default') %>
@@ -16,7 +16,12 @@
<%= f.fields_for :preferences, Struct.new(:request_email_subject, :request_email_body).new(*(template_email_preferences_values.presence || default_template_email_preferences_values)) do |ff| %>
<div class="form-control">
<div class="flex justify-between">
<%= ff.label :request_email_subject, t('email_subject'), class: 'label' %>
<div class="flex items-center">
<%= ff.label :request_email_subject, t('email_subject'), class: 'label' %>
<span class="tooltip tooltip-right" data-tip="<%= t('signature_request_email_sent_to_recipients_with_a_link_to_review_and_sign') %>">
<%= svg_icon('info_circle', class: 'w-4 h-4') %>
</span>
</div>
<% if is_custom_template_email %>
<label for="submitter_invitation_email_reset_link" class="label underline">
<%= t('reset_default') %>
@@ -10,7 +10,12 @@
<%= f.fields_for :preferences, Struct.new(:invitation_view_email_subject, :invitation_view_email_body).new(@template.preferences['invitation_view_email_subject'].presence || configs['subject'], @template.preferences['invitation_view_email_body'].presence || configs['body']) do |ff| %>
<div class="form-control">
<div class="flex justify-between">
<%= ff.label :invitation_view_email_subject, t('email_subject'), class: 'label' %>
<div class="flex items-center">
<%= ff.label :invitation_view_email_subject, t('email_subject'), class: 'label' %>
<span class="tooltip tooltip-right" data-tip="<%= t('view_only_email_sent_to_recipients_that_need_to_only_review_the_documents') %>">
<%= svg_icon('info_circle', class: 'w-4 h-4') %>
</span>
</div>
<% if is_custom_template_email %>
<label for="submitter_view_invitation_email_reset_link" class="label underline">
<%= t('reset_default') %>
+2
View File
@@ -112,6 +112,8 @@ module HexaPDF
end
module CycleSafeInheritedValue
using HexaPDF::Type::AcroForm::Field::HashRefinement
def inherited_value(field, name)
seen = Set.new.compare_by_identity
seen << field.value
+56
View File
@@ -992,6 +992,13 @@ en: &en
open_file_from: 'Open file from'
cancel: Cancel
open: Open
signature_request_email_sent_to_recipients_with_a_link_to_review_and_sign: Signature request email sent to recipients with a link to review and sign.
view_only_email_sent_to_recipients_that_need_to_only_review_the_documents: View only email sent to recipients that need to only review the documents.
reminds_recipients_who_havent_completed_their_part_yet_to_sign_following_the_reminders_schedule: Reminds recipients who haven't completed their part yet to sign, following the reminders schedule.
signature_request_sms_sent_to_recipients_with_a_link_to_review_and_sign: Signature request SMS sent to recipients with a link to review and sign.
phone_2fa_verification_text_message: Phone 2FA verification text message.
notification_email_sent_to_the_sender_user_and_bcc_addresses_once_the_submission_is_completed_by_all_parties: Notification email sent to the sender user and BCC addresses once the submission is completed by all parties.
completed_documents_copy_email_sent_to_recipients_upon_completion_containing_their_documents: Completed documents copy email sent to recipients upon completion containing their documents.
devise:
confirmations:
confirmed: Your email address has been successfully confirmed.
@@ -1007,6 +1014,7 @@ en: &en
invite: Invite
link: Link
mcp: MCP
self: Sign Yourself
submission_event_names:
send_email_to_html: '<b>Email sent</b> to %{submitter_name}'
bounce_email_html: '<b>Email bounced</b> %{submitter_name}'
@@ -1470,6 +1478,13 @@ es: &es
completed_form_message: Mensaje de formulario completado
completed_form_redirect_button: Botón de redirección de formulario completado
documents_copy_email: Correo de copia de documentos
signature_request_email_sent_to_recipients_with_a_link_to_review_and_sign: Correo de solicitud de firma enviado a los destinatarios con un enlace para revisar y firmar.
view_only_email_sent_to_recipients_that_need_to_only_review_the_documents: Correo de solo lectura enviado a los destinatarios que solo necesitan revisar los documentos.
reminds_recipients_who_havent_completed_their_part_yet_to_sign_following_the_reminders_schedule: Recuerda firmar a los destinatarios que aún no han completado su parte, según la programación de recordatorios.
signature_request_sms_sent_to_recipients_with_a_link_to_review_and_sign: SMS de solicitud de firma enviado a los destinatarios con un enlace para revisar y firmar.
phone_2fa_verification_text_message: Mensaje de texto de verificación 2FA por teléfono.
notification_email_sent_to_the_sender_user_and_bcc_addresses_once_the_submission_is_completed_by_all_parties: Correo de notificación enviado al usuario remitente y a las direcciones BCC una vez que todas las partes han completado el envío.
completed_documents_copy_email_sent_to_recipients_upon_completion_containing_their_documents: Correo con la copia de los documentos completados enviado a los destinatarios al finalizar, con sus documentos.
form_custom_css: CSS personalizado del formulario
subject: Asunto
body: Cuerpo
@@ -2118,6 +2133,7 @@ es: &es
invite: Invitación
link: Enlace
mcp: MCP
self: Firma tú mismo
submission_event_names:
send_email_to_html: '<b>Correo electrónico enviado</b> a %{submitter_name}'
bounce_email_html: '<b>Correo electrónico rebotado</b> %{submitter_name}'
@@ -2595,6 +2611,13 @@ it: &it
completed_form_message: Messaggio di completamento del modulo
completed_form_redirect_button: Pulsante di reindirizzamento dopo il completamento del modulo
documents_copy_email: Email con copia dei documenti
signature_request_email_sent_to_recipients_with_a_link_to_review_and_sign: Email di richiesta di firma inviata ai destinatari con un link per revisionare e firmare.
view_only_email_sent_to_recipients_that_need_to_only_review_the_documents: Email di sola lettura inviata ai destinatari che devono solo revisionare i documenti.
reminds_recipients_who_havent_completed_their_part_yet_to_sign_following_the_reminders_schedule: Ricorda di firmare ai destinatari che non hanno ancora completato la loro parte, in base alla pianificazione dei promemoria.
signature_request_sms_sent_to_recipients_with_a_link_to_review_and_sign: SMS di richiesta di firma inviato ai destinatari con un link per revisionare e firmare.
phone_2fa_verification_text_message: Messaggio di testo per la verifica 2FA tramite telefono.
notification_email_sent_to_the_sender_user_and_bcc_addresses_once_the_submission_is_completed_by_all_parties: Email di notifica inviata all'utente mittente e agli indirizzi BCC una volta che l'invio è stato completato da tutte le parti.
completed_documents_copy_email_sent_to_recipients_upon_completion_containing_their_documents: Email con la copia dei documenti completati inviata ai destinatari al completamento, con i loro documenti.
form_custom_css: CSS personalizzato del modulo
subject: Oggetto
body: Corpo
@@ -3243,6 +3266,7 @@ it: &it
invite: Invito
link: Link
mcp: MCP
self: Firma te stesso
submission_event_names:
send_email_to_html: '<b>Email inviata</b> a %{submitter_name}'
bounce_email_html: '<b>Email respinta</b> %{submitter_name}'
@@ -3706,6 +3730,13 @@ fr: &fr
completed_form_message: Message de formulaire complété
completed_form_redirect_button: Bouton de redirection après finalisation
documents_copy_email: E‑mail avec copie des documents
signature_request_email_sent_to_recipients_with_a_link_to_review_and_sign: E-mail de demande de signature envoyé aux destinataires avec un lien pour consulter et signer.
view_only_email_sent_to_recipients_that_need_to_only_review_the_documents: E-mail en lecture seule envoyé aux destinataires qui doivent uniquement consulter les documents.
reminds_recipients_who_havent_completed_their_part_yet_to_sign_following_the_reminders_schedule: Rappelle de signer aux destinataires qui n'ont pas encore terminé leur partie, selon la planification des rappels.
signature_request_sms_sent_to_recipients_with_a_link_to_review_and_sign: SMS de demande de signature envoyé aux destinataires avec un lien pour consulter et signer.
phone_2fa_verification_text_message: Message texte de vérification 2FA par téléphone.
notification_email_sent_to_the_sender_user_and_bcc_addresses_once_the_submission_is_completed_by_all_parties: E-mail de notification envoyé à l'utilisateur expéditeur et aux adresses Cci une fois la soumission complétée par toutes les parties.
completed_documents_copy_email_sent_to_recipients_upon_completion_containing_their_documents: E-mail avec la copie des documents finalisés, envoyé aux destinataires à la finalisation, contenant leurs documents.
form_custom_css: CSS personnalisé du formulaire
subject: Objet
body: Corps
@@ -4351,6 +4382,7 @@ fr: &fr
invite: Invitation
link: Lien
mcp: MCP
self: Signer vous-même
submission_event_names:
send_email_to_html: "<b>E‑mail envoyé</b> à %{submitter_name}"
bounce_email_html: "<b>E‑mail rejeté</b> %{submitter_name}"
@@ -4827,6 +4859,13 @@ pt: &pt
completed_form_message: Mensagem de formulário concluído
completed_form_redirect_button: Botão de redirecionamento do formulário concluído
documents_copy_email: E-mail com cópia dos documentos
signature_request_email_sent_to_recipients_with_a_link_to_review_and_sign: E-mail de solicitação de assinatura enviado aos destinatários com um link para revisar e assinar.
view_only_email_sent_to_recipients_that_need_to_only_review_the_documents: E-mail somente de leitura enviado aos destinatários que precisam apenas revisar os documentos.
reminds_recipients_who_havent_completed_their_part_yet_to_sign_following_the_reminders_schedule: Lembra os destinatários que ainda não concluíram a sua parte de assinar, de acordo com o agendamento de lembretes.
signature_request_sms_sent_to_recipients_with_a_link_to_review_and_sign: SMS de solicitação de assinatura enviado aos destinatários com um link para revisar e assinar.
phone_2fa_verification_text_message: Mensagem de texto de verificação 2FA por telefone.
notification_email_sent_to_the_sender_user_and_bcc_addresses_once_the_submission_is_completed_by_all_parties: E-mail de notificação enviado ao usuário remetente e aos endereços BCC assim que a submissão for concluída por todas as partes.
completed_documents_copy_email_sent_to_recipients_upon_completion_containing_their_documents: E-mail com a cópia dos documentos concluídos enviado aos destinatários na conclusão, com os seus documentos.
form_custom_css: CSS personalizado do formulário
subject: Assunto
body: Corpo
@@ -5475,6 +5514,7 @@ pt: &pt
invite: Convite
link: Link
mcp: MCP
self: Assine você mesmo
submission_event_names:
send_email_to_html: '<b>Email enviado</b> para %{submitter_name}'
bounce_email_html: '<b>Email não entregue</b> %{submitter_name}'
@@ -5951,6 +5991,13 @@ de: &de
completed_form_message: Nachricht zum abgeschlossenen Formular
completed_form_redirect_button: Weiterleitungsschaltfläche nach Formularabschluss
documents_copy_email: E-Mail mit Dokumentenkopie
signature_request_email_sent_to_recipients_with_a_link_to_review_and_sign: E-Mail mit der Signaturanfrage, die mit einem Link zum Prüfen und Signieren an die Empfänger gesendet wird.
view_only_email_sent_to_recipients_that_need_to_only_review_the_documents: E-Mail nur zur Ansicht, die an Empfänger gesendet wird, die die Dokumente nur prüfen müssen.
reminds_recipients_who_havent_completed_their_part_yet_to_sign_following_the_reminders_schedule: Erinnert Empfänger, die ihren Teil noch nicht abgeschlossen haben, an das Signieren, gemäß dem Erinnerungszeitplan.
signature_request_sms_sent_to_recipients_with_a_link_to_review_and_sign: SMS mit der Signaturanfrage, die mit einem Link zum Prüfen und Signieren an die Empfänger gesendet wird.
phone_2fa_verification_text_message: Textnachricht zur 2FA-Verifizierung per Telefon.
notification_email_sent_to_the_sender_user_and_bcc_addresses_once_the_submission_is_completed_by_all_parties: Benachrichtigungs-E-Mail, die an den absendenden Benutzer und die BCC-Adressen gesendet wird, sobald die Einreichung von allen Parteien abgeschlossen wurde.
completed_documents_copy_email_sent_to_recipients_upon_completion_containing_their_documents: E-Mail mit der Kopie der abgeschlossenen Dokumente, die nach Abschluss an die Empfänger gesendet wird.
form_custom_css: Benutzerdefiniertes CSS für Formular
subject: Betreff
body: Inhalt
@@ -6599,6 +6646,7 @@ de: &de
invite: Einladung
link: Link
mcp: MCP
self: Selbst unterschreiben
submission_event_names:
send_email_to_html: '<b>E-Mail gesendet</b> an %{submitter_name}'
bounce_email_html: '<b>E-Mail unzustellbar</b> %{submitter_name}'
@@ -7595,6 +7643,13 @@ nl: &nl
completed_form_message: Bericht voltooid formulier
completed_form_redirect_button: Knop voor doorverwijzing na voltooid formulier
documents_copy_email: E-mail met kopie van documenten
signature_request_email_sent_to_recipients_with_a_link_to_review_and_sign: E-mail met het handtekeningverzoek die naar de ontvangers wordt verzonden met een link om te bekijken en te ondertekenen.
view_only_email_sent_to_recipients_that_need_to_only_review_the_documents: E-mail met alleen weergave die wordt verzonden naar ontvangers die de documenten alleen hoeven te bekijken.
reminds_recipients_who_havent_completed_their_part_yet_to_sign_following_the_reminders_schedule: Herinnert ontvangers die hun deel nog niet hebben voltooid eraan te ondertekenen, volgens het herinneringsschema.
signature_request_sms_sent_to_recipients_with_a_link_to_review_and_sign: Sms met het handtekeningverzoek die naar de ontvangers wordt verzonden met een link om te bekijken en te ondertekenen.
phone_2fa_verification_text_message: Sms-bericht voor 2FA-verificatie via telefoon.
notification_email_sent_to_the_sender_user_and_bcc_addresses_once_the_submission_is_completed_by_all_parties: Meldingse-mail die naar de verzendende gebruiker en de BCC-adressen wordt gestuurd zodra de indiening door alle partijen is voltooid.
completed_documents_copy_email_sent_to_recipients_upon_completion_containing_their_documents: E-mail met een kopie van de voltooide documenten die na voltooiing naar de ontvangers wordt verzonden.
form_custom_css: Aangepaste CSS voor formulier
subject: Onderwerp
body: Inhoud
@@ -8239,6 +8294,7 @@ nl: &nl
invite: Uitnodiging
link: Link
mcp: MCP
self: Zelf ondertekenen
submission_event_names:
send_email_to_html: "<b>E-mail verzonden</b> naar %{submitter_name}"
bounce_email_html: "<b>E-mail geretourneerd</b> %{submitter_name}"
+6 -2
View File
@@ -142,7 +142,8 @@ Rails.application.routes.draw do
get :completed
end
resource :resubmit_form, controller: 'start_form', only: :update
resource :resubmit_form, controller: 'start_form_resubmit', only: :update
resources :start_form_self, only: :update
resource :submit_form_email_2fa, only: %i[create update]
resources :start_form_email_2fa_send, only: :create
@@ -174,10 +175,13 @@ Rails.application.routes.draw do
resources :submitters, only: %i[] do
resources :download, only: %i[index], controller: 'submitters_download', constraints: { submitter_id: /\d+/ }
resources :download, only: %i[index], controller: 'submit_form_completed_download'
resources :send_email, only: %i[create], controller: 'submitters_send_email'
end
resources :submitters, only: %i[], param: 'slug' do
resources :download, only: %i[index], controller: 'submit_form_completed_download'
end
resources :settings, only: %i[index]
scope '/settings', as: :settings do
-8
View File
@@ -1,8 +0,0 @@
development:
secret_key_base: 5e626815c7ef00cc5d7b46ff3099160f25e5271b00ced3b8eba1d5045eb0db0ddb925c85b2fea6b2acb4cbdcb779ba9c5a31600f31e6ee8944ada47a768f6a7f
test:
secret_key_base: 7d754998067691dc61859c6c3109966c44e1756a46fd2409f1d81403599605c103ff84b12102673b498bdbaba04d9949d8d7a2f7af47529ee6dc4dd19f0aef3e
production:
secret_key_base: <%= ENV["SECRET_KEY_BASE"] %>
@@ -0,0 +1,18 @@
# frozen_string_literal: true
class AddSubmissionsAccountTemplateStatistics < ActiveRecord::Migration[8.1]
def up
return unless adapter_name == 'PostgreSQL'
execute <<~SQL.squish
CREATE STATISTICS IF NOT EXISTS submissions_account_template_stats (dependencies, ndistinct)
ON account_id, template_id FROM submissions
SQL
end
def down
return unless adapter_name == 'PostgreSQL'
execute 'DROP STATISTICS IF EXISTS submissions_account_template_stats'
end
end
+1 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.1].define(version: 2026_07_07_055354) do
ActiveRecord::Schema[8.1].define(version: 2026_08_19_091500) do
# These are extensions that must be enabled in order to support this database
enable_extension "btree_gin"
enable_extension "pg_catalog.plpgsql"
+34 -78
View File
@@ -3,6 +3,9 @@
module LoadBmp
BPPS = [1, 4, 8, 24, 32].freeze
MAX_COORD = ENV.fetch('VIPS_MAX_COORD', '17000').to_i
MAX_PIXELS = 145_000_000
module_function
# rubocop:disable Metrics
@@ -18,40 +21,28 @@ module LoadBmp
header_data[:height]
)
if header_data[:bpp] <= 8
final_pixel_data = decode_indexed_pixel_data(
raw_pixel_data_from_file,
header_data[:bpp],
header_data[:width],
header_data[:height],
header_data[:bmp_stride],
header_data[:color_table]
)
bands = 3
else
final_pixel_data = prepare_unpadded_pixel_data_string(
raw_pixel_data_from_file,
header_data[:bpp],
header_data[:width],
header_data[:height],
header_data[:bmp_stride]
)
bands = header_data[:bpp] / 8
end
image = Vips::Image.new_from_memory_copy(final_pixel_data, header_data[:width], header_data[:height], bands, :uchar)
image = image.flip(:vertical) if header_data[:orientation] == -1
padded_rows = Vips::Image.new_from_memory_copy(
raw_pixel_data_from_file,
header_data[:bmp_stride],
header_data[:height],
1,
:uchar
)
image_rgb =
if header_data[:bpp] <= 8
image
elsif bands == 3
image.recomb(band3_recomb)
elsif bands == 4
image.recomb(band4_recomb)
decode_indexed_pixel_data(padded_rows, header_data[:bpp], header_data[:width], header_data[:color_table])
else
bands = header_data[:bpp] / 8
image = padded_rows.extract_area(0, 0, header_data[:width] * bands, header_data[:height])
.bandfold(factor: bands)
bands == 3 ? image.recomb(band3_recomb) : image.recomb(band4_recomb)
end
image_rgb = image_rgb.flip(:vertical) if header_data[:orientation] == -1
image_rgb = image_rgb.copy(interpretation: :srgb) if image_rgb.interpretation != :srgb
image_rgb
@@ -98,6 +89,9 @@ module LoadBmp
raise ArgumentError, 'BMP width must be positive.' if width <= 0
raise ArgumentError, 'BMP height must be positive.' if height <= 0
if width > MAX_COORD || height > MAX_COORD || width * height > MAX_PIXELS
raise ArgumentError, "BMP dimensions are too large: #{width}x#{height}."
end
if compression != 0
raise ArgumentError,
@@ -164,62 +158,24 @@ module LoadBmp
raw_pixel_data_from_file
end
def prepare_unpadded_pixel_data_string(raw_pixel_data_from_file, bpp, width, height, bmp_stride)
bytes_per_pixel = bpp / 8
actual_row_width_bytes = width * bytes_per_pixel
def decode_indexed_pixel_data(padded_rows, bpp, width, color_table)
pixels_per_byte = 8 / bpp
unpadded_rows = Array.new(height)
current_offset_in_blob = 0
image = padded_rows.maplut(build_palette_lut(bpp, color_table))
image = image.bandunfold.bandfold(factor: 3) if pixels_per_byte > 1
height.times do |i|
if current_offset_in_blob + actual_row_width_bytes > raw_pixel_data_from_file.bytesize
raise ArgumentError,
"Not enough data in pixel blob for row #{i}. Offset #{current_offset_in_blob}, " \
"row width #{actual_row_width_bytes}, blob size #{raw_pixel_data_from_file.bytesize}"
end
unpadded_row_slice = raw_pixel_data_from_file.byteslice(current_offset_in_blob, actual_row_width_bytes)
if unpadded_row_slice.nil? || unpadded_row_slice.bytesize < actual_row_width_bytes
raise ArgumentError, "Failed to slice a full unpadded row from pixel data blob for row #{i}."
end
unpadded_rows[i] = unpadded_row_slice
current_offset_in_blob += bmp_stride
end
unpadded_rows.join
image.extract_area(0, 0, width, padded_rows.height)
end
def decode_indexed_pixel_data(raw_data, bpp, width, height, bmp_stride, color_table)
palette = color_table.map { |r, g, b| [r, g, b].pack('CCC') }
def build_palette_lut(bpp, color_table)
pixels_per_byte = 8 / bpp
mask = (1 << bpp) - 1
output = String.new(capacity: width * height * 3)
height.times do |y|
row_offset = y * bmp_stride
case bpp
when 1
width.times do |x|
byte_val = raw_data.getbyte(row_offset + (x >> 3))
index = (byte_val >> (7 - (x & 7))) & 0x01
output << palette[index]
end
when 4
width.times do |x|
byte_val = raw_data.getbyte(row_offset + (x >> 1))
index = x.even? ? (byte_val >> 4) & 0x0F : byte_val & 0x0F
output << palette[index]
end
when 8
width.times do |x|
output << palette[raw_data.getbyte(row_offset + x)]
end
end
entries = Array.new(256) do |byte_val|
(0...pixels_per_byte).flat_map { |i| color_table[(byte_val >> ((pixels_per_byte - 1 - i) * bpp)) & mask] }
end
output
Vips::Image.new_from_memory_copy(entries.flatten.pack('C*'), 256, 1, pixels_per_byte * 3, :uchar)
end
def band3_recomb
+1 -1
View File
@@ -94,7 +94,7 @@ module LoadIco
palette = []
if dib_bpp <= 8
num_palette_entries = dib_clr_used.zero? ? (1 << dib_bpp) : dib_clr_used
num_palette_entries = [dib_clr_used.zero? ? (1 << dib_bpp) : dib_clr_used, 1 << dib_bpp].min
num_palette_entries.times do
palette_color_bytes = dib_io.read(4)
return nil unless palette_color_bytes && palette_color_bytes.bytesize == 4
+2
View File
@@ -236,6 +236,8 @@ module SearchEntries
end
def add_hyphens(entry, text)
text = text.tr('\\', ' ')
hyphens = text.scan(/\b[^\s]*?\d-[^\s]+?\b/) + text.scan(/\b[^\s]+-\d[^\s]*?\b/)
hyphens.uniq.each_with_index do |item, index|
+14 -3
View File
@@ -3,6 +3,11 @@
module Submissions
DEFAULT_SUBMITTERS_ORDER = 'random'
SKIP_EMAIL_FIX_LABELS = %w[ga gami gasi gil gma gmao gmi gmsi goi ol].freeze
SKIP_EMAIL_FIX_TLDS =
/\.(?:gob(?:\.\w+)?|om|mm|cm|et|mo|nz|za|ie|mom|free|comsec|unicom|ed(?:\.\w+){1,2})\z/i
module_function
def maybe_update_completed_at(submission)
@@ -210,7 +215,7 @@ module Submissions
return email.downcase.sub(/@gmail?\z/i, '@gmail.com') if email.match?(/@gmail?\z/i)
return email.downcase if email.include?(',') ||
email.match?(/\.(?:gob(?:\.\w+)?|om|mm|cm|et|mo|nz|za|ie|ed\.jp)\z/i) ||
email.match?(SKIP_EMAIL_FIX_TLDS) ||
email.exclude?('.')
fixed_email = EmailTypo.call(email.delete_prefix('<'))
@@ -220,8 +225,7 @@ module Submissions
domain = email.split('@').last.to_s.downcase
fixed_domain = fixed_email.to_s.split('@').last
return email.downcase if domain == fixed_domain
return email.downcase if fixed_domain.match?(/\Agmail\.(?!com\z)/i)
return email.downcase if domain == fixed_domain || skip_email_fix?(domain, fixed_domain)
threshold = fixed_domain.start_with?('hotmail.') ? 2 : 3
@@ -236,6 +240,13 @@ module Submissions
fixed_email
end
def skip_email_fix?(domain, fixed_domain)
return true if SKIP_EMAIL_FIX_LABELS.include?(domain.split('.').first)
return true if fixed_domain.match?(/\Agmail\.(?!com\z)/i)
fixed_domain.match?(/\A(?:comcast|verizon)\./i) && !domain.match?(/\A(?:comcast|verizon)\./i)
end
def filtered_conditions_schema(submission, values: nil, include_submitter_uuid: nil)
(submission.template_schema || submission.template.schema).filter_map do |item|
if item['conditions'].present?
+19 -9
View File
@@ -19,6 +19,7 @@ module Submissions
}.freeze
TESTING_FOOTER = GenerateResultAttachments::TESTING_FOOTER
UNSUPPORTED_IMAGE_TYPES = GenerateResultAttachments::UNSUPPORTED_IMAGE_TYPES
RTL_REGEXP = TextUtils::RTL_REGEXP
MAX_IMAGE_HEIGHT = 100
@@ -219,7 +220,7 @@ module Submissions
[
composer.document.layout.formatted_text_box(
[{ text: document.filename.to_s }]
[{ text: TextUtils.maybe_rtl_reverse(document.filename.to_s) }]
),
composer.document.layout.formatted_text_box(
[
@@ -272,7 +273,9 @@ module Submissions
[
composer.document.layout.formatted_text_box(
[
submission.template_submitters.size > 1 && { text: "#{item['name']}\n" },
submission.template_submitters.size > 1 && {
text: "#{TextUtils.maybe_rtl_reverse(item['name'].to_s)}\n"
},
submitter.email && { text: "#{submitter.email}\n", font: [FONT_NAME, { variant: :bold }] },
submitter.name && { text: "#{TextUtils.maybe_rtl_reverse(submitter.name)}\n" },
submitter.phone && { text: "#{submitter.phone}\n" }
@@ -299,7 +302,7 @@ module Submissions
},
completed_event.data['ip'] && { text: "IP: #{completed_event.data['ip']}\n" },
completed_event.data['sid'] && { text: "#{I18n.t('session_id')}: #{completed_event.data['sid']}\n" },
completed_event.data['ua'] && { text: "User agent: #{completed_event.data['ua']}\n" },
completed_event.data['ua'] && { text: "User agent: #{completed_event.data['ua'].to_s.squish}\n" },
submitter.timezone && { text: "Time zone: #{submitter.timezone.to_s.sub('Kiev', 'Kyiv')}\n" },
"\n"
].compact_blank, line_spacing: 1.3, padding: [10, 20, 20, 0]
@@ -352,8 +355,9 @@ module Submissions
field_type = field['type']
if field_type == 'image' &&
submitter.attachments.find { |a| a.uuid == value }.then { |a| !a.image? || a.content_type == 'image/heic' }
if (field_type == 'image' || field_type == 'stamp') &&
submitter.attachments.find { |a| a.uuid == value }
.then { |a| !a.image? || a.content_type.in?(UNSUPPORTED_IMAGE_TYPES) }
field_type = 'file'
end
@@ -425,7 +429,7 @@ module Submissions
r.submissions_preview_url(submission.slug, **Docuseal.default_url_options)
end
{ link:, text: "#{attachment.filename}\n", style: :link }
{ link:, text: "#{TextUtils.maybe_rtl_reverse(attachment.filename.to_s)}\n", style: :link }
end,
padding: [0, 0, 10, 0]
)
@@ -476,6 +480,8 @@ module Submissions
submitter.name || submitter.email || submitter.phone
end
submitter_name = TextUtils.maybe_rtl_reverse(submitter_name.to_s.squish)
text =
if event.event_type == 'complete_verification'
I18n.t('submission_event_names.complete_verification_by_html', provider: event.data['method'],
@@ -483,18 +489,22 @@ module Submissions
elsif event.event_type == 'invite_party' &&
(invited_submitter = submission.submitters.find { |e| e.uuid == event.data['uuid'] }) &&
(name = submission.template_submitters.find { |e| e['uuid'] == event.data['uuid'] }&.dig('name'))
invited_submitter_name = [invited_submitter.name || invited_submitter.email || invited_submitter.phone,
name].join(' ')
invited_submitter_name = TextUtils.maybe_rtl_reverse(
[invited_submitter.name || invited_submitter.email || invited_submitter.phone, name].join(' ').squish
)
I18n.t('submission_event_names.invite_party_by_html', invited_submitter_name:,
submitter_name:)
elsif with_audit_sender && (event.event_type == 'send_email' || event.event_type == 'send_sms')
created_by_name = TextUtils.maybe_rtl_reverse(submission.created_by_user.full_name.to_s.squish)
[
I18n.t("submission_event_names.#{event.event_type}_to_html", submitter_name:),
"<b>#{I18n.t(:from)}</b> #{submission.created_by_user.full_name} #{submission.created_by_user.email}"
"<b>#{I18n.t(:from)}</b> #{created_by_name} #{submission.created_by_user.email}"
].join("\n")
elsif event.event_type == 'delegate_form'
from = event.data['old_email'].presence ||
versions.rfind { |v| v.created_at <= event.event_timestamp }&.then { |v| v.name || v.phone }
from = TextUtils.maybe_rtl_reverse(from.to_s.squish)
I18n.t('submission_event_names.delegate_form_by_html', from:, to: event.data['email'])
elsif event.event_type.include?('send_')
I18n.t("submission_event_names.#{event.event_type}_to_html", submitter_name:)
+13 -5
View File
@@ -36,6 +36,8 @@ module Submissions
SIGN_REASON = 'Signed with DocuSeal.com'
UNSUPPORTED_IMAGE_TYPES = ['image/heic', 'image/avif'].freeze
RTL_REGEXP = TextUtils::RTL_REGEXP
TEXT_LEFT_MARGIN = 1
@@ -300,8 +302,9 @@ module Submissions
field_type = field['type']
if field_type == 'image' &&
submitter.attachments.find { |a| a.uuid == value }.then { |a| !a.image? || a.content_type == 'image/heic' }
if (field_type == 'image' || field_type == 'stamp') &&
submitter.attachments.find { |a| a.uuid == value }
.then { |a| !a.image? || a.content_type.in?(UNSUPPORTED_IMAGE_TYPES) }
field_type = 'file'
end
@@ -346,8 +349,11 @@ module Submissions
(with_signature_id_completed_at ? submitter.completed_at : nil) || attachment.created_at
if with_signature_id_reason || field.dig('preferences', 'reasons').present?
"#{"#{I18n.t('reason')}: " if reason_value}#{reason_value || I18n.t('digitally_signed_by')} " \
"#{submitter.name}#{" <#{submitter.email}>" if submitter.email.present?}\n" \
reason_line =
"#{"#{I18n.t('reason')}: " if reason_value}#{reason_value || I18n.t('digitally_signed_by')} " \
"#{submitter.name}#{" <#{submitter.email}>" if submitter.email.present?}"
"#{TextUtils.maybe_rtl_reverse(reason_line)}\n" \
"#{I18n.l(signature_timestamp.in_time_zone(timezone), format: time_format)} " \
"#{TimeUtils.timezone_abbr(timezone, signature_timestamp)}"
else
@@ -509,7 +515,9 @@ module Submissions
cv.image(PdfIcons.paperclip_io, at: [0, 0], width: box.content_width)
end
acc << HexaPDF::Layout::TextFragment.create("#{attachment.filename}\n", font:, font_size:)
acc << HexaPDF::Layout::TextFragment.create(
"#{TextUtils.maybe_rtl_reverse(attachment.filename.to_s)}\n", font:, font_size:
)
end
lines = layouter.fit(items, area['w'] * width, height).lines
+1 -1
View File
@@ -49,7 +49,7 @@ module Submitters
end
def fulltext_search_field(current_user, submitters, keyword, field_name)
keyword = keyword.delete("\0\\")
keyword = keyword.delete("\0").tr('\\', ' ').squish
return submitters.none if keyword.blank?
@@ -5,14 +5,9 @@ module Submitters
module_function
def call(submitter, current_user)
user =
if current_user && current_user.email == submitter.email
current_user
else
submitter.account.users.find_by(email: submitter.email)
end
return if current_user.blank? || current_user.email != submitter.email
return if user.blank?
user = current_user
fields = submitter.submission.template_fields || submitter.submission.template.fields
+145
View File
@@ -0,0 +1,145 @@
# frozen_string_literal: true
module Submitters
module StartForm
COOKIES_TTL = 12.hours
START_FORM_COOKIES_TTL = 7.days
RESUBMIT_TTL = 3.days
COOKIES_DEFAULTS = { httponly: true, secure: Rails.env.production? }.freeze
NotSaved = Class.new(StandardError)
module_function
def can_resubmit?(submitter)
submitter.completed_at? && submitter.completed_at > RESUBMIT_TTL.ago &&
%w[api embed mcp].exclude?(submitter.submission.source) &&
submitter.account.account_configs.find_or_initialize_by(key: AccountConfig::ALLOW_TO_RESUBMIT).value != false
end
def can_reopen?(template, submitter, request:, current_user:)
return false if submitter.submission&.completed_at? && submitter.viewer?
return true if request.cookie_jar.encrypted[:start_form_slug] == submitter.slug
return true if request.cookie_jar.encrypted[:email_2fa_slug] == submitter.slug
return false if submitter.email.blank?
return true if submitter.completed_at?
return false if submitter.submission.source_embed?
return true if template.preferences['shared_link_2fa'] == true
return false if submitter.submission.source_link?
return true if current_user && submitter.email == current_user.email &&
current_user.account_id == submitter.account_id
Docuseal.multitenant? || Accounts.can_send_emails?(template.account)
end
def assign_start_form_cookie(submitter, request)
request.cookie_jar.encrypted[:start_form_slug] =
{ value: submitter.slug, expires: START_FORM_COOKIES_TTL.from_now, **COOKIES_DEFAULTS }
end
def find_or_initialize_submitter(template, submitter_params, exclude_completed:, request:, current_user: nil)
required_fields = template.preferences.fetch('link_form_fields', ['email'])
required_params = required_fields.index_with { |key| submitter_params[key] }
find_params = required_params.except('name')
submitter =
if find_params.compact_blank.blank?
Submitter.new
else
build_submitters_scope(template, exclude_completed:).find_or_initialize_by(find_params)
end
if submitter.persisted? && !can_reopen?(template, submitter, request:, current_user:)
submitter = Submitter.new(find_params)
end
submitter.name = required_params['name'] if submitter.new_record?
submitter
end
def build_submitters_scope(template, exclude_completed: false, source: nil)
submissions = template.submissions.non_expired.active
submissions = submissions.where(source:) if source
Submitter
.where(submission: submissions)
.order(id: :desc)
.where(declined_at: nil)
.where(external_id: nil)
.then { |rel| exclude_completed ? rel.where(completed_at: nil) : rel }
end
def assign_submission_attributes(submitter, template, ip:, user_agent:, source: :link, resubmit_submitter: nil)
submitter.assign_attributes(
uuid: first_submitter_uuid(template),
ip:,
ua: user_agent,
values: resubmit_submitter&.preferences&.fetch('default_values', nil) || {},
preferences: resubmit_submitter&.preferences.presence || { 'send_email' => true },
metadata: resubmit_submitter&.metadata.presence || {}
)
submitter.assign_attributes(resubmit_submitter.slice(:name, :email, :phone)) if resubmit_submitter
if submitter.values.present?
resubmit_submitter.attachments.each do |attachment|
submitter.attachments << attachment.dup if submitter.values.value?(attachment.uuid)
end
end
submitter.submission ||= Submission.new(template:,
account_id: template.account_id,
template_submitters: template.submitters,
expire_at: Templates.build_default_expire_at(template),
submitters: [submitter],
source:)
Submissions::CreateFromSubmitters.maybe_set_dynamic_documents(submitter.submission)
submitter.account_id = submitter.submission.account_id
submitter
end
def first_submitter_uuid(template)
(Templates.filter_undefined_submitters(template.submitters).first || template.submitters.first)['uuid']
end
def verify_2fa_and_save_submitter(submitter, request, is_new_record:)
is_otp_verified = Submitters.verify_link_otp!(request.params[:one_time_code], submitter)
return false if !is_otp_verified && request.cookie_jar.encrypted[:email_2fa_slug] != submitter.slug
raise NotSaved unless submitter.save
enqueue_new_submitter_jobs(submitter) if is_new_record
assign_start_form_cookie(submitter, request)
if is_otp_verified
SubmissionEvents.create_with_tracking_data(submitter, 'email_verified', request)
request.cookie_jar.encrypted[:email_2fa_slug] =
{ value: submitter.slug, expires: COOKIES_TTL.from_now, **COOKIES_DEFAULTS }
end
true
end
def enqueue_new_submitter_jobs(submitter)
WebhookUrls.enqueue_events(submitter.submission, 'submission.created')
SearchEntries.enqueue_reindex(submitter)
expire_at = submitter.submission.expire_at
return unless expire_at
ProcessSubmissionExpiredJob.perform_at(expire_at, 'submission_id' => submitter.submission_id,
'expire_at' => expire_at.to_i)
end
end
end
+4 -1
View File
@@ -8,6 +8,7 @@ module Submitters
VARIABLE_REGEXP = /\{\{?(\w+)\}\}?/
PHONE_REGEXP = /[+\d()\s-]+/
NONEDITABLE_FIELD_TYPES = %w[stamp heading strikethrough].freeze
REQUIRED_FIELD_TYPES = %w[payment kba verification].freeze
STRFTIME_MAP = {
'hour' => '%-k',
@@ -95,7 +96,9 @@ module Submitters
required_field_uuids_acc.each do |uuid|
next if submitter.values[uuid].present?
raise RequiredFieldError, uuid if validate_required
if validate_required || submitter.submission.fields_uuid_index.dig(uuid, 'type').in?(REQUIRED_FIELD_TYPES)
raise RequiredFieldError, uuid
end
Rollbar.warning("Required field #{submitter.id}: #{uuid}") if defined?(Rollbar)
end
+13 -5
View File
@@ -41,16 +41,24 @@ module Templates
attachment
end
def process(attachment, data, extract_fields: false)
def process(attachment, data, extract_fields: false, doc: nil)
if attachment.content_type == PDF_CONTENT_TYPE && extract_fields && data.size < MAX_FLATTEN_FILE_SIZE
pdf = HexaPDF::Document.new(io: StringIO.new(data))
if doc
fields = Templates::FindPdfiumAcroFields.call(attachment, doc, data)
else
pdf = HexaPDF::Document.new(io: StringIO.new(data))
fields = Templates::FindAcroFields.call(pdf, attachment, data)
fields = Templates::FindAcroFields.call(pdf, attachment, data)
end
end
pdf ||= HexaPDF::Document.new(io: StringIO.new(data))
if doc
number_of_pages = doc.page_count
else
pdf ||= HexaPDF::Document.new(io: StringIO.new(data))
number_of_pages = pdf.pages.size
number_of_pages = pdf.pages.size
end
attachment.metadata['pdf'] ||= {}
attachment.metadata['pdf']['number_of_pages'] = number_of_pages
+40 -12
View File
@@ -15,18 +15,29 @@ module VerifyPdfSignature
next [] if signatures.blank?
has_unsigned_changes = unsigned_changes?(document, io)
verified_signatures = signatures.select { |e| verified_signature?(e, io, trusted_certs) }
trusted_signatures = verified_signatures.select { |e| trusted_signature?(e, trusted_certs) }
last_signature = (trusted_signatures.presence || verified_signatures).max_by(&:signed_end)
has_unsigned_changes = last_signature && unsigned_changes?(document, io, last_signature.signed_end)
signatures.map.with_index do |signature, index|
build_signature(signature, io, trusted_certs,
has_unsigned_changes && index == signatures.size - 1)
signatures.map do |signature|
build_signature(signature, trusted_certs,
verified: verified_signatures.include?(signature),
has_unsigned_changes: has_unsigned_changes && signature == last_signature)
end
end
end
def build_signature(signature, io, trusted_certs, has_unsigned_changes)
def verified_signature?(signature, io, trusted_certs)
return false unless covers_signed_revision?(signature, io)
verify_contents(OpenSSL::PKCS7.new(signature.contents), signed_data(io, signature.byte_range), trusted_certs)
rescue OpenSSL::PKCS7::PKCS7Error
false
end
def build_signature(signature, trusted_certs, verified:, has_unsigned_changes:)
pkcs7 = OpenSSL::PKCS7.new(signature.contents)
verified = verify_contents(pkcs7, signed_data(io, signature.byte_range), trusted_certs)
SignatureStruct.new(
messages: build_messages(pkcs7, verified, trusted_certs, has_unsigned_changes),
@@ -62,15 +73,25 @@ module VerifyPdfSignature
end
def certificate_message(pkcs7, trusted_certs)
public_key = signer_certificate(pkcs7)&.public_key&.to_der
if trusted_certs.any? { |e| e.public_key.to_der == public_key }
if trusted_certificate?(pkcs7, trusted_certs)
MessageStruct.new(text: I18n.t('signed_with_trusted_certificate'), status: :success)
else
MessageStruct.new(text: I18n.t('signed_with_external_certificate'), status: :error)
end
end
def trusted_signature?(signature, trusted_certs)
trusted_certificate?(OpenSSL::PKCS7.new(signature.contents), trusted_certs)
rescue OpenSSL::PKCS7::PKCS7Error
false
end
def trusted_certificate?(pkcs7, trusted_certs)
public_key = signer_certificate(pkcs7)&.public_key&.to_der
trusted_certs.any? { |e| e.public_key.to_der == public_key }
end
def verify_contents(pkcs7, signed_data, trusted_certs)
return false if digest_algorithms(pkcs7).blank?
@@ -137,9 +158,16 @@ module VerifyPdfSignature
Time.strptime("#{time.first(14)}#{offset.start_with?('+', '-') ? offset : '+0000'}", TIME_FORMAT)
end
def unsigned_changes?(document, io)
signed_end = document.signatures.map(&:signed_end).max
def covers_signed_revision?(signature, io)
byte_range = signature.byte_range
return false if byte_range.size != 4 || byte_range.any?(&:negative?) || byte_range[0].positive?
return false if signature.signed_end > io.size
byte_range[2] == byte_range[1] + (signature.contents.bytesize * 2) + 2
end
def unsigned_changes?(document, io, signed_end)
return false if document.trailer_ends.none? { |offset| offset > signed_end }
io.seek(0)
@@ -164,7 +192,7 @@ module VerifyPdfSignature
def signed_data(io, byte_range)
byte_range.each_slice(2).map do |offset, length|
io.seek(offset)
io.read(length)
io.read(length.clamp(0, [io.size - offset, 0].max))
end.join
end
end