- <% if !template.archived_at? && can?(:destroy, template) %>
- <%= button_to button_title(title: t('archive'), disabled_with: t('archiving')[..-4], title_class: 'inline', icon: svg_icon('archive', class: 'w-6 h-6')), template_path(template), class: 'btn btn-outline btn-sm w-full', form_class: 'flex-1', method: :delete %>
+ <% if can?(:destroy, template) %>
+ <% if template.archived_at? %>
+ <%= button_to button_title(title: t('restore'), disabled_with: t('restoring')[..-4], icon: svg_icon('rotate', class: 'w-6 h-6')), template_restore_index_path(template), class: 'btn btn-outline btn-sm w-full', form_class: 'flex-1' %>
+ <% else %>
+ <%= button_to button_title(title: t('archive'), disabled_with: t('archiving')[..-4], title_class: 'inline', icon: svg_icon('archive', class: 'w-6 h-6')), template_path(template), class: 'btn btn-outline btn-sm w-full', form_class: 'flex-1', method: :delete %>
+ <% end %>
<% end %>
<% if can?(:create, current_account.templates.new(author: current_user)) %>
- <%= render 'submissions_filters/applied_filters', filter_params:, with_status: true %>
+ <%= render 'submissions_filters/applied_filters', filter_params:, with_status: true, with_default_status: true %>
<%= 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') %>
diff --git a/config/routes.rb b/config/routes.rb
index a1f566d9..a4c09d05 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -175,10 +175,13 @@ Rails.application.routes.draw do
resources :submitters, only: %i[] do
resources :download, only: %i[index], controller: 'submitters_download', constraints: { submitter_id: /\d+/ }
- resources :download, only: %i[index], controller: 'submit_form_completed_download'
resources :send_email, only: %i[create], controller: 'submitters_send_email'
end
+ resources :submitters, only: %i[], param: 'slug' do
+ resources :download, only: %i[index], controller: 'submit_form_completed_download'
+ end
+
resources :settings, only: %i[index]
scope '/settings', as: :settings do
diff --git a/lib/load_bmp.rb b/lib/load_bmp.rb
index 48320fdb..53e41200 100644
--- a/lib/load_bmp.rb
+++ b/lib/load_bmp.rb
@@ -3,6 +3,9 @@
module LoadBmp
BPPS = [1, 4, 8, 24, 32].freeze
+ MAX_COORD = ENV.fetch('VIPS_MAX_COORD', '17000').to_i
+ MAX_PIXELS = 145_000_000
+
module_function
# rubocop:disable Metrics
@@ -18,40 +21,28 @@ module LoadBmp
header_data[:height]
)
- if header_data[:bpp] <= 8
- final_pixel_data = decode_indexed_pixel_data(
- raw_pixel_data_from_file,
- header_data[:bpp],
- header_data[:width],
- header_data[:height],
- header_data[:bmp_stride],
- header_data[:color_table]
- )
- bands = 3
- else
- final_pixel_data = prepare_unpadded_pixel_data_string(
- raw_pixel_data_from_file,
- header_data[:bpp],
- header_data[:width],
- header_data[:height],
- header_data[:bmp_stride]
- )
- bands = header_data[:bpp] / 8
- end
-
- image = Vips::Image.new_from_memory_copy(final_pixel_data, header_data[:width], header_data[:height], bands, :uchar)
-
- image = image.flip(:vertical) if header_data[:orientation] == -1
+ padded_rows = Vips::Image.new_from_memory_copy(
+ raw_pixel_data_from_file,
+ header_data[:bmp_stride],
+ header_data[:height],
+ 1,
+ :uchar
+ )
image_rgb =
if header_data[:bpp] <= 8
- image
- elsif bands == 3
- image.recomb(band3_recomb)
- elsif bands == 4
- image.recomb(band4_recomb)
+ decode_indexed_pixel_data(padded_rows, header_data[:bpp], header_data[:width], header_data[:color_table])
+ else
+ bands = header_data[:bpp] / 8
+
+ image = padded_rows.extract_area(0, 0, header_data[:width] * bands, header_data[:height])
+ .bandfold(factor: bands)
+
+ bands == 3 ? image.recomb(band3_recomb) : image.recomb(band4_recomb)
end
+ image_rgb = image_rgb.flip(:vertical) if header_data[:orientation] == -1
+
image_rgb = image_rgb.copy(interpretation: :srgb) if image_rgb.interpretation != :srgb
image_rgb
@@ -98,6 +89,9 @@ module LoadBmp
raise ArgumentError, 'BMP width must be positive.' if width <= 0
raise ArgumentError, 'BMP height must be positive.' if height <= 0
+ if width > MAX_COORD || height > MAX_COORD || width * height > MAX_PIXELS
+ raise ArgumentError, "BMP dimensions are too large: #{width}x#{height}."
+ end
if compression != 0
raise ArgumentError,
@@ -164,62 +158,24 @@ module LoadBmp
raw_pixel_data_from_file
end
- def prepare_unpadded_pixel_data_string(raw_pixel_data_from_file, bpp, width, height, bmp_stride)
- bytes_per_pixel = bpp / 8
- actual_row_width_bytes = width * bytes_per_pixel
-
- unpadded_rows = Array.new(height)
- current_offset_in_blob = 0
+ def decode_indexed_pixel_data(padded_rows, bpp, width, color_table)
+ pixels_per_byte = 8 / bpp
- height.times do |i|
- if current_offset_in_blob + actual_row_width_bytes > raw_pixel_data_from_file.bytesize
- raise ArgumentError,
- "Not enough data in pixel blob for row #{i}. Offset #{current_offset_in_blob}, " \
- "row width #{actual_row_width_bytes}, blob size #{raw_pixel_data_from_file.bytesize}"
- end
-
- unpadded_row_slice = raw_pixel_data_from_file.byteslice(current_offset_in_blob, actual_row_width_bytes)
+ image = padded_rows.maplut(build_palette_lut(bpp, color_table))
+ image = image.bandunfold.bandfold(factor: 3) if pixels_per_byte > 1
- if unpadded_row_slice.nil? || unpadded_row_slice.bytesize < actual_row_width_bytes
- raise ArgumentError, "Failed to slice a full unpadded row from pixel data blob for row #{i}."
- end
-
- unpadded_rows[i] = unpadded_row_slice
- current_offset_in_blob += bmp_stride
- end
-
- unpadded_rows.join
+ image.extract_area(0, 0, width, padded_rows.height)
end
- def decode_indexed_pixel_data(raw_data, bpp, width, height, bmp_stride, color_table)
- palette = color_table.map { |r, g, b| [r, g, b].pack('CCC') }
-
- output = String.new(capacity: width * height * 3)
-
- height.times do |y|
- row_offset = y * bmp_stride
-
- case bpp
- when 1
- width.times do |x|
- byte_val = raw_data.getbyte(row_offset + (x >> 3))
- index = (byte_val >> (7 - (x & 7))) & 0x01
- output << palette[index]
- end
- when 4
- width.times do |x|
- byte_val = raw_data.getbyte(row_offset + (x >> 1))
- index = x.even? ? (byte_val >> 4) & 0x0F : byte_val & 0x0F
- output << palette[index]
- end
- when 8
- width.times do |x|
- output << palette[raw_data.getbyte(row_offset + x)]
- end
- end
+ def build_palette_lut(bpp, color_table)
+ pixels_per_byte = 8 / bpp
+ mask = (1 << bpp) - 1
+
+ entries = Array.new(256) do |byte_val|
+ (0...pixels_per_byte).flat_map { |i| color_table[(byte_val >> ((pixels_per_byte - 1 - i) * bpp)) & mask] }
end
- output
+ Vips::Image.new_from_memory_copy(entries.flatten.pack('C*'), 256, 1, pixels_per_byte * 3, :uchar)
end
def band3_recomb
diff --git a/lib/load_ico.rb b/lib/load_ico.rb
index 1eeb5ea9..c65268b5 100644
--- a/lib/load_ico.rb
+++ b/lib/load_ico.rb
@@ -94,7 +94,7 @@ module LoadIco
palette = []
if dib_bpp <= 8
- num_palette_entries = dib_clr_used.zero? ? (1 << dib_bpp) : dib_clr_used
+ num_palette_entries = [dib_clr_used.zero? ? (1 << dib_bpp) : dib_clr_used, 1 << dib_bpp].min
num_palette_entries.times do
palette_color_bytes = dib_io.read(4)
return nil unless palette_color_bytes && palette_color_bytes.bytesize == 4
diff --git a/lib/search_entries.rb b/lib/search_entries.rb
index 96c1dd52..f199872f 100644
--- a/lib/search_entries.rb
+++ b/lib/search_entries.rb
@@ -236,6 +236,8 @@ module SearchEntries
end
def add_hyphens(entry, text)
+ text = text.tr('\\', ' ')
+
hyphens = text.scan(/\b[^\s]*?\d-[^\s]+?\b/) + text.scan(/\b[^\s]+-\d[^\s]*?\b/)
hyphens.uniq.each_with_index do |item, index|
diff --git a/lib/submissions.rb b/lib/submissions.rb
index 9587c74a..59fef7a4 100644
--- a/lib/submissions.rb
+++ b/lib/submissions.rb
@@ -3,6 +3,11 @@
module Submissions
DEFAULT_SUBMITTERS_ORDER = 'random'
+ SKIP_EMAIL_FIX_LABELS = %w[ga gami gasi gil gma gmao gmi gmsi goi ol].freeze
+
+ SKIP_EMAIL_FIX_TLDS =
+ /\.(?:gob(?:\.\w+)?|om|mm|cm|et|mo|nz|za|ie|mom|free|comsec|unicom|ed(?:\.\w+){1,2})\z/i
+
module_function
def maybe_update_completed_at(submission)
@@ -210,7 +215,7 @@ module Submissions
return email.downcase.sub(/@gmail?\z/i, '@gmail.com') if email.match?(/@gmail?\z/i)
return email.downcase if email.include?(',') ||
- email.match?(/\.(?:gob(?:\.\w+)?|om|mm|cm|et|mo|nz|za|ie|ed\.jp)\z/i) ||
+ email.match?(SKIP_EMAIL_FIX_TLDS) ||
email.exclude?('.')
fixed_email = EmailTypo.call(email.delete_prefix('<'))
@@ -220,8 +225,7 @@ module Submissions
domain = email.split('@').last.to_s.downcase
fixed_domain = fixed_email.to_s.split('@').last
- return email.downcase if domain == fixed_domain
- return email.downcase if fixed_domain.match?(/\Agmail\.(?!com\z)/i)
+ return email.downcase if domain == fixed_domain || skip_email_fix?(domain, fixed_domain)
threshold = fixed_domain.start_with?('hotmail.') ? 2 : 3
@@ -236,6 +240,13 @@ module Submissions
fixed_email
end
+ def skip_email_fix?(domain, fixed_domain)
+ return true if SKIP_EMAIL_FIX_LABELS.include?(domain.split('.').first)
+ return true if fixed_domain.match?(/\Agmail\.(?!com\z)/i)
+
+ fixed_domain.match?(/\A(?:comcast|verizon)\./i) && !domain.match?(/\A(?:comcast|verizon)\./i)
+ end
+
def filtered_conditions_schema(submission, values: nil, include_submitter_uuid: nil)
(submission.template_schema || submission.template.schema).filter_map do |item|
if item['conditions'].present?
diff --git a/lib/submissions/generate_audit_trail.rb b/lib/submissions/generate_audit_trail.rb
index 327ea7cc..1e7bc33d 100644
--- a/lib/submissions/generate_audit_trail.rb
+++ b/lib/submissions/generate_audit_trail.rb
@@ -19,6 +19,7 @@ module Submissions
}.freeze
TESTING_FOOTER = GenerateResultAttachments::TESTING_FOOTER
+ UNSUPPORTED_IMAGE_TYPES = GenerateResultAttachments::UNSUPPORTED_IMAGE_TYPES
RTL_REGEXP = TextUtils::RTL_REGEXP
MAX_IMAGE_HEIGHT = 100
@@ -301,7 +302,7 @@ module Submissions
},
completed_event.data['ip'] && { text: "IP: #{completed_event.data['ip']}\n" },
completed_event.data['sid'] && { text: "#{I18n.t('session_id')}: #{completed_event.data['sid']}\n" },
- completed_event.data['ua'] && { text: "User agent: #{completed_event.data['ua']}\n" },
+ completed_event.data['ua'] && { text: "User agent: #{completed_event.data['ua'].to_s.squish}\n" },
submitter.timezone && { text: "Time zone: #{submitter.timezone.to_s.sub('Kiev', 'Kyiv')}\n" },
"\n"
].compact_blank, line_spacing: 1.3, padding: [10, 20, 20, 0]
@@ -354,8 +355,9 @@ module Submissions
field_type = field['type']
- if field_type == 'image' &&
- submitter.attachments.find { |a| a.uuid == value }.then { |a| !a.image? || a.content_type == 'image/heic' }
+ if (field_type == 'image' || field_type == 'stamp') &&
+ submitter.attachments.find { |a| a.uuid == value }
+ .then { |a| !a.image? || a.content_type.in?(UNSUPPORTED_IMAGE_TYPES) }
field_type = 'file'
end
@@ -478,7 +480,7 @@ module Submissions
submitter.name || submitter.email || submitter.phone
end
- submitter_name = TextUtils.maybe_rtl_reverse(submitter_name.to_s)
+ submitter_name = TextUtils.maybe_rtl_reverse(submitter_name.to_s.squish)
text =
if event.event_type == 'complete_verification'
@@ -488,12 +490,12 @@ module Submissions
(invited_submitter = submission.submitters.find { |e| e.uuid == event.data['uuid'] }) &&
(name = submission.template_submitters.find { |e| e['uuid'] == event.data['uuid'] }&.dig('name'))
invited_submitter_name = 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:,
submitter_name:)
elsif with_audit_sender && (event.event_type == 'send_email' || event.event_type == 'send_sms')
- created_by_name = TextUtils.maybe_rtl_reverse(submission.created_by_user.full_name)
+ 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:),
@@ -502,7 +504,7 @@ module Submissions
elsif event.event_type == 'delegate_form'
from = event.data['old_email'].presence ||
versions.rfind { |v| v.created_at <= event.event_timestamp }&.then { |v| v.name || v.phone }
- from = TextUtils.maybe_rtl_reverse(from.to_s)
+ from = TextUtils.maybe_rtl_reverse(from.to_s.squish)
I18n.t('submission_event_names.delegate_form_by_html', from:, to: event.data['email'])
elsif event.event_type.include?('send_')
I18n.t("submission_event_names.#{event.event_type}_to_html", submitter_name:)
diff --git a/lib/submissions/generate_result_attachments.rb b/lib/submissions/generate_result_attachments.rb
index 8e72b91e..98d67c79 100644
--- a/lib/submissions/generate_result_attachments.rb
+++ b/lib/submissions/generate_result_attachments.rb
@@ -36,6 +36,8 @@ module Submissions
SIGN_REASON = 'Signed with DocuSeal.com'
+ UNSUPPORTED_IMAGE_TYPES = ['image/heic', 'image/avif'].freeze
+
RTL_REGEXP = TextUtils::RTL_REGEXP
TEXT_LEFT_MARGIN = 1
@@ -300,8 +302,9 @@ module Submissions
field_type = field['type']
- if field_type == 'image' &&
- submitter.attachments.find { |a| a.uuid == value }.then { |a| !a.image? || a.content_type == 'image/heic' }
+ if (field_type == 'image' || field_type == 'stamp') &&
+ submitter.attachments.find { |a| a.uuid == value }
+ .then { |a| !a.image? || a.content_type.in?(UNSUPPORTED_IMAGE_TYPES) }
field_type = 'file'
end
diff --git a/lib/submitters.rb b/lib/submitters.rb
index 979ba6bc..71041a3d 100644
--- a/lib/submitters.rb
+++ b/lib/submitters.rb
@@ -49,7 +49,7 @@ module Submitters
end
def fulltext_search_field(current_user, submitters, keyword, field_name)
- keyword = keyword.delete("\0\\")
+ keyword = keyword.delete("\0").tr('\\', ' ').squish
return submitters.none if keyword.blank?
diff --git a/lib/submitters/maybe_update_default_values.rb b/lib/submitters/maybe_update_default_values.rb
index c7ffba63..d7a44911 100644
--- a/lib/submitters/maybe_update_default_values.rb
+++ b/lib/submitters/maybe_update_default_values.rb
@@ -5,14 +5,9 @@ module Submitters
module_function
def call(submitter, current_user)
- user =
- if current_user && current_user.email == submitter.email
- current_user
- else
- submitter.account.users.find_by(email: submitter.email)
- end
-
- return if user.blank?
+ return if current_user.blank? || current_user.email != submitter.email
+
+ user = current_user
fields = submitter.submission.template_fields || submitter.submission.template.fields
diff --git a/lib/submitters/submit_values.rb b/lib/submitters/submit_values.rb
index 6c83a1ef..3686b71c 100644
--- a/lib/submitters/submit_values.rb
+++ b/lib/submitters/submit_values.rb
@@ -8,6 +8,7 @@ module Submitters
VARIABLE_REGEXP = /\{\{?(\w+)\}\}?/
PHONE_REGEXP = /[+\d()\s-]+/
NONEDITABLE_FIELD_TYPES = %w[stamp heading strikethrough].freeze
+ REQUIRED_FIELD_TYPES = %w[payment kba verification].freeze
STRFTIME_MAP = {
'hour' => '%-k',
@@ -95,7 +96,9 @@ module Submitters
required_field_uuids_acc.each do |uuid|
next if submitter.values[uuid].present?
- raise RequiredFieldError, uuid if validate_required
+ if validate_required || submitter.submission.fields_uuid_index.dig(uuid, 'type').in?(REQUIRED_FIELD_TYPES)
+ raise RequiredFieldError, uuid
+ end
Rollbar.warning("Required field #{submitter.id}: #{uuid}") if defined?(Rollbar)
end
diff --git a/lib/verify_pdf_signature.rb b/lib/verify_pdf_signature.rb
index 22717759..ec588e5e 100644
--- a/lib/verify_pdf_signature.rb
+++ b/lib/verify_pdf_signature.rb
@@ -15,18 +15,29 @@ module VerifyPdfSignature
next [] if signatures.blank?
- has_unsigned_changes = unsigned_changes?(document, io)
-
- signatures.map.with_index do |signature, index|
- build_signature(signature, io, trusted_certs,
- has_unsigned_changes && index == signatures.size - 1)
+ 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 do |signature|
+ build_signature(signature, trusted_certs,
+ verified: verified_signatures.include?(signature),
+ has_unsigned_changes: has_unsigned_changes && signature == last_signature)
end
end
end
- def build_signature(signature, io, trusted_certs, has_unsigned_changes)
+ def verified_signature?(signature, io, trusted_certs)
+ return false unless covers_signed_revision?(signature, io)
+
+ verify_contents(OpenSSL::PKCS7.new(signature.contents), signed_data(io, signature.byte_range), trusted_certs)
+ rescue OpenSSL::PKCS7::PKCS7Error
+ false
+ end
+
+ def build_signature(signature, trusted_certs, verified:, has_unsigned_changes:)
pkcs7 = OpenSSL::PKCS7.new(signature.contents)
- verified = verify_contents(pkcs7, signed_data(io, signature.byte_range), trusted_certs)
SignatureStruct.new(
messages: build_messages(pkcs7, verified, trusted_certs, has_unsigned_changes),
@@ -62,15 +73,25 @@ module VerifyPdfSignature
end
def certificate_message(pkcs7, trusted_certs)
- public_key = signer_certificate(pkcs7)&.public_key&.to_der
-
- if trusted_certs.any? { |e| e.public_key.to_der == public_key }
+ if trusted_certificate?(pkcs7, trusted_certs)
MessageStruct.new(text: I18n.t('signed_with_trusted_certificate'), status: :success)
else
MessageStruct.new(text: I18n.t('signed_with_external_certificate'), status: :error)
end
end
+ def trusted_signature?(signature, trusted_certs)
+ trusted_certificate?(OpenSSL::PKCS7.new(signature.contents), trusted_certs)
+ rescue OpenSSL::PKCS7::PKCS7Error
+ false
+ end
+
+ def trusted_certificate?(pkcs7, trusted_certs)
+ public_key = signer_certificate(pkcs7)&.public_key&.to_der
+
+ trusted_certs.any? { |e| e.public_key.to_der == public_key }
+ end
+
def verify_contents(pkcs7, signed_data, trusted_certs)
return false if digest_algorithms(pkcs7).blank?
@@ -137,9 +158,16 @@ module VerifyPdfSignature
Time.strptime("#{time.first(14)}#{offset.start_with?('+', '-') ? offset : '+0000'}", TIME_FORMAT)
end
- def unsigned_changes?(document, io)
- signed_end = document.signatures.map(&:signed_end).max
+ def covers_signed_revision?(signature, io)
+ byte_range = signature.byte_range
+
+ return false if byte_range.size != 4 || byte_range.any?(&:negative?) || byte_range[0].positive?
+ return false if signature.signed_end > io.size
+
+ byte_range[2] == byte_range[1] + (signature.contents.bytesize * 2) + 2
+ end
+ def unsigned_changes?(document, io, signed_end)
return false if document.trailer_ends.none? { |offset| offset > signed_end }
io.seek(0)
@@ -164,7 +192,7 @@ module VerifyPdfSignature
def signed_data(io, byte_range)
byte_range.each_slice(2).map do |offset, length|
io.seek(offset)
- io.read(length)
+ io.read(length.clamp(0, [io.size - offset, 0].max))
end.join
end
end