diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 162d553c..37591527 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -23,17 +23,20 @@ class AccountsController < ApplicationController unless Docuseal.multitenant? @encrypted_config = EncryptedConfig.find_or_initialize_by(account: current_account, key: EncryptedConfig::APP_URL_KEY) - @encrypted_config.assign_attributes(app_url_params) - unless URI.parse(@encrypted_config.value.to_s).class.in?([URI::HTTP, URI::HTTPS]) - @encrypted_config.errors.add(:value, I18n.t('should_be_a_valid_url')) + if can?(:manage, @encrypted_config) + @encrypted_config.assign_attributes(app_url_params) - return render :show, status: :unprocessable_content - end + 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! + @encrypted_config.save! - Docuseal.refresh_default_url_options! + Docuseal.refresh_default_url_options! + end end with_locale do diff --git a/app/controllers/start_form_controller.rb b/app/controllers/start_form_controller.rb index a5b6fce1..86471d38 100644 --- a/app/controllers/start_form_controller.rb +++ b/app/controllers/start_form_controller.rb @@ -162,6 +162,10 @@ class StartFormController < ApplicationController end rescue Submitters::StartForm::NotSaved render :show, status: :unprocessable_content + rescue Submitters::BouncedEmail => e + @error_message = e.message + + render :error, status: :unprocessable_content rescue Submitters::UnableToSendCode, Submitters::InvalidOtp => e redirect_to start_form_path(template.slug, params: submitter_params.merge(email_verification: true)), alert: e.message diff --git a/app/controllers/start_form_email_2fa_send_controller.rb b/app/controllers/start_form_email_2fa_send_controller.rb index 07dd18c4..0787ab46 100644 --- a/app/controllers/start_form_email_2fa_send_controller.rb +++ b/app/controllers/start_form_email_2fa_send_controller.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class StartFormEmail2faSendController < ApplicationController + layout 'form' + around_action :with_browser_locale skip_before_action :authenticate_user! @@ -23,6 +25,10 @@ class StartFormEmail2faSendController < ApplicationController redirect_to start_form_path(@template.slug, params: submitter_params.merge(email_verification: true)), **redir_params + rescue Submitters::BouncedEmail => e + @error_message = e.message + + render 'start_form/error', status: :unprocessable_content rescue Submitters::UnableToSendCode => e redirect_to start_form_path(@template.slug, params: submitter_params.merge(email_verification: true)), alert: e.message diff --git a/app/controllers/start_form_resubmit_controller.rb b/app/controllers/start_form_resubmit_controller.rb index d0c91c69..d616a4e0 100644 --- a/app/controllers/start_form_resubmit_controller.rb +++ b/app/controllers/start_form_resubmit_controller.rb @@ -87,6 +87,10 @@ class StartFormResubmitController < ApplicationController end rescue Submitters::StartForm::NotSaved render 'start_form/show', status: :unprocessable_content + rescue Submitters::BouncedEmail => e + @error_message = e.message + + render 'start_form/error', status: :unprocessable_content rescue Submitters::UnableToSendCode, Submitters::InvalidOtp => e flash.now[:alert] = e.message diff --git a/app/controllers/submit_form_email_2fas_controller.rb b/app/controllers/submit_form_email_2fas_controller.rb index dfe518b0..377f99ca 100644 --- a/app/controllers/submit_form_email_2fas_controller.rb +++ b/app/controllers/submit_form_email_2fas_controller.rb @@ -35,6 +35,12 @@ class SubmitFormEmail2fasController < ApplicationController RateLimit.call("send-email-code-#{@submitter.id}", limit: 2, ttl: 45.seconds, enabled: true) + if Docuseal.multitenant? && Submitters.email_bounced_recently?(@submitter.email) + Rollbar.warning("Bounced OTP email for submitter: #{@submitter.id}") if defined?(Rollbar) + + return redirect_to submit_form_path(@submitter.slug, status: :error), alert: I18n.t(:verification_email_bounced) + end + SendSubmitterVerificationEmailJob.perform_async('submitter_id' => @submitter.id, 'locale' => I18n.locale.to_s) redir_params = params[:resend] ? { alert: I18n.t(:code_has_been_resent) } : {} diff --git a/app/controllers/templates_debug_controller.rb b/app/controllers/templates_debug_controller.rb index 333e8847..551d167f 100644 --- a/app/controllers/templates_debug_controller.rb +++ b/app/controllers/templates_debug_controller.rb @@ -13,9 +13,10 @@ class TemplatesDebugController < ApplicationController data = attachment.download unless attachment.image? - pdf = HexaPDF::Document.new(io: StringIO.new(data)) - - fields = Templates::FindAcroFields.call(pdf, attachment, data) + fields = + Pdfium::Document.open_io(StringIO.new(data)) do |doc| + Templates::FindPdfiumAcroFields.call(attachment, doc, data) + end end # fields, = Templates::DetectFields.call(StringIO.new(data), attachment:) if fields.blank? diff --git a/app/controllers/templates_detect_fields_controller.rb b/app/controllers/templates_detect_fields_controller.rb index a0cd54ff..d01925a1 100644 --- a/app/controllers/templates_detect_fields_controller.rb +++ b/app/controllers/templates_detect_fields_controller.rb @@ -10,8 +10,14 @@ class TemplatesDetectFieldsController < ApplicationController sse = SSE.new(response.stream) - documents = @template.schema_documents.preload(:blob) - documents = documents.where(uuid: params[:attachment_uuid]) if params[:attachment_uuid].present? + documents = + if params[:attachment_uuid].present? + @template.documents.where(uuid: params[:attachment_uuid]) + else + @template.schema_documents + end + + documents = documents.preload(:blob) page_number = params[:page].presence&.to_i diff --git a/app/javascript/template_builder/builder.vue b/app/javascript/template_builder/builder.vue index a834e373..f0146f39 100644 --- a/app/javascript/template_builder/builder.vue +++ b/app/javascript/template_builder/builder.vue @@ -3525,6 +3525,8 @@ export default { this.onChange(this.template) } + this.pushUndo() + if (!this.autosave && !force) { return Promise.resolve({}) } @@ -3535,8 +3537,6 @@ export default { } }) - this.pushUndo() - return this.baseFetch(`/templates/${this.template.id}`, { method: 'PUT', body: JSON.stringify({ diff --git a/app/javascript/template_builder/fields.vue b/app/javascript/template_builder/fields.vue index cab03639..29107103 100644 --- a/app/javascript/template_builder/fields.vue +++ b/app/javascript/template_builder/fields.vue @@ -555,8 +555,10 @@ export default { return this.template.schema.some((item) => item.dynamic) }, numberOfPages () { - return this.template.documents.reduce((acc, doc) => { - return acc + doc.metadata?.pdf?.number_of_pages || doc.preview_images.length + return this.template.schema.reduce((acc, item) => { + const doc = this.template.documents.find((d) => d.uuid === item.attachment_uuid) + + return acc + (doc?.metadata?.pdf?.number_of_pages || doc?.preview_images?.length || 0) }, 0) }, isShowFieldSearch () { @@ -774,9 +776,10 @@ export default { headers: { 'Content-Type': 'application/json' }, - ...(this.withDetectExistingFields - ? { body: JSON.stringify({ fields: this.buildExistingFields() }) } - : {}) + body: JSON.stringify({ + attachment_uuid: this.template.schema.map((item) => item.attachment_uuid), + ...(this.withDetectExistingFields ? { fields: this.buildExistingFields() } : {}) + }) }).then(async (response) => { const reader = response.body.getReader() const decoder = new TextDecoder('utf-8') diff --git a/app/models/email_message.rb b/app/models/email_message.rb index 5fdc1021..e2411e1c 100644 --- a/app/models/email_message.rb +++ b/app/models/email_message.rb @@ -31,6 +31,8 @@ class EmailMessage < ApplicationRecord attribute :uuid, :string, default: -> { SecureRandom.uuid } + normalizes :body, with: ->(value) { value.to_s }, apply_to_nil: true + before_validation :set_sha1, on: :create def normalized_body diff --git a/app/views/start_form/error.html.erb b/app/views/start_form/error.html.erb new file mode 100644 index 00000000..45221209 --- /dev/null +++ b/app/views/start_form/error.html.erb @@ -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 %> +
+
+
+
+ <%= render 'start_form/banner' %> +
+
+
+
+ <%= svg_icon('writing_sign', class: 'w-10 h-10') %> +
+
+

<%= @template.name %>

+

<%= t('invited_by_html', name: @template.account.name) %>

+
+
+
+
+
+ <%= svg_icon('info_circle', class: 'stroke-current shrink-0 h-6 w-6 mt-1') %> +
<%= @error_message %>
+
+
+ <%= 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' %> +
+
+
diff --git a/config/initializers/hexapdf.rb b/config/initializers/hexapdf.rb index a8dd5203..f639374f 100644 --- a/config/initializers/hexapdf.rb +++ b/config/initializers/hexapdf.rb @@ -46,6 +46,20 @@ module HexaPDF def color_space(name) GlobalConfiguration.constantize('color_space.map', name).new end + + def [](name) + return super unless value[name].nil? && INHERITABLE_FIELDS.include?(name) + + seen = Set.new.compare_by_identity + seen << value + node = self + + while node.value[name].nil? && (parent = node[:Parent]) && seen.add?(parent.value) + node = parent + end + + node == self || node.value[name].nil? ? super : node[name] + end end # fix NoMethodError: undefined method `field_value' for #= 0 end @@ -983,6 +1009,8 @@ class Pdfium def rotation=(value) Pdfium.FPDFPage_SetRotation(@page_ptr, value) + @document.reset_page_size(@page_index) + @rotation = value end @@ -2278,14 +2306,14 @@ class Pdfium [PageObject, *to_a].hash end - private - def object_ptr page.ensure_not_closed! Pdfium.FPDFPage_GetObject(page.page_ptr, index) end + private + def read_bounds buffer = Array.new(4) { FFI::MemoryPointer.new(:float) } diff --git a/lib/submissions/generate_result_attachments.rb b/lib/submissions/generate_result_attachments.rb index 98d67c79..c221d38a 100644 --- a/lib/submissions/generate_result_attachments.rb +++ b/lib/submissions/generate_result_attachments.rb @@ -40,6 +40,8 @@ module Submissions RTL_REGEXP = TextUtils::RTL_REGEXP + TEXT_ALIGNS = %w[left center right justify].freeze + TEXT_VALIGNS = %w[top center bottom].freeze TEXT_LEFT_MARGIN = 1 TEXT_TOP_MARGIN = 1 MAX_PAGE_ROTATE = 50 @@ -280,10 +282,10 @@ module Submissions value = field['default_value'] if field['type'] == 'heading' value = field['default_value'] if field['type'] == 'strikethrough' && value.nil? && field['conditions'].blank? - text_align = field.dig('preferences', 'align').to_s.to_sym.presence || + text_align = field.dig('preferences', 'align').to_s.presence_in(TEXT_ALIGNS)&.to_sym || (value.to_s.match?(RTL_REGEXP) ? :right : :left) - text_valign = (field.dig('preferences', 'valign').to_s.presence || 'center').to_sym + text_valign = (field.dig('preferences', 'valign').to_s.presence_in(TEXT_VALIGNS) || 'center').to_sym layouter = HexaPDF::Layout::TextLayouter.new(text_valign:, text_align:, font:, font_size:) @@ -597,7 +599,7 @@ module Submissions ) when ->(type) { type == 'cells' && !area['cell_w'].to_f.zero? } cell_width = area['cell_w'] * width - cell_valign = field.dig('preferences', 'valign').to_s.presence || 'center' + cell_valign = field.dig('preferences', 'valign').to_s.presence_in(TEXT_VALIGNS) || 'center' cell_layouter = cell_layouters[cell_valign] if (mask = field.dig('preferences', 'mask').presence) diff --git a/lib/submitters.rb b/lib/submitters.rb index 71041a3d..3ea1aa07 100644 --- a/lib/submitters.rb +++ b/lib/submitters.rb @@ -11,6 +11,7 @@ module Submitters }.freeze UnableToSendCode = Class.new(StandardError) + BouncedEmail = Class.new(UnableToSendCode) InvalidOtp = Class.new(StandardError) MaliciousFileExtension = Class.new(StandardError) ParamsError = Class.new(StandardError) @@ -143,7 +144,7 @@ module Submitters def normalize_preferences(account, user, params) preferences = {} - message_params = params['message'].presence || params.slice('subject', 'body').presence + message_params = (params['message'].presence || params.slice('subject', 'body')).compact_blank if message_params.present? email_message = EmailMessages.find_or_create_for_account_user(account, user, @@ -243,6 +244,12 @@ module Submitters def send_shared_link_email_verification_code(submitter, request:) RateLimit.call("send-otp-code-#{request.remote_ip}", limit: 2, ttl: 45.seconds, enabled: true) + if Docuseal.multitenant? && email_bounced_recently?(submitter.email) + Rollbar.warning("Bounced OTP email for template: #{submitter.submission.template.id}") if defined?(Rollbar) + + raise BouncedEmail, I18n.t(:verification_email_bounced) + end + TemplateMailer.otp_verification_email(submitter.submission.template, email: submitter.email).deliver_later! rescue RateLimit::LimitApproached Rollbar.warning("Limit verification code for template: #{submitter.submission.template.id}") if defined?(Rollbar) @@ -250,6 +257,11 @@ module Submitters raise UnableToSendCode, I18n.t('too_many_attempts') end + def email_bounced_recently?(email) + EmailEvent.exists?(email:, event_type: %w[bounce soft_bounce permanent_bounce], + event_datetime: 24.hours.ago..Time.current) + end + def verify_link_otp!(otp, submitter) return false if otp.blank? diff --git a/lib/templates/build_annotations.rb b/lib/templates/build_annotations.rb deleted file mode 100644 index 8ce566d1..00000000 --- a/lib/templates/build_annotations.rb +++ /dev/null @@ -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 diff --git a/lib/templates/create_attachments.rb b/lib/templates/create_attachments.rb index 8b3a555f..f5b47fd9 100644 --- a/lib/templates/create_attachments.rb +++ b/lib/templates/create_attachments.rb @@ -39,23 +39,6 @@ module Templates end def handle_pdf_or_image(template, file, document_data = nil, params = {}, extract_fields: false, metadata: {}) - return handle_pdf_or_image_v2(template, file, document_data, params, extract_fields:, metadata:) if v2? - - document_data ||= file.read - - if file.content_type == PDF_CONTENT_TYPE - document_data = maybe_decrypt_pdf_or_raise(document_data, params) - - annotations = - document_data.size < ANNOTATIONS_SIZE_LIMIT ? Templates::BuildAnnotations.call(document_data) : [] - end - - document = create_document(template, file, document_data, metadata, annotations) - - Templates::ProcessDocument.call(document, document_data, extract_fields:) - end - - def handle_pdf_or_image_v2(template, file, document_data = nil, params = {}, extract_fields: false, metadata: {}) document_data ||= file.read unless file.content_type == PDF_CONTENT_TYPE @@ -80,16 +63,6 @@ module Templates doc&.close end - def maybe_decrypt_pdf_or_raise(data, params) - if data.size < ANNOTATIONS_SIZE_LIMIT && PdfUtils.encrypted?(data) - PdfUtils.decrypt(data, params[:password]) - else - data - end - rescue Pdfium::PasswordError - raise PdfEncrypted - end - def decrypt_document(doc) io = StringIO.new @@ -163,9 +136,5 @@ module Templates raise InvalidFileType, "#{file.content_type}/#{dynamic}" end - - def v2? - true - end end end diff --git a/lib/templates/find_acro_fields.rb b/lib/templates/find_acro_fields.rb deleted file mode 100644 index 6da0bf72..00000000 --- a/lib/templates/find_acro_fields.rb +++ /dev/null @@ -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 diff --git a/lib/templates/find_pdfium_acro_fields.rb b/lib/templates/find_pdfium_acro_fields.rb index d9133aaa..805ed75c 100644 --- a/lib/templates/find_pdfium_acro_fields.rb +++ b/lib/templates/find_pdfium_acro_fields.rb @@ -6,6 +6,31 @@ module Templates SKIP_FIELD_TYPES = %i[unknown pushbutton].freeze TEXT_OPERATOR_REGEXP = /\bT[jJ]\b/ + FIELD_NAME_REGEXP = /\A(?=.*\p{L})[\p{L}\d\s-]+\z/ + SKIP_FIELD_DESCRIPTION = %w[undefined].freeze + SELECT_PLACEHOLDER_REGEXP = /\b( + Select | + Choose | + Wählen | + Auswählen | + Sélectionner| + Choisir | + Seleccionar | + Elegir | + Seleziona | + Scegliere | + Selecionar | + Escolher + )\b/ix + + DATE_FORMAT_REGEXP = %r{[myd]{2,4}[-\\/\s.][myd]{2,4}[-\\/\s.][myd]{2,4}}i + + FIELD_ALIGNMENT = { + 0 => 'left', + 1 => 'center', + 2 => 'right' + }.freeze + module_function def call(attachment, doc, data) @@ -108,12 +133,12 @@ module Templates def build_field_properties(widgets) field = widgets.first.field - field_name = field.name if field.name.match?(FindAcroFields::FIELD_NAME_REGEXP) + field_name = field.name if field.name.match?(FIELD_NAME_REGEXP) attrs = { name: field_name.to_s } attrs[:description] = field.alternate_name if field.alternate_name.present? && field.alternate_name != field.name && - !field.alternate_name.in?(FindAcroFields::SKIP_FIELD_DESCRIPTION) + !field.alternate_name.in?(SKIP_FIELD_DESCRIPTION) case field.type when :checkbox, :radio @@ -172,7 +197,7 @@ module Templates **attrs, type: 'select', options: build_options(field.options, 'select'), - default_value: value.to_s.match?(FindAcroFields::SELECT_PLACEHOLDER_REGEXP) ? nil : value + default_value: value.to_s.match?(SELECT_PLACEHOLDER_REGEXP) ? nil : value } end @@ -183,7 +208,7 @@ module Templates end def build_text_properties(attrs, field) - preferences = { align: FindAcroFields::FIELD_ALIGNMENT.fetch(field.quadding.to_i, 'left') } + preferences = { align: FIELD_ALIGNMENT.fetch(field.quadding.to_i, 'left') } attrs = { **attrs, preferences: } @@ -191,7 +216,7 @@ module Templates { **attrs, type: 'cells', default_value: field.value.presence } elsif date?(field) format = [field.format_js, field.keystroke_js].compact - .filter_map { |js| js[FindAcroFields::DATE_FORMAT_REGEXP] } + .filter_map { |js| js[DATE_FORMAT_REGEXP] } .first preferences[:format] = format.upcase if format @@ -218,7 +243,7 @@ module Templates option = option.to_s.encode('utf-8', invalid: :replace, undef: :replace, replace: '') end - next if type == 'select' && option.to_s.match?(FindAcroFields::SELECT_PLACEHOLDER_REGEXP) + next if type == 'select' && option.to_s.match?(SELECT_PLACEHOLDER_REGEXP) { uuid: SecureRandom.uuid, diff --git a/lib/templates/modify_documents.rb b/lib/templates/modify_documents.rb index 8136520e..d10cd97d 100644 --- a/lib/templates/modify_documents.rb +++ b/lib/templates/modify_documents.rb @@ -345,10 +345,7 @@ module Templates uuid = pdf_ref['attachment_uuid'] source = sources[[uuid, nil]] ||= open_or_build_pdf(attachments_index[uuid]) - page = source.get_page(pdf_ref['page']) - - width = page.width - height = page.height + width, height = source.page_size(pdf_ref['page']) width, height = height, width unless (pdf_ref['rotate'].to_i % 180).zero? @@ -435,7 +432,9 @@ module Templates end def save_document(template, old_attachment, data) - annotations = data.size < ANNOTATIONS_SIZE_LIMIT ? Templates::BuildAnnotations.call(data) : [] + doc = Pdfium::Document.open_io(StringIO.new(data)) + + annotations = data.size < ANNOTATIONS_SIZE_LIMIT ? Templates::BuildPdfiumAnnotations.call(doc) : [] sha256 = Base64.urlsafe_encode64(Digest::SHA256.digest(data)) blob = ActiveStorage::Blob.create_and_upload!( @@ -448,7 +447,9 @@ module Templates document = template.documents.create!(blob:) - Templates::ProcessDocument.call(document, data) + Templates::ProcessDocument.call(document, data, doc:) + ensure + doc&.close end def remap_fields(template, mapping) diff --git a/lib/templates/process_document.rb b/lib/templates/process_document.rb index 2d60665e..e8f138f3 100644 --- a/lib/templates/process_document.rb +++ b/lib/templates/process_document.rb @@ -22,16 +22,10 @@ module Templates def call(attachment, data, extract_fields: false, max_pages: MAX_NUMBER_OF_PAGES_PROCESSED, doc: nil) if attachment.content_type == PDF_CONTENT_TYPE if extract_fields && data.size < MAX_FLATTEN_FILE_SIZE - if doc - fields = Templates::FindPdfiumAcroFields.call(attachment, doc, data) - else - pdf = HexaPDF::Document.new(io: StringIO.new(data)) - - fields = Templates::FindAcroFields.call(pdf, attachment, data) - end + fields = Templates::FindPdfiumAcroFields.call(attachment, doc, data) end - generate_pdf_preview_images(attachment, data, pdf, max_pages:, doc:) + generate_pdf_preview_images(attachment, data, max_pages:, doc:) attachment.metadata['pdf']['fields'] = fields if fields elsif attachment.image? @@ -41,27 +35,13 @@ module Templates attachment end - def process(attachment, data, extract_fields: false, doc: nil) + def process(attachment, data, doc:, extract_fields: false) if attachment.content_type == PDF_CONTENT_TYPE && extract_fields && data.size < MAX_FLATTEN_FILE_SIZE - if doc - fields = Templates::FindPdfiumAcroFields.call(attachment, doc, data) - else - pdf = HexaPDF::Document.new(io: StringIO.new(data)) - - fields = Templates::FindAcroFields.call(pdf, attachment, data) - end - end - - if doc - number_of_pages = doc.page_count - else - pdf ||= HexaPDF::Document.new(io: StringIO.new(data)) - - number_of_pages = pdf.pages.size + fields = Templates::FindPdfiumAcroFields.call(attachment, doc, data) end attachment.metadata['pdf'] ||= {} - attachment.metadata['pdf']['number_of_pages'] = number_of_pages + attachment.metadata['pdf']['number_of_pages'] = doc.page_count attachment.metadata['pdf']['fields'] = fields if fields attachment @@ -88,17 +68,10 @@ module Templates ) end - def generate_pdf_preview_images(attachment, data, pdf = nil, max_pages: MAX_NUMBER_OF_PAGES_PROCESSED, doc: nil) + def generate_pdf_preview_images(attachment, data, doc:, max_pages: MAX_NUMBER_OF_PAGES_PROCESSED) ActiveStorage::Attachment.where(name: ATTACHMENT_NAME, record: attachment).destroy_all - if doc - number_of_pages = doc.page_count - else - pdf ||= HexaPDF::Document.new(io: StringIO.new(data)) - number_of_pages = pdf.pages.size - - data = maybe_flatten_form(data, pdf) - end + number_of_pages = doc.page_count attachment.metadata['pdf'] ||= {} attachment.metadata['pdf']['number_of_pages'] = number_of_pages @@ -109,20 +82,19 @@ module Templates max_pages_to_process = data.size < GENERATE_PREVIEW_SIZE_LIMIT ? max_pages : 1 - generate_document_preview_images(attachment, data, 0..[number_of_pages - 1, max_pages_to_process].min, doc:) + generate_document_preview_images(attachment, 0..[number_of_pages - 1, max_pages_to_process].min, doc:) end - def generate_document_preview_images(attachment, data, range, concurrency: CONCURRENCY, doc: nil) - flatten_pages = doc&.form? - pdfium_doc = doc || Pdfium::Document.open_bytes(data) + def generate_document_preview_images(attachment, range, doc:, concurrency: CONCURRENCY) + flatten_pages = doc.form? pool = Concurrent::FixedThreadPool.new(concurrency) promises = range.map do |page_number| - doc_page = pdfium_doc.get_page(page_number) + doc_page = doc.get_page(page_number) - hide_placeholder_widgets(doc_page, hide_empty: !flatten_pages) if doc + hide_placeholder_widgets(doc_page, hide_empty: !flatten_pages) doc_page.flatten if flatten_pages bytes, width, height = doc_page.render_to_bitmap(width: MAX_WIDTH) @@ -146,7 +118,6 @@ module Templates end end ensure - pdfium_doc&.close if doc.nil? pool&.kill end @@ -162,7 +133,7 @@ module Templates if value.blank? next unless hide_empty elsif handle.option_labels.blank? || - !value.match?(FindAcroFields::SELECT_PLACEHOLDER_REGEXP) + !value.match?(FindPdfiumAcroFields::SELECT_PLACEHOLDER_REGEXP) next end @@ -194,33 +165,6 @@ module Templates blob end - def maybe_flatten_form(data, pdf) - return data if data.size > MAX_FLATTEN_FILE_SIZE - return data if pdf.acro_form.blank? - - io = StringIO.new - - pdf.acro_form.each_field do |field| - next if field.field_type != :Ch || - field[:Opt].blank? || - %i[combo_box editable_combo_box].exclude?(field.concrete_field_type) || - !field.field_value.to_s.match?(FindAcroFields::SELECT_PLACEHOLDER_REGEXP) - - field[:V] = '' - end - - pdf.acro_form.create_appearances(force: true) if pdf.acro_form[:NeedAppearances] - pdf.acro_form.flatten - - pdf.write(io, incremental: false, validate: false) - - io.string - rescue StandardError - raise if Rails.env.development? - - data - end - def normalize_attachment_fields(template, attachments = template.documents) attachments.flat_map do |a| pdf_fields = a.metadata['pdf'].delete('fields').to_a if a.metadata['pdf'].present? diff --git a/lib/verify_pdf_signature.rb b/lib/verify_pdf_signature.rb index ec588e5e..f8da1408 100644 --- a/lib/verify_pdf_signature.rb +++ b/lib/verify_pdf_signature.rb @@ -173,7 +173,7 @@ module VerifyPdfSignature io.seek(0) Pdfium::Document.open_bytes(io.read(signed_end)) do |signed_document| - next false unless signed_document.valid_cross_reference_table? + next true unless signed_document.valid_cross_reference_table? serialized_document(signed_document) != serialized_document(document) end @@ -182,13 +182,20 @@ module VerifyPdfSignature def serialized_document(document) pages = (0...document.page_count).map do |index| page = document.get_page(index) + objects = page.objects.map { |object| [*object.to_a, image_digest(page, object)] } - [page.rotation, page.objects, page.annotations, page.text] + [page.rotation, objects, page.annotations, page.text] end [pages, document.bookmarks] end + def image_digest(page, object) + return unless object.image? + + Digest::SHA256.hexdigest(page.extract_image_bitmap(object.object_ptr)[:data]) + end + def signed_data(io, byte_range) byte_range.each_slice(2).map do |offset, length| io.seek(offset) diff --git a/spec/factories/templates.rb b/spec/factories/templates.rb index 33988385..09d5a3fd 100644 --- a/spec/factories/templates.rb +++ b/spec/factories/templates.rb @@ -42,7 +42,11 @@ FactoryBot.define do record: template ) - Templates::ProcessDocument.call(attachment, attachment.download) + data = attachment.download + + Pdfium::Document.open_io(StringIO.new(data)) do |doc| + Templates::ProcessDocument.call(attachment, data, doc:) + end template.schema << { attachment_uuid: attachment.uuid, diff --git a/spec/requests/submissions_spec.rb b/spec/requests/submissions_spec.rb index 3adf6092..6dacc802 100644 --- a/spec/requests/submissions_spec.rb +++ b/spec/requests/submissions_spec.rb @@ -197,7 +197,7 @@ describe 'Submission API' do expect(response.parsed_body).to eq({ 'error' => 'Defined more signing parties than in template' }) end - it 'returns an error if the message has no body value' do + it 'creates a submission when the message has only a subject' do post '/api/submissions', headers: { 'x-auth-token': author.access_token.token }, params: { template_id: templates[0].id, send_email: true, @@ -209,8 +209,13 @@ describe 'Submission API' do } }.to_json - expect(response).to have_http_status(:unprocessable_content) - expect(response.parsed_body).to eq({ 'error' => 'body is required in `message`.' }) + expect(response).to have_http_status(:ok) + + submission = Submission.last + email_message = EmailMessage.last + + expect(submission.submitters.first.preferences['email_message_uuid']).to eq(email_message.uuid) + expect(email_message).to have_attributes(subject: 'Custom Email Subject', body: '') end end