mirror of
https://github.com/docusealco/docuseal
synced 2026-11-05 14:42:33 -05:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11856a17ac | ||
|
|
7b4c74d711 | ||
|
|
e0b0d4e803 | ||
|
|
ec04a95e36 | ||
|
|
887d80f6d7 | ||
|
|
9ad281d457 | ||
|
|
e316d2006f | ||
|
|
f9669b0e16 | ||
|
|
785eb789ad | ||
|
|
436d947f6a | ||
|
|
033ce3a169 | ||
|
|
c216e43d24 | ||
|
|
d2373c1578 | ||
|
|
474603e1c9 | ||
|
|
1fc1444b8f | ||
|
|
47361da26f | ||
|
|
11dea042ad | ||
|
|
8756931638 | ||
|
|
180b201dcf | ||
|
|
a1c30c2093 | ||
|
|
66bf6af7ba | ||
|
|
1a65e7bcff | ||
|
|
59b5f03948 | ||
|
|
4626518c83 | ||
|
|
c18e263f34 | ||
|
|
c3dae1bfe4 | ||
|
|
b44152fd52 | ||
|
|
2889a7c937 | ||
|
|
91e65e4a19 | ||
|
|
2a1a2cbd82 | ||
|
|
99b3247cf7 | ||
|
|
fa91c6d6be |
No files matched your search
@@ -23,17 +23,20 @@ class AccountsController < ApplicationController
|
|||||||
unless Docuseal.multitenant?
|
unless Docuseal.multitenant?
|
||||||
@encrypted_config = EncryptedConfig.find_or_initialize_by(account: current_account,
|
@encrypted_config = EncryptedConfig.find_or_initialize_by(account: current_account,
|
||||||
key: EncryptedConfig::APP_URL_KEY)
|
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])
|
if can?(:manage, @encrypted_config)
|
||||||
@encrypted_config.errors.add(:value, I18n.t('should_be_a_valid_url'))
|
@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
|
end
|
||||||
|
|
||||||
@encrypted_config.save!
|
|
||||||
|
|
||||||
Docuseal.refresh_default_url_options!
|
|
||||||
end
|
end
|
||||||
|
|
||||||
with_locale do
|
with_locale do
|
||||||
@@ -47,7 +50,8 @@ class AccountsController < ApplicationController
|
|||||||
authorize!(:manage, current_account)
|
authorize!(:manage, current_account)
|
||||||
|
|
||||||
true_user.skip_reconfirmation!
|
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)
|
true_user.account.update!(archived_at: Time.current)
|
||||||
|
|
||||||
# rubocop:disable Layout/LineLength
|
# rubocop:disable Layout/LineLength
|
||||||
|
|||||||
@@ -17,24 +17,23 @@ module Api
|
|||||||
|
|
||||||
def verify
|
def verify
|
||||||
file = Base64.decode64(params[:file])
|
file = Base64.decode64(params[:file])
|
||||||
pdf = HexaPDF::Document.new(io: StringIO.new(file))
|
|
||||||
|
|
||||||
trusted_certs = Accounts.load_trusted_certs(current_account)
|
trusted_certs = Accounts.load_trusted_certs(current_account)
|
||||||
is_checksum_found = CompletedDocument.exists?(sha256: Base64.urlsafe_encode64(Digest::SHA256.digest(file)))
|
is_checksum_found = CompletedDocument.exists?(sha256: Base64.urlsafe_encode64(Digest::SHA256.digest(file)))
|
||||||
|
|
||||||
render json: {
|
render json: {
|
||||||
checksum_status: is_checksum_found ? 'verified' : 'not_found',
|
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,
|
verification_result: sig.messages.map { |m| { type: m.status || :info, content: m.text } },
|
||||||
signer_name: sig.signer_name,
|
signer_name: sig.common_name,
|
||||||
signing_reason: sig.signing_reason,
|
signing_reason: sig.reason,
|
||||||
signing_time: sig.signing_time,
|
signing_time: sig.signing_time,
|
||||||
signature_type: sig.signature_type
|
signature_type: sig.type
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
}
|
}
|
||||||
rescue HexaPDF::MalformedPDFError
|
rescue Pdfium::PdfiumError
|
||||||
render json: { error: 'Malformed PDF' }, status: :unprocessable_content
|
render json: { error: 'Malformed PDF' }, status: :unprocessable_content
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -14,14 +14,14 @@ class MfaSetupController < ApplicationController
|
|||||||
def edit; end
|
def edit; end
|
||||||
|
|
||||||
def create
|
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])
|
if current_user.validate_and_consume_otp!(params[:otp_attempt])
|
||||||
current_user.otp_required_for_login = true
|
current_user.otp_required_for_login = true
|
||||||
current_user.save!
|
current_user.save!
|
||||||
|
|
||||||
redirect_to settings_profile_index_path, notice: I18n.t('2fa_has_been_configured')
|
redirect_to settings_profile_index_path, notice: I18n.t('2fa_has_been_configured')
|
||||||
else
|
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)
|
@provision_url = current_user.otp_provisioning_uri(current_user.email, issuer: Docuseal.product_name)
|
||||||
|
|
||||||
@error_message = I18n.t('code_is_invalid')
|
@error_message = I18n.t('code_is_invalid')
|
||||||
@@ -31,13 +31,13 @@ class MfaSetupController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def destroy
|
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])
|
if current_user.validate_and_consume_otp!(params[:otp_attempt])
|
||||||
current_user.update!(otp_required_for_login: false, otp_secret: nil)
|
current_user.update!(otp_required_for_login: false, otp_secret: nil)
|
||||||
|
|
||||||
redirect_to settings_profile_index_path, notice: I18n.t('2fa_has_been_removed')
|
redirect_to settings_profile_index_path, notice: I18n.t('2fa_has_been_removed')
|
||||||
else
|
else
|
||||||
RateLimit.call("mfa-setup-otp-#{current_user.id}", limit: 5, ttl: 5.minutes, enabled: true)
|
|
||||||
|
|
||||||
@error_message = I18n.t('code_is_invalid')
|
@error_message = I18n.t('code_is_invalid')
|
||||||
|
|
||||||
render turbo_stream: turbo_stream.replace(:modal, template: 'mfa_setup/edit'), status: :unprocessable_content
|
render turbo_stream: turbo_stream.replace(:modal, template: 'mfa_setup/edit'), status: :unprocessable_content
|
||||||
|
|||||||
@@ -162,6 +162,10 @@ class StartFormController < ApplicationController
|
|||||||
end
|
end
|
||||||
rescue Submitters::StartForm::NotSaved
|
rescue Submitters::StartForm::NotSaved
|
||||||
render :show, status: :unprocessable_content
|
render :show, status: :unprocessable_content
|
||||||
|
rescue Submitters::BouncedEmail => e
|
||||||
|
@error_message = e.message
|
||||||
|
|
||||||
|
render :error, status: :unprocessable_content
|
||||||
rescue Submitters::UnableToSendCode, Submitters::InvalidOtp => e
|
rescue Submitters::UnableToSendCode, Submitters::InvalidOtp => e
|
||||||
redirect_to start_form_path(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
|
alert: e.message
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class StartFormEmail2faSendController < ApplicationController
|
class StartFormEmail2faSendController < ApplicationController
|
||||||
|
layout 'form'
|
||||||
|
|
||||||
around_action :with_browser_locale
|
around_action :with_browser_locale
|
||||||
|
|
||||||
skip_before_action :authenticate_user!
|
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)),
|
redirect_to start_form_path(@template.slug, params: submitter_params.merge(email_verification: true)),
|
||||||
**redir_params
|
**redir_params
|
||||||
|
rescue Submitters::BouncedEmail => e
|
||||||
|
@error_message = e.message
|
||||||
|
|
||||||
|
render 'start_form/error', status: :unprocessable_content
|
||||||
rescue Submitters::UnableToSendCode => e
|
rescue Submitters::UnableToSendCode => e
|
||||||
redirect_to start_form_path(@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
|
alert: e.message
|
||||||
|
|||||||
@@ -87,6 +87,10 @@ class StartFormResubmitController < ApplicationController
|
|||||||
end
|
end
|
||||||
rescue Submitters::StartForm::NotSaved
|
rescue Submitters::StartForm::NotSaved
|
||||||
render 'start_form/show', status: :unprocessable_content
|
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
|
rescue Submitters::UnableToSendCode, Submitters::InvalidOtp => e
|
||||||
flash.now[:alert] = e.message
|
flash.now[:alert] = e.message
|
||||||
|
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ class SubmitFormCompletedDownloadController < ApplicationController
|
|||||||
private
|
private
|
||||||
|
|
||||||
def submitter_slug
|
def submitter_slug
|
||||||
params[:submit_form_slug] || params[:submitter_slug] || params[:submitter_id]
|
params[:submit_form_slug] || params[:submitter_slug]
|
||||||
end
|
end
|
||||||
|
|
||||||
def respond_with_combined(submitter)
|
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_version = SubmitterVersion.find_by!(slug: params[:slug] || params[:submit_form_slug])
|
||||||
|
|
||||||
@submitter = submitter_version.submitter
|
@submitter = submitter_version.submitter
|
||||||
|
|
||||||
|
maybe_render_locked_page
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|||||||
@@ -35,6 +35,12 @@ class SubmitFormEmail2fasController < ApplicationController
|
|||||||
|
|
||||||
RateLimit.call("send-email-code-#{@submitter.id}", limit: 2, ttl: 45.seconds, enabled: true)
|
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)
|
SendSubmitterVerificationEmailJob.perform_async('submitter_id' => @submitter.id, 'locale' => I18n.locale.to_s)
|
||||||
|
|
||||||
redir_params = params[:resend] ? { alert: I18n.t(:code_has_been_resent) } : {}
|
redir_params = params[:resend] ? { alert: I18n.t(:code_has_been_resent) } : {}
|
||||||
|
|||||||
@@ -13,9 +13,10 @@ class TemplatesDebugController < ApplicationController
|
|||||||
data = attachment.download
|
data = attachment.download
|
||||||
|
|
||||||
unless attachment.image?
|
unless attachment.image?
|
||||||
pdf = HexaPDF::Document.new(io: StringIO.new(data))
|
fields =
|
||||||
|
Pdfium::Document.open_io(StringIO.new(data)) do |doc|
|
||||||
fields = Templates::FindAcroFields.call(pdf, attachment, data)
|
Templates::FindPdfiumAcroFields.call(attachment, doc, data)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# fields, = Templates::DetectFields.call(StringIO.new(data), attachment:) if fields.blank?
|
# fields, = Templates::DetectFields.call(StringIO.new(data), attachment:) if fields.blank?
|
||||||
|
|||||||
@@ -10,8 +10,14 @@ class TemplatesDetectFieldsController < ApplicationController
|
|||||||
|
|
||||||
sse = SSE.new(response.stream)
|
sse = SSE.new(response.stream)
|
||||||
|
|
||||||
documents = @template.schema_documents.preload(:blob)
|
documents =
|
||||||
documents = documents.where(uuid: params[:attachment_uuid]) if params[:attachment_uuid].present?
|
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
|
page_number = params[:page].presence&.to_i
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ class TestingAccountsController < ApplicationController
|
|||||||
def create
|
def create
|
||||||
authorize!(:manage, current_account)
|
authorize!(:manage, current_account)
|
||||||
authorize!(:manage, current_user)
|
authorize!(:manage, current_user)
|
||||||
|
authorize!(:manage, EncryptedConfig)
|
||||||
|
|
||||||
impersonate_user(Accounts.find_or_create_testing_user(true_user.account))
|
impersonate_user(Accounts.find_or_create_testing_user(true_user.account))
|
||||||
|
|
||||||
|
|||||||
@@ -230,7 +230,7 @@ export default {
|
|||||||
download () {
|
download () {
|
||||||
this.isDownloading = true
|
this.isDownloading = true
|
||||||
|
|
||||||
fetch(this.baseUrl + `/submitters/${this.submitterSlug}/download`, {
|
fetch(this.baseUrl + `/s/${this.submitterSlug}/documents`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
...this.fetchOptions
|
...this.fetchOptions
|
||||||
}).then(async (response) => {
|
}).then(async (response) => {
|
||||||
|
|||||||
@@ -3525,6 +3525,8 @@ export default {
|
|||||||
this.onChange(this.template)
|
this.onChange(this.template)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.pushUndo()
|
||||||
|
|
||||||
if (!this.autosave && !force) {
|
if (!this.autosave && !force) {
|
||||||
return Promise.resolve({})
|
return Promise.resolve({})
|
||||||
}
|
}
|
||||||
@@ -3535,8 +3537,6 @@ export default {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
this.pushUndo()
|
|
||||||
|
|
||||||
return this.baseFetch(`/templates/${this.template.id}`, {
|
return this.baseFetch(`/templates/${this.template.id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
|||||||
@@ -555,8 +555,10 @@ export default {
|
|||||||
return this.template.schema.some((item) => item.dynamic)
|
return this.template.schema.some((item) => item.dynamic)
|
||||||
},
|
},
|
||||||
numberOfPages () {
|
numberOfPages () {
|
||||||
return this.template.documents.reduce((acc, doc) => {
|
return this.template.schema.reduce((acc, item) => {
|
||||||
return acc + doc.metadata?.pdf?.number_of_pages || doc.preview_images.length
|
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)
|
}, 0)
|
||||||
},
|
},
|
||||||
isShowFieldSearch () {
|
isShowFieldSearch () {
|
||||||
@@ -774,9 +776,10 @@ export default {
|
|||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
},
|
},
|
||||||
...(this.withDetectExistingFields
|
body: JSON.stringify({
|
||||||
? { body: JSON.stringify({ fields: this.buildExistingFields() }) }
|
attachment_uuid: this.template.schema.map((item) => item.attachment_uuid),
|
||||||
: {})
|
...(this.withDetectExistingFields ? { fields: this.buildExistingFields() } : {})
|
||||||
|
})
|
||||||
}).then(async (response) => {
|
}).then(async (response) => {
|
||||||
const reader = response.body.getReader()
|
const reader = response.body.getReader()
|
||||||
const decoder = new TextDecoder('utf-8')
|
const decoder = new TextDecoder('utf-8')
|
||||||
|
|||||||
@@ -366,6 +366,9 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
beforeUnmount () {
|
||||||
|
document.getElementById('list_form_buttons')?.classList?.add('hidden')
|
||||||
|
},
|
||||||
methods: {
|
methods: {
|
||||||
t (key) {
|
t (key) {
|
||||||
return this.i18n[key] || key
|
return this.i18n[key] || key
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ class EmailMessage < ApplicationRecord
|
|||||||
|
|
||||||
attribute :uuid, :string, default: -> { SecureRandom.uuid }
|
attribute :uuid, :string, default: -> { SecureRandom.uuid }
|
||||||
|
|
||||||
|
normalizes :body, with: ->(value) { value.to_s }, apply_to_nil: true
|
||||||
|
|
||||||
before_validation :set_sha1, on: :create
|
before_validation :set_sha1, on: :create
|
||||||
|
|
||||||
def normalized_body
|
def normalized_body
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center md:items-end gap-2">
|
<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: %>
|
<%= render 'submissions_filters/filter_button', filter_params: %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,15 +3,16 @@
|
|||||||
<% ordered_filters = request.query_parameters.keys.filter_map { |key| filter_names.find { |name| key.start_with?(name) } }.uniq %>
|
<% 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 = ordered_filters.reverse.each_with_index.to_h { |name, index| [name, index + 1] } %>
|
||||||
<% chip_order.default = ordered_filters.size + 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' } %>
|
<% status_icons = { 'all' => 'list', 'pending' => 'clock', 'completed' => 'circle_check' } %>
|
||||||
<% current_status = status_icons.key?(params[:status].to_s) ? params[:status].to_s : 'all' %>
|
<% 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? %>
|
<% with_status_button = local_assigns[:with_status] && status_icon.blank? %>
|
||||||
<% chips_html = capture do %>
|
<% status_chip_html = capture do %>
|
||||||
<% if status_icon %>
|
<% 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">
|
<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 %>
|
<%= 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>
|
<span class="font-normal truncate"><%= t(params[:status]) %></span>
|
||||||
<% end %>
|
<% 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 %>
|
<%= 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 %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
<% chips_html = capture do %>
|
||||||
<% if params[:folder].present? %>
|
<% 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') %>">
|
<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 %>
|
<%= 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>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% 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">
|
<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' %>">
|
<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') %>
|
<%= svg_icon(status_icons[current_status], class: 'w-5 h-5 shrink-0') %>
|
||||||
@@ -122,15 +126,16 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% if chips_html.present? %>
|
<% 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">
|
<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 %>
|
<% 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' %>">
|
<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') %>
|
<%= svg_icon(status_icons[current_status], class: 'w-5 h-5 shrink-0') %>
|
||||||
<span><%= t(current_status) %></span>
|
<span><%= t(current_status) %></span>
|
||||||
<%= svg_icon('chevron_down', class: 'w-4 h-4 shrink-0') %>
|
<%= svg_icon('chevron_down', class: 'w-4 h-4 shrink-0') %>
|
||||||
</a>
|
</a>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
<%= status_chip_html %>
|
||||||
<%= chips_html %>
|
<%= chips_html %>
|
||||||
</scroll-fade>
|
</scroll-fade>
|
||||||
<% end %>
|
<% end %>
|
||||||
@@ -64,8 +64,12 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap gap-2 w-full md:w-fit md:justify-between md:flex-none md:pt-1">
|
<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) %>
|
<% if 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 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 %>
|
<% end %>
|
||||||
<% if can?(:create, current_account.templates.new(author: current_user)) %>
|
<% if can?(:create, current_account.templates.new(author: current_user)) %>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
@@ -99,9 +103,6 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% if template.archived_at? %>
|
<% 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">
|
<div class="flex-1">
|
||||||
<%= link_to template_preview_path(template), class: 'btn btn-outline btn-sm w-full' do %>
|
<%= 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">
|
<span class="flex items-center justify-center space-x-2">
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-nowrap md:flex-wrap md:justify-end items-center gap-2">
|
<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">
|
<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 %>
|
<%= 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') %>
|
<%= svg_icon('download', class: 'w-6 h-6 stroke-2') %>
|
||||||
|
|||||||
@@ -46,6 +46,20 @@ module HexaPDF
|
|||||||
def color_space(name)
|
def color_space(name)
|
||||||
GlobalConfiguration.constantize('color_space.map', name).new
|
GlobalConfiguration.constantize('color_space.map', name).new
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
# fix NoMethodError: undefined method `field_value' for #<HexaPDF::Type::AcroForm::Field
|
# fix NoMethodError: undefined method `field_value' for #<HexaPDF::Type::AcroForm::Field
|
||||||
@@ -169,8 +183,53 @@ module HexaPDF
|
|||||||
end
|
end
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
HexaPDF::Type::AcroForm::Field.singleton_class.prepend(HexaPDF::CycleSafeInheritedValue)
|
HexaPDF::Type::AcroForm::Field.singleton_class.prepend(HexaPDF::CycleSafeInheritedValue)
|
||||||
HexaPDF::Type::AcroForm::Field.prepend(HexaPDF::CycleSafeFullFieldName)
|
HexaPDF::Type::AcroForm::Field.prepend(HexaPDF::CycleSafeFullFieldName)
|
||||||
HexaPDF::Type::AcroForm::Form.prepend(HexaPDF::CycleSafeEachField)
|
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)
|
||||||
@@ -790,6 +790,7 @@ en: &en
|
|||||||
the_code_has_been_sent_to_your_email: The code has been sent to your email.
|
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.
|
enter_the_verification_code_from_your_email: Enter the verification code from your email.
|
||||||
too_many_attempts: Too many attempts.
|
too_many_attempts: Too many attempts.
|
||||||
|
verification_email_bounced: "Unable to send the verification code: email to this address bounced."
|
||||||
verification_code: Verification Code
|
verification_code: Verification Code
|
||||||
resend_code: Resend Code
|
resend_code: Resend Code
|
||||||
verify_new_sign_in: Verify new sign in
|
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.
|
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.
|
enter_the_verification_code_from_your_email: Ingresa el código de verificación de tu correo electrónico.
|
||||||
too_many_attempts: Demasiados intentos.
|
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
|
verification_code: Código de Verificación
|
||||||
resend_code: Reenviar Código
|
resend_code: Reenviar Código
|
||||||
verify_new_sign_in: Verificar nuevo inicio de sesión
|
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.
|
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.
|
enter_the_verification_code_from_your_email: Inserisci il codice di verifica dalla tua e-mail.
|
||||||
too_many_attempts: Troppi tentativi.
|
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
|
verification_code: Codice di Verifica
|
||||||
resend_code: Reinvia Codice
|
resend_code: Reinvia Codice
|
||||||
verify_new_sign_in: Verifica nuovo accesso
|
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.
|
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.
|
enter_the_verification_code_from_your_email: Saisissez le code de vérification reçu par e‑mail.
|
||||||
too_many_attempts: Trop de tentatives.
|
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
|
verification_code: Code de vérification
|
||||||
resend_code: Renvoyer le code
|
resend_code: Renvoyer le code
|
||||||
verify_new_sign_in: Vérifier une nouvelle connexion
|
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.
|
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.
|
enter_the_verification_code_from_your_email: Insira o código de verificação do seu e-mail.
|
||||||
too_many_attempts: Muitas tentativas.
|
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
|
verification_code: Código de Verificação
|
||||||
resend_code: Reenviar Código
|
resend_code: Reenviar Código
|
||||||
verify_new_sign_in: Verificar novo login
|
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.
|
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.
|
enter_the_verification_code_from_your_email: Geben Sie den Verifizierungscode aus Ihrer E-Mail ein.
|
||||||
too_many_attempts: Zu viele Versuche.
|
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
|
verification_code: Verifizierungscode
|
||||||
resend_code: Code erneut senden
|
resend_code: Code erneut senden
|
||||||
verify_new_sign_in: Neue Anmeldung verifizieren
|
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.
|
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.
|
enter_the_verification_code_from_your_email: Wprowadź kod weryfikacyjny z Twojego e-maila.
|
||||||
too_many_attempts: Zbyt wiele prób.
|
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
|
verification_code: Kod Weryfikacyjny
|
||||||
resend_code: Wyślij Kod Ponownie
|
resend_code: Wyślij Kod Ponownie
|
||||||
powered_by: 'Napędzany przez'
|
powered_by: 'Napędzany przez'
|
||||||
@@ -7008,6 +7016,8 @@ uk:
|
|||||||
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: "Не вдалося надіслати код підтвердження: лист на цю адресу було відхилено."
|
||||||
|
back: Назад
|
||||||
verification_code: Код перевірки
|
verification_code: Код перевірки
|
||||||
resend_code: Надіслати код знову
|
resend_code: Надіслати код знову
|
||||||
powered_by: 'Працює на базі'
|
powered_by: 'Працює на базі'
|
||||||
@@ -7148,6 +7158,8 @@ cs:
|
|||||||
the_code_has_been_sent_to_your_email: Kód byl odeslán na váš e-mail.
|
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.
|
enter_the_verification_code_from_your_email: Zadejte ověřovací kód z vašeho e-mailu.
|
||||||
too_many_attempts: Příliš mnoho pokusů.
|
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
|
verification_code: Ověřovací kód
|
||||||
resend_code: Znovu odeslat kód
|
resend_code: Znovu odeslat kód
|
||||||
powered_by: 'Poháněno'
|
powered_by: 'Poháněno'
|
||||||
@@ -7274,6 +7286,8 @@ he:
|
|||||||
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: "לא ניתן לשלוח את קוד האימות: האימייל לכתובת זו נדחה."
|
||||||
|
back: חזרה
|
||||||
verification_code: קוד אימות
|
verification_code: קוד אימות
|
||||||
resend_code: שלח קוד מחדש
|
resend_code: שלח קוד מחדש
|
||||||
powered_by: 'מופעל על ידי'
|
powered_by: 'מופעל על ידי'
|
||||||
@@ -8081,6 +8095,7 @@ nl: &nl
|
|||||||
the_code_has_been_sent_to_your_email: De code is naar uw e-mail verzonden.
|
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.
|
enter_the_verification_code_from_your_email: Voer de verificatiecode uit uw e-mail in.
|
||||||
too_many_attempts: Te veel pogingen.
|
too_many_attempts: Te veel pogingen.
|
||||||
|
verification_email_bounced: "Verificatiecode kan niet worden verzonden: e-mail naar dit adres is geretourneerd."
|
||||||
verification_code: Verificatiecode
|
verification_code: Verificatiecode
|
||||||
resend_code: Code opnieuw verzenden
|
resend_code: Code opnieuw verzenden
|
||||||
verify_new_sign_in: Nieuwe aanmelding verifiëren
|
verify_new_sign_in: Nieuwe aanmelding verifiëren
|
||||||
@@ -8503,6 +8518,8 @@ ar:
|
|||||||
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: "تعذر إرسال رمز التحقق: تم رفض البريد الإلكتروني المرسل إلى هذا العنوان."
|
||||||
|
back: رجوع
|
||||||
verification_code: رمز التحقق
|
verification_code: رمز التحقق
|
||||||
resend_code: إعادة إرسال الرمز
|
resend_code: إعادة إرسال الرمز
|
||||||
powered_by: 'مشغل بواسطة'
|
powered_by: 'مشغل بواسطة'
|
||||||
@@ -8615,6 +8632,8 @@ ko:
|
|||||||
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: "인증 코드를 보낼 수 없습니다: 이 주소로 보낸 이메일이 반송되었습니다."
|
||||||
|
back: 뒤로
|
||||||
verification_code: 인증 코드
|
verification_code: 인증 코드
|
||||||
resend_code: 코드 재전송
|
resend_code: 코드 재전송
|
||||||
powered_by: '제공:'
|
powered_by: '제공:'
|
||||||
@@ -8727,6 +8746,8 @@ ja:
|
|||||||
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: "認証コードを送信できません:このアドレス宛のメールが配信できませんでした。"
|
||||||
|
back: 戻る
|
||||||
verification_code: 認証コード
|
verification_code: 認証コード
|
||||||
resend_code: コードを再送信
|
resend_code: コードを再送信
|
||||||
powered_by: '提供元:'
|
powered_by: '提供元:'
|
||||||
|
|||||||
+4
-1
@@ -175,10 +175,13 @@ Rails.application.routes.draw do
|
|||||||
|
|
||||||
resources :submitters, only: %i[] do
|
resources :submitters, only: %i[] do
|
||||||
resources :download, only: %i[index], controller: 'submitters_download', constraints: { submitter_id: /\d+/ }
|
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'
|
resources :send_email, only: %i[create], controller: 'submitters_send_email'
|
||||||
end
|
end
|
||||||
|
|
||||||
|
resources :submitters, only: %i[], param: 'slug' do
|
||||||
|
resources :download, only: %i[index], controller: 'submit_form_completed_download'
|
||||||
|
end
|
||||||
|
|
||||||
resources :settings, only: %i[index]
|
resources :settings, only: %i[index]
|
||||||
|
|
||||||
scope '/settings', as: :settings do
|
scope '/settings', as: :settings do
|
||||||
|
|||||||
+34
-78
@@ -3,6 +3,9 @@
|
|||||||
module LoadBmp
|
module LoadBmp
|
||||||
BPPS = [1, 4, 8, 24, 32].freeze
|
BPPS = [1, 4, 8, 24, 32].freeze
|
||||||
|
|
||||||
|
MAX_COORD = ENV.fetch('VIPS_MAX_COORD', '17000').to_i
|
||||||
|
MAX_PIXELS = 145_000_000
|
||||||
|
|
||||||
module_function
|
module_function
|
||||||
|
|
||||||
# rubocop:disable Metrics
|
# rubocop:disable Metrics
|
||||||
@@ -18,40 +21,28 @@ module LoadBmp
|
|||||||
header_data[:height]
|
header_data[:height]
|
||||||
)
|
)
|
||||||
|
|
||||||
if header_data[:bpp] <= 8
|
padded_rows = Vips::Image.new_from_memory_copy(
|
||||||
final_pixel_data = decode_indexed_pixel_data(
|
raw_pixel_data_from_file,
|
||||||
raw_pixel_data_from_file,
|
header_data[:bmp_stride],
|
||||||
header_data[:bpp],
|
header_data[:height],
|
||||||
header_data[:width],
|
1,
|
||||||
header_data[:height],
|
:uchar
|
||||||
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
|
|
||||||
|
|
||||||
image_rgb =
|
image_rgb =
|
||||||
if header_data[:bpp] <= 8
|
if header_data[:bpp] <= 8
|
||||||
image
|
decode_indexed_pixel_data(padded_rows, header_data[:bpp], header_data[:width], header_data[:color_table])
|
||||||
elsif bands == 3
|
else
|
||||||
image.recomb(band3_recomb)
|
bands = header_data[:bpp] / 8
|
||||||
elsif bands == 4
|
|
||||||
image.recomb(band4_recomb)
|
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
|
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 = image_rgb.copy(interpretation: :srgb) if image_rgb.interpretation != :srgb
|
||||||
|
|
||||||
image_rgb
|
image_rgb
|
||||||
@@ -98,6 +89,9 @@ module LoadBmp
|
|||||||
|
|
||||||
raise ArgumentError, 'BMP width must be positive.' if width <= 0
|
raise ArgumentError, 'BMP width must be positive.' if width <= 0
|
||||||
raise ArgumentError, 'BMP height must be positive.' if height <= 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
|
if compression != 0
|
||||||
raise ArgumentError,
|
raise ArgumentError,
|
||||||
@@ -164,62 +158,24 @@ module LoadBmp
|
|||||||
raw_pixel_data_from_file
|
raw_pixel_data_from_file
|
||||||
end
|
end
|
||||||
|
|
||||||
def prepare_unpadded_pixel_data_string(raw_pixel_data_from_file, bpp, width, height, bmp_stride)
|
def decode_indexed_pixel_data(padded_rows, bpp, width, color_table)
|
||||||
bytes_per_pixel = bpp / 8
|
pixels_per_byte = 8 / bpp
|
||||||
actual_row_width_bytes = width * bytes_per_pixel
|
|
||||||
|
|
||||||
unpadded_rows = Array.new(height)
|
image = padded_rows.maplut(build_palette_lut(bpp, color_table))
|
||||||
current_offset_in_blob = 0
|
image = image.bandunfold.bandfold(factor: 3) if pixels_per_byte > 1
|
||||||
|
|
||||||
height.times do |i|
|
image.extract_area(0, 0, width, padded_rows.height)
|
||||||
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
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def decode_indexed_pixel_data(raw_data, bpp, width, height, bmp_stride, color_table)
|
def build_palette_lut(bpp, color_table)
|
||||||
palette = color_table.map { |r, g, b| [r, g, b].pack('CCC') }
|
pixels_per_byte = 8 / bpp
|
||||||
|
mask = (1 << bpp) - 1
|
||||||
|
|
||||||
output = String.new(capacity: width * height * 3)
|
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] }
|
||||||
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
|
|
||||||
end
|
end
|
||||||
|
|
||||||
output
|
Vips::Image.new_from_memory_copy(entries.flatten.pack('C*'), 256, 1, pixels_per_byte * 3, :uchar)
|
||||||
end
|
end
|
||||||
|
|
||||||
def band3_recomb
|
def band3_recomb
|
||||||
|
|||||||
+1
-1
@@ -94,7 +94,7 @@ module LoadIco
|
|||||||
|
|
||||||
palette = []
|
palette = []
|
||||||
if dib_bpp <= 8
|
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
|
num_palette_entries.times do
|
||||||
palette_color_bytes = dib_io.read(4)
|
palette_color_bytes = dib_io.read(4)
|
||||||
return nil unless palette_color_bytes && palette_color_bytes.bytesize == 4
|
return nil unless palette_color_bytes && palette_color_bytes.bytesize == 4
|
||||||
|
|||||||
@@ -29,8 +29,6 @@ module Params
|
|||||||
in_path(params, :message) do |message_params|
|
in_path(params, :message) do |message_params|
|
||||||
type(message_params, :subject, String)
|
type(message_params, :subject, String)
|
||||||
type(message_params, :body, String)
|
type(message_params, :body, String)
|
||||||
|
|
||||||
required(message_params, :body)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -53,8 +51,6 @@ module Params
|
|||||||
in_path(params, :message, skip_blank: true) do |message_params|
|
in_path(params, :message, skip_blank: true) do |message_params|
|
||||||
type(message_params, :subject, String)
|
type(message_params, :subject, String)
|
||||||
type(message_params, :body, String)
|
type(message_params, :body, String)
|
||||||
|
|
||||||
required(message_params, :body)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
value_in(params, :order, %w[preserved random], allow_nil: true)
|
value_in(params, :order, %w[preserved random], allow_nil: true)
|
||||||
@@ -119,8 +115,6 @@ module Params
|
|||||||
in_path(params, :message) do |message_params|
|
in_path(params, :message) do |message_params|
|
||||||
type(message_params, :subject, String)
|
type(message_params, :subject, String)
|
||||||
type(message_params, :body, String)
|
type(message_params, :body, String)
|
||||||
|
|
||||||
required(message_params, :body)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
value_in(params, :order, %w[preserved random], allow_nil: true)
|
value_in(params, :order, %w[preserved random], allow_nil: true)
|
||||||
|
|||||||
@@ -6,24 +6,6 @@ module PdfUtils
|
|||||||
|
|
||||||
module_function
|
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)
|
def merge(io_files)
|
||||||
merged_content = StringIO.new
|
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_LoadCustomDocument, %i[pointer FPDF_STRING], :FPDF_DOCUMENT
|
||||||
attach_function :FPDF_CloseDocument, [:FPDF_DOCUMENT], :void
|
attach_function :FPDF_CloseDocument, [:FPDF_DOCUMENT], :void
|
||||||
attach_function :FPDF_GetPageCount, [:FPDF_DOCUMENT], :int
|
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_GetLastError, [], :ulong
|
||||||
attach_function :FPDF_GetTrailerEnds, %i[FPDF_DOCUMENT pointer ulong], :ulong
|
attach_function :FPDF_GetTrailerEnds, %i[FPDF_DOCUMENT pointer ulong], :ulong
|
||||||
attach_function :FPDF_DocumentHasValidCrossReferenceTable, [:FPDF_DOCUMENT], :int
|
attach_function :FPDF_DocumentHasValidCrossReferenceTable, [:FPDF_DOCUMENT], :int
|
||||||
@@ -282,6 +283,7 @@ class Pdfium
|
|||||||
attach_function :FPDFPage_GetAnnotCount, [:FPDF_PAGE], :int
|
attach_function :FPDFPage_GetAnnotCount, [:FPDF_PAGE], :int
|
||||||
|
|
||||||
attach_function :FPDFPage_GetAnnotCountRaw, %i[FPDF_DOCUMENT int], :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_GetAnnot, %i[FPDF_PAGE int], :FPDF_ANNOTATION
|
||||||
attach_function :FPDFPage_CloseAnnot, [:FPDF_ANNOTATION], :void
|
attach_function :FPDFPage_CloseAnnot, [:FPDF_ANNOTATION], :void
|
||||||
attach_function :FPDFAnnot_GetSubtype, [:FPDF_ANNOTATION], :int
|
attach_function :FPDFAnnot_GetSubtype, [:FPDF_ANNOTATION], :int
|
||||||
@@ -368,6 +370,11 @@ class Pdfium
|
|||||||
:bottom, :float
|
:bottom, :float
|
||||||
end
|
end
|
||||||
|
|
||||||
|
class FS_SIZEF < FFI::Struct
|
||||||
|
layout :width, :float,
|
||||||
|
:height, :float
|
||||||
|
end
|
||||||
|
|
||||||
class FS_MATRIX < FFI::Struct
|
class FS_MATRIX < FFI::Struct
|
||||||
layout :a, :float,
|
layout :a, :float,
|
||||||
:b, :float,
|
:b, :float,
|
||||||
@@ -531,6 +538,8 @@ class Pdfium
|
|||||||
|
|
||||||
@pages = {}
|
@pages = {}
|
||||||
@annot_counts = {}
|
@annot_counts = {}
|
||||||
|
@page_sizes = {}
|
||||||
|
@page_rotations = {}
|
||||||
@closed = false
|
@closed = false
|
||||||
@source_buffer = source_buffer
|
@source_buffer = source_buffer
|
||||||
@form_handle = FFI::Pointer::NULL
|
@form_handle = FFI::Pointer::NULL
|
||||||
@@ -555,6 +564,23 @@ class Pdfium
|
|||||||
@page_count ||= Pdfium.FPDF_GetPageCount(@document_ptr)
|
@page_count ||= Pdfium.FPDF_GetPageCount(@document_ptr)
|
||||||
end
|
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?
|
def encrypted?
|
||||||
Pdfium.FPDF_GetSecurityHandlerRevision(@document_ptr) >= 0
|
Pdfium.FPDF_GetSecurityHandlerRevision(@document_ptr) >= 0
|
||||||
end
|
end
|
||||||
@@ -983,6 +1009,8 @@ class Pdfium
|
|||||||
def rotation=(value)
|
def rotation=(value)
|
||||||
Pdfium.FPDFPage_SetRotation(@page_ptr, value)
|
Pdfium.FPDFPage_SetRotation(@page_ptr, value)
|
||||||
|
|
||||||
|
@document.reset_page_size(@page_index)
|
||||||
|
|
||||||
@rotation = value
|
@rotation = value
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -2278,14 +2306,14 @@ class Pdfium
|
|||||||
[PageObject, *to_a].hash
|
[PageObject, *to_a].hash
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def object_ptr
|
def object_ptr
|
||||||
page.ensure_not_closed!
|
page.ensure_not_closed!
|
||||||
|
|
||||||
Pdfium.FPDFPage_GetObject(page.page_ptr, index)
|
Pdfium.FPDFPage_GetObject(page.page_ptr, index)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
def read_bounds
|
def read_bounds
|
||||||
buffer = Array.new(4) { FFI::MemoryPointer.new(:float) }
|
buffer = Array.new(4) { FFI::MemoryPointer.new(:float) }
|
||||||
|
|
||||||
|
|||||||
@@ -236,6 +236,8 @@ module SearchEntries
|
|||||||
end
|
end
|
||||||
|
|
||||||
def add_hyphens(entry, text)
|
def add_hyphens(entry, text)
|
||||||
|
text = text.tr('\\', ' ')
|
||||||
|
|
||||||
hyphens = text.scan(/\b[^\s]*?\d-[^\s]+?\b/) + text.scan(/\b[^\s]+-\d[^\s]*?\b/)
|
hyphens = text.scan(/\b[^\s]*?\d-[^\s]+?\b/) + text.scan(/\b[^\s]+-\d[^\s]*?\b/)
|
||||||
|
|
||||||
hyphens.uniq.each_with_index do |item, index|
|
hyphens.uniq.each_with_index do |item, index|
|
||||||
|
|||||||
+14
-3
@@ -3,6 +3,11 @@
|
|||||||
module Submissions
|
module Submissions
|
||||||
DEFAULT_SUBMITTERS_ORDER = 'random'
|
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
|
module_function
|
||||||
|
|
||||||
def maybe_update_completed_at(submission)
|
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.sub(/@gmail?\z/i, '@gmail.com') if email.match?(/@gmail?\z/i)
|
||||||
|
|
||||||
return email.downcase if email.include?(',') ||
|
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?('.')
|
email.exclude?('.')
|
||||||
|
|
||||||
fixed_email = EmailTypo.call(email.delete_prefix('<'))
|
fixed_email = EmailTypo.call(email.delete_prefix('<'))
|
||||||
@@ -220,8 +225,7 @@ module Submissions
|
|||||||
domain = email.split('@').last.to_s.downcase
|
domain = email.split('@').last.to_s.downcase
|
||||||
fixed_domain = fixed_email.to_s.split('@').last
|
fixed_domain = fixed_email.to_s.split('@').last
|
||||||
|
|
||||||
return email.downcase if domain == fixed_domain
|
return email.downcase if domain == fixed_domain || skip_email_fix?(domain, fixed_domain)
|
||||||
return email.downcase if fixed_domain.match?(/\Agmail\.(?!com\z)/i)
|
|
||||||
|
|
||||||
threshold = fixed_domain.start_with?('hotmail.') ? 2 : 3
|
threshold = fixed_domain.start_with?('hotmail.') ? 2 : 3
|
||||||
|
|
||||||
@@ -236,6 +240,13 @@ module Submissions
|
|||||||
fixed_email
|
fixed_email
|
||||||
end
|
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)
|
def filtered_conditions_schema(submission, values: nil, include_submitter_uuid: nil)
|
||||||
(submission.template_schema || submission.template.schema).filter_map do |item|
|
(submission.template_schema || submission.template.schema).filter_map do |item|
|
||||||
if item['conditions'].present?
|
if item['conditions'].present?
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ module Submissions
|
|||||||
}.freeze
|
}.freeze
|
||||||
|
|
||||||
TESTING_FOOTER = GenerateResultAttachments::TESTING_FOOTER
|
TESTING_FOOTER = GenerateResultAttachments::TESTING_FOOTER
|
||||||
|
UNSUPPORTED_IMAGE_TYPES = GenerateResultAttachments::UNSUPPORTED_IMAGE_TYPES
|
||||||
|
|
||||||
RTL_REGEXP = TextUtils::RTL_REGEXP
|
RTL_REGEXP = TextUtils::RTL_REGEXP
|
||||||
MAX_IMAGE_HEIGHT = 100
|
MAX_IMAGE_HEIGHT = 100
|
||||||
@@ -301,7 +302,7 @@ module Submissions
|
|||||||
},
|
},
|
||||||
completed_event.data['ip'] && { text: "IP: #{completed_event.data['ip']}\n" },
|
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['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" },
|
submitter.timezone && { text: "Time zone: #{submitter.timezone.to_s.sub('Kiev', 'Kyiv')}\n" },
|
||||||
"\n"
|
"\n"
|
||||||
].compact_blank, line_spacing: 1.3, padding: [10, 20, 20, 0]
|
].compact_blank, line_spacing: 1.3, padding: [10, 20, 20, 0]
|
||||||
@@ -354,8 +355,9 @@ module Submissions
|
|||||||
|
|
||||||
field_type = field['type']
|
field_type = field['type']
|
||||||
|
|
||||||
if field_type == 'image' &&
|
if (field_type == 'image' || field_type == 'stamp') &&
|
||||||
submitter.attachments.find { |a| a.uuid == value }.then { |a| !a.image? || a.content_type == 'image/heic' }
|
submitter.attachments.find { |a| a.uuid == value }
|
||||||
|
.then { |a| !a.image? || a.content_type.in?(UNSUPPORTED_IMAGE_TYPES) }
|
||||||
field_type = 'file'
|
field_type = 'file'
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -478,7 +480,7 @@ module Submissions
|
|||||||
submitter.name || submitter.email || submitter.phone
|
submitter.name || submitter.email || submitter.phone
|
||||||
end
|
end
|
||||||
|
|
||||||
submitter_name = TextUtils.maybe_rtl_reverse(submitter_name.to_s)
|
submitter_name = TextUtils.maybe_rtl_reverse(submitter_name.to_s.squish)
|
||||||
|
|
||||||
text =
|
text =
|
||||||
if event.event_type == 'complete_verification'
|
if event.event_type == 'complete_verification'
|
||||||
@@ -488,12 +490,12 @@ module Submissions
|
|||||||
(invited_submitter = submission.submitters.find { |e| e.uuid == event.data['uuid'] }) &&
|
(invited_submitter = submission.submitters.find { |e| e.uuid == event.data['uuid'] }) &&
|
||||||
(name = submission.template_submitters.find { |e| e['uuid'] == event.data['uuid'] }&.dig('name'))
|
(name = submission.template_submitters.find { |e| e['uuid'] == event.data['uuid'] }&.dig('name'))
|
||||||
invited_submitter_name = TextUtils.maybe_rtl_reverse(
|
invited_submitter_name = TextUtils.maybe_rtl_reverse(
|
||||||
[invited_submitter.name || invited_submitter.email || invited_submitter.phone, name].join(' ')
|
[invited_submitter.name || invited_submitter.email || invited_submitter.phone, name].join(' ').squish
|
||||||
)
|
)
|
||||||
I18n.t('submission_event_names.invite_party_by_html', invited_submitter_name:,
|
I18n.t('submission_event_names.invite_party_by_html', invited_submitter_name:,
|
||||||
submitter_name:)
|
submitter_name:)
|
||||||
elsif with_audit_sender && (event.event_type == 'send_email' || event.event_type == 'send_sms')
|
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)
|
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:),
|
I18n.t("submission_event_names.#{event.event_type}_to_html", submitter_name:),
|
||||||
@@ -502,7 +504,7 @@ module Submissions
|
|||||||
elsif event.event_type == 'delegate_form'
|
elsif event.event_type == 'delegate_form'
|
||||||
from = event.data['old_email'].presence ||
|
from = event.data['old_email'].presence ||
|
||||||
versions.rfind { |v| v.created_at <= event.event_timestamp }&.then { |v| v.name || v.phone }
|
versions.rfind { |v| v.created_at <= event.event_timestamp }&.then { |v| v.name || v.phone }
|
||||||
from = TextUtils.maybe_rtl_reverse(from.to_s)
|
from = TextUtils.maybe_rtl_reverse(from.to_s.squish)
|
||||||
I18n.t('submission_event_names.delegate_form_by_html', from:, to: event.data['email'])
|
I18n.t('submission_event_names.delegate_form_by_html', from:, to: event.data['email'])
|
||||||
elsif event.event_type.include?('send_')
|
elsif event.event_type.include?('send_')
|
||||||
I18n.t("submission_event_names.#{event.event_type}_to_html", submitter_name:)
|
I18n.t("submission_event_names.#{event.event_type}_to_html", submitter_name:)
|
||||||
|
|||||||
@@ -36,8 +36,12 @@ module Submissions
|
|||||||
|
|
||||||
SIGN_REASON = 'Signed with DocuSeal.com'
|
SIGN_REASON = 'Signed with DocuSeal.com'
|
||||||
|
|
||||||
|
UNSUPPORTED_IMAGE_TYPES = ['image/heic', 'image/avif'].freeze
|
||||||
|
|
||||||
RTL_REGEXP = TextUtils::RTL_REGEXP
|
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_LEFT_MARGIN = 1
|
||||||
TEXT_TOP_MARGIN = 1
|
TEXT_TOP_MARGIN = 1
|
||||||
MAX_PAGE_ROTATE = 50
|
MAX_PAGE_ROTATE = 50
|
||||||
@@ -278,10 +282,10 @@ module Submissions
|
|||||||
value = field['default_value'] if field['type'] == 'heading'
|
value = field['default_value'] if field['type'] == 'heading'
|
||||||
value = field['default_value'] if field['type'] == 'strikethrough' && value.nil? && field['conditions'].blank?
|
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)
|
(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:)
|
layouter = HexaPDF::Layout::TextLayouter.new(text_valign:, text_align:, font:, font_size:)
|
||||||
|
|
||||||
@@ -300,8 +304,9 @@ module Submissions
|
|||||||
|
|
||||||
field_type = field['type']
|
field_type = field['type']
|
||||||
|
|
||||||
if field_type == 'image' &&
|
if (field_type == 'image' || field_type == 'stamp') &&
|
||||||
submitter.attachments.find { |a| a.uuid == value }.then { |a| !a.image? || a.content_type == 'image/heic' }
|
submitter.attachments.find { |a| a.uuid == value }
|
||||||
|
.then { |a| !a.image? || a.content_type.in?(UNSUPPORTED_IMAGE_TYPES) }
|
||||||
field_type = 'file'
|
field_type = 'file'
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -594,7 +599,7 @@ module Submissions
|
|||||||
)
|
)
|
||||||
when ->(type) { type == 'cells' && !area['cell_w'].to_f.zero? }
|
when ->(type) { type == 'cells' && !area['cell_w'].to_f.zero? }
|
||||||
cell_width = area['cell_w'] * width
|
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]
|
cell_layouter = cell_layouters[cell_valign]
|
||||||
|
|
||||||
if (mask = field.dig('preferences', 'mask').presence)
|
if (mask = field.dig('preferences', 'mask').presence)
|
||||||
|
|||||||
+14
-2
@@ -11,6 +11,7 @@ module Submitters
|
|||||||
}.freeze
|
}.freeze
|
||||||
|
|
||||||
UnableToSendCode = Class.new(StandardError)
|
UnableToSendCode = Class.new(StandardError)
|
||||||
|
BouncedEmail = Class.new(UnableToSendCode)
|
||||||
InvalidOtp = Class.new(StandardError)
|
InvalidOtp = Class.new(StandardError)
|
||||||
MaliciousFileExtension = Class.new(StandardError)
|
MaliciousFileExtension = Class.new(StandardError)
|
||||||
ParamsError = Class.new(StandardError)
|
ParamsError = Class.new(StandardError)
|
||||||
@@ -49,7 +50,7 @@ module Submitters
|
|||||||
end
|
end
|
||||||
|
|
||||||
def fulltext_search_field(current_user, submitters, keyword, field_name)
|
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?
|
return submitters.none if keyword.blank?
|
||||||
|
|
||||||
@@ -143,7 +144,7 @@ module Submitters
|
|||||||
def normalize_preferences(account, user, params)
|
def normalize_preferences(account, user, params)
|
||||||
preferences = {}
|
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?
|
if message_params.present?
|
||||||
email_message = EmailMessages.find_or_create_for_account_user(account, user,
|
email_message = EmailMessages.find_or_create_for_account_user(account, user,
|
||||||
@@ -243,6 +244,12 @@ module Submitters
|
|||||||
def send_shared_link_email_verification_code(submitter, request:)
|
def send_shared_link_email_verification_code(submitter, request:)
|
||||||
RateLimit.call("send-otp-code-#{request.remote_ip}", limit: 2, ttl: 45.seconds, enabled: true)
|
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!
|
TemplateMailer.otp_verification_email(submitter.submission.template, email: submitter.email).deliver_later!
|
||||||
rescue RateLimit::LimitApproached
|
rescue RateLimit::LimitApproached
|
||||||
Rollbar.warning("Limit verification code for template: #{submitter.submission.template.id}") if defined?(Rollbar)
|
Rollbar.warning("Limit verification code for template: #{submitter.submission.template.id}") if defined?(Rollbar)
|
||||||
@@ -250,6 +257,11 @@ module Submitters
|
|||||||
raise UnableToSendCode, I18n.t('too_many_attempts')
|
raise UnableToSendCode, I18n.t('too_many_attempts')
|
||||||
end
|
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)
|
def verify_link_otp!(otp, submitter)
|
||||||
return false if otp.blank?
|
return false if otp.blank?
|
||||||
|
|
||||||
|
|||||||
@@ -5,14 +5,9 @@ module Submitters
|
|||||||
module_function
|
module_function
|
||||||
|
|
||||||
def call(submitter, current_user)
|
def call(submitter, current_user)
|
||||||
user =
|
return if current_user.blank? || current_user.email != submitter.email
|
||||||
if current_user && current_user.email == submitter.email
|
|
||||||
current_user
|
|
||||||
else
|
|
||||||
submitter.account.users.find_by(email: submitter.email)
|
|
||||||
end
|
|
||||||
|
|
||||||
return if user.blank?
|
user = current_user
|
||||||
|
|
||||||
fields = submitter.submission.template_fields || submitter.submission.template.fields
|
fields = submitter.submission.template_fields || submitter.submission.template.fields
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ module Submitters
|
|||||||
VARIABLE_REGEXP = /\{\{?(\w+)\}\}?/
|
VARIABLE_REGEXP = /\{\{?(\w+)\}\}?/
|
||||||
PHONE_REGEXP = /[+\d()\s-]+/
|
PHONE_REGEXP = /[+\d()\s-]+/
|
||||||
NONEDITABLE_FIELD_TYPES = %w[stamp heading strikethrough].freeze
|
NONEDITABLE_FIELD_TYPES = %w[stamp heading strikethrough].freeze
|
||||||
|
REQUIRED_FIELD_TYPES = %w[payment kba verification].freeze
|
||||||
|
|
||||||
STRFTIME_MAP = {
|
STRFTIME_MAP = {
|
||||||
'hour' => '%-k',
|
'hour' => '%-k',
|
||||||
@@ -95,7 +96,9 @@ module Submitters
|
|||||||
required_field_uuids_acc.each do |uuid|
|
required_field_uuids_acc.each do |uuid|
|
||||||
next if submitter.values[uuid].present?
|
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)
|
Rollbar.warning("Required field #{submitter.id}: #{uuid}") if defined?(Rollbar)
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
def handle_pdf_or_image(template, file, document_data = nil, params = {}, extract_fields: false, metadata: {})
|
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
|
document_data ||= file.read
|
||||||
|
|
||||||
unless file.content_type == PDF_CONTENT_TYPE
|
unless file.content_type == PDF_CONTENT_TYPE
|
||||||
@@ -80,16 +63,6 @@ module Templates
|
|||||||
doc&.close
|
doc&.close
|
||||||
end
|
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)
|
def decrypt_document(doc)
|
||||||
io = StringIO.new
|
io = StringIO.new
|
||||||
|
|
||||||
@@ -163,9 +136,5 @@ module Templates
|
|||||||
|
|
||||||
raise InvalidFileType, "#{file.content_type}/#{dynamic}"
|
raise InvalidFileType, "#{file.content_type}/#{dynamic}"
|
||||||
end
|
end
|
||||||
|
|
||||||
def v2?
|
|
||||||
true
|
|
||||||
end
|
|
||||||
end
|
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
|
SKIP_FIELD_TYPES = %i[unknown pushbutton].freeze
|
||||||
TEXT_OPERATOR_REGEXP = /\bT[jJ]\b/
|
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
|
module_function
|
||||||
|
|
||||||
def call(attachment, doc, data)
|
def call(attachment, doc, data)
|
||||||
@@ -108,12 +133,12 @@ module Templates
|
|||||||
def build_field_properties(widgets)
|
def build_field_properties(widgets)
|
||||||
field = widgets.first.field
|
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 = { name: field_name.to_s }
|
||||||
attrs[:description] = field.alternate_name if field.alternate_name.present? &&
|
attrs[:description] = field.alternate_name if field.alternate_name.present? &&
|
||||||
field.alternate_name != field.name &&
|
field.alternate_name != field.name &&
|
||||||
!field.alternate_name.in?(FindAcroFields::SKIP_FIELD_DESCRIPTION)
|
!field.alternate_name.in?(SKIP_FIELD_DESCRIPTION)
|
||||||
|
|
||||||
case field.type
|
case field.type
|
||||||
when :checkbox, :radio
|
when :checkbox, :radio
|
||||||
@@ -172,7 +197,7 @@ module Templates
|
|||||||
**attrs,
|
**attrs,
|
||||||
type: 'select',
|
type: 'select',
|
||||||
options: build_options(field.options, '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
|
end
|
||||||
|
|
||||||
@@ -183,7 +208,7 @@ module Templates
|
|||||||
end
|
end
|
||||||
|
|
||||||
def build_text_properties(attrs, field)
|
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: }
|
attrs = { **attrs, preferences: }
|
||||||
|
|
||||||
@@ -191,7 +216,7 @@ module Templates
|
|||||||
{ **attrs, type: 'cells', default_value: field.value.presence }
|
{ **attrs, type: 'cells', default_value: field.value.presence }
|
||||||
elsif date?(field)
|
elsif date?(field)
|
||||||
format = [field.format_js, field.keystroke_js].compact
|
format = [field.format_js, field.keystroke_js].compact
|
||||||
.filter_map { |js| js[FindAcroFields::DATE_FORMAT_REGEXP] }
|
.filter_map { |js| js[DATE_FORMAT_REGEXP] }
|
||||||
.first
|
.first
|
||||||
|
|
||||||
preferences[:format] = format.upcase if format
|
preferences[:format] = format.upcase if format
|
||||||
@@ -218,7 +243,7 @@ module Templates
|
|||||||
option = option.to_s.encode('utf-8', invalid: :replace, undef: :replace, replace: '')
|
option = option.to_s.encode('utf-8', invalid: :replace, undef: :replace, replace: '')
|
||||||
end
|
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,
|
uuid: SecureRandom.uuid,
|
||||||
|
|||||||
@@ -345,10 +345,7 @@ module Templates
|
|||||||
|
|
||||||
uuid = pdf_ref['attachment_uuid']
|
uuid = pdf_ref['attachment_uuid']
|
||||||
source = sources[[uuid, nil]] ||= open_or_build_pdf(attachments_index[uuid])
|
source = sources[[uuid, nil]] ||= open_or_build_pdf(attachments_index[uuid])
|
||||||
page = source.get_page(pdf_ref['page'])
|
width, height = source.page_size(pdf_ref['page'])
|
||||||
|
|
||||||
width = page.width
|
|
||||||
height = page.height
|
|
||||||
|
|
||||||
width, height = height, width unless (pdf_ref['rotate'].to_i % 180).zero?
|
width, height = height, width unless (pdf_ref['rotate'].to_i % 180).zero?
|
||||||
|
|
||||||
@@ -435,7 +432,9 @@ module Templates
|
|||||||
end
|
end
|
||||||
|
|
||||||
def save_document(template, old_attachment, data)
|
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))
|
sha256 = Base64.urlsafe_encode64(Digest::SHA256.digest(data))
|
||||||
|
|
||||||
blob = ActiveStorage::Blob.create_and_upload!(
|
blob = ActiveStorage::Blob.create_and_upload!(
|
||||||
@@ -448,7 +447,9 @@ module Templates
|
|||||||
|
|
||||||
document = template.documents.create!(blob:)
|
document = template.documents.create!(blob:)
|
||||||
|
|
||||||
Templates::ProcessDocument.call(document, data)
|
Templates::ProcessDocument.call(document, data, doc:)
|
||||||
|
ensure
|
||||||
|
doc&.close
|
||||||
end
|
end
|
||||||
|
|
||||||
def remap_fields(template, mapping)
|
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)
|
def call(attachment, data, extract_fields: false, max_pages: MAX_NUMBER_OF_PAGES_PROCESSED, doc: nil)
|
||||||
if attachment.content_type == PDF_CONTENT_TYPE
|
if attachment.content_type == PDF_CONTENT_TYPE
|
||||||
if extract_fields && data.size < MAX_FLATTEN_FILE_SIZE
|
if extract_fields && data.size < MAX_FLATTEN_FILE_SIZE
|
||||||
if doc
|
fields = Templates::FindPdfiumAcroFields.call(attachment, doc, data)
|
||||||
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
|
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
|
attachment.metadata['pdf']['fields'] = fields if fields
|
||||||
elsif attachment.image?
|
elsif attachment.image?
|
||||||
@@ -41,27 +35,13 @@ module Templates
|
|||||||
attachment
|
attachment
|
||||||
end
|
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 attachment.content_type == PDF_CONTENT_TYPE && extract_fields && data.size < MAX_FLATTEN_FILE_SIZE
|
||||||
if doc
|
fields = Templates::FindPdfiumAcroFields.call(attachment, doc, data)
|
||||||
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
|
|
||||||
end
|
end
|
||||||
|
|
||||||
attachment.metadata['pdf'] ||= {}
|
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.metadata['pdf']['fields'] = fields if fields
|
||||||
|
|
||||||
attachment
|
attachment
|
||||||
@@ -88,17 +68,10 @@ module Templates
|
|||||||
)
|
)
|
||||||
end
|
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
|
ActiveStorage::Attachment.where(name: ATTACHMENT_NAME, record: attachment).destroy_all
|
||||||
|
|
||||||
if doc
|
number_of_pages = doc.page_count
|
||||||
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
|
|
||||||
|
|
||||||
attachment.metadata['pdf'] ||= {}
|
attachment.metadata['pdf'] ||= {}
|
||||||
attachment.metadata['pdf']['number_of_pages'] = number_of_pages
|
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
|
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
|
end
|
||||||
|
|
||||||
def generate_document_preview_images(attachment, data, range, concurrency: CONCURRENCY, doc: nil)
|
def generate_document_preview_images(attachment, range, doc:, concurrency: CONCURRENCY)
|
||||||
flatten_pages = doc&.form?
|
flatten_pages = doc.form?
|
||||||
pdfium_doc = doc || Pdfium::Document.open_bytes(data)
|
|
||||||
|
|
||||||
pool = Concurrent::FixedThreadPool.new(concurrency)
|
pool = Concurrent::FixedThreadPool.new(concurrency)
|
||||||
|
|
||||||
promises =
|
promises =
|
||||||
range.map do |page_number|
|
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
|
doc_page.flatten if flatten_pages
|
||||||
|
|
||||||
bytes, width, height = doc_page.render_to_bitmap(width: MAX_WIDTH)
|
bytes, width, height = doc_page.render_to_bitmap(width: MAX_WIDTH)
|
||||||
@@ -146,7 +118,6 @@ module Templates
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
ensure
|
ensure
|
||||||
pdfium_doc&.close if doc.nil?
|
|
||||||
pool&.kill
|
pool&.kill
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -162,7 +133,7 @@ module Templates
|
|||||||
if value.blank?
|
if value.blank?
|
||||||
next unless hide_empty
|
next unless hide_empty
|
||||||
elsif handle.option_labels.blank? ||
|
elsif handle.option_labels.blank? ||
|
||||||
!value.match?(FindAcroFields::SELECT_PLACEHOLDER_REGEXP)
|
!value.match?(FindPdfiumAcroFields::SELECT_PLACEHOLDER_REGEXP)
|
||||||
next
|
next
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -194,33 +165,6 @@ module Templates
|
|||||||
blob
|
blob
|
||||||
end
|
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)
|
def normalize_attachment_fields(template, attachments = template.documents)
|
||||||
attachments.flat_map do |a|
|
attachments.flat_map do |a|
|
||||||
pdf_fields = a.metadata['pdf'].delete('fields').to_a if a.metadata['pdf'].present?
|
pdf_fields = a.metadata['pdf'].delete('fields').to_a if a.metadata['pdf'].present?
|
||||||
|
|||||||
+49
-14
@@ -15,18 +15,29 @@ module VerifyPdfSignature
|
|||||||
|
|
||||||
next [] if signatures.blank?
|
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|
|
signatures.map do |signature|
|
||||||
build_signature(signature, io, trusted_certs,
|
build_signature(signature, trusted_certs,
|
||||||
has_unsigned_changes && index == signatures.size - 1)
|
verified: verified_signatures.include?(signature),
|
||||||
|
has_unsigned_changes: has_unsigned_changes && signature == last_signature)
|
||||||
end
|
end
|
||||||
end
|
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)
|
pkcs7 = OpenSSL::PKCS7.new(signature.contents)
|
||||||
verified = verify_contents(pkcs7, signed_data(io, signature.byte_range), trusted_certs)
|
|
||||||
|
|
||||||
SignatureStruct.new(
|
SignatureStruct.new(
|
||||||
messages: build_messages(pkcs7, verified, trusted_certs, has_unsigned_changes),
|
messages: build_messages(pkcs7, verified, trusted_certs, has_unsigned_changes),
|
||||||
@@ -62,15 +73,25 @@ module VerifyPdfSignature
|
|||||||
end
|
end
|
||||||
|
|
||||||
def certificate_message(pkcs7, trusted_certs)
|
def certificate_message(pkcs7, trusted_certs)
|
||||||
public_key = signer_certificate(pkcs7)&.public_key&.to_der
|
if trusted_certificate?(pkcs7, trusted_certs)
|
||||||
|
|
||||||
if trusted_certs.any? { |e| e.public_key.to_der == public_key }
|
|
||||||
MessageStruct.new(text: I18n.t('signed_with_trusted_certificate'), status: :success)
|
MessageStruct.new(text: I18n.t('signed_with_trusted_certificate'), status: :success)
|
||||||
else
|
else
|
||||||
MessageStruct.new(text: I18n.t('signed_with_external_certificate'), status: :error)
|
MessageStruct.new(text: I18n.t('signed_with_external_certificate'), status: :error)
|
||||||
end
|
end
|
||||||
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)
|
def verify_contents(pkcs7, signed_data, trusted_certs)
|
||||||
return false if digest_algorithms(pkcs7).blank?
|
return false if digest_algorithms(pkcs7).blank?
|
||||||
|
|
||||||
@@ -137,15 +158,22 @@ module VerifyPdfSignature
|
|||||||
Time.strptime("#{time.first(14)}#{offset.start_with?('+', '-') ? offset : '+0000'}", TIME_FORMAT)
|
Time.strptime("#{time.first(14)}#{offset.start_with?('+', '-') ? offset : '+0000'}", TIME_FORMAT)
|
||||||
end
|
end
|
||||||
|
|
||||||
def unsigned_changes?(document, io)
|
def covers_signed_revision?(signature, io)
|
||||||
signed_end = document.signatures.map(&:signed_end).max
|
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 }
|
return false if document.trailer_ends.none? { |offset| offset > signed_end }
|
||||||
|
|
||||||
io.seek(0)
|
io.seek(0)
|
||||||
|
|
||||||
Pdfium::Document.open_bytes(io.read(signed_end)) do |signed_document|
|
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)
|
serialized_document(signed_document) != serialized_document(document)
|
||||||
end
|
end
|
||||||
@@ -154,17 +182,24 @@ module VerifyPdfSignature
|
|||||||
def serialized_document(document)
|
def serialized_document(document)
|
||||||
pages = (0...document.page_count).map do |index|
|
pages = (0...document.page_count).map do |index|
|
||||||
page = document.get_page(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
|
end
|
||||||
|
|
||||||
[pages, document.bookmarks]
|
[pages, document.bookmarks]
|
||||||
end
|
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)
|
def signed_data(io, byte_range)
|
||||||
byte_range.each_slice(2).map do |offset, length|
|
byte_range.each_slice(2).map do |offset, length|
|
||||||
io.seek(offset)
|
io.seek(offset)
|
||||||
io.read(length)
|
io.read(length.clamp(0, [io.size - offset, 0].max))
|
||||||
end.join
|
end.join
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -42,7 +42,11 @@ FactoryBot.define do
|
|||||||
record: template
|
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 << {
|
template.schema << {
|
||||||
attachment_uuid: attachment.uuid,
|
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' })
|
expect(response.parsed_body).to eq({ 'error' => 'Defined more signing parties than in template' })
|
||||||
end
|
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: {
|
post '/api/submissions', headers: { 'x-auth-token': author.access_token.token }, params: {
|
||||||
template_id: templates[0].id,
|
template_id: templates[0].id,
|
||||||
send_email: true,
|
send_email: true,
|
||||||
@@ -209,8 +209,13 @@ describe 'Submission API' do
|
|||||||
}
|
}
|
||||||
}.to_json
|
}.to_json
|
||||||
|
|
||||||
expect(response).to have_http_status(:unprocessable_content)
|
expect(response).to have_http_status(:ok)
|
||||||
expect(response.parsed_body).to eq({ 'error' => 'body is required in `message`.' })
|
|
||||||
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user