mirror of https://github.com/docusealco/docuseal
Compare commits
No commits in common. '004a22c1c88109c7ba0b567df011a8cb13894001' and '54454d1dfcc20a82ff862619a7266730dc0dd446' have entirely different histories.
004a22c1c8
...
54454d1dfc
File diff suppressed because it is too large
Load Diff
@ -1,53 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
module Templates
|
|
||||||
module BuildPdfiumAnnotations
|
|
||||||
URI_PREFIXES = %w[https:// http://].freeze
|
|
||||||
LINKS_LIMIT = 250
|
|
||||||
|
|
||||||
module_function
|
|
||||||
|
|
||||||
def call(doc)
|
|
||||||
annotations = []
|
|
||||||
|
|
||||||
doc.page_count.times do |page_index|
|
|
||||||
break if annotations.size >= LINKS_LIMIT
|
|
||||||
next if doc.annot_count(page_index).zero?
|
|
||||||
|
|
||||||
page = doc.get_page(page_index)
|
|
||||||
geometry = { box: page.box, rotation: page.rotation }
|
|
||||||
|
|
||||||
page.annotations.each do |annotation|
|
|
||||||
next unless annotation.link? && annotation.rect?
|
|
||||||
|
|
||||||
url = annotation.link.url
|
|
||||||
|
|
||||||
next if url.blank? || URI_PREFIXES.none? { |prefix| url.start_with?(prefix) }
|
|
||||||
|
|
||||||
annotations << build_external_link_hash(url, annotation, geometry).merge('page' => page_index)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
annotations
|
|
||||||
rescue StandardError => e
|
|
||||||
Rollbar.error(e) if defined?(Rollbar)
|
|
||||||
|
|
||||||
raise if Rails.env.development?
|
|
||||||
|
|
||||||
[]
|
|
||||||
end
|
|
||||||
|
|
||||||
def build_external_link_hash(url, area, geometry)
|
|
||||||
x, y, w, h, page_width, page_height = Pdfium.transform_rect(area.bounds, **geometry)
|
|
||||||
|
|
||||||
{
|
|
||||||
'type' => 'external_link',
|
|
||||||
'value' => url,
|
|
||||||
'x' => x / page_width,
|
|
||||||
'y' => y / page_height,
|
|
||||||
'w' => w / page_width,
|
|
||||||
'h' => h / page_height
|
|
||||||
}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@ -1,230 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
module Templates
|
|
||||||
module FindPdfiumAcroFields
|
|
||||||
DATE_JS_PREFIX = 'AFDate_'
|
|
||||||
SKIP_FIELD_TYPES = %i[unknown pushbutton].freeze
|
|
||||||
TEXT_OPERATOR_REGEXP = /\bT[jJ]\b/
|
|
||||||
|
|
||||||
module_function
|
|
||||||
|
|
||||||
def call(attachment, doc, data)
|
|
||||||
return [] if !doc.form? && data.exclude?('/Form')
|
|
||||||
|
|
||||||
pages = {}
|
|
||||||
widgets = []
|
|
||||||
|
|
||||||
doc.page_count.times do |page_index|
|
|
||||||
next if doc.annot_count(page_index).zero?
|
|
||||||
|
|
||||||
page = doc.get_page(page_index)
|
|
||||||
|
|
||||||
pages[page_index] = { box: page.box, rotation: page.rotation }
|
|
||||||
|
|
||||||
page.annotations.each do |annotation|
|
|
||||||
next unless annotation.widget? && annotation.rect?
|
|
||||||
|
|
||||||
field = annotation.field
|
|
||||||
|
|
||||||
next if field.nil? || field.type.in?(SKIP_FIELD_TYPES)
|
|
||||||
|
|
||||||
widgets << annotation
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
group_widgets(widgets).filter_map { |field_widgets| build_field(field_widgets, pages, attachment) }
|
|
||||||
rescue StandardError => e
|
|
||||||
Rollbar.error(e) if defined?(Rollbar)
|
|
||||||
|
|
||||||
raise if Rails.env.development?
|
|
||||||
|
|
||||||
[]
|
|
||||||
end
|
|
||||||
|
|
||||||
def group_widgets(widgets)
|
|
||||||
widgets.group_by do |annotation|
|
|
||||||
field = annotation.field
|
|
||||||
|
|
||||||
if (field.control_count > 1 && field.own?) || field.detached? || (field.name.blank? && field.control_count <= 1)
|
|
||||||
[:widget, annotation.page_index, annotation.index]
|
|
||||||
else
|
|
||||||
[:field, field.type, field.name]
|
|
||||||
end
|
|
||||||
end.values
|
|
||||||
end
|
|
||||||
|
|
||||||
def build_field(widgets, pages, attachment)
|
|
||||||
widgets = widgets.sort_by.with_index { |annotation, index| [annotation.field.control_index, index] }
|
|
||||||
|
|
||||||
areas = widgets.filter_map { |annotation| build_area(annotation, pages[annotation.page_index], attachment) }
|
|
||||||
|
|
||||||
return if areas.blank?
|
|
||||||
|
|
||||||
field_properties = build_field_properties(widgets)
|
|
||||||
|
|
||||||
return if field_properties.blank?
|
|
||||||
return if field_properties[:default_value].present?
|
|
||||||
|
|
||||||
if field_properties[:type] == 'radio'
|
|
||||||
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: widgets.first.field.required?,
|
|
||||||
preferences: {},
|
|
||||||
areas:,
|
|
||||||
**field_properties
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
def build_area(annotation, page, attachment)
|
|
||||||
x, y, w, h, page_width, page_height = Pdfium.transform_rect(annotation.bounds, **page)
|
|
||||||
|
|
||||||
attrs = {
|
|
||||||
page: annotation.page_index,
|
|
||||||
x: x / page_width,
|
|
||||||
y: y / page_height,
|
|
||||||
w: w / page_width,
|
|
||||||
h: h / page_height,
|
|
||||||
attachment_uuid: attachment.uuid
|
|
||||||
}
|
|
||||||
|
|
||||||
return if attrs[:w].zero? || attrs[:h].zero?
|
|
||||||
|
|
||||||
field = annotation.field
|
|
||||||
|
|
||||||
attrs[:cell_w] = attrs[:w] / field.max_len if field.comb? && field.max_len.to_f.positive?
|
|
||||||
|
|
||||||
attrs
|
|
||||||
end
|
|
||||||
|
|
||||||
def build_field_properties(widgets)
|
|
||||||
field = widgets.first.field
|
|
||||||
|
|
||||||
field_name = field.name if field.name.match?(FindAcroFields::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)
|
|
||||||
|
|
||||||
case field.type
|
|
||||||
when :checkbox, :radio
|
|
||||||
build_button_properties(attrs, widgets)
|
|
||||||
when :combobox
|
|
||||||
build_select_properties(attrs, widgets.first)
|
|
||||||
when :text
|
|
||||||
build_text_properties(attrs, field)
|
|
||||||
when :signature
|
|
||||||
{
|
|
||||||
**attrs,
|
|
||||||
type: field.name.to_s.downcase.include?('initials') ? 'initials' : 'signature'
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{}
|
|
||||||
end.compact
|
|
||||||
end
|
|
||||||
|
|
||||||
def build_button_properties(attrs, widgets)
|
|
||||||
field = widgets.first.field
|
|
||||||
options = widgets.find { |w| w.field.options.present? }&.field&.options.to_a
|
|
||||||
checked = widgets.find { |w| w.field.checked? }
|
|
||||||
export_values = widgets.filter_map { |w| w.field.export_value.presence }.uniq
|
|
||||||
|
|
||||||
if field.type == :radio && options.present?
|
|
||||||
{
|
|
||||||
**attrs,
|
|
||||||
type: 'radio',
|
|
||||||
options: build_options(options, 'radio'),
|
|
||||||
default_value: checked && options[checked.field.control_index]
|
|
||||||
}
|
|
||||||
elsif field.control_count > 1 && export_values.size > 1
|
|
||||||
{
|
|
||||||
**attrs,
|
|
||||||
type: 'radio',
|
|
||||||
options: build_options(export_values.map(&:to_sym), 'radio'),
|
|
||||||
default_value: checked&.field&.export_value.presence
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
**attrs,
|
|
||||||
type: 'checkbox',
|
|
||||||
default_value: checked.present?
|
|
||||||
}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def build_select_properties(attrs, annotation)
|
|
||||||
field = annotation.field
|
|
||||||
|
|
||||||
return {} if field.options.blank?
|
|
||||||
|
|
||||||
value = field.value.presence if renders_text?(annotation)
|
|
||||||
|
|
||||||
{
|
|
||||||
**attrs,
|
|
||||||
type: 'select',
|
|
||||||
options: build_options(field.options, 'select'),
|
|
||||||
default_value: value.to_s.match?(FindAcroFields::SELECT_PLACEHOLDER_REGEXP) ? nil : value
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
def renders_text?(annotation)
|
|
||||||
appearance = annotation.page.with_annotation(annotation.index, &:appearance)
|
|
||||||
|
|
||||||
appearance.to_s.match?(TEXT_OPERATOR_REGEXP)
|
|
||||||
end
|
|
||||||
|
|
||||||
def build_text_properties(attrs, field)
|
|
||||||
preferences = { align: FindAcroFields::FIELD_ALIGNMENT.fetch(field.quadding.to_i, 'left') }
|
|
||||||
|
|
||||||
attrs = { **attrs, preferences: }
|
|
||||||
|
|
||||||
if field.comb?
|
|
||||||
{ **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] }
|
|
||||||
.first
|
|
||||||
|
|
||||||
preferences[:format] = format.upcase if format
|
|
||||||
|
|
||||||
{ **attrs, type: 'date', default_value: field.value.presence }
|
|
||||||
else
|
|
||||||
{ **attrs, type: 'text', default_value: field.value.presence }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def date?(field)
|
|
||||||
field.format_js.to_s.include?(DATE_JS_PREFIX) || field.keystroke_js.to_s.include?(DATE_JS_PREFIX)
|
|
||||||
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?(FindAcroFields::SELECT_PLACEHOLDER_REGEXP)
|
|
||||||
|
|
||||||
{
|
|
||||||
uuid: SecureRandom.uuid,
|
|
||||||
value: is_option_number || is_skip_single_value ? '' : option.presence
|
|
||||||
}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@ -1,170 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
module VerifyPdfSignature
|
|
||||||
COMMON_NAME = 'CN'
|
|
||||||
TIME_FORMAT = '%Y%m%d%H%M%S%z'
|
|
||||||
|
|
||||||
SignatureStruct = Struct.new(:messages, :reason, :signing_time, :common_name, :type)
|
|
||||||
MessageStruct = Struct.new(:text, :status)
|
|
||||||
|
|
||||||
module_function
|
|
||||||
|
|
||||||
def call(io, trusted_certs)
|
|
||||||
Pdfium::Document.open_io(io) do |document|
|
|
||||||
signatures = document.signatures.select { |e| e.byte_range.any?(&:positive?) && e.contents.present? }
|
|
||||||
|
|
||||||
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)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def build_signature(signature, io, trusted_certs, 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),
|
|
||||||
reason: signature.reason,
|
|
||||||
signing_time: signing_time(pkcs7, signature),
|
|
||||||
common_name: common_name(pkcs7),
|
|
||||||
type: signature.sub_filter
|
|
||||||
)
|
|
||||||
rescue OpenSSL::PKCS7::PKCS7Error
|
|
||||||
SignatureStruct.new(
|
|
||||||
messages: [MessageStruct.new(text: I18n.t('signature_verification_failed'), status: :error)],
|
|
||||||
reason: signature.reason,
|
|
||||||
signing_time: parse_time(signature.time),
|
|
||||||
type: signature.sub_filter
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def build_messages(pkcs7, verified, trusted_certs, has_unsigned_changes)
|
|
||||||
messages =
|
|
||||||
if verified
|
|
||||||
[MessageStruct.new(text: I18n.t('signature_valid'), status: :success),
|
|
||||||
certificate_message(pkcs7, trusted_certs)]
|
|
||||||
else
|
|
||||||
[MessageStruct.new(text: I18n.t('signature_verification_failed'), status: :error)]
|
|
||||||
end
|
|
||||||
|
|
||||||
if has_unsigned_changes
|
|
||||||
messages << MessageStruct.new(text: I18n.t('contains_unsigned_changes_after_the_last_signature'),
|
|
||||||
status: :warning)
|
|
||||||
end
|
|
||||||
|
|
||||||
messages << MessageStruct.new(text: "Certificate chain: #{certificate_chain(pkcs7).join(' -> ')}")
|
|
||||||
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 }
|
|
||||||
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 verify_contents(pkcs7, signed_data, trusted_certs)
|
|
||||||
return false if digest_algorithms(pkcs7).blank?
|
|
||||||
|
|
||||||
store = OpenSSL::X509::Store.new
|
|
||||||
store.set_default_paths
|
|
||||||
store.purpose = OpenSSL::X509::PURPOSE_SMIME_SIGN
|
|
||||||
store.verify_callback = ->(_success, _context) { true }
|
|
||||||
trusted_certs.each { |cert| store.add_cert(cert) }
|
|
||||||
|
|
||||||
pkcs7.verify(pkcs7.certificates, store, signed_data,
|
|
||||||
OpenSSL::PKCS7::DETACHED | OpenSSL::PKCS7::BINARY)
|
|
||||||
end
|
|
||||||
|
|
||||||
def digest_algorithms(pkcs7)
|
|
||||||
OpenSSL::ASN1.decode(pkcs7.to_der).value[1].value[0].value[1].value
|
|
||||||
end
|
|
||||||
|
|
||||||
def common_name(pkcs7)
|
|
||||||
cert = signer_certificate(pkcs7)
|
|
||||||
|
|
||||||
return if cert.nil?
|
|
||||||
|
|
||||||
cert.subject.to_a.assoc(COMMON_NAME)&.dig(1)
|
|
||||||
end
|
|
||||||
|
|
||||||
def signer_certificate(pkcs7)
|
|
||||||
info = pkcs7.signers.first
|
|
||||||
|
|
||||||
pkcs7.certificates&.find { |cert| cert.issuer == info.issuer && cert.serial == info.serial }
|
|
||||||
end
|
|
||||||
|
|
||||||
def certificate_chain(pkcs7)
|
|
||||||
signer = signer_certificate(pkcs7)
|
|
||||||
|
|
||||||
return [] if signer.nil?
|
|
||||||
|
|
||||||
certs = [signer]
|
|
||||||
|
|
||||||
while (issuer = pkcs7.certificates.find { |cert| cert.subject == certs.last.issuer })
|
|
||||||
break if certs.include?(issuer)
|
|
||||||
|
|
||||||
certs << issuer
|
|
||||||
end
|
|
||||||
|
|
||||||
certs.map { |cert| cert.subject.to_a.assoc(COMMON_NAME)&.dig(1) }
|
|
||||||
end
|
|
||||||
|
|
||||||
def signing_time(pkcs7, signature)
|
|
||||||
cms_signing_time(pkcs7) || parse_time(signature.time)
|
|
||||||
end
|
|
||||||
|
|
||||||
def cms_signing_time(pkcs7)
|
|
||||||
pkcs7.signers.first&.signed_time
|
|
||||||
rescue StandardError
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
|
|
||||||
def parse_time(value)
|
|
||||||
return if value.blank?
|
|
||||||
|
|
||||||
time = value.delete("'").delete_prefix('D:')
|
|
||||||
offset = time[14..].to_s
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
return false if document.trailer_ends.none? { |offset| offset > signed_end }
|
|
||||||
|
|
||||||
io.seek(0)
|
|
||||||
|
|
||||||
Pdfium::Document.open_bytes(io.read(signed_end)) do |signed_document|
|
|
||||||
next false unless signed_document.valid_cross_reference_table?
|
|
||||||
|
|
||||||
serialized_document(signed_document) != serialized_document(document)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def serialized_document(document)
|
|
||||||
pages = (0...document.page_count).map do |index|
|
|
||||||
page = document.get_page(index)
|
|
||||||
|
|
||||||
[page.rotation, page.objects, page.annotations, page.text]
|
|
||||||
end
|
|
||||||
|
|
||||||
[pages, document.bookmarks]
|
|
||||||
end
|
|
||||||
|
|
||||||
def signed_data(io, byte_range)
|
|
||||||
byte_range.each_slice(2).map do |offset, length|
|
|
||||||
io.seek(offset)
|
|
||||||
io.read(length)
|
|
||||||
end.join
|
|
||||||
end
|
|
||||||
end
|
|
||||||
Loading…
Reference in new issue