mirror of
https://github.com/docusealco/docuseal
synced 2026-11-04 06:42:32 -05:00
Compare commits
27
Commits
c216e43d24
...
3.2.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e2430e5a2 | ||
|
|
85fbae1813 | ||
|
|
9941122d90 | ||
|
|
71be6db358 | ||
|
|
994c1b3155 | ||
|
|
92b297a620 | ||
|
|
bd03498b0f | ||
|
|
660b24710e | ||
|
|
1416cf72e8 | ||
|
|
ae7b0ce608 | ||
|
|
a3828ec390 | ||
|
|
b8c1691bd7 | ||
|
|
4b00c896e4 | ||
|
|
781da32a07 | ||
|
|
307036eae6 | ||
|
|
9944a9b1a5 | ||
|
|
11856a17ac | ||
|
|
7b4c74d711 | ||
|
|
e0b0d4e803 | ||
|
|
ec04a95e36 | ||
|
|
887d80f6d7 | ||
|
|
9ad281d457 | ||
|
|
e316d2006f | ||
|
|
f9669b0e16 | ||
|
|
785eb789ad | ||
|
|
436d947f6a | ||
|
|
033ce3a169 |
No files matched your search
@@ -36,7 +36,7 @@ gem 'rotp'
|
||||
gem 'rouge', require: false
|
||||
gem 'rqrcode', require: false
|
||||
gem 'ruby-vips'
|
||||
gem 'rubyXL', require: false
|
||||
gem 'rubyzip', require: false
|
||||
gem 'shakapacker'
|
||||
gem 'sidekiq'
|
||||
gem 'sqlite3', require: false
|
||||
@@ -45,6 +45,7 @@ gem 'trilogy', require: false
|
||||
gem 'turbo-rails'
|
||||
gem 'twitter_cldr', require: false
|
||||
gem 'tzinfo-data'
|
||||
gem 'xlsxtream', require: false
|
||||
|
||||
group :development, :test do
|
||||
gem 'better_html'
|
||||
|
||||
+5
-4
@@ -497,9 +497,6 @@ GEM
|
||||
ruby-vips (2.3.0)
|
||||
ffi (~> 1.12)
|
||||
logger
|
||||
rubyXL (3.4.35)
|
||||
nokogiri (>= 1.10.8)
|
||||
rubyzip (>= 3.2.2)
|
||||
rubyzip (3.2.2)
|
||||
securerandom (0.4.1)
|
||||
semantic_range (3.1.1)
|
||||
@@ -575,10 +572,13 @@ GEM
|
||||
base64
|
||||
websocket-extensions (>= 0.1.0)
|
||||
websocket-extensions (0.1.5)
|
||||
xlsxtream (3.1.0)
|
||||
zip_kit (>= 6.2, < 7)
|
||||
xpath (3.2.0)
|
||||
nokogiri (~> 1.8)
|
||||
yaml (0.4.0)
|
||||
zeitwerk (2.8.2)
|
||||
zip_kit (6.3.4)
|
||||
|
||||
PLATFORMS
|
||||
aarch64-linux
|
||||
@@ -638,7 +638,7 @@ DEPENDENCIES
|
||||
rubocop-rails
|
||||
rubocop-rspec
|
||||
ruby-vips
|
||||
rubyXL
|
||||
rubyzip
|
||||
shakapacker
|
||||
sidekiq
|
||||
simplecov
|
||||
@@ -650,6 +650,7 @@ DEPENDENCIES
|
||||
tzinfo-data
|
||||
web-console
|
||||
webmock
|
||||
xlsxtream
|
||||
|
||||
RUBY VERSION
|
||||
ruby 4.0.5
|
||||
|
||||
@@ -23,17 +23,20 @@ class AccountsController < ApplicationController
|
||||
unless Docuseal.multitenant?
|
||||
@encrypted_config = EncryptedConfig.find_or_initialize_by(account: current_account,
|
||||
key: EncryptedConfig::APP_URL_KEY)
|
||||
@encrypted_config.assign_attributes(app_url_params)
|
||||
|
||||
unless URI.parse(@encrypted_config.value.to_s).class.in?([URI::HTTP, URI::HTTPS])
|
||||
@encrypted_config.errors.add(:value, I18n.t('should_be_a_valid_url'))
|
||||
if can?(:manage, @encrypted_config)
|
||||
@encrypted_config.assign_attributes(app_url_params)
|
||||
|
||||
return render :show, status: :unprocessable_content
|
||||
unless URI.parse(@encrypted_config.value.to_s).class.in?([URI::HTTP, URI::HTTPS])
|
||||
@encrypted_config.errors.add(:value, I18n.t('should_be_a_valid_url'))
|
||||
|
||||
return render :show, status: :unprocessable_content
|
||||
end
|
||||
|
||||
@encrypted_config.save!
|
||||
|
||||
Docuseal.refresh_default_url_options!
|
||||
end
|
||||
|
||||
@encrypted_config.save!
|
||||
|
||||
Docuseal.refresh_default_url_options!
|
||||
end
|
||||
|
||||
with_locale do
|
||||
|
||||
@@ -162,6 +162,10 @@ class StartFormController < ApplicationController
|
||||
end
|
||||
rescue Submitters::StartForm::NotSaved
|
||||
render :show, status: :unprocessable_content
|
||||
rescue Submitters::BouncedEmail => e
|
||||
@error_message = e.message
|
||||
|
||||
render :error, status: :unprocessable_content
|
||||
rescue Submitters::UnableToSendCode, Submitters::InvalidOtp => e
|
||||
redirect_to start_form_path(template.slug, params: submitter_params.merge(email_verification: true)),
|
||||
alert: e.message
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class StartFormEmail2faSendController < ApplicationController
|
||||
layout 'form'
|
||||
|
||||
around_action :with_browser_locale
|
||||
|
||||
skip_before_action :authenticate_user!
|
||||
@@ -23,6 +25,10 @@ class StartFormEmail2faSendController < ApplicationController
|
||||
|
||||
redirect_to start_form_path(@template.slug, params: submitter_params.merge(email_verification: true)),
|
||||
**redir_params
|
||||
rescue Submitters::BouncedEmail => e
|
||||
@error_message = e.message
|
||||
|
||||
render 'start_form/error', status: :unprocessable_content
|
||||
rescue Submitters::UnableToSendCode => e
|
||||
redirect_to start_form_path(@template.slug, params: submitter_params.merge(email_verification: true)),
|
||||
alert: e.message
|
||||
|
||||
@@ -87,6 +87,10 @@ class StartFormResubmitController < ApplicationController
|
||||
end
|
||||
rescue Submitters::StartForm::NotSaved
|
||||
render 'start_form/show', status: :unprocessable_content
|
||||
rescue Submitters::BouncedEmail => e
|
||||
@error_message = e.message
|
||||
|
||||
render 'start_form/error', status: :unprocessable_content
|
||||
rescue Submitters::UnableToSendCode, Submitters::InvalidOtp => e
|
||||
flash.now[:alert] = e.message
|
||||
|
||||
|
||||
@@ -26,7 +26,8 @@ class SubmitFormDeclineController < ApplicationController
|
||||
|
||||
user = @submitter.submission.created_by_user || @submitter.template.author
|
||||
|
||||
if user.user_configs.find_by(key: UserConfig::RECEIVE_DECLINED_EMAIL)&.value != false
|
||||
if Users.send_emails?(user) &&
|
||||
user.user_configs.find_by(key: UserConfig::RECEIVE_DECLINED_EMAIL)&.value != false
|
||||
SubmitterMailer.declined_email(@submitter, user).deliver_later!
|
||||
end
|
||||
|
||||
|
||||
@@ -32,7 +32,8 @@ class SubmitFormDelegateController < ApplicationController
|
||||
SubmissionEvents.create_with_tracking_data(@submitter, 'delegate_form', request,
|
||||
{ old_email: @submitter.email, email: })
|
||||
|
||||
@submitter.update!(email:, phone: nil, name: nil, slug: SecureRandom.base58(14))
|
||||
@submitter.update!(email:, phone: nil, name: nil, slug: SecureRandom.base58(14),
|
||||
values: Submitters.fetch_values_for_delegate(@submitter))
|
||||
end
|
||||
|
||||
SendSubmitterInvitationEmailJob.perform_async('submitter_id' => @submitter.id)
|
||||
|
||||
@@ -35,6 +35,12 @@ class SubmitFormEmail2fasController < ApplicationController
|
||||
|
||||
RateLimit.call("send-email-code-#{@submitter.id}", limit: 2, ttl: 45.seconds, enabled: true)
|
||||
|
||||
if Docuseal.multitenant? && Submitters.email_bounced_recently?(@submitter.email)
|
||||
Rollbar.warning("Bounced OTP email for submitter: #{@submitter.id}") if defined?(Rollbar)
|
||||
|
||||
return redirect_to submit_form_path(@submitter.slug, status: :error), alert: I18n.t(:verification_email_bounced)
|
||||
end
|
||||
|
||||
SendSubmitterVerificationEmailJob.perform_async('submitter_id' => @submitter.id, 'locale' => I18n.locale.to_s)
|
||||
|
||||
redir_params = params[:resend] ? { alert: I18n.t(:code_has_been_resent) } : {}
|
||||
|
||||
@@ -23,7 +23,7 @@ class SubmitFormInviteController < ApplicationController
|
||||
|
||||
@submitter.submission.submitters.create!(uuid: attrs[:uuid], email:, account_id: @submitter.account_id)
|
||||
|
||||
SubmissionEvents.create_with_tracking_data(@submitter, 'invite_party', request, { uuid: @submitter.uuid })
|
||||
SubmissionEvents.create_with_tracking_data(@submitter, 'invite_party', request, { uuid: attrs[:uuid] })
|
||||
end
|
||||
|
||||
@submitter.submission.update!(submitters_order: :preserved)
|
||||
|
||||
@@ -13,9 +13,10 @@ class TemplatesDebugController < ApplicationController
|
||||
data = attachment.download
|
||||
|
||||
unless attachment.image?
|
||||
pdf = HexaPDF::Document.new(io: StringIO.new(data))
|
||||
|
||||
fields = Templates::FindAcroFields.call(pdf, attachment, data)
|
||||
fields =
|
||||
Pdfium::Document.open_io(StringIO.new(data)) do |doc|
|
||||
Templates::FindPdfiumAcroFields.call(attachment, doc, data)
|
||||
end
|
||||
end
|
||||
|
||||
# fields, = Templates::DetectFields.call(StringIO.new(data), attachment:) if fields.blank?
|
||||
|
||||
@@ -10,8 +10,14 @@ class TemplatesDetectFieldsController < ApplicationController
|
||||
|
||||
sse = SSE.new(response.stream)
|
||||
|
||||
documents = @template.schema_documents.preload(:blob)
|
||||
documents = documents.where(uuid: params[:attachment_uuid]) if params[:attachment_uuid].present?
|
||||
documents =
|
||||
if params[:attachment_uuid].present?
|
||||
@template.documents.where(uuid: params[:attachment_uuid])
|
||||
else
|
||||
@template.schema_documents
|
||||
end
|
||||
|
||||
documents = documents.preload(:blob)
|
||||
|
||||
page_number = params[:page].presence&.to_i
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ class TemplatesUploadsController < ApplicationController
|
||||
tempfile.write(DownloadUtils.call(params[:url], validate: true).body)
|
||||
tempfile.rewind
|
||||
|
||||
filename = URI.decode_www_form_component(params[:filename]) if params[:filename].present?
|
||||
filename = URI.decode_www_form_component(params[:filename]).tr('/', '-') if params[:filename].present?
|
||||
filename ||= File.basename(URI.decode_www_form_component(params[:url]))
|
||||
|
||||
file = ActionDispatch::Http::UploadedFile.new(
|
||||
|
||||
@@ -3,11 +3,19 @@
|
||||
class WebhookSecretController < ApplicationController
|
||||
load_and_authorize_resource :webhook_url, parent: false
|
||||
|
||||
HEADER_NAME_REGEXP = /\A[\w-]+\z/
|
||||
|
||||
def show; end
|
||||
|
||||
def update
|
||||
key = webhook_secret_params[:key]
|
||||
|
||||
if key.present? && !HEADER_NAME_REGEXP.match?(key)
|
||||
return redirect_back(fallback_location: settings_webhook_path(@webhook_url), alert: I18n.t('unable_to_save'))
|
||||
end
|
||||
|
||||
@webhook_url.update!(secret: {
|
||||
webhook_secret_params[:key] => webhook_secret_params[:value]
|
||||
key => webhook_secret_params[:value]
|
||||
}.compact_blank)
|
||||
|
||||
redirect_back(fallback_location: settings_webhook_path(@webhook_url),
|
||||
|
||||
@@ -261,7 +261,7 @@ export default {
|
||||
}
|
||||
},
|
||||
resendCode () {
|
||||
if (this.codeSentAt && Date.now() - this.codeSentAt < 15000) {
|
||||
if (this.codeSentAt && Date.now() - this.codeSentAt < 30000) {
|
||||
this.startResendCodeCountdown()
|
||||
} else {
|
||||
this.isResendLoading = true
|
||||
@@ -274,7 +274,7 @@ export default {
|
||||
}
|
||||
},
|
||||
startResendCodeCountdown () {
|
||||
this.resendCodeCountdown = 15 - parseInt((Date.now() - this.codeSentAt) / 1000)
|
||||
this.resendCodeCountdown = 30 - parseInt((Date.now() - this.codeSentAt) / 1000)
|
||||
|
||||
this.interval = setInterval(() => {
|
||||
this.resendCodeCountdown--
|
||||
|
||||
@@ -2371,7 +2371,6 @@ export default {
|
||||
const areaCopy = JSON.parse(JSON.stringify(area))
|
||||
|
||||
delete fieldCopy.areas
|
||||
delete fieldCopy.submitter_uuid
|
||||
|
||||
areaCopy.relativeX = area.x - minX
|
||||
areaCopy.relativeY = area.y - minY
|
||||
@@ -2381,6 +2380,7 @@ export default {
|
||||
|
||||
const clipboardData = {
|
||||
items,
|
||||
submitters: this.template.submitters.map((submitter) => ({ uuid: submitter.uuid, name: submitter.name })),
|
||||
templateId: this.template.id,
|
||||
timestamp: Date.now(),
|
||||
isGroup: true
|
||||
@@ -2491,6 +2491,20 @@ export default {
|
||||
|
||||
const fieldUuidIndex = {}
|
||||
const fieldOptionsMap = {}
|
||||
const submitterUuidsMap = {}
|
||||
|
||||
const copiedSubmitterUuids = [...new Set(data.items.map((item) => item.field.submitter_uuid).filter(Boolean))]
|
||||
|
||||
if (copiedSubmitterUuids.length > 1) {
|
||||
copiedSubmitterUuids.forEach((uuid) => {
|
||||
const name = data.submitters?.find((submitter) => submitter.uuid === uuid)?.name
|
||||
|
||||
const submitter = this.template.submitters.find((s) => s.uuid === uuid) ||
|
||||
(name && this.template.submitters.find((s) => s.name.toLowerCase() === name.toLowerCase()))
|
||||
|
||||
submitterUuidsMap[uuid] = (submitter || this.selectedSubmitter).uuid
|
||||
})
|
||||
}
|
||||
|
||||
data.items.forEach((item) => {
|
||||
const field = JSON.parse(JSON.stringify(item.field))
|
||||
@@ -2515,7 +2529,7 @@ export default {
|
||||
const newField = fieldUuidIndex[field.uuid] || {
|
||||
...field,
|
||||
uuid: v4(),
|
||||
submitter_uuid: this.selectedSubmitter.uuid,
|
||||
submitter_uuid: submitterUuidsMap[field.submitter_uuid] || this.selectedSubmitter.uuid,
|
||||
areas: []
|
||||
}
|
||||
|
||||
@@ -3525,6 +3539,8 @@ export default {
|
||||
this.onChange(this.template)
|
||||
}
|
||||
|
||||
this.pushUndo()
|
||||
|
||||
if (!this.autosave && !force) {
|
||||
return Promise.resolve({})
|
||||
}
|
||||
@@ -3535,8 +3551,6 @@ export default {
|
||||
}
|
||||
})
|
||||
|
||||
this.pushUndo()
|
||||
|
||||
return this.baseFetch(`/templates/${this.template.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
|
||||
@@ -555,8 +555,10 @@ export default {
|
||||
return this.template.schema.some((item) => item.dynamic)
|
||||
},
|
||||
numberOfPages () {
|
||||
return this.template.documents.reduce((acc, doc) => {
|
||||
return acc + doc.metadata?.pdf?.number_of_pages || doc.preview_images.length
|
||||
return this.template.schema.reduce((acc, item) => {
|
||||
const doc = this.template.documents.find((d) => d.uuid === item.attachment_uuid)
|
||||
|
||||
return acc + (doc?.metadata?.pdf?.number_of_pages || doc?.preview_images?.length || 0)
|
||||
}, 0)
|
||||
},
|
||||
isShowFieldSearch () {
|
||||
@@ -774,9 +776,10 @@ export default {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
...(this.withDetectExistingFields
|
||||
? { body: JSON.stringify({ fields: this.buildExistingFields() }) }
|
||||
: {})
|
||||
body: JSON.stringify({
|
||||
attachment_uuid: this.template.schema.map((item) => item.attachment_uuid),
|
||||
...(this.withDetectExistingFields ? { fields: this.buildExistingFields() } : {})
|
||||
})
|
||||
}).then(async (response) => {
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder('utf-8')
|
||||
|
||||
@@ -133,7 +133,7 @@ class ProcessSubmitterCompletionJob
|
||||
user_submitter = submission.submitters.find { |s| s.email == user.email }
|
||||
|
||||
is_sent_to_user =
|
||||
if user.role != 'integration' &&
|
||||
if user.role != 'integration' && Users.send_emails?(user) &&
|
||||
(!user_submitter || user_submitter.preferences['send_email'] == false || !is_copy_email_enabled) &&
|
||||
user.user_configs.find_by(key: UserConfig::RECEIVE_COMPLETED_EMAIL)&.value != false
|
||||
SubmitterMailer.completed_email(submitter, user).deliver_later!
|
||||
|
||||
@@ -31,6 +31,8 @@ class EmailMessage < ApplicationRecord
|
||||
|
||||
attribute :uuid, :string, default: -> { SecureRandom.uuid }
|
||||
|
||||
normalizes :body, with: ->(value) { value.to_s }, apply_to_nil: true
|
||||
|
||||
before_validation :set_sha1, on: :create
|
||||
|
||||
def normalized_body
|
||||
|
||||
@@ -90,7 +90,7 @@ class Submitter < ApplicationRecord
|
||||
|
||||
def friendly_name
|
||||
if name.present? && email.present? && email.exclude?(',')
|
||||
%("#{name.delete('"')}" <#{email}>)
|
||||
%("#{name.delete('"').squish}" <#{email}>)
|
||||
else
|
||||
email
|
||||
end
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="collapse-content">
|
||||
<%= form_for AccountConfigs.find_or_initialize_for_key(current_account, AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY), url: settings_personalization_path, method: :post, html: { autocomplete: 'off', class: 'space-y-4' } do |f| %>
|
||||
<%= form_for AccountConfigs.find_or_initialize_for_key(current_account, AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY), url: settings_personalization_path, method: :post, html: { autocomplete: 'off', class: 'flex flex-col gap-4' } do |f| %>
|
||||
<%= 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">
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="collapse-content">
|
||||
<%= form_for AccountConfigs.find_or_initialize_for_key(current_account, AccountConfig::FORM_COMPLETED_BUTTON_KEY), url: settings_personalization_path, method: :post, html: { autocomplete: 'off', class: 'space-y-4' } do |f| %>
|
||||
<%= form_for AccountConfigs.find_or_initialize_for_key(current_account, AccountConfig::FORM_COMPLETED_BUTTON_KEY), url: settings_personalization_path, method: :post, html: { autocomplete: 'off', class: 'flex flex-col gap-4' } do |f| %>
|
||||
<%= f.hidden_field :key %>
|
||||
<%= f.fields_for :value, Struct.new(:title, :url).new(*(f.object.value || {}).values_at('title', 'url')) do |ff| %>
|
||||
<div class="form-control">
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="collapse-content">
|
||||
<%= form_for AccountConfigs.find_or_initialize_for_key(current_account, AccountConfig::FORM_COMPLETED_MESSAGE_KEY), url: settings_personalization_path, method: :post, html: { autocomplete: 'off', class: 'space-y-4' } do |f| %>
|
||||
<%= form_for AccountConfigs.find_or_initialize_for_key(current_account, AccountConfig::FORM_COMPLETED_MESSAGE_KEY), url: settings_personalization_path, method: :post, html: { autocomplete: 'off', class: 'flex flex-col gap-4' } do |f| %>
|
||||
<%= f.hidden_field :key %>
|
||||
<%= f.fields_for :value, Struct.new(:title, :body).new(*(f.object.value || {}).values_at('title', 'body')) do |ff| %>
|
||||
<div class="form-control">
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="collapse-content">
|
||||
<%= form_for AccountConfigs.find_or_initialize_for_key(current_account, AccountConfig::POLICY_LINKS_KEY), url: settings_personalization_path, method: :post, html: { autocomplete: 'off', class: 'space-y-4' } do |f| %>
|
||||
<%= form_for AccountConfigs.find_or_initialize_for_key(current_account, AccountConfig::POLICY_LINKS_KEY), url: settings_personalization_path, method: :post, html: { autocomplete: 'off', class: 'flex flex-col gap-4' } do |f| %>
|
||||
<%= f.hidden_field :key %>
|
||||
<div class="form-control">
|
||||
<autoresize-textarea>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="collapse-content">
|
||||
<%= form_for AccountConfigs.find_or_initialize_for_key(current_account, AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY), url: settings_personalization_path, method: :post, html: { autocomplete: 'off', class: 'space-y-4' } do |f| %>
|
||||
<%= form_for AccountConfigs.find_or_initialize_for_key(current_account, AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY), url: settings_personalization_path, method: :post, html: { autocomplete: 'off', class: 'flex flex-col gap-4' } do |f| %>
|
||||
<%= 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">
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="collapse-content">
|
||||
<%= form_for AccountConfigs.find_or_initialize_for_key(current_account, AccountConfig::SUBMITTER_COMPLETED_EMAIL_KEY), url: settings_personalization_path, method: :post, html: { autocomplete: 'off', class: 'space-y-4' } do |f| %>
|
||||
<%= form_for AccountConfigs.find_or_initialize_for_key(current_account, AccountConfig::SUBMITTER_COMPLETED_EMAIL_KEY), url: settings_personalization_path, method: :post, html: { autocomplete: 'off', class: 'flex flex-col gap-4' } do |f| %>
|
||||
<%= f.hidden_field :key %>
|
||||
<%= f.fields_for :value, Struct.new(:subject, :body, :attach_audit_log, :attach_documents).new(*f.object.value.values_at('subject', 'body', 'attach_audit_log', 'attach_documents')) do |ff| %>
|
||||
<div class="form-control">
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<% content_for(:html_title, "#{@template.name} | DocuSeal") %>
|
||||
<% I18n.with_locale(@template.account.locale) do %>
|
||||
<% content_for(:html_description, t('account_name_has_invited_you_to_fill_and_sign_documents_online_effortlessly_with_a_secure_fast_and_user_friendly_digital_document_signing_solution', account_name: @template.account.name)) %>
|
||||
<% end %>
|
||||
<main class="max-w-md mx-auto px-2 mt-12 mb-4">
|
||||
<div class="space-y-6 mx-auto">
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-center">
|
||||
<%= render 'start_form/banner' %>
|
||||
</div>
|
||||
<div class="flex items-center bg-base-200 rounded-xl p-4 mb-4">
|
||||
<div class="flex items-center">
|
||||
<div class="mr-3">
|
||||
<%= svg_icon('writing_sign', class: 'w-10 h-10') %>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-lg font-bold mb-1"><%= @template.name %></p>
|
||||
<p dir="auto" class="text-sm"><%= t('invited_by_html', name: @template.account.name) %></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="alert items-start bg-base-100 border border-base-300">
|
||||
<%= svg_icon('info_circle', class: 'stroke-current shrink-0 h-6 w-6 mt-1') %>
|
||||
<div class="text-sm"><%= @error_message %></div>
|
||||
</div>
|
||||
<div dir="auto" class="form-control">
|
||||
<%= link_to t('back'), @resubmit_submitter ? submit_form_path(@resubmit_submitter.slug) : start_form_path(@template.slug, @submitter.slice(:name, :email, :phone).compact_blank), class: 'base-button' %>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
@@ -28,7 +28,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<% view_archived_html = capture do %>
|
||||
<% if can?(:manage, :countless) || current_account.submissions.where.not(archived_at: nil).exists? %>
|
||||
<% if can?(:manage, :countless) || current_account.submissions.archived.exists? || current_account.templates.where(Submission.where(Submission.arel_table[:template_id].eq(::Template.arel_table[:id])).arel.exists).archived.exists? %>
|
||||
<div>
|
||||
<a href="<%= submissions_archived_index_path %>" class="link text-sm"><%= t('view_archived') %></a>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<% has_archived = can?(:manage, :countless) || current_account.templates.where.not(archived_at: nil).exists? %>
|
||||
<% has_archived = can?(:manage, :countless) || current_account.templates.archived.exists? %>
|
||||
<% show_dropzone = params[:q].blank? && @pagy.pages == 1 && ((@template_folders.size < 10 && @templates.size.zero?) || (@template_folders.size < 7 && @templates.size < 4) || (@template_folders.size < 4 && @templates.size < 7)) %>
|
||||
<% if Docuseal.demo? %><%= render 'shared/demo_alert' %><% end %>
|
||||
<dashboard-dropzone>
|
||||
|
||||
@@ -83,7 +83,7 @@
|
||||
</div>
|
||||
<% if @webhook_events.present? || params[:status].present? %>
|
||||
<div class="mt-6">
|
||||
<h2 id="log" class="text-2xl md:text-3xl font-bold"><%= t('events_log') %></h2>
|
||||
<h2 id="log" class="text-2xl md:text-3xl font-bold"><%= t('event_log') %></h2>
|
||||
<div class="tabs border-b mt-4">
|
||||
<%= link_to t('all'), url_for(params: request.query_parameters.except('status', 'page')), style: 'margin-bottom: -1px', class: "tab h-10 text-base #{params[:status].blank? ? 'tab-active tab-bordered' : 'pb-[3px]'}" %>
|
||||
<%= link_to t('succeeded'), url_for(params: request.query_parameters.except('page').merge('status' => 'success')), style: 'margin-bottom: -1px', class: "tab h-10 text-base #{params[:status] == 'success' ? 'tab-active tab-bordered' : 'pb-[3px]'}" %>
|
||||
|
||||
+1
-1
@@ -105,6 +105,6 @@ if ENV['REDIS_URL'].to_s.empty?
|
||||
|
||||
redis_password = Digest::SHA1.hexdigest("redis#{ENV.fetch('SECRET_KEY_BASE', '')}")
|
||||
|
||||
ENV['REDIS_URL'] = "redis://default:#{redis_password}@0.0.0.0:6379/0"
|
||||
ENV['REDIS_URL'] = "redis://default:#{redis_password}@127.0.0.1:16379/0"
|
||||
ENV['LOCAL_REDIS_URL'] = ENV.fetch('REDIS_URL', nil)
|
||||
end
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
autoload :CSV, 'csv'
|
||||
autoload :CSVSafe, 'csv-safe'
|
||||
autoload :RubyXL, 'rubyXL'
|
||||
autoload :Xlsxtream, 'xlsxtream'
|
||||
autoload :Zip, 'zip'
|
||||
autoload :Numo, 'numo/narray'
|
||||
autoload :OnnxRuntime, 'onnxruntime'
|
||||
|
||||
@@ -46,6 +46,20 @@ module HexaPDF
|
||||
def color_space(name)
|
||||
GlobalConfiguration.constantize('color_space.map', name).new
|
||||
end
|
||||
|
||||
def [](name)
|
||||
return super unless value[name].nil? && INHERITABLE_FIELDS.include?(name)
|
||||
|
||||
seen = Set.new.compare_by_identity
|
||||
seen << value
|
||||
node = self
|
||||
|
||||
while node.value[name].nil? && (parent = node[:Parent]) && seen.add?(parent.value)
|
||||
node = parent
|
||||
end
|
||||
|
||||
node == self || node.value[name].nil? ? super : node[name]
|
||||
end
|
||||
end
|
||||
|
||||
# fix NoMethodError: undefined method `field_value' for #<HexaPDF::Type::AcroForm::Field
|
||||
@@ -169,8 +183,53 @@ module HexaPDF
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
module CycleSafePageAncestors
|
||||
def index
|
||||
acyclic_ancestors? ? super : 0
|
||||
end
|
||||
|
||||
def ancestor_nodes
|
||||
acyclic_ancestors? ? super : []
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def perform_validation
|
||||
super if acyclic_ancestors?
|
||||
end
|
||||
|
||||
def acyclic_ancestors?
|
||||
seen = Set.new.compare_by_identity
|
||||
node = self
|
||||
node = node[:Parent] while node && seen.add?(node.value)
|
||||
node.nil?
|
||||
end
|
||||
end
|
||||
|
||||
module CycleSafeOutlineEndpoints
|
||||
private
|
||||
|
||||
def perform_validation
|
||||
first = value[:First]
|
||||
last = value[:Last]
|
||||
|
||||
if (first && !last) || (!first && last)
|
||||
node, dir = first ? [self[:First], :Next] : [self[:Last], :Prev]
|
||||
seen = Set.new.compare_by_identity
|
||||
node = node[dir] while node && seen.add?(node.value)
|
||||
|
||||
return if node
|
||||
end
|
||||
|
||||
super
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
HexaPDF::Type::AcroForm::Field.singleton_class.prepend(HexaPDF::CycleSafeInheritedValue)
|
||||
HexaPDF::Type::AcroForm::Field.prepend(HexaPDF::CycleSafeFullFieldName)
|
||||
HexaPDF::Type::AcroForm::Form.prepend(HexaPDF::CycleSafeEachField)
|
||||
HexaPDF::Type::Page.prepend(HexaPDF::CycleSafePageAncestors)
|
||||
HexaPDF::Type::Outline.prepend(HexaPDF::CycleSafeOutlineEndpoints)
|
||||
HexaPDF::Type::OutlineItem.prepend(HexaPDF::CycleSafeOutlineEndpoints)
|
||||
+22
-1
@@ -790,6 +790,7 @@ en: &en
|
||||
the_code_has_been_sent_to_your_email: The code has been sent to your email.
|
||||
enter_the_verification_code_from_your_email: Enter the verification code from your email.
|
||||
too_many_attempts: Too many attempts.
|
||||
verification_email_bounced: "Unable to send the verification code: email to this address bounced."
|
||||
verification_code: Verification Code
|
||||
resend_code: Resend Code
|
||||
verify_new_sign_in: Verify new sign in
|
||||
@@ -1916,6 +1917,7 @@ es: &es
|
||||
the_code_has_been_sent_to_your_email: El código ha sido enviado a tu correo electrónico.
|
||||
enter_the_verification_code_from_your_email: Ingresa el código de verificación de tu correo electrónico.
|
||||
too_many_attempts: Demasiados intentos.
|
||||
verification_email_bounced: "No se pudo enviar el código de verificación: el correo a esta dirección rebotó."
|
||||
verification_code: Código de Verificación
|
||||
resend_code: Reenviar Código
|
||||
verify_new_sign_in: Verificar nuevo inicio de sesión
|
||||
@@ -3049,6 +3051,7 @@ it: &it
|
||||
the_code_has_been_sent_to_your_email: Il codice è stato inviato alla tua e-mail.
|
||||
enter_the_verification_code_from_your_email: Inserisci il codice di verifica dalla tua e-mail.
|
||||
too_many_attempts: Troppi tentativi.
|
||||
verification_email_bounced: "Impossibile inviare il codice di verifica: l'email a questo indirizzo è stata respinta."
|
||||
verification_code: Codice di Verifica
|
||||
resend_code: Reinvia Codice
|
||||
verify_new_sign_in: Verifica nuovo accesso
|
||||
@@ -4168,6 +4171,7 @@ fr: &fr
|
||||
the_code_has_been_sent_to_your_email: Le code a été envoyé à votre e‑mail.
|
||||
enter_the_verification_code_from_your_email: Saisissez le code de vérification reçu par e‑mail.
|
||||
too_many_attempts: Trop de tentatives.
|
||||
verification_email_bounced: "Impossible d'envoyer le code de vérification : l'e-mail à cette adresse a été rejeté."
|
||||
verification_code: Code de vérification
|
||||
resend_code: Renvoyer le code
|
||||
verify_new_sign_in: Vérifier une nouvelle connexion
|
||||
@@ -5297,6 +5301,7 @@ pt: &pt
|
||||
the_code_has_been_sent_to_your_email: O código foi enviado para seu e-mail.
|
||||
enter_the_verification_code_from_your_email: Insira o código de verificação do seu e-mail.
|
||||
too_many_attempts: Muitas tentativas.
|
||||
verification_email_bounced: "Não foi possível enviar o código de verificação: o e-mail para este endereço não foi entregue."
|
||||
verification_code: Código de Verificação
|
||||
resend_code: Reenviar Código
|
||||
verify_new_sign_in: Verificar novo login
|
||||
@@ -6429,6 +6434,7 @@ de: &de
|
||||
the_code_has_been_sent_to_your_email: Der Code wurde an Ihre E-Mail gesendet.
|
||||
enter_the_verification_code_from_your_email: Geben Sie den Verifizierungscode aus Ihrer E-Mail ein.
|
||||
too_many_attempts: Zu viele Versuche.
|
||||
verification_email_bounced: "Der Verifizierungscode konnte nicht gesendet werden: E-Mail an diese Adresse ist unzustellbar."
|
||||
verification_code: Verifizierungscode
|
||||
resend_code: Code erneut senden
|
||||
verify_new_sign_in: Neue Anmeldung verifizieren
|
||||
@@ -6868,6 +6874,8 @@ pl:
|
||||
the_code_has_been_sent_to_your_email: Kod został wysłany na Twój e-mail.
|
||||
enter_the_verification_code_from_your_email: Wprowadź kod weryfikacyjny z Twojego e-maila.
|
||||
too_many_attempts: Zbyt wiele prób.
|
||||
verification_email_bounced: "Nie można wysłać kodu weryfikacyjnego: e-mail na ten adres został odrzucony."
|
||||
back: Wstecz
|
||||
verification_code: Kod Weryfikacyjny
|
||||
resend_code: Wyślij Kod Ponownie
|
||||
powered_by: 'Napędzany przez'
|
||||
@@ -7008,6 +7016,8 @@ uk:
|
||||
the_code_has_been_sent_to_your_email: Код було надіслано на вашу електронну пошту.
|
||||
enter_the_verification_code_from_your_email: Введіть код перевірки з вашої електронної пошти.
|
||||
too_many_attempts: Забагато спроб.
|
||||
verification_email_bounced: "Не вдалося надіслати код підтвердження: лист на цю адресу було відхилено."
|
||||
back: Назад
|
||||
verification_code: Код перевірки
|
||||
resend_code: Надіслати код знову
|
||||
powered_by: 'Працює на базі'
|
||||
@@ -7148,6 +7158,8 @@ cs:
|
||||
the_code_has_been_sent_to_your_email: Kód byl odeslán na váš e-mail.
|
||||
enter_the_verification_code_from_your_email: Zadejte ověřovací kód z vašeho e-mailu.
|
||||
too_many_attempts: Příliš mnoho pokusů.
|
||||
verification_email_bounced: "Ověřovací kód nelze odeslat: e-mail na tuto adresu byl odmítnut."
|
||||
back: Zpět
|
||||
verification_code: Ověřovací kód
|
||||
resend_code: Znovu odeslat kód
|
||||
powered_by: 'Poháněno'
|
||||
@@ -7274,6 +7286,8 @@ he:
|
||||
the_code_has_been_sent_to_your_email: הקוד נשלח לדוא"ל שלך.
|
||||
enter_the_verification_code_from_your_email: הזן את קוד האימות מדוא"ל שלך.
|
||||
too_many_attempts: יותר מדי ניסיונות.
|
||||
verification_email_bounced: "לא ניתן לשלוח את קוד האימות: האימייל לכתובת זו נדחה."
|
||||
back: חזרה
|
||||
verification_code: קוד אימות
|
||||
resend_code: שלח קוד מחדש
|
||||
powered_by: 'מופעל על ידי'
|
||||
@@ -8020,7 +8034,7 @@ nl: &nl
|
||||
awaiting: Wachten
|
||||
document_id: Document-ID
|
||||
envelope_id: Envelop-ID
|
||||
event_log: Gebeurtenissenlogboek
|
||||
event_log: Gebeurtenislogboek
|
||||
verify: Verifiëren
|
||||
testing_log_not_for_production_use: Testlogboek - Niet voor productiegebruik
|
||||
original_sha256: Oorspronkelijke SHA256
|
||||
@@ -8081,6 +8095,7 @@ nl: &nl
|
||||
the_code_has_been_sent_to_your_email: De code is naar uw e-mail verzonden.
|
||||
enter_the_verification_code_from_your_email: Voer de verificatiecode uit uw e-mail in.
|
||||
too_many_attempts: Te veel pogingen.
|
||||
verification_email_bounced: "Verificatiecode kan niet worden verzonden: e-mail naar dit adres is geretourneerd."
|
||||
verification_code: Verificatiecode
|
||||
resend_code: Code opnieuw verzenden
|
||||
verify_new_sign_in: Nieuwe aanmelding verifiëren
|
||||
@@ -8503,6 +8518,8 @@ ar:
|
||||
the_code_has_been_sent_to_your_email: تم إرسال الرمز إلى بريدك الإلكتروني.
|
||||
enter_the_verification_code_from_your_email: أدخل رمز التحقق من بريدك الإلكتروني.
|
||||
too_many_attempts: عدد المحاولات كبير جدًا.
|
||||
verification_email_bounced: "تعذر إرسال رمز التحقق: تم رفض البريد الإلكتروني المرسل إلى هذا العنوان."
|
||||
back: رجوع
|
||||
verification_code: رمز التحقق
|
||||
resend_code: إعادة إرسال الرمز
|
||||
powered_by: 'مشغل بواسطة'
|
||||
@@ -8615,6 +8632,8 @@ ko:
|
||||
the_code_has_been_sent_to_your_email: 코드가 이메일로 전송되었습니다.
|
||||
enter_the_verification_code_from_your_email: 이메일로 받은 인증 코드를 입력하세요.
|
||||
too_many_attempts: 시도 횟수가 너무 많습니다.
|
||||
verification_email_bounced: "인증 코드를 보낼 수 없습니다: 이 주소로 보낸 이메일이 반송되었습니다."
|
||||
back: 뒤로
|
||||
verification_code: 인증 코드
|
||||
resend_code: 코드 재전송
|
||||
powered_by: '제공:'
|
||||
@@ -8727,6 +8746,8 @@ ja:
|
||||
the_code_has_been_sent_to_your_email: コードがあなたのメールに送信されました
|
||||
enter_the_verification_code_from_your_email: メールに記載された認証コードを入力してください
|
||||
too_many_attempts: 試行回数が多すぎます
|
||||
verification_email_bounced: "認証コードを送信できません:このアドレス宛のメールが配信できませんでした。"
|
||||
back: 戻る
|
||||
verification_code: 認証コード
|
||||
resend_code: コードを再送信
|
||||
powered_by: '提供元:'
|
||||
|
||||
@@ -29,8 +29,6 @@ module Params
|
||||
in_path(params, :message) do |message_params|
|
||||
type(message_params, :subject, String)
|
||||
type(message_params, :body, String)
|
||||
|
||||
required(message_params, :body)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -53,8 +51,6 @@ module Params
|
||||
in_path(params, :message, skip_blank: true) do |message_params|
|
||||
type(message_params, :subject, String)
|
||||
type(message_params, :body, String)
|
||||
|
||||
required(message_params, :body)
|
||||
end
|
||||
|
||||
value_in(params, :order, %w[preserved random], allow_nil: true)
|
||||
@@ -119,8 +115,6 @@ module Params
|
||||
in_path(params, :message) do |message_params|
|
||||
type(message_params, :subject, String)
|
||||
type(message_params, :body, String)
|
||||
|
||||
required(message_params, :body)
|
||||
end
|
||||
|
||||
value_in(params, :order, %w[preserved random], allow_nil: true)
|
||||
|
||||
@@ -6,24 +6,6 @@ module PdfUtils
|
||||
|
||||
module_function
|
||||
|
||||
def encrypted?(data, password: nil)
|
||||
HexaPDF::Document.new(io: StringIO.new(data), decryption_opts: { password: })
|
||||
|
||||
false
|
||||
rescue HexaPDF::EncryptionError
|
||||
true
|
||||
end
|
||||
|
||||
def decrypt(data, password)
|
||||
decrypted_io = StringIO.new
|
||||
|
||||
Pdfium::Document.open_bytes(data, password) do |doc|
|
||||
doc.save(decrypted_io, flags: Pdfium::FPDF_REMOVE_SECURITY)
|
||||
end
|
||||
|
||||
decrypted_io.tap(&:rewind).read
|
||||
end
|
||||
|
||||
def merge(io_files)
|
||||
merged_content = StringIO.new
|
||||
|
||||
|
||||
+30
-2
@@ -105,6 +105,7 @@ class Pdfium
|
||||
attach_function :FPDF_LoadCustomDocument, %i[pointer FPDF_STRING], :FPDF_DOCUMENT
|
||||
attach_function :FPDF_CloseDocument, [:FPDF_DOCUMENT], :void
|
||||
attach_function :FPDF_GetPageCount, [:FPDF_DOCUMENT], :int
|
||||
attach_function :FPDF_GetPageSizeByIndexF, %i[FPDF_DOCUMENT int pointer], :int
|
||||
attach_function :FPDF_GetLastError, [], :ulong
|
||||
attach_function :FPDF_GetTrailerEnds, %i[FPDF_DOCUMENT pointer ulong], :ulong
|
||||
attach_function :FPDF_DocumentHasValidCrossReferenceTable, [:FPDF_DOCUMENT], :int
|
||||
@@ -282,6 +283,7 @@ class Pdfium
|
||||
attach_function :FPDFPage_GetAnnotCount, [:FPDF_PAGE], :int
|
||||
|
||||
attach_function :FPDFPage_GetAnnotCountRaw, %i[FPDF_DOCUMENT int], :int
|
||||
attach_function :FPDFPage_GetRotationRaw, %i[FPDF_DOCUMENT int], :int
|
||||
attach_function :FPDFPage_GetAnnot, %i[FPDF_PAGE int], :FPDF_ANNOTATION
|
||||
attach_function :FPDFPage_CloseAnnot, [:FPDF_ANNOTATION], :void
|
||||
attach_function :FPDFAnnot_GetSubtype, [:FPDF_ANNOTATION], :int
|
||||
@@ -368,6 +370,11 @@ class Pdfium
|
||||
:bottom, :float
|
||||
end
|
||||
|
||||
class FS_SIZEF < FFI::Struct
|
||||
layout :width, :float,
|
||||
:height, :float
|
||||
end
|
||||
|
||||
class FS_MATRIX < FFI::Struct
|
||||
layout :a, :float,
|
||||
:b, :float,
|
||||
@@ -531,6 +538,8 @@ class Pdfium
|
||||
|
||||
@pages = {}
|
||||
@annot_counts = {}
|
||||
@page_sizes = {}
|
||||
@page_rotations = {}
|
||||
@closed = false
|
||||
@source_buffer = source_buffer
|
||||
@form_handle = FFI::Pointer::NULL
|
||||
@@ -555,6 +564,23 @@ class Pdfium
|
||||
@page_count ||= Pdfium.FPDF_GetPageCount(@document_ptr)
|
||||
end
|
||||
|
||||
def page_size(page_index)
|
||||
@page_sizes[page_index] ||=
|
||||
begin
|
||||
size = Pdfium::FS_SIZEF.new
|
||||
|
||||
[size[:width], size[:height]] if Pdfium.FPDF_GetPageSizeByIndexF(@document_ptr, page_index, size) == 1
|
||||
end
|
||||
end
|
||||
|
||||
def reset_page_size(page_index)
|
||||
@page_sizes.delete(page_index)
|
||||
end
|
||||
|
||||
def page_rotation(page_index)
|
||||
@page_rotations[page_index] ||= Pdfium.FPDFPage_GetRotationRaw(@document_ptr, page_index)
|
||||
end
|
||||
|
||||
def encrypted?
|
||||
Pdfium.FPDF_GetSecurityHandlerRevision(@document_ptr) >= 0
|
||||
end
|
||||
@@ -983,6 +1009,8 @@ class Pdfium
|
||||
def rotation=(value)
|
||||
Pdfium.FPDFPage_SetRotation(@page_ptr, value)
|
||||
|
||||
@document.reset_page_size(@page_index)
|
||||
|
||||
@rotation = value
|
||||
end
|
||||
|
||||
@@ -2278,14 +2306,14 @@ class Pdfium
|
||||
[PageObject, *to_a].hash
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def object_ptr
|
||||
page.ensure_not_closed!
|
||||
|
||||
Pdfium.FPDFPage_GetObject(page.page_ptr, index)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def read_bounds
|
||||
buffer = Array.new(4) { FFI::MemoryPointer.new(:float) }
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ Puma::Plugin.create do
|
||||
Dir.chdir(ENV.fetch('WORKDIR', nil)) unless ENV['WORKDIR'].to_s.empty?
|
||||
|
||||
exec('redis-server', '--requirepass', Digest::SHA1.hexdigest("redis#{ENV.fetch('SECRET_KEY_BASE', '')}"),
|
||||
'--bind', '127.0.0.1', '--port', '16379',
|
||||
'--loglevel', 'warning')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -62,7 +62,7 @@ module Submissions
|
||||
ActiveStorage::Attachment.create!(
|
||||
blob: ActiveStorage::Blob.create_and_upload!(
|
||||
io: io.tap(&:rewind), filename: "#{I18n.t('audit_log')} - " \
|
||||
"#{submission.name || submission.template&.name}.pdf"
|
||||
"#{submission.name || submission.template&.name}.pdf".tr('/', '-')
|
||||
),
|
||||
name: 'audit_trail',
|
||||
record: submission
|
||||
|
||||
@@ -37,7 +37,7 @@ module Submissions
|
||||
|
||||
ActiveStorage::Attachment.create!(
|
||||
blob: ActiveStorage::Blob.create_and_upload!(
|
||||
io: io.tap(&:rewind), filename: "#{submission.name || submission.template&.name}.pdf"
|
||||
io: io.tap(&:rewind), filename: "#{submission.name || submission.template&.name}.pdf".tr('/', '-')
|
||||
),
|
||||
name: with_audit ? 'combined_document' : 'merged_document',
|
||||
record: submission
|
||||
|
||||
@@ -19,22 +19,19 @@ module Submissions
|
||||
end
|
||||
|
||||
def rows_to_xlsx(rows)
|
||||
workbook = RubyXL::Workbook.new
|
||||
worksheet = workbook[0]
|
||||
worksheet.sheet_name = Time.current.to_date.to_s
|
||||
|
||||
headers = build_headers(rows)
|
||||
headers.each_with_index do |column_name, column_index|
|
||||
worksheet.add_cell(0, column_index, column_name)
|
||||
end
|
||||
|
||||
rows.each.with_index(1) do |row, row_index|
|
||||
extract_columns(row, headers).each_with_index do |value, column_index|
|
||||
worksheet.add_cell(row_index, column_index, value)
|
||||
io = StringIO.new
|
||||
|
||||
Xlsxtream::Workbook.open(io) do |workbook|
|
||||
workbook.write_worksheet(Time.current.to_date.to_s) do |sheet|
|
||||
sheet << headers.to_a
|
||||
|
||||
rows.each { |row| sheet << extract_columns(row, headers) }
|
||||
end
|
||||
end
|
||||
|
||||
workbook.stream.string
|
||||
io.string
|
||||
end
|
||||
|
||||
def rows_to_csv(rows)
|
||||
@@ -150,8 +147,6 @@ module Submissions
|
||||
|
||||
ActiveStorage::Blob.proxy_url(attachment.blob, expires_at:) if attachment
|
||||
end
|
||||
elsif submitter_value == true || submitter_value == false
|
||||
submitter_value.to_s
|
||||
else
|
||||
submitter_value
|
||||
end
|
||||
|
||||
@@ -71,7 +71,7 @@ module Submissions
|
||||
submission:,
|
||||
values_hash:,
|
||||
name: 'preview_merged_document',
|
||||
filename: "#{submission.name || template&.name}.pdf"
|
||||
filename: "#{submission.name || template&.name}.pdf".tr('/', '-')
|
||||
)
|
||||
|
||||
ApplicationRecord.no_touching { attachment.save! }
|
||||
@@ -115,7 +115,7 @@ module Submissions
|
||||
submitter:,
|
||||
uuid: GenerateResultAttachments.images_pdf_uuid(original_documents.select(&:image?)),
|
||||
values_hash:,
|
||||
filename: "#{submission.name || template&.name}.pdf"
|
||||
filename: "#{submission.name || template&.name}.pdf".tr('/', '-')
|
||||
)
|
||||
|
||||
ApplicationRecord.no_touching do
|
||||
|
||||
@@ -40,6 +40,8 @@ module Submissions
|
||||
|
||||
RTL_REGEXP = TextUtils::RTL_REGEXP
|
||||
|
||||
TEXT_ALIGNS = %w[left center right justify].freeze
|
||||
TEXT_VALIGNS = %w[top center bottom].freeze
|
||||
TEXT_LEFT_MARGIN = 1
|
||||
TEXT_TOP_MARGIN = 1
|
||||
MAX_PAGE_ROTATE = 50
|
||||
@@ -280,10 +282,10 @@ module Submissions
|
||||
value = field['default_value'] if field['type'] == 'heading'
|
||||
value = field['default_value'] if field['type'] == 'strikethrough' && value.nil? && field['conditions'].blank?
|
||||
|
||||
text_align = field.dig('preferences', 'align').to_s.to_sym.presence ||
|
||||
text_align = field.dig('preferences', 'align').to_s.presence_in(TEXT_ALIGNS)&.to_sym ||
|
||||
(value.to_s.match?(RTL_REGEXP) ? :right : :left)
|
||||
|
||||
text_valign = (field.dig('preferences', 'valign').to_s.presence || 'center').to_sym
|
||||
text_valign = (field.dig('preferences', 'valign').to_s.presence_in(TEXT_VALIGNS) || 'center').to_sym
|
||||
|
||||
layouter = HexaPDF::Layout::TextLayouter.new(text_valign:, text_align:, font:, font_size:)
|
||||
|
||||
@@ -597,7 +599,7 @@ module Submissions
|
||||
)
|
||||
when ->(type) { type == 'cells' && !area['cell_w'].to_f.zero? }
|
||||
cell_width = area['cell_w'] * width
|
||||
cell_valign = field.dig('preferences', 'valign').to_s.presence || 'center'
|
||||
cell_valign = field.dig('preferences', 'valign').to_s.presence_in(TEXT_VALIGNS) || 'center'
|
||||
cell_layouter = cell_layouters[cell_valign]
|
||||
|
||||
if (mask = field.dig('preferences', 'mask').presence)
|
||||
@@ -812,7 +814,7 @@ module Submissions
|
||||
end
|
||||
|
||||
ActiveStorage::Attachment.new(
|
||||
blob: ActiveStorage::Blob.create_and_upload!(io: io.tap(&:rewind), filename: "#{name}.pdf"),
|
||||
blob: ActiveStorage::Blob.create_and_upload!(io: io.tap(&:rewind), filename: "#{name}.pdf".tr('/', '-')),
|
||||
metadata: { original_uuid: uuid,
|
||||
analyzed: true,
|
||||
sha256: Base64.urlsafe_encode64(Digest::SHA256.digest(io.string)) },
|
||||
|
||||
+29
-2
@@ -11,6 +11,7 @@ module Submitters
|
||||
}.freeze
|
||||
|
||||
UnableToSendCode = Class.new(StandardError)
|
||||
BouncedEmail = Class.new(UnableToSendCode)
|
||||
InvalidOtp = Class.new(StandardError)
|
||||
MaliciousFileExtension = Class.new(StandardError)
|
||||
ParamsError = Class.new(StandardError)
|
||||
@@ -143,7 +144,7 @@ module Submitters
|
||||
def normalize_preferences(account, user, params)
|
||||
preferences = {}
|
||||
|
||||
message_params = params['message'].presence || params.slice('subject', 'body').presence
|
||||
message_params = (params['message'].presence || params.slice('subject', 'body')).compact_blank
|
||||
|
||||
if message_params.present?
|
||||
email_message = EmailMessages.find_or_create_for_account_user(account, user,
|
||||
@@ -164,6 +165,21 @@ module Submitters
|
||||
preferences
|
||||
end
|
||||
|
||||
def fetch_values_for_delegate(submitter)
|
||||
fields = submitter.submission.template_fields || submitter.template.fields
|
||||
default_values = submitter.preferences['default_values'] || {}
|
||||
|
||||
field_uuids = fields.filter_map do |field|
|
||||
next if field['submitter_uuid'] != submitter.uuid
|
||||
next unless field['type'].in?(%w[signature phone verification kba initials])
|
||||
next if default_values[field['uuid']].present?
|
||||
|
||||
field['uuid']
|
||||
end
|
||||
|
||||
submitter.values.except(*field_uuids)
|
||||
end
|
||||
|
||||
def send_signature_requests(submitters, delay_seconds: nil)
|
||||
submitters.each_with_index do |submitter, index|
|
||||
next if submitter.email.blank?
|
||||
@@ -237,12 +253,18 @@ module Submitters
|
||||
I18n.l(completed_at.in_time_zone(submitter.account.timezone), format: :short)
|
||||
end
|
||||
|
||||
"#{filename}.#{blob.filename.extension}"
|
||||
"#{filename}.#{blob.filename.extension}".tr('/', '-')
|
||||
end
|
||||
|
||||
def send_shared_link_email_verification_code(submitter, request:)
|
||||
RateLimit.call("send-otp-code-#{request.remote_ip}", limit: 2, ttl: 45.seconds, enabled: true)
|
||||
|
||||
if Docuseal.multitenant? && email_bounced_recently?(submitter.email)
|
||||
Rollbar.warning("Bounced OTP email for template: #{submitter.submission.template.id}") if defined?(Rollbar)
|
||||
|
||||
raise BouncedEmail, I18n.t(:verification_email_bounced)
|
||||
end
|
||||
|
||||
TemplateMailer.otp_verification_email(submitter.submission.template, email: submitter.email).deliver_later!
|
||||
rescue RateLimit::LimitApproached
|
||||
Rollbar.warning("Limit verification code for template: #{submitter.submission.template.id}") if defined?(Rollbar)
|
||||
@@ -250,6 +272,11 @@ module Submitters
|
||||
raise UnableToSendCode, I18n.t('too_many_attempts')
|
||||
end
|
||||
|
||||
def email_bounced_recently?(email)
|
||||
EmailEvent.exists?(email:, event_type: %w[bounce soft_bounce permanent_bounce],
|
||||
event_datetime: 24.hours.ago..Time.current)
|
||||
end
|
||||
|
||||
def verify_link_otp!(otp, submitter)
|
||||
return false if otp.blank?
|
||||
|
||||
|
||||
@@ -490,7 +490,7 @@ module Submitters
|
||||
|
||||
submission.submitters.create!(uuid: s['uuid'], email:, phone:, account_id: submitter.account_id)
|
||||
|
||||
SubmissionEvents.create_with_tracking_data(submitter, 'invite_party', request, { uuid: submitter.uuid })
|
||||
SubmissionEvents.create_with_tracking_data(submitter, 'invite_party', request, { uuid: s['uuid'] })
|
||||
|
||||
is_invited = true
|
||||
end
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Templates
|
||||
module BuildAnnotations
|
||||
module_function
|
||||
|
||||
def call(data)
|
||||
pdf = HexaPDF::Document.new(io: StringIO.new(data))
|
||||
|
||||
pdf.pages.flat_map.with_index do |page, index|
|
||||
(page[:Annots] || []).filter_map do |annot|
|
||||
next if annot.blank?
|
||||
next if annot.is_a?(Integer) || annot.is_a?(Symbol) || annot.is_a?(HexaPDF::PDFArray)
|
||||
next if annot[:A].blank? || annot[:A][:URI].blank?
|
||||
next unless annot[:Subtype] == :Link
|
||||
next if !annot[:A][:URI].starts_with?('https://') && !annot[:A][:URI].starts_with?('http://')
|
||||
|
||||
build_external_link_hash(page, annot).merge('page' => index)
|
||||
end
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rollbar.error(e) if defined?(Rollbar)
|
||||
|
||||
[]
|
||||
end
|
||||
|
||||
def build_external_link_hash(page, annot)
|
||||
left, bottom, right, top = annot[:Rect]
|
||||
|
||||
{
|
||||
'type' => 'external_link',
|
||||
'value' => annot[:A][:URI],
|
||||
'x' => left / page.box.width.to_f,
|
||||
'y' => (page.box.height - top) / page.box.height.to_f,
|
||||
'w' => (right - left) / page.box.width.to_f,
|
||||
'h' => (top - bottom) / page.box.height.to_f
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -39,23 +39,6 @@ module Templates
|
||||
end
|
||||
|
||||
def handle_pdf_or_image(template, file, document_data = nil, params = {}, extract_fields: false, metadata: {})
|
||||
return handle_pdf_or_image_v2(template, file, document_data, params, extract_fields:, metadata:) if v2?
|
||||
|
||||
document_data ||= file.read
|
||||
|
||||
if file.content_type == PDF_CONTENT_TYPE
|
||||
document_data = maybe_decrypt_pdf_or_raise(document_data, params)
|
||||
|
||||
annotations =
|
||||
document_data.size < ANNOTATIONS_SIZE_LIMIT ? Templates::BuildAnnotations.call(document_data) : []
|
||||
end
|
||||
|
||||
document = create_document(template, file, document_data, metadata, annotations)
|
||||
|
||||
Templates::ProcessDocument.call(document, document_data, extract_fields:)
|
||||
end
|
||||
|
||||
def handle_pdf_or_image_v2(template, file, document_data = nil, params = {}, extract_fields: false, metadata: {})
|
||||
document_data ||= file.read
|
||||
|
||||
unless file.content_type == PDF_CONTENT_TYPE
|
||||
@@ -80,16 +63,6 @@ module Templates
|
||||
doc&.close
|
||||
end
|
||||
|
||||
def maybe_decrypt_pdf_or_raise(data, params)
|
||||
if data.size < ANNOTATIONS_SIZE_LIMIT && PdfUtils.encrypted?(data)
|
||||
PdfUtils.decrypt(data, params[:password])
|
||||
else
|
||||
data
|
||||
end
|
||||
rescue Pdfium::PasswordError
|
||||
raise PdfEncrypted
|
||||
end
|
||||
|
||||
def decrypt_document(doc)
|
||||
io = StringIO.new
|
||||
|
||||
@@ -103,7 +76,7 @@ module Templates
|
||||
|
||||
blob = ActiveStorage::Blob.create_and_upload!(
|
||||
io: StringIO.new(document_data),
|
||||
filename: file.original_filename,
|
||||
filename: file.original_filename.tr('/', '-'),
|
||||
metadata: {
|
||||
**metadata,
|
||||
identified: file.content_type == PDF_CONTENT_TYPE,
|
||||
@@ -163,9 +136,5 @@ module Templates
|
||||
|
||||
raise InvalidFileType, "#{file.content_type}/#{dynamic}"
|
||||
end
|
||||
|
||||
def v2?
|
||||
true
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,288 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Templates
|
||||
module FindAcroFields
|
||||
PDF_CONTENT_TYPE = 'application/pdf'
|
||||
|
||||
FIELD_NAME_REGEXP = /\A(?=.*\p{L})[\p{L}\d\s-]+\z/
|
||||
SKIP_FIELD_DESCRIPTION = %w[undefined].freeze
|
||||
SELECT_PLACEHOLDER_REGEXP = /\b(
|
||||
Select |
|
||||
Choose |
|
||||
Wählen |
|
||||
Auswählen |
|
||||
Sélectionner|
|
||||
Choisir |
|
||||
Seleccionar |
|
||||
Elegir |
|
||||
Seleziona |
|
||||
Scegliere |
|
||||
Selecionar |
|
||||
Escolher
|
||||
)\b/ix
|
||||
|
||||
DATE_FORMAT_REGEXP = %r{[myd]{2,4}[-\\/\s.][myd]{2,4}[-\\/\s.][myd]{2,4}}i
|
||||
|
||||
FIELD_ALIGNMENT = {
|
||||
0 => 'left',
|
||||
1 => 'center',
|
||||
2 => 'right'
|
||||
}.freeze
|
||||
|
||||
module_function
|
||||
|
||||
# rubocop:disable Metrics
|
||||
def call(pdf, attachment, data)
|
||||
return [] if pdf.acro_form.blank? && data.exclude?('/Form')
|
||||
|
||||
fields, annots_index = build_fields_with_pages(pdf)
|
||||
|
||||
fields.filter_map do |field|
|
||||
areas = Array.wrap(field[:Kids] || field).filter_map do |child_field|
|
||||
page = annots_index[child_field.hash]
|
||||
|
||||
next unless page
|
||||
|
||||
media_box = page[:CropBox] || page[:MediaBox]
|
||||
crop_box = page[:CropBox] || media_box
|
||||
|
||||
media_box_start = [media_box[0], media_box[1]]
|
||||
crop_shift = [crop_box[0] - media_box[0], crop_box[1] - media_box[1]]
|
||||
|
||||
next unless child_field[:Rect]
|
||||
|
||||
x0, y0, x1, y1 = child_field[:Rect]
|
||||
|
||||
x0, y0 = correct_coordinates(x0, y0, crop_shift, media_box_start)
|
||||
x1, y1 = correct_coordinates(x1, y1, crop_shift, media_box_start)
|
||||
|
||||
page_width = media_box[2] - media_box[0]
|
||||
page_height = media_box[3] - media_box[1]
|
||||
|
||||
x = x0
|
||||
y = y0
|
||||
w = x1 - x0
|
||||
h = y1 - y0
|
||||
|
||||
transformed_y = page_height - y - h
|
||||
|
||||
attrs = {
|
||||
page: page.index,
|
||||
x: x / page_width.to_f,
|
||||
y: transformed_y / page_height.to_f,
|
||||
w: w / page_width.to_f,
|
||||
h: h / page_height.to_f,
|
||||
attachment_uuid: attachment.uuid
|
||||
}
|
||||
|
||||
next if attrs[:w].zero? || attrs[:h].zero?
|
||||
|
||||
if child_field[:MaxLen] && child_field.try(:concrete_field_type) == :comb_text_field
|
||||
attrs[:cell_w] = w / page_width.to_f / child_field[:MaxLen].to_f
|
||||
end
|
||||
|
||||
attrs
|
||||
end
|
||||
|
||||
next if areas.blank?
|
||||
|
||||
field_properties = build_field_properties(field)
|
||||
|
||||
next if field_properties.blank?
|
||||
next if field_properties[:default_value].present?
|
||||
|
||||
if field_properties[:type].in?(%w[radio multiple])
|
||||
if areas.size != field_properties[:options].size
|
||||
field_properties[:options] = build_options(Array.new(areas.size, ''))
|
||||
end
|
||||
|
||||
areas.each_with_index do |area, index|
|
||||
area[:option_uuid] = field_properties[:options][index][:uuid]
|
||||
end
|
||||
end
|
||||
|
||||
{
|
||||
uuid: SecureRandom.uuid,
|
||||
required: field.flags.include?(:required),
|
||||
preferences: {},
|
||||
areas:,
|
||||
**field_properties
|
||||
}
|
||||
end
|
||||
rescue StandardError => e
|
||||
raise if Rails.env.local?
|
||||
|
||||
Rollbar.error(e) if defined?(Rollbar)
|
||||
|
||||
[]
|
||||
end
|
||||
|
||||
def correct_coordinates(x_coord, y_coord, shift, media_box_start)
|
||||
corrected_x = x_coord + shift[0] - media_box_start[0]
|
||||
corrected_y = y_coord + shift[1] - media_box_start[1]
|
||||
|
||||
[corrected_x, corrected_y]
|
||||
end
|
||||
|
||||
def build_field_properties(field)
|
||||
field_name = field.full_field_name if field.full_field_name.to_s.match?(FIELD_NAME_REGEXP)
|
||||
|
||||
field_name = field_name&.encode('utf-8', invalid: :replace, undef: :replace, replace: '')
|
||||
|
||||
attrs = { name: field_name.to_s }
|
||||
attrs[:description] = field[:TU] if field[:TU].present? &&
|
||||
field[:TU] != field.full_field_name &&
|
||||
!field[:TU].in?(SKIP_FIELD_DESCRIPTION)
|
||||
|
||||
if field[:Q].present? && field.field_type == :Tx
|
||||
attrs[:preferences] ||= {}
|
||||
attrs[:preferences][:align] = FIELD_ALIGNMENT.fetch(field[:Q], 'left')
|
||||
end
|
||||
|
||||
if field.field_type == :Btn && field.concrete_field_type == :radio_button && field[:Opt].present?
|
||||
selected_option_index = (field.allowed_values || []).find_index(field.field_value)
|
||||
selected_option = field[:Opt][selected_option_index] if selected_option_index
|
||||
|
||||
{
|
||||
**attrs,
|
||||
type: 'radio',
|
||||
options: build_options(field[:Opt], 'radio'),
|
||||
default_value: selected_option
|
||||
}
|
||||
elsif field.field_type == :Btn && %i[check_box radio_button].include?(field.concrete_field_type) &&
|
||||
field[:Kids].present? && field[:Kids].size > 1 && field.allowed_values.size > 1
|
||||
selected_option = (field.allowed_values || []).find { |v| v == field.field_value }
|
||||
|
||||
return {} if field.allowed_values.include?(:BBox)
|
||||
|
||||
{
|
||||
**attrs,
|
||||
type: 'radio',
|
||||
options: build_options(field.allowed_values, 'radio'),
|
||||
default_value: selected_option
|
||||
}
|
||||
elsif field.field_type == :Btn && %i[check_box radio_button].include?(field.concrete_field_type)
|
||||
{
|
||||
**attrs,
|
||||
type: 'checkbox',
|
||||
default_value: field.field_value.present?
|
||||
}
|
||||
elsif field.field_type == :Ch &&
|
||||
%i[combo_box editable_combo_box].include?(field.concrete_field_type) && field[:Opt].present?
|
||||
{
|
||||
**attrs,
|
||||
type: 'select',
|
||||
options: build_options(field[:Opt], 'select'),
|
||||
default_value: field.field_value.to_s.match?(SELECT_PLACEHOLDER_REGEXP) ? nil : field.field_value.presence
|
||||
}
|
||||
elsif field.field_type == :Ch && field.concrete_field_type == :multi_select && field[:Opt].present?
|
||||
{
|
||||
**attrs,
|
||||
type: 'multiple',
|
||||
options: build_options(field[:Opt], 'multiple'),
|
||||
default_value: field.field_value.presence
|
||||
}
|
||||
elsif field.field_type == :Tx && field.concrete_field_type == :comb_text_field
|
||||
{
|
||||
**attrs,
|
||||
type: 'cells',
|
||||
default_value: field.field_value.presence
|
||||
}
|
||||
elsif field.field_type == :Tx
|
||||
if field[:AA] && ((field[:AA][:F] && field[:AA][:F][:JS].include?('AFDate_')) ||
|
||||
(field[:AA][:K] && field[:AA][:K][:JS].include?('AFDate_')))
|
||||
if (format = field[:AA][:F][:JS][DATE_FORMAT_REGEXP])
|
||||
attrs[:preferences] ||= {}
|
||||
attrs[:preferences][:format] = format.upcase
|
||||
end
|
||||
|
||||
{
|
||||
**attrs,
|
||||
type: 'date',
|
||||
default_value: field.field_value.presence
|
||||
}
|
||||
else
|
||||
{
|
||||
**attrs,
|
||||
type: 'text',
|
||||
default_value: field.field_value.presence
|
||||
}
|
||||
end
|
||||
elsif field.field_type == :Sig
|
||||
{
|
||||
**attrs,
|
||||
type: field.try(:field_name).to_s.downcase.include?('initials') ? 'initials' : 'signature'
|
||||
}
|
||||
else
|
||||
{}
|
||||
end.compact
|
||||
end
|
||||
|
||||
def build_options(values, type = nil)
|
||||
is_skip_single_value = type.in?(%w[radio multiple]) && values.uniq.size == 1
|
||||
|
||||
values.filter_map do |option|
|
||||
is_option_number = option.is_a?(Symbol) && option.to_s.match?(/\A\d+\z/)
|
||||
|
||||
option = option[1] if option.is_a?(Array) && option.size == 2
|
||||
|
||||
if option.is_a?(String) || option.is_a?(Symbol)
|
||||
option = option.to_s.encode('utf-8', invalid: :replace, undef: :replace, replace: '')
|
||||
end
|
||||
|
||||
next if type == 'select' && option.to_s.match?(SELECT_PLACEHOLDER_REGEXP)
|
||||
|
||||
{
|
||||
uuid: SecureRandom.uuid,
|
||||
value: is_option_number || is_skip_single_value ? '' : option.presence
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def build_fields_with_pages(pdf)
|
||||
fields_index = {}
|
||||
annots_index = {}
|
||||
|
||||
pdf.pages.each do |page|
|
||||
page.each_annotation do |annot|
|
||||
annots_index[annot.hash] = page
|
||||
|
||||
if !annot.key?(:Parent) && annot.key?(:FT)
|
||||
fields_index[annot.hash] ||= HexaPDF::Type::AcroForm::Field.wrap(pdf, annot)
|
||||
elsif annot.key?(:Parent)
|
||||
field = annot[:Parent]
|
||||
seen = Set.new.compare_by_identity
|
||||
|
||||
field = field[:Parent] while field[:Parent] && seen.add?(field.value)
|
||||
|
||||
annots_index[field.hash] ||= page
|
||||
fields_index[field.hash] ||= HexaPDF::Type::AcroForm::Field.wrap(pdf, field)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
[process_fields_array(pdf, fields_index.values), annots_index]
|
||||
end
|
||||
|
||||
def process_fields_array(pdf, array, acc = [], seen = Set.new.compare_by_identity)
|
||||
array.each_with_index do |field, index|
|
||||
next if field.nil?
|
||||
|
||||
unless field.respond_to?(:type) && field.type == :XXAcroFormField
|
||||
array[index] = field = HexaPDF::Type::AcroForm::Field.wrap(pdf, field)
|
||||
end
|
||||
|
||||
next unless seen.add?(field.value)
|
||||
|
||||
if field.terminal_field?
|
||||
acc << field
|
||||
else
|
||||
process_fields_array(pdf, field[:Kids], acc, seen)
|
||||
end
|
||||
end
|
||||
|
||||
acc
|
||||
end
|
||||
# rubocop:enable Metrics
|
||||
end
|
||||
end
|
||||
@@ -6,6 +6,31 @@ module Templates
|
||||
SKIP_FIELD_TYPES = %i[unknown pushbutton].freeze
|
||||
TEXT_OPERATOR_REGEXP = /\bT[jJ]\b/
|
||||
|
||||
FIELD_NAME_REGEXP = /\A(?=.*\p{L})[\p{L}\d\s-]+\z/
|
||||
SKIP_FIELD_DESCRIPTION = %w[undefined].freeze
|
||||
SELECT_PLACEHOLDER_REGEXP = /\b(
|
||||
Select |
|
||||
Choose |
|
||||
Wählen |
|
||||
Auswählen |
|
||||
Sélectionner|
|
||||
Choisir |
|
||||
Seleccionar |
|
||||
Elegir |
|
||||
Seleziona |
|
||||
Scegliere |
|
||||
Selecionar |
|
||||
Escolher
|
||||
)\b/ix
|
||||
|
||||
DATE_FORMAT_REGEXP = %r{[myd]{2,4}[-\\/\s.][myd]{2,4}[-\\/\s.][myd]{2,4}}i
|
||||
|
||||
FIELD_ALIGNMENT = {
|
||||
0 => 'left',
|
||||
1 => 'center',
|
||||
2 => 'right'
|
||||
}.freeze
|
||||
|
||||
module_function
|
||||
|
||||
def call(attachment, doc, data)
|
||||
@@ -108,12 +133,12 @@ module Templates
|
||||
def build_field_properties(widgets)
|
||||
field = widgets.first.field
|
||||
|
||||
field_name = field.name if field.name.match?(FindAcroFields::FIELD_NAME_REGEXP)
|
||||
field_name = field.name if field.name.match?(FIELD_NAME_REGEXP)
|
||||
|
||||
attrs = { name: field_name.to_s }
|
||||
attrs[:description] = field.alternate_name if field.alternate_name.present? &&
|
||||
field.alternate_name != field.name &&
|
||||
!field.alternate_name.in?(FindAcroFields::SKIP_FIELD_DESCRIPTION)
|
||||
!field.alternate_name.in?(SKIP_FIELD_DESCRIPTION)
|
||||
|
||||
case field.type
|
||||
when :checkbox, :radio
|
||||
@@ -172,7 +197,7 @@ module Templates
|
||||
**attrs,
|
||||
type: 'select',
|
||||
options: build_options(field.options, 'select'),
|
||||
default_value: value.to_s.match?(FindAcroFields::SELECT_PLACEHOLDER_REGEXP) ? nil : value
|
||||
default_value: value.to_s.match?(SELECT_PLACEHOLDER_REGEXP) ? nil : value
|
||||
}
|
||||
end
|
||||
|
||||
@@ -183,7 +208,7 @@ module Templates
|
||||
end
|
||||
|
||||
def build_text_properties(attrs, field)
|
||||
preferences = { align: FindAcroFields::FIELD_ALIGNMENT.fetch(field.quadding.to_i, 'left') }
|
||||
preferences = { align: FIELD_ALIGNMENT.fetch(field.quadding.to_i, 'left') }
|
||||
|
||||
attrs = { **attrs, preferences: }
|
||||
|
||||
@@ -191,7 +216,7 @@ module Templates
|
||||
{ **attrs, type: 'cells', default_value: field.value.presence }
|
||||
elsif date?(field)
|
||||
format = [field.format_js, field.keystroke_js].compact
|
||||
.filter_map { |js| js[FindAcroFields::DATE_FORMAT_REGEXP] }
|
||||
.filter_map { |js| js[DATE_FORMAT_REGEXP] }
|
||||
.first
|
||||
|
||||
preferences[:format] = format.upcase if format
|
||||
@@ -218,7 +243,7 @@ module Templates
|
||||
option = option.to_s.encode('utf-8', invalid: :replace, undef: :replace, replace: '')
|
||||
end
|
||||
|
||||
next if type == 'select' && option.to_s.match?(FindAcroFields::SELECT_PLACEHOLDER_REGEXP)
|
||||
next if type == 'select' && option.to_s.match?(SELECT_PLACEHOLDER_REGEXP)
|
||||
|
||||
{
|
||||
uuid: SecureRandom.uuid,
|
||||
|
||||
@@ -345,10 +345,7 @@ module Templates
|
||||
|
||||
uuid = pdf_ref['attachment_uuid']
|
||||
source = sources[[uuid, nil]] ||= open_or_build_pdf(attachments_index[uuid])
|
||||
page = source.get_page(pdf_ref['page'])
|
||||
|
||||
width = page.width
|
||||
height = page.height
|
||||
width, height = source.page_size(pdf_ref['page'])
|
||||
|
||||
width, height = height, width unless (pdf_ref['rotate'].to_i % 180).zero?
|
||||
|
||||
@@ -435,7 +432,9 @@ module Templates
|
||||
end
|
||||
|
||||
def save_document(template, old_attachment, data)
|
||||
annotations = data.size < ANNOTATIONS_SIZE_LIMIT ? Templates::BuildAnnotations.call(data) : []
|
||||
doc = Pdfium::Document.open_io(StringIO.new(data))
|
||||
|
||||
annotations = data.size < ANNOTATIONS_SIZE_LIMIT ? Templates::BuildPdfiumAnnotations.call(doc) : []
|
||||
sha256 = Base64.urlsafe_encode64(Digest::SHA256.digest(data))
|
||||
|
||||
blob = ActiveStorage::Blob.create_and_upload!(
|
||||
@@ -448,7 +447,9 @@ module Templates
|
||||
|
||||
document = template.documents.create!(blob:)
|
||||
|
||||
Templates::ProcessDocument.call(document, data)
|
||||
Templates::ProcessDocument.call(document, data, doc:)
|
||||
ensure
|
||||
doc&.close
|
||||
end
|
||||
|
||||
def remap_fields(template, mapping)
|
||||
|
||||
@@ -22,16 +22,10 @@ module Templates
|
||||
def call(attachment, data, extract_fields: false, max_pages: MAX_NUMBER_OF_PAGES_PROCESSED, doc: nil)
|
||||
if attachment.content_type == PDF_CONTENT_TYPE
|
||||
if extract_fields && data.size < MAX_FLATTEN_FILE_SIZE
|
||||
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)
|
||||
end
|
||||
fields = Templates::FindPdfiumAcroFields.call(attachment, doc, data)
|
||||
end
|
||||
|
||||
generate_pdf_preview_images(attachment, data, pdf, max_pages:, doc:)
|
||||
generate_pdf_preview_images(attachment, data, max_pages:, doc:)
|
||||
|
||||
attachment.metadata['pdf']['fields'] = fields if fields
|
||||
elsif attachment.image?
|
||||
@@ -41,27 +35,13 @@ module Templates
|
||||
attachment
|
||||
end
|
||||
|
||||
def process(attachment, data, extract_fields: false, doc: nil)
|
||||
def process(attachment, data, doc:, extract_fields: false)
|
||||
if attachment.content_type == PDF_CONTENT_TYPE && extract_fields && data.size < MAX_FLATTEN_FILE_SIZE
|
||||
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)
|
||||
end
|
||||
end
|
||||
|
||||
if doc
|
||||
number_of_pages = doc.page_count
|
||||
else
|
||||
pdf ||= HexaPDF::Document.new(io: StringIO.new(data))
|
||||
|
||||
number_of_pages = pdf.pages.size
|
||||
fields = Templates::FindPdfiumAcroFields.call(attachment, doc, data)
|
||||
end
|
||||
|
||||
attachment.metadata['pdf'] ||= {}
|
||||
attachment.metadata['pdf']['number_of_pages'] = number_of_pages
|
||||
attachment.metadata['pdf']['number_of_pages'] = doc.page_count
|
||||
attachment.metadata['pdf']['fields'] = fields if fields
|
||||
|
||||
attachment
|
||||
@@ -88,17 +68,10 @@ module Templates
|
||||
)
|
||||
end
|
||||
|
||||
def generate_pdf_preview_images(attachment, data, pdf = nil, max_pages: MAX_NUMBER_OF_PAGES_PROCESSED, doc: nil)
|
||||
def generate_pdf_preview_images(attachment, data, doc:, max_pages: MAX_NUMBER_OF_PAGES_PROCESSED)
|
||||
ActiveStorage::Attachment.where(name: ATTACHMENT_NAME, record: attachment).destroy_all
|
||||
|
||||
if doc
|
||||
number_of_pages = doc.page_count
|
||||
else
|
||||
pdf ||= HexaPDF::Document.new(io: StringIO.new(data))
|
||||
number_of_pages = pdf.pages.size
|
||||
|
||||
data = maybe_flatten_form(data, pdf)
|
||||
end
|
||||
number_of_pages = doc.page_count
|
||||
|
||||
attachment.metadata['pdf'] ||= {}
|
||||
attachment.metadata['pdf']['number_of_pages'] = number_of_pages
|
||||
@@ -109,20 +82,19 @@ module Templates
|
||||
|
||||
max_pages_to_process = data.size < GENERATE_PREVIEW_SIZE_LIMIT ? max_pages : 1
|
||||
|
||||
generate_document_preview_images(attachment, data, 0..[number_of_pages - 1, max_pages_to_process].min, doc:)
|
||||
generate_document_preview_images(attachment, 0..[number_of_pages - 1, max_pages_to_process].min, doc:)
|
||||
end
|
||||
|
||||
def generate_document_preview_images(attachment, data, range, concurrency: CONCURRENCY, doc: nil)
|
||||
flatten_pages = doc&.form?
|
||||
pdfium_doc = doc || Pdfium::Document.open_bytes(data)
|
||||
def generate_document_preview_images(attachment, range, doc:, concurrency: CONCURRENCY)
|
||||
flatten_pages = doc.form?
|
||||
|
||||
pool = Concurrent::FixedThreadPool.new(concurrency)
|
||||
|
||||
promises =
|
||||
range.map do |page_number|
|
||||
doc_page = pdfium_doc.get_page(page_number)
|
||||
doc_page = doc.get_page(page_number)
|
||||
|
||||
hide_placeholder_widgets(doc_page, hide_empty: !flatten_pages) if doc
|
||||
hide_placeholder_widgets(doc_page, hide_empty: !flatten_pages)
|
||||
doc_page.flatten if flatten_pages
|
||||
|
||||
bytes, width, height = doc_page.render_to_bitmap(width: MAX_WIDTH)
|
||||
@@ -146,7 +118,6 @@ module Templates
|
||||
end
|
||||
end
|
||||
ensure
|
||||
pdfium_doc&.close if doc.nil?
|
||||
pool&.kill
|
||||
end
|
||||
|
||||
@@ -162,7 +133,7 @@ module Templates
|
||||
if value.blank?
|
||||
next unless hide_empty
|
||||
elsif handle.option_labels.blank? ||
|
||||
!value.match?(FindAcroFields::SELECT_PLACEHOLDER_REGEXP)
|
||||
!value.match?(FindPdfiumAcroFields::SELECT_PLACEHOLDER_REGEXP)
|
||||
next
|
||||
end
|
||||
|
||||
@@ -194,33 +165,6 @@ module Templates
|
||||
blob
|
||||
end
|
||||
|
||||
def maybe_flatten_form(data, pdf)
|
||||
return data if data.size > MAX_FLATTEN_FILE_SIZE
|
||||
return data if pdf.acro_form.blank?
|
||||
|
||||
io = StringIO.new
|
||||
|
||||
pdf.acro_form.each_field do |field|
|
||||
next if field.field_type != :Ch ||
|
||||
field[:Opt].blank? ||
|
||||
%i[combo_box editable_combo_box].exclude?(field.concrete_field_type) ||
|
||||
!field.field_value.to_s.match?(FindAcroFields::SELECT_PLACEHOLDER_REGEXP)
|
||||
|
||||
field[:V] = ''
|
||||
end
|
||||
|
||||
pdf.acro_form.create_appearances(force: true) if pdf.acro_form[:NeedAppearances]
|
||||
pdf.acro_form.flatten
|
||||
|
||||
pdf.write(io, incremental: false, validate: false)
|
||||
|
||||
io.string
|
||||
rescue StandardError
|
||||
raise if Rails.env.development?
|
||||
|
||||
data
|
||||
end
|
||||
|
||||
def normalize_attachment_fields(template, attachments = template.documents)
|
||||
attachments.flat_map do |a|
|
||||
pdf_fields = a.metadata['pdf'].delete('fields').to_a if a.metadata['pdf'].present?
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
module Users
|
||||
module_function
|
||||
|
||||
def send_emails?(user)
|
||||
!user.archived_at?
|
||||
end
|
||||
|
||||
def generate_csv(users)
|
||||
headers = %w[email first_name last_name role current_sign_in_at last_sign_in_at updated_at created_at]
|
||||
|
||||
|
||||
@@ -173,7 +173,7 @@ module VerifyPdfSignature
|
||||
io.seek(0)
|
||||
|
||||
Pdfium::Document.open_bytes(io.read(signed_end)) do |signed_document|
|
||||
next false unless signed_document.valid_cross_reference_table?
|
||||
next true unless signed_document.valid_cross_reference_table?
|
||||
|
||||
serialized_document(signed_document) != serialized_document(document)
|
||||
end
|
||||
@@ -182,13 +182,20 @@ module VerifyPdfSignature
|
||||
def serialized_document(document)
|
||||
pages = (0...document.page_count).map do |index|
|
||||
page = document.get_page(index)
|
||||
objects = page.objects.map { |object| [*object.to_a, image_digest(page, object)] }
|
||||
|
||||
[page.rotation, page.objects, page.annotations, page.text]
|
||||
[page.rotation, objects, page.annotations, page.text]
|
||||
end
|
||||
|
||||
[pages, document.bookmarks]
|
||||
end
|
||||
|
||||
def image_digest(page, object)
|
||||
return unless object.image?
|
||||
|
||||
Digest::SHA256.hexdigest(page.extract_image_bitmap(object.object_ptr)[:data])
|
||||
end
|
||||
|
||||
def signed_data(io, byte_range)
|
||||
byte_range.each_slice(2).map do |offset, length|
|
||||
io.seek(offset)
|
||||
|
||||
@@ -42,7 +42,11 @@ FactoryBot.define do
|
||||
record: template
|
||||
)
|
||||
|
||||
Templates::ProcessDocument.call(attachment, attachment.download)
|
||||
data = attachment.download
|
||||
|
||||
Pdfium::Document.open_io(StringIO.new(data)) do |doc|
|
||||
Templates::ProcessDocument.call(attachment, data, doc:)
|
||||
end
|
||||
|
||||
template.schema << {
|
||||
attachment_uuid: attachment.uuid,
|
||||
|
||||
@@ -197,7 +197,7 @@ describe 'Submission API' do
|
||||
expect(response.parsed_body).to eq({ 'error' => 'Defined more signing parties than in template' })
|
||||
end
|
||||
|
||||
it 'returns an error if the message has no body value' do
|
||||
it 'creates a submission when the message has only a subject' do
|
||||
post '/api/submissions', headers: { 'x-auth-token': author.access_token.token }, params: {
|
||||
template_id: templates[0].id,
|
||||
send_email: true,
|
||||
@@ -209,8 +209,13 @@ describe 'Submission API' do
|
||||
}
|
||||
}.to_json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_content)
|
||||
expect(response.parsed_body).to eq({ 'error' => 'body is required in `message`.' })
|
||||
expect(response).to have_http_status(:ok)
|
||||
|
||||
submission = Submission.last
|
||||
email_message = EmailMessage.last
|
||||
|
||||
expect(submission.submitters.first.preferences['email_message_uuid']).to eq(email_message.uuid)
|
||||
expect(email_message).to have_attributes(subject: 'Custom Email Subject', body: '')
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
Reference in new issue
Block a user