Merge from docusealco/wip
CI / Rubocop (push) Has been cancelled
CI / Erblint (push) Has been cancelled
CI / ESLint (push) Has been cancelled
CI / Brakeman (push) Has been cancelled
CI / RSpec (push) Has been cancelled

This commit is contained in:
Alex Turchyn
2026-08-17 12:31:40 +03:00
committed by GitHub
19 changed files with 1535 additions and 147 deletions
+19 -25
View File
@@ -111,48 +111,39 @@ jobs:
rspec: rspec:
name: RSpec name: RSpec
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10 timeout-minutes: 15
container:
image: ruby:4.0.5-alpine
services: services:
postgres: postgres:
image: postgres:14 image: postgres:18
env: env:
POSTGRES_USER: postgres POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres POSTGRES_PASSWORD: postgres
POSTGRES_DB: docuseal_test POSTGRES_DB: docuseal_test
ports: ["5432:5432"]
options: >- options: >-
--health-cmd pg_isready --health-cmd "pg_isready -U postgres -d docuseal_test"
--health-interval 10s --health-interval 10s
--health-timeout 5s --health-timeout 5s
--health-retries 5 --health-retries 5
steps: steps:
- name: Install packages
run: apk add --no-cache build-base git libpq-dev yaml-dev nodejs yarn tar zstd gzip wget unzip vips leptonica chromium
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Install Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: 4.0.5
- name: Set up Node
uses: actions/setup-node@v1
with:
node-version: 20.19.0
- name: Install Chrome
uses: browser-actions/setup-chrome@latest
with:
chrome-version: 125
- name: Cache node_modules - name: Cache node_modules
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
path: node_modules path: node_modules
key: ${{ runner.os }}-node-${{ hashFiles('**/yarn.lock') }} key: ${{ runner.os }}-musl-node-${{ hashFiles('**/yarn.lock') }}
- name: Cache gems - name: Cache gems
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
path: vendor/bundle path: vendor/bundle
key: ${{ runner.os }}-gem-${{ hashFiles('**/Gemfile.lock') }} key: ${{ runner.os }}-musl-gem-${{ hashFiles('**/Gemfile.lock') }}
restore-keys: | restore-keys: |
${{ runner.os }}-gem- ${{ runner.os }}-musl-gem-
- name: Install dependencies - name: Install dependencies
env: env:
RAILS_ENV: test RAILS_ENV: test
@@ -161,17 +152,20 @@ jobs:
bundle config path vendor/bundle bundle config path vendor/bundle
bundle install --jobs 4 --retry 4 bundle install --jobs 4 --retry 4
yarn install yarn install
sudo apt-get update wget -O pdfium-linux.zip "https://github.com/docusealco/pdfium-binaries/releases/download/20260813/pdfium-musl-$(uname -m).zip"
sudo apt-get install -y libvips liblept5 case "$(uname -m)" in
wget -O pdfium-linux.tgz "https://github.com/bblanchon/pdfium-binaries/releases/latest/download/pdfium-linux-$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/').tgz" x86_64) echo "c5c7dde243ecb66ab0819c8193515ef38ad53549fe260f3c2dfd93ea56eda2e7 pdfium-linux.zip" ;;
sudo tar -xzf pdfium-linux.tgz --strip-components=1 -C /usr/lib lib/libpdfium.so aarch64) echo "64c4483449b1b4dccc696ad0c5c96e0b7f74dcc57b4f23c676b7a70671b0bbb5 pdfium-linux.zip" ;;
rm -f pdfium-linux.tgz esac | sha256sum -c -
unzip -q pdfium-linux.zip -d /tmp/pdfium-linux
cp /tmp/pdfium-linux/lib/libpdfium.so /usr/lib/libpdfium.so
rm -rf pdfium-linux.zip /tmp/pdfium-linux
- name: Run - name: Run
env: env:
RAILS_ENV: test RAILS_ENV: test
NODE_ENV: test NODE_ENV: test
COVERAGE: true COVERAGE: true
DATABASE_URL: postgres://postgres:postgres@localhost:5432/docuseal_test DATABASE_URL: postgres://postgres:postgres@postgres:5432/docuseal_test
run: | run: |
bundle exec rake db:create bundle exec rake db:create
bundle exec rake db:migrate bundle exec rake db:migrate
+3 -3
View File
@@ -9,10 +9,10 @@ RUN apk --no-cache add wget unzip && \
wget https://raw.githubusercontent.com/impallari/DancingScript/master/OFL.txt && \ wget https://raw.githubusercontent.com/impallari/DancingScript/master/OFL.txt && \
wget https://raw.githubusercontent.com/notofonts/noto-fonts/refs/heads/main/LICENSE && \ wget https://raw.githubusercontent.com/notofonts/noto-fonts/refs/heads/main/LICENSE && \
wget -O /model.onnx "https://github.com/docusealco/fields-detection/releases/download/2.0.0/model_704_int8.onnx" && \ wget -O /model.onnx "https://github.com/docusealco/fields-detection/releases/download/2.0.0/model_704_int8.onnx" && \
wget -O pdfium-linux.zip "https://github.com/docusealco/pdfium-binaries/releases/download/20260613/pdfium-musl-$(uname -m).zip" && \ wget -O pdfium-linux.zip "https://github.com/docusealco/pdfium-binaries/releases/download/20260813/pdfium-musl-$(uname -m).zip" && \
case "$(uname -m)" in \ case "$(uname -m)" in \
x86_64) echo "2c953ff72ee2dda07e7fc577e25841cc3d6464468a7c5adfaea574efcbc3b90b pdfium-linux.zip" ;; \ x86_64) echo "c5c7dde243ecb66ab0819c8193515ef38ad53549fe260f3c2dfd93ea56eda2e7 pdfium-linux.zip" ;; \
aarch64) echo "23bbe287d2753fdb05741c7660647eb0ef0d2e4da2ce0722bfa9d9d455bd64e2 pdfium-linux.zip" ;; \ aarch64) echo "64c4483449b1b4dccc696ad0c5c96e0b7f74dcc57b4f23c676b7a70671b0bbb5 pdfium-linux.zip" ;; \
esac | sha256sum -c - && \ esac | sha256sum -c - && \
mkdir -p /pdfium-linux && \ mkdir -p /pdfium-linux && \
unzip -q pdfium-linux.zip -d /pdfium-linux unzip -q pdfium-linux.zip -d /pdfium-linux
+1 -1
View File
@@ -271,7 +271,7 @@ GEM
rdoc (>= 4.0.0) rdoc (>= 4.0.0)
reline (>= 0.4.2) reline (>= 0.4.2)
jmespath (1.6.2) jmespath (1.6.2)
json (2.21.1) json (2.21.2)
jwt (3.2.0) jwt (3.2.0)
base64 base64
language_server-protocol (3.17.0.5) language_server-protocol (3.17.0.5)
@@ -6,7 +6,9 @@ class TemplatesUploadsController < ApplicationController
layout 'plain' layout 'plain'
def show def show
redirect_to root_path if params[:url].blank? url_uri = Addressable::URI.parse(params[:url].to_s)
redirect_to root_path if url_uri.normalized_scheme != 'https' || url_uri.host.blank?
end end
def create def create
@@ -4,16 +4,16 @@ class VerifyPdfSignatureController < ApplicationController
skip_authorization_check skip_authorization_check
def create def create
pdfs =
params[:files].map do |file|
HexaPDF::Document.new(io: file.open)
end
trusted_certs = Accounts.load_trusted_certs(current_account) trusted_certs = Accounts.load_trusted_certs(current_account)
signatures =
params[:files].map do |file|
VerifyPdfSignature.call(file.open, trusted_certs)
end
render turbo_stream: turbo_stream.replace('result', partial: 'result', render turbo_stream: turbo_stream.replace('result', partial: 'result',
locals: { pdfs:, files: params[:files], trusted_certs: }) locals: { signatures:, files: params[:files] })
rescue HexaPDF::MalformedPDFError rescue Pdfium::PdfiumError
render turbo_stream: turbo_stream.replace('result', html: helpers.tag.div(I18n.t('invalid_pdf'), id: 'result')) render turbo_stream: turbo_stream.replace('result', html: helpers.tag.div(I18n.t('invalid_pdf'), id: 'result'))
end end
end end
+13 -9
View File
@@ -123,13 +123,18 @@ class ProcessSubmitterCompletionJob
user = submission.created_by_user || template.author user = submission.created_by_user || template.author
copy_email_configs = AccountConfigs.find_or_initialize_for_key(submitter.account,
AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY)
is_copy_email_enabled = documents_copy_email_enabled?(submitter, copy_email_configs)
if submitter.account.users.exists?(id: user.id) && submission.preferences['send_email'] != false && if submitter.account.users.exists?(id: user.id) && submission.preferences['send_email'] != false &&
(!template || template.preferences['completed_notification_email_enabled'] != false) (!template || template.preferences['completed_notification_email_enabled'] != false)
user_submitter = submission.submitters.find { |s| s.email == user.email } user_submitter = submission.submitters.find { |s| s.email == user.email }
is_sent_to_user = is_sent_to_user =
if user.role != 'integration' && if user.role != 'integration' &&
(!user_submitter || user_submitter.preferences['send_email'] == false) && (!user_submitter || user_submitter.preferences['send_email'] == false || !is_copy_email_enabled) &&
user.user_configs.find_by(key: UserConfig::RECEIVE_COMPLETED_EMAIL)&.value != false user.user_configs.find_by(key: UserConfig::RECEIVE_COMPLETED_EMAIL)&.value != false
SubmitterMailer.completed_email(submitter, user).deliver_later! SubmitterMailer.completed_email(submitter, user).deliver_later!
@@ -139,11 +144,11 @@ class ProcessSubmitterCompletionJob
enqueue_bcc_completed_emails(submitter, user, is_sent_to_user) enqueue_bcc_completed_emails(submitter, user, is_sent_to_user)
end end
maybe_enqueue_copy_emails(submitter) enqueue_copy_emails(submitter, copy_email_configs) if is_copy_email_enabled
end end
def enqueue_bcc_completed_emails(submitter, user, is_sent_to_user) def enqueue_bcc_completed_emails(submitter, user, is_sent_to_user)
bcc_addresses = build_bcc_addresses(submitter.submission) bcc_addresses = build_bcc_addresses(submitter.submission).uniq
raise TooManyBcc, submitter.account_id if Docuseal.multitenant? && bcc_addresses.size > BCC_LIMIT raise TooManyBcc, submitter.account_id if Docuseal.multitenant? && bcc_addresses.size > BCC_LIMIT
@@ -154,14 +159,13 @@ class ProcessSubmitterCompletionJob
end end
end end
def maybe_enqueue_copy_emails(submitter) def documents_copy_email_enabled?(submitter, configs)
return if submitter.template&.preferences&.dig('documents_copy_email_enabled') == false return false if submitter.template&.preferences&.dig('documents_copy_email_enabled') == false
configs = AccountConfigs.find_or_initialize_for_key(submitter.account, configs.value['enabled'] != false
AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY) end
return if configs.value['enabled'] == false
def enqueue_copy_emails(submitter, configs)
to = submitter.submission.submitters.reject { |e| e.preferences['send_email'] == false } to = submitter.submission.submitters.reject { |e| e.preferences['send_email'] == false }
.sort_by { |e| e.completed_at || Time.current }.select(&:email?).map(&:friendly_name) .sort_by { |e| e.completed_at || Time.current }.select(&:email?).map(&:friendly_name)
@@ -1,5 +1,7 @@
<% path_uri = Addressable::URI.parse(params[:path].to_s) %>
<% path = url_for(**Rails.application.routes.recognize_path(path_uri.path, method: :get)) %>
<%= render 'shared/turbo_modal', title: local_assigns[:title] do %> <%= render 'shared/turbo_modal', title: local_assigns[:title] do %>
<%= form_for '', url: params[:path], method: :get, data: { turbo_frame: :_top }, html: { autocomplete: :off } do |f| %> <%= form_for '', url: path, method: :get, data: { turbo_frame: :_top }, html: { autocomplete: :off } do |f| %>
<%= hidden_field_tag :q, params[:q] if params[:q].present? %> <%= hidden_field_tag :q, params[:q] if params[:q].present? %>
<% local_assigns[:default_params].each do |key, value| %> <% local_assigns[:default_params].each do |key, value| %>
<%= hidden_field_tag(key, value) if value.present? %> <%= hidden_field_tag(key, value) if value.present? %>
@@ -10,7 +12,7 @@
</div> </div>
<% if params[:with_remove] %> <% if params[:with_remove] %>
<div class="text-center w-full mt-4"> <div class="text-center w-full mt-4">
<%= link_to t('remove_filter'), "#{params[:path]}?#{request.query_parameters.slice('q').merge(local_assigns[:default_params]).to_query}", class: 'link', data: { turbo_frame: :_top } %> <%= link_to t('remove_filter'), "#{path}?#{request.query_parameters.slice('q').merge(local_assigns[:default_params]).to_query}", class: 'link', data: { turbo_frame: :_top } %>
</div> </div>
<% end %> <% end %>
<% end %> <% end %>
+13 -26
View File
@@ -1,7 +1,7 @@
<div id="result"> <div id="result">
<% files.zip(pdfs).each do |file, pdf| %> <% files.zip(signatures).each do |file, file_signatures| %>
<div class="mb-4 border border-base-300 rounded-md py-2 px-3"> <div class="mb-4 border border-base-300 rounded-md py-2 px-3">
<% if pdf.signatures.to_a.size == 0 %> <% if file_signatures.size == 0 %>
<div class="text-sm"> <div class="text-sm">
<%= file.original_filename %> <%= file.original_filename %>
</div> </div>
@@ -11,42 +11,29 @@
<% else %> <% else %>
<div class="flex items-center space-x-1 border-b border-dashed border-base-300 pb-2"> <div class="flex items-center space-x-1 border-b border-dashed border-base-300 pb-2">
<%= svg_icon('file_text', class: 'w-5 h-5 inline') %> <%= svg_icon('file_text', class: 'w-5 h-5 inline') %>
<span><%= file.original_filename %> - <%= pluralize(pdf.signatures.to_a.size, 'Signature') %></span> <span><%= file.original_filename %> - <%= pluralize(file_signatures.size, 'Signature') %></span>
</div> </div>
<% pdf.signatures.to_a.each do |signature| %> <% file_signatures.each do |signature| %>
<div class="mt-3"> <div class="mt-3">
<div class="space-y-1 font-medium pb-2 text-xl"> <div class="space-y-1 font-medium pb-2 text-xl">
<% signature.verify(trusted_certs:).messages.map(&:content).each do |message| %> <% signature.messages.each do |message| %>
<p class="flex space-x-1 items-center"> <p class="flex space-x-1 items-center">
<% if message == 'Signature verification failed' %> <% if message.status == :error %>
<%= svg_icon('x_circle', class: 'w-6 h-6 text-red-500') %> <%= svg_icon('x_circle', class: 'w-6 h-6 text-red-500') %>
<% elsif message == 'Signature valid' %> <% elsif message.status == :success %>
<%= svg_icon('circle_check', class: 'w-6 h-6 text-green-500') %> <%= svg_icon('circle_check', class: 'w-6 h-6 text-green-500') %>
<% elsif message.status == :warning %>
<%= svg_icon('alert_circle', class: 'w-6 h-6 text-yellow-500') %>
<% end %> <% end %>
<span> <span>
<%= message %> <%= message.text %>
</span> </span>
</p> </p>
<% if message == 'Signature valid' %>
<p class="flex space-x-1 items-center">
<% if trusted_certs.any? { |e| e.public_key.to_der == signature.signature_handler.signer_certificate.public_key.to_der } %>
<%= svg_icon('circle_check', class: 'w-6 h-6 text-green-500') %>
<span>
<%= t('signed_with_trusted_certificate') %>
</span>
<% else %>
<%= svg_icon('x_circle', class: 'w-6 h-6 text-red-500') %>
<span>
<%= t('signed_with_external_certificate') %>
</span>
<% end %>
</p>
<% end %>
<% end %> <% end %>
</div> </div>
<div class="flex items-center space-x-1"> <div class="flex items-center space-x-1">
<%= svg_icon('user', class: 'w-5 h-5 inline') %> <%= svg_icon('user', class: 'w-5 h-5 inline') %>
<span><%= signature.signing_reason %></span> <span><%= signature.reason %></span>
</div> </div>
<div class="flex items-center space-x-1"> <div class="flex items-center space-x-1">
<%= svg_icon('calendar', class: 'w-5 h-5 inline') %> <%= svg_icon('calendar', class: 'w-5 h-5 inline') %>
@@ -54,11 +41,11 @@
</div> </div>
<div class="flex items-center space-x-1"> <div class="flex items-center space-x-1">
<%= svg_icon('certificate', class: 'w-5 h-5 inline') %> <%= svg_icon('certificate', class: 'w-5 h-5 inline') %>
<span><%= signature.signer_name.force_encoding('UTF-8') %></span> <span><%= signature.common_name %></span>
</div> </div>
<div class="flex items-center space-x-1"> <div class="flex items-center space-x-1">
<%= svg_icon('lock_access', class: 'w-5 h-5 inline') %> <%= svg_icon('lock_access', class: 'w-5 h-5 inline') %>
<span><%= signature.signature_type %></span> <span><%= signature.type %></span>
</div> </div>
</div> </div>
<% end %> <% end %>
+2
View File
@@ -19,6 +19,8 @@ module DocuSeal
class Application < Rails::Application class Application < Rails::Application
config.load_defaults 8.1 config.load_defaults 8.1
config.active_support.message_serializer = :json
config.autoload_lib(ignore: %w[assets tasks puma]) config.autoload_lib(ignore: %w[assets tasks puma])
config.active_storage.routes_prefix = '' config.active_storage.routes_prefix = ''
+35
View File
@@ -228,6 +228,7 @@ en: &en
profile_details: Profile Details profile_details: Profile Details
sign_up_with_google: Sign up with Google sign_up_with_google: Sign up with Google
sign_up_with_microsoft: Sign up with Microsoft sign_up_with_microsoft: Sign up with Microsoft
redirecting_to_provider: Redirecting to %{provider}...
by_creating_an_account_you_agree_to_our_html: 'By creating an account, you agree to our <a target="_blank" href="https://www.docuseal.com/privacy">Privacy Policy</a> and <a target="_blank" href="https://www.docuseal.com/terms">Terms of Service</a>.' by_creating_an_account_you_agree_to_our_html: 'By creating an account, you agree to our <a target="_blank" href="https://www.docuseal.com/privacy">Privacy Policy</a> and <a target="_blank" href="https://www.docuseal.com/terms">Terms of Service</a>.'
enter_email_to_continue: Enter email to continue enter_email_to_continue: Enter email to continue
account: Account account: Account
@@ -596,6 +597,9 @@ en: &en
there_are_no_signatures: There are no signatures there_are_no_signatures: There are no signatures
signed_with_trusted_certificate: Signed with trusted certificate signed_with_trusted_certificate: Signed with trusted certificate
signed_with_external_certificate: Signed with external certificate signed_with_external_certificate: Signed with external certificate
signature_valid: Signature valid
signature_verification_failed: Signature verification failed
contains_unsigned_changes_after_the_last_signature: Contains unsigned changes after the last signature
setup_2fa_to_continue: Setup 2FA to continue. setup_2fa_to_continue: Setup 2FA to continue.
create_a_new_account: Create a new account. create_a_new_account: Create a new account.
you_are_already_signed_in: You are already signed in. you_are_already_signed_in: You are already signed in.
@@ -1338,6 +1342,7 @@ es: &es
profile_details: Detalles del perfil profile_details: Detalles del perfil
sign_up_with_google: Registrarse con Google sign_up_with_google: Registrarse con Google
sign_up_with_microsoft: Registrarse con Microsoft sign_up_with_microsoft: Registrarse con Microsoft
redirecting_to_provider: Redirigiendo a %{provider}...
by_creating_an_account_you_agree_to_our_html: 'Al crear una cuenta, aceptas nuestra <a target="_blank" href="https://www.docuseal.com/privacy">Política de Privacidad</a> y <a target="_blank" href="https://www.docuseal.com/terms">Términos de Servicio</a>.' by_creating_an_account_you_agree_to_our_html: 'Al crear una cuenta, aceptas nuestra <a target="_blank" href="https://www.docuseal.com/privacy">Política de Privacidad</a> y <a target="_blank" href="https://www.docuseal.com/terms">Términos de Servicio</a>.'
enter_email_to_continue: Ingresa tu correo electrónico para continuar enter_email_to_continue: Ingresa tu correo electrónico para continuar
account: Cuenta account: Cuenta
@@ -1706,6 +1711,9 @@ es: &es
there_are_no_signatures: No hay firmas there_are_no_signatures: No hay firmas
signed_with_trusted_certificate: Firmado con certificado de confianza signed_with_trusted_certificate: Firmado con certificado de confianza
signed_with_external_certificate: Firmado con certificado externo signed_with_external_certificate: Firmado con certificado externo
signature_valid: Firma válida
signature_verification_failed: Verificación de firma fallida
contains_unsigned_changes_after_the_last_signature: Contiene cambios no firmados después de la última firma
setup_2fa_to_continue: Configura 2FA para continuar. setup_2fa_to_continue: Configura 2FA para continuar.
create_a_new_account: Crear una nueva cuenta. create_a_new_account: Crear una nueva cuenta.
you_are_already_signed_in: Ya has iniciado sesión. you_are_already_signed_in: Ya has iniciado sesión.
@@ -2459,6 +2467,7 @@ it: &it
profile_details: Dettagli del profilo profile_details: Dettagli del profilo
sign_up_with_google: Registrati con Google sign_up_with_google: Registrati con Google
sign_up_with_microsoft: Registrati con Microsoft sign_up_with_microsoft: Registrati con Microsoft
redirecting_to_provider: Reindirizzamento a %{provider}...
by_creating_an_account_you_agree_to_our_html: 'Creando un account, accetti la nostra <a target="_blank" href="https://www.docuseal.com/privacy">Privacy Policy</a> e i nostri <a target="_blank" href="https://www.docuseal.com/terms">Termini di servizio</a>.' by_creating_an_account_you_agree_to_our_html: 'Creando un account, accetti la nostra <a target="_blank" href="https://www.docuseal.com/privacy">Privacy Policy</a> e i nostri <a target="_blank" href="https://www.docuseal.com/terms">Termini di servizio</a>.'
enter_email_to_continue: "Inserisci l'email per continuare" enter_email_to_continue: "Inserisci l'email per continuare"
account: Account account: Account
@@ -2827,6 +2836,9 @@ it: &it
there_are_no_signatures: Non ci sono firme there_are_no_signatures: Non ci sono firme
signed_with_trusted_certificate: Firmato con certificato affidabile signed_with_trusted_certificate: Firmato con certificato affidabile
signed_with_external_certificate: Firmato con certificato esterno signed_with_external_certificate: Firmato con certificato esterno
signature_valid: Firma valida
signature_verification_failed: Verifica della firma non riuscita
contains_unsigned_changes_after_the_last_signature: Contiene modifiche non firmate dopo lultima firma
setup_2fa_to_continue: Configura 2FA per continuare. setup_2fa_to_continue: Configura 2FA per continuare.
create_a_new_account: Crea un nuovo account. create_a_new_account: Crea un nuovo account.
you_are_already_signed_in: "Hai già effettuato l'accesso." you_are_already_signed_in: "Hai già effettuato l'accesso."
@@ -3566,6 +3578,7 @@ fr: &fr
profile_details: Détails du profil profile_details: Détails du profil
sign_up_with_google: Sinscrire avec Google sign_up_with_google: Sinscrire avec Google
sign_up_with_microsoft: Sinscrire avec Microsoft sign_up_with_microsoft: Sinscrire avec Microsoft
redirecting_to_provider: Redirection vers %{provider}...
by_creating_an_account_you_agree_to_our_html: En créant un compte, vous acceptez notre <a target="_blank" href="https://www.docuseal.com/privacy">Politique de confidentialité</a> et nos <a target="_blank" href="https://www.docuseal.com/terms">Conditions dutilisation</a>. by_creating_an_account_you_agree_to_our_html: En créant un compte, vous acceptez notre <a target="_blank" href="https://www.docuseal.com/privacy">Politique de confidentialité</a> et nos <a target="_blank" href="https://www.docuseal.com/terms">Conditions dutilisation</a>.
enter_email_to_continue: Entrez votre email pour continuer enter_email_to_continue: Entrez votre email pour continuer
account: Compte account: Compte
@@ -3934,6 +3947,9 @@ fr: &fr
there_are_no_signatures: Il ny a pas de signatures there_are_no_signatures: Il ny a pas de signatures
signed_with_trusted_certificate: Signé avec un certificat de confiance signed_with_trusted_certificate: Signé avec un certificat de confiance
signed_with_external_certificate: Signé avec un certificat externe signed_with_external_certificate: Signé avec un certificat externe
signature_valid: Signature valide
signature_verification_failed: Échec de la vérification de la signature
contains_unsigned_changes_after_the_last_signature: Contient des modifications non signées après la dernière signature
setup_2fa_to_continue: Configurez la 2FA pour continuer. setup_2fa_to_continue: Configurez la 2FA pour continuer.
create_a_new_account: Créer un nouveau compte. create_a_new_account: Créer un nouveau compte.
you_are_already_signed_in: Vous êtes déjà connecté. you_are_already_signed_in: Vous êtes déjà connecté.
@@ -4683,6 +4699,7 @@ pt: &pt
profile_details: Detalhes do perfil profile_details: Detalhes do perfil
sign_up_with_google: Inscrever-se com Google sign_up_with_google: Inscrever-se com Google
sign_up_with_microsoft: Inscrever-se com Microsoft sign_up_with_microsoft: Inscrever-se com Microsoft
redirecting_to_provider: Redirecionando para %{provider}...
by_creating_an_account_you_agree_to_our_html: 'Ao criar uma conta, você concorda com nossa <a target="_blank" href="https://www.docuseal.com/privacy">Política de Privacidade</a> e <a target="_blank" href="https://www.docuseal.com/terms">Termos de Serviço</a>.' by_creating_an_account_you_agree_to_our_html: 'Ao criar uma conta, você concorda com nossa <a target="_blank" href="https://www.docuseal.com/privacy">Política de Privacidade</a> e <a target="_blank" href="https://www.docuseal.com/terms">Termos de Serviço</a>.'
enter_email_to_continue: Insira o e-mail para continuar enter_email_to_continue: Insira o e-mail para continuar
account: Conta account: Conta
@@ -5051,6 +5068,9 @@ pt: &pt
there_are_no_signatures: Não há assinaturas there_are_no_signatures: Não há assinaturas
signed_with_trusted_certificate: Assinado com certificado confiável signed_with_trusted_certificate: Assinado com certificado confiável
signed_with_external_certificate: Assinado com certificado externo signed_with_external_certificate: Assinado com certificado externo
signature_valid: Assinatura válida
signature_verification_failed: Falha na verificação da assinatura
contains_unsigned_changes_after_the_last_signature: Contém alterações não assinadas após a última assinatura
setup_2fa_to_continue: Configure 2FA para continuar. setup_2fa_to_continue: Configure 2FA para continuar.
create_a_new_account: Criar uma nova conta. create_a_new_account: Criar uma nova conta.
you_are_already_signed_in: Você já está conectado. you_are_already_signed_in: Você já está conectado.
@@ -5803,6 +5823,7 @@ de: &de
profile_details: Profildetails profile_details: Profildetails
sign_up_with_google: Mit Google registrieren sign_up_with_google: Mit Google registrieren
sign_up_with_microsoft: Mit Microsoft registrieren sign_up_with_microsoft: Mit Microsoft registrieren
redirecting_to_provider: Weiterleitung zu %{provider}...
by_creating_an_account_you_agree_to_our_html: 'Durch die Erstellung eines Kontos stimmen Sie unseren <a target="_blank" href="https://www.docuseal.com/privacy">Datenschutzrichtlinien</a> und <a target="_blank" href="https://www.docuseal.com/terms">Nutzungsbedingungen</a> zu.' by_creating_an_account_you_agree_to_our_html: 'Durch die Erstellung eines Kontos stimmen Sie unseren <a target="_blank" href="https://www.docuseal.com/privacy">Datenschutzrichtlinien</a> und <a target="_blank" href="https://www.docuseal.com/terms">Nutzungsbedingungen</a> zu.'
enter_email_to_continue: E-Mail eingeben, um fortzufahren enter_email_to_continue: E-Mail eingeben, um fortzufahren
account: Konto account: Konto
@@ -6171,6 +6192,9 @@ de: &de
there_are_no_signatures: Es gibt keine Unterschriften there_are_no_signatures: Es gibt keine Unterschriften
signed_with_trusted_certificate: Signiert mit vertrauenswürdigem Zertifikat signed_with_trusted_certificate: Signiert mit vertrauenswürdigem Zertifikat
signed_with_external_certificate: Signiert mit externem Zertifikat signed_with_external_certificate: Signiert mit externem Zertifikat
signature_valid: Signatur gültig
signature_verification_failed: Signaturprüfung fehlgeschlagen
contains_unsigned_changes_after_the_last_signature: Enthält nicht signierte Änderungen nach der letzten Signatur
setup_2fa_to_continue: Richten Sie 2FA ein, um fortzufahren. setup_2fa_to_continue: Richten Sie 2FA ein, um fortzufahren.
create_a_new_account: Neues Konto erstellen. create_a_new_account: Neues Konto erstellen.
you_are_already_signed_in: Sie sind bereits angemeldet. you_are_already_signed_in: Sie sind bereits angemeldet.
@@ -6790,6 +6814,7 @@ pl:
profile_details: Szczegóły profilu profile_details: Szczegóły profilu
sign_up_with_google: Zarejestruj się przez Google sign_up_with_google: Zarejestruj się przez Google
sign_up_with_microsoft: Zarejestruj się przez Microsoft sign_up_with_microsoft: Zarejestruj się przez Microsoft
redirecting_to_provider: Przekierowywanie do %{provider}...
by_creating_an_account_you_agree_to_our_html: 'Tworząc konto, akceptujesz naszą <a target="_blank" href="https://www.docuseal.com/privacy">Politykę Prywatności</a> i <a target="_blank" href="https://www.docuseal.com/terms">Regulamin</a>.' by_creating_an_account_you_agree_to_our_html: 'Tworząc konto, akceptujesz naszą <a target="_blank" href="https://www.docuseal.com/privacy">Politykę Prywatności</a> i <a target="_blank" href="https://www.docuseal.com/terms">Regulamin</a>.'
enter_email_to_continue: Wprowadź e-mail, aby kontynuować enter_email_to_continue: Wprowadź e-mail, aby kontynuować
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.
@@ -6929,6 +6954,7 @@ uk:
profile_details: Дані профілю profile_details: Дані профілю
sign_up_with_google: Зареєструватися через Google sign_up_with_google: Зареєструватися через Google
sign_up_with_microsoft: Зареєструватися через Microsoft sign_up_with_microsoft: Зареєструватися через Microsoft
redirecting_to_provider: Перенаправлення до %{provider}...
by_creating_an_account_you_agree_to_our_html: 'Створюючи акаунт, ви погоджуєтесь з нашою <a target="_blank" href="https://www.docuseal.com/privacy">Політикою конфіденційності</a> і <a target="_blank" href="https://www.docuseal.com/terms">Умовами надання послуг</a>.' by_creating_an_account_you_agree_to_our_html: 'Створюючи акаунт, ви погоджуєтесь з нашою <a target="_blank" href="https://www.docuseal.com/privacy">Політикою конфіденційності</a> і <a target="_blank" href="https://www.docuseal.com/terms">Умовами надання послуг</a>.'
enter_email_to_continue: Введіть електронну пошту, щоб продовжити enter_email_to_continue: Введіть електронну пошту, щоб продовжити
the_code_has_been_sent_to_your_email: Код було надіслано на вашу електронну пошту. the_code_has_been_sent_to_your_email: Код було надіслано на вашу електронну пошту.
@@ -7068,6 +7094,7 @@ cs:
profile_details: Detaily profilu profile_details: Detaily profilu
sign_up_with_google: Zaregistrovat se pomocí Googlu sign_up_with_google: Zaregistrovat se pomocí Googlu
sign_up_with_microsoft: Zaregistrovat se pomocí Microsoftu sign_up_with_microsoft: Zaregistrovat se pomocí Microsoftu
redirecting_to_provider: Přesměrování na %{provider}...
by_creating_an_account_you_agree_to_our_html: 'Vytvořením účtu souhlasíte s našimi <a target="_blank" href="https://www.docuseal.com/privacy">Zásadami ochrany osobních údajů</a> a <a target="_blank" href="https://www.docuseal.com/terms">Podmínkami služby</a>.' by_creating_an_account_you_agree_to_our_html: 'Vytvořením účtu souhlasíte s našimi <a target="_blank" href="https://www.docuseal.com/privacy">Zásadami ochrany osobních údajů</a> a <a target="_blank" href="https://www.docuseal.com/terms">Podmínkami služby</a>.'
enter_email_to_continue: Zadejte e-mail pro pokračování enter_email_to_continue: Zadejte e-mail pro pokračování
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.
@@ -7193,6 +7220,7 @@ he:
profile_details: פרטי הפרופיל profile_details: פרטי הפרופיל
sign_up_with_google: הירשם עם גוגל sign_up_with_google: הירשם עם גוגל
sign_up_with_microsoft: הירשם עם מיקרוסופט sign_up_with_microsoft: הירשם עם מיקרוסופט
redirecting_to_provider: מפנה אל %{provider}...
by_creating_an_account_you_agree_to_our_html: 'על ידי יצירת חשבון, אתה מסכים ל<a target="_blank" href="https://www.docuseal.com/privacy">מדיניות הפרטיות</a> ול<a target="_blank" href="https://www.docuseal.com/terms">תנאי השירות</a> שלנו.' by_creating_an_account_you_agree_to_our_html: 'על ידי יצירת חשבון, אתה מסכים ל<a target="_blank" href="https://www.docuseal.com/privacy">מדיניות הפרטיות</a> ול<a target="_blank" href="https://www.docuseal.com/terms">תנאי השירות</a> שלנו.'
enter_email_to_continue: הכנס דוא"ל כדי להמשיך enter_email_to_continue: הכנס דוא"ל כדי להמשיך
the_code_has_been_sent_to_your_email: הקוד נשלח לדוא"ל שלך. the_code_has_been_sent_to_your_email: הקוד נשלח לדוא"ל שלך.
@@ -7439,6 +7467,7 @@ nl: &nl
profile_details: Profielgegevens profile_details: Profielgegevens
sign_up_with_google: Registreren met Google sign_up_with_google: Registreren met Google
sign_up_with_microsoft: Registreren met Microsoft sign_up_with_microsoft: Registreren met Microsoft
redirecting_to_provider: Doorverwijzen naar %{provider}...
by_creating_an_account_you_agree_to_our_html: Door een account aan te maken gaat u akkoord met ons <a target="_blank" href="https://www.docuseal.com/privacy">Privacybeleid</a> en onze <a target="_blank" href="https://www.docuseal.com/terms">Servicevoorwaarden</a>. by_creating_an_account_you_agree_to_our_html: Door een account aan te maken gaat u akkoord met ons <a target="_blank" href="https://www.docuseal.com/privacy">Privacybeleid</a> en onze <a target="_blank" href="https://www.docuseal.com/terms">Servicevoorwaarden</a>.
enter_email_to_continue: Voer e-mailadres in om door te gaan enter_email_to_continue: Voer e-mailadres in om door te gaan
account: Account account: Account
@@ -7807,6 +7836,9 @@ nl: &nl
there_are_no_signatures: Er zijn geen handtekeningen there_are_no_signatures: Er zijn geen handtekeningen
signed_with_trusted_certificate: Ondertekend met vertrouwd certificaat signed_with_trusted_certificate: Ondertekend met vertrouwd certificaat
signed_with_external_certificate: Ondertekend met extern certificaat signed_with_external_certificate: Ondertekend met extern certificaat
signature_valid: Handtekening geldig
signature_verification_failed: Verificatie van handtekening mislukt
contains_unsigned_changes_after_the_last_signature: Bevat niet-ondertekende wijzigingen na de laatste handtekening
setup_2fa_to_continue: Stel 2FA in om door te gaan. setup_2fa_to_continue: Stel 2FA in om door te gaan.
create_a_new_account: Maak een nieuw account aan. create_a_new_account: Maak een nieuw account aan.
you_are_already_signed_in: U bent al aangemeld. you_are_already_signed_in: U bent al aangemeld.
@@ -8409,6 +8441,7 @@ ar:
profile_details: تفاصيل الملف الشخصي profile_details: تفاصيل الملف الشخصي
sign_up_with_google: الاشتراك باستخدام جوجل sign_up_with_google: الاشتراك باستخدام جوجل
sign_up_with_microsoft: الاشتراك باستخدام مايكروسوفت sign_up_with_microsoft: الاشتراك باستخدام مايكروسوفت
redirecting_to_provider: جارٍ إعادة التوجيه إلى %{provider}...
by_creating_an_account_you_agree_to_our_html: 'من خلال إنشاء حساب، فإنك توافق على <a target="_blank" href="https://www.docuseal.com/privacy">سياسة الخصوصية</a> و<a target="_blank" href="https://www.docuseal.com/terms">شروط الخدمة</a> الخاصة بنا.' by_creating_an_account_you_agree_to_our_html: 'من خلال إنشاء حساب، فإنك توافق على <a target="_blank" href="https://www.docuseal.com/privacy">سياسة الخصوصية</a> و<a target="_blank" href="https://www.docuseal.com/terms">شروط الخدمة</a> الخاصة بنا.'
enter_email_to_continue: أدخل البريد الإلكتروني للمتابعة enter_email_to_continue: أدخل البريد الإلكتروني للمتابعة
the_code_has_been_sent_to_your_email: تم إرسال الرمز إلى بريدك الإلكتروني. the_code_has_been_sent_to_your_email: تم إرسال الرمز إلى بريدك الإلكتروني.
@@ -8520,6 +8553,7 @@ ko:
profile_details: 프로필 세부 정보 profile_details: 프로필 세부 정보
sign_up_with_google: Google로 가입 sign_up_with_google: Google로 가입
sign_up_with_microsoft: Microsoft로 가입 sign_up_with_microsoft: Microsoft로 가입
redirecting_to_provider: "%{provider}(으)로 이동 중..."
by_creating_an_account_you_agree_to_our_html: '계정을 생성함으로써, <a target="_blank" href="https://www.docuseal.com/privacy">개인정보 처리방침</a> 및 <a target="_blank" href="https://www.docuseal.com/terms">서비스 약관</a>에 동의하게 됩니다.' by_creating_an_account_you_agree_to_our_html: '계정을 생성함으로써, <a target="_blank" href="https://www.docuseal.com/privacy">개인정보 처리방침</a> 및 <a target="_blank" href="https://www.docuseal.com/terms">서비스 약관</a>에 동의하게 됩니다.'
enter_email_to_continue: 계속하려면 이메일을 입력하세요 enter_email_to_continue: 계속하려면 이메일을 입력하세요
the_code_has_been_sent_to_your_email: 코드가 이메일로 전송되었습니다. the_code_has_been_sent_to_your_email: 코드가 이메일로 전송되었습니다.
@@ -8631,6 +8665,7 @@ ja:
profile_details: プロフィールの詳細 profile_details: プロフィールの詳細
sign_up_with_google: Googleで登録 sign_up_with_google: Googleで登録
sign_up_with_microsoft: Microsoftで登録 sign_up_with_microsoft: Microsoftで登録
redirecting_to_provider: "%{provider} にリダイレクトしています..."
by_creating_an_account_you_agree_to_our_html: '<a target="_blank" href="https://www.docuseal.com/privacy">プライバシーポリシー</a>および<a target="_blank" href="https://www.docuseal.com/terms">利用規約</a>に同意の上、アカウントを作成します。' by_creating_an_account_you_agree_to_our_html: '<a target="_blank" href="https://www.docuseal.com/privacy">プライバシーポリシー</a>および<a target="_blank" href="https://www.docuseal.com/terms">利用規約</a>に同意の上、アカウントを作成します。'
enter_email_to_continue: 続行するにはメールを入力してください enter_email_to_continue: 続行するにはメールを入力してください
the_code_has_been_sent_to_your_email: コードがあなたのメールに送信されました the_code_has_been_sent_to_your_email: コードがあなたのメールに送信されました
+14 -20
View File
@@ -15,34 +15,28 @@ module PdfUtils
end end
def decrypt(data, password) def decrypt(data, password)
encrypted_doc = HexaPDF::Document.new(io: StringIO.new(data), decryption_opts: { password: })
decrypted_doc = HexaPDF::Document.new
encrypted_doc.pages.each do |page|
decrypted_doc.pages << decrypted_doc.import(page)
end
decrypted_io = StringIO.new decrypted_io = StringIO.new
decrypted_doc.write(decrypted_io, validate: false) Pdfium::Document.open_bytes(data, password) do |doc|
doc.save(decrypted_io, flags: Pdfium::FPDF_REMOVE_SECURITY)
end
decrypted_io.tap(&:rewind).read decrypted_io.tap(&:rewind).read
end end
def merge(files) def merge(io_files)
merged_pdf = HexaPDF::Document.new merged_content = StringIO.new
files.each do |file| Pdfium.with_instance do
pdf = HexaPDF::Document.new(io: file) Pdfium::Document.create do |merged_pdf|
pdf.pages.each { |page| merged_pdf.pages << merged_pdf.import(page) } io_files.each do |io|
Pdfium::Document.open_io(io) { |pdf| merged_pdf.import_pages(pdf) }
end
merged_pdf.save(merged_content)
end
end end
merged_content = StringIO.new merged_content.tap(&:rewind)
merged_pdf.validate(auto_correct: true)
merged_pdf.write(merged_content, validate: false)
merged_content.rewind
merged_content
end end
end end
+856 -18
View File
File diff suppressed because it is too large Load Diff
+53
View File
@@ -0,0 +1,53 @@
# 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
+56 -13
View File
@@ -39,6 +39,8 @@ 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 document_data ||= file.read
if file.content_type == PDF_CONTENT_TYPE if file.content_type == PDF_CONTENT_TYPE
@@ -48,6 +50,55 @@ module Templates
document_data.size < ANNOTATIONS_SIZE_LIMIT ? Templates::BuildAnnotations.call(document_data) : [] document_data.size < ANNOTATIONS_SIZE_LIMIT ? Templates::BuildAnnotations.call(document_data) : []
end 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
document = create_document(template, file, document_data, metadata)
return Templates::ProcessDocument.call(document, document_data, extract_fields:)
end
doc = Pdfium::Document.open_bytes(document_data, params[:password])
document_data = decrypt_document(doc) if doc.encrypted?
annotations =
document_data.size < ANNOTATIONS_SIZE_LIMIT ? Templates::BuildPdfiumAnnotations.call(doc) : []
document = create_document(template, file, document_data, metadata, annotations)
Templates::ProcessDocument.call(document, document_data, extract_fields:, doc:)
rescue Pdfium::PasswordError
raise PdfEncrypted
ensure
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
doc.save(io, flags: Pdfium::FPDF_REMOVE_SECURITY)
io.tap(&:rewind).read
end
def create_document(template, file, document_data, metadata, annotations = nil)
sha256 = Base64.urlsafe_encode64(Digest::SHA256.digest(document_data)) sha256 = Base64.urlsafe_encode64(Digest::SHA256.digest(document_data))
blob = ActiveStorage::Blob.create_and_upload!( blob = ActiveStorage::Blob.create_and_upload!(
@@ -62,19 +113,7 @@ module Templates
content_type: file.content_type content_type: file.content_type
) )
document = template.documents.create!(blob:) template.documents.create!(blob:)
Templates::ProcessDocument.call(document, document_data, extract_fields:)
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 HexaPDF::EncryptionError
raise PdfEncrypted
end end
def extract_zip_files(files) def extract_zip_files(files)
@@ -124,5 +163,9 @@ module Templates
raise InvalidFileType, "#{file.content_type}/#{dynamic}" raise InvalidFileType, "#{file.content_type}/#{dynamic}"
end end
def v2?
true
end
end end
end end
+6 -6
View File
@@ -173,20 +173,20 @@ module Templates
**attrs, **attrs,
type: 'select', type: 'select',
options: build_options(field[:Opt], 'select'), options: build_options(field[:Opt], 'select'),
default_value: field.field_value.to_s.match?(SELECT_PLACEHOLDER_REGEXP) ? nil : field.field_value 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? elsif field.field_type == :Ch && field.concrete_field_type == :multi_select && field[:Opt].present?
{ {
**attrs, **attrs,
type: 'multiple', type: 'multiple',
options: build_options(field[:Opt], 'multiple'), options: build_options(field[:Opt], 'multiple'),
default_value: field.field_value default_value: field.field_value.presence
} }
elsif field.field_type == :Tx && field.concrete_field_type == :comb_text_field elsif field.field_type == :Tx && field.concrete_field_type == :comb_text_field
{ {
**attrs, **attrs,
type: 'cells', type: 'cells',
default_value: field.field_value default_value: field.field_value.presence
} }
elsif field.field_type == :Tx elsif field.field_type == :Tx
if field[:AA] && ((field[:AA][:F] && field[:AA][:F][:JS].include?('AFDate_')) || if field[:AA] && ((field[:AA][:F] && field[:AA][:F][:JS].include?('AFDate_')) ||
@@ -199,13 +199,13 @@ module Templates
{ {
**attrs, **attrs,
type: 'date', type: 'date',
default_value: field.field_value default_value: field.field_value.presence
} }
else else
{ {
**attrs, **attrs,
type: 'text', type: 'text',
default_value: field.field_value default_value: field.field_value.presence
} }
end end
elsif field.field_type == :Sig elsif field.field_type == :Sig
@@ -234,7 +234,7 @@ module Templates
{ {
uuid: SecureRandom.uuid, uuid: SecureRandom.uuid,
value: is_option_number || is_skip_single_value ? '' : option value: is_option_number || is_skip_single_value ? '' : option.presence
} }
end end
end end
+230
View File
@@ -0,0 +1,230 @@
# 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
+46 -13
View File
@@ -19,15 +19,19 @@ module Templates
module_function module_function
def call(attachment, data, extract_fields: false, max_pages: MAX_NUMBER_OF_PAGES_PROCESSED) 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
pdf = HexaPDF::Document.new(io: StringIO.new(data)) 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) fields = Templates::FindAcroFields.call(pdf, attachment, data)
end
end end
generate_pdf_preview_images(attachment, data, pdf, max_pages:) generate_pdf_preview_images(attachment, data, pdf, max_pages:, doc:)
attachment.metadata['pdf']['fields'] = fields if fields attachment.metadata['pdf']['fields'] = fields if fields
elsif attachment.image? elsif attachment.image?
@@ -76,13 +80,17 @@ module Templates
) )
end end
def generate_pdf_preview_images(attachment, data, pdf = nil, max_pages: MAX_NUMBER_OF_PAGES_PROCESSED) def generate_pdf_preview_images(attachment, data, pdf = nil, max_pages: MAX_NUMBER_OF_PAGES_PROCESSED, doc: nil)
ActiveStorage::Attachment.where(name: ATTACHMENT_NAME, record: attachment).destroy_all ActiveStorage::Attachment.where(name: ATTACHMENT_NAME, record: attachment).destroy_all
pdf ||= HexaPDF::Document.new(io: StringIO.new(data)) if doc
number_of_pages = pdf.pages.size 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) 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
@@ -93,17 +101,21 @@ 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) generate_document_preview_images(attachment, data, 0..[number_of_pages - 1, max_pages_to_process].min, doc:)
end end
def generate_document_preview_images(attachment, data, range, concurrency: CONCURRENCY) def generate_document_preview_images(attachment, data, range, concurrency: CONCURRENCY, doc: nil)
doc = Pdfium::Document.open_bytes(data) 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 = doc.get_page(page_number) doc_page = pdfium_doc.get_page(page_number)
hide_placeholder_widgets(doc_page, hide_empty: !flatten_pages) if doc
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)
@@ -126,10 +138,31 @@ module Templates
end end
end end
ensure ensure
doc&.close pdfium_doc&.close if doc.nil?
pool&.kill pool&.kill
end end
def hide_placeholder_widgets(page, hide_empty: true)
page.annotations.each do |annotation|
next unless annotation.widget?
page.with_annotation(annotation.index) do |handle|
next if handle.field_type != Pdfium::FPDF_FORMFIELD_COMBOBOX
value = handle.field_value.to_s
if value.blank?
next unless hide_empty
elsif handle.option_labels.blank? ||
!value.match?(FindAcroFields::SELECT_PLACEHOLDER_REGEXP)
next
end
handle.hide!
end
end
end
def build_and_upload_blob(image, page_number, format = FORMAT) def build_and_upload_blob(image, page_number, format = FORMAT)
image = image.copy(interpretation: :srgb) image = image.copy(interpretation: :srgb)
+170
View File
@@ -0,0 +1,170 @@
# 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
+4 -3
View File
@@ -1153,9 +1153,10 @@ RSpec.describe 'Signing Form' do
click_on 'next' click_on 'next'
draw_canvas draw_canvas
expect do click_on 'Sign and Complete'
click_on 'Sign and Complete'
end.to change(ProcessSubmitterCompletionJob.jobs, :size).by(1) expect(page).to have_content('Document has been signed!')
expect(ProcessSubmitterCompletionJob.jobs.size).to eq(1)
end end
end end