mirror of
https://github.com/docusealco/docuseal
synced 2026-11-04 14:42:32 -05:00
Compare commits
36
Commits
a2d8b85549
..
3.2.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
004a22c1c8 | ||
|
|
69343b1bdd | ||
|
|
81a306928c | ||
|
|
2bf3d42ed6 | ||
|
|
eec08266c9 | ||
|
|
cbbc75b485 | ||
|
|
3d93bbe5aa | ||
|
|
5308e3e15b | ||
|
|
22c146d564 | ||
|
|
c8d3af9841 | ||
|
|
ee1d45d461 | ||
|
|
27d075ed63 | ||
|
|
df63acf0c8 | ||
|
|
185827c951 | ||
|
|
e361cbabf4 | ||
|
|
e867bf5c48 | ||
|
|
726d0ea4f3 | ||
|
|
90be34b243 | ||
|
|
e87c06d811 | ||
|
|
54454d1dfc | ||
|
|
726174deff | ||
|
|
52eb8ac863 | ||
|
|
97d91d8f27 | ||
|
|
dd358a8401 | ||
|
|
8e254b7d3c | ||
|
|
b9018fa442 | ||
|
|
09310f3690 | ||
|
|
2dd38260ba | ||
|
|
ffb3247cdc | ||
|
|
c59c470321 | ||
|
|
f35510ffd6 | ||
|
|
eeec852b3b | ||
|
|
485b7f2c8f | ||
|
|
b6498ec049 | ||
|
|
dc1de72e18 | ||
|
|
7dddef4c94 |
No files matched your search
+19
-25
@@ -111,48 +111,39 @@ jobs:
|
||||
rspec:
|
||||
name: RSpec
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
timeout-minutes: 15
|
||||
container:
|
||||
image: ruby:4.0.5-alpine
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:14
|
||||
image: postgres:18
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: docuseal_test
|
||||
ports: ["5432:5432"]
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-cmd "pg_isready -U postgres -d docuseal_test"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
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
|
||||
- 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
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-node-${{ hashFiles('**/yarn.lock') }}
|
||||
key: ${{ runner.os }}-musl-node-${{ hashFiles('**/yarn.lock') }}
|
||||
- name: Cache gems
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: vendor/bundle
|
||||
key: ${{ runner.os }}-gem-${{ hashFiles('**/Gemfile.lock') }}
|
||||
key: ${{ runner.os }}-musl-gem-${{ hashFiles('**/Gemfile.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gem-
|
||||
${{ runner.os }}-musl-gem-
|
||||
- name: Install dependencies
|
||||
env:
|
||||
RAILS_ENV: test
|
||||
@@ -161,17 +152,20 @@ jobs:
|
||||
bundle config path vendor/bundle
|
||||
bundle install --jobs 4 --retry 4
|
||||
yarn install
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libvips liblept5
|
||||
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"
|
||||
sudo tar -xzf pdfium-linux.tgz --strip-components=1 -C /usr/lib lib/libpdfium.so
|
||||
rm -f pdfium-linux.tgz
|
||||
wget -O pdfium-linux.zip "https://github.com/docusealco/pdfium-binaries/releases/download/20260813/pdfium-musl-$(uname -m).zip"
|
||||
case "$(uname -m)" in
|
||||
x86_64) echo "c5c7dde243ecb66ab0819c8193515ef38ad53549fe260f3c2dfd93ea56eda2e7 pdfium-linux.zip" ;;
|
||||
aarch64) echo "64c4483449b1b4dccc696ad0c5c96e0b7f74dcc57b4f23c676b7a70671b0bbb5 pdfium-linux.zip" ;;
|
||||
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
|
||||
env:
|
||||
RAILS_ENV: test
|
||||
NODE_ENV: test
|
||||
COVERAGE: true
|
||||
DATABASE_URL: postgres://postgres:postgres@localhost:5432/docuseal_test
|
||||
DATABASE_URL: postgres://postgres:postgres@postgres:5432/docuseal_test
|
||||
run: |
|
||||
bundle exec rake db:create
|
||||
bundle exec rake db:migrate
|
||||
|
||||
+3
-3
@@ -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/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 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 \
|
||||
x86_64) echo "2c953ff72ee2dda07e7fc577e25841cc3d6464468a7c5adfaea574efcbc3b90b pdfium-linux.zip" ;; \
|
||||
aarch64) echo "23bbe287d2753fdb05741c7660647eb0ef0d2e4da2ce0722bfa9d9d455bd64e2 pdfium-linux.zip" ;; \
|
||||
x86_64) echo "c5c7dde243ecb66ab0819c8193515ef38ad53549fe260f3c2dfd93ea56eda2e7 pdfium-linux.zip" ;; \
|
||||
aarch64) echo "64c4483449b1b4dccc696ad0c5c96e0b7f74dcc57b4f23c676b7a70671b0bbb5 pdfium-linux.zip" ;; \
|
||||
esac | sha256sum -c - && \
|
||||
mkdir -p /pdfium-linux && \
|
||||
unzip -q pdfium-linux.zip -d /pdfium-linux
|
||||
|
||||
+1
-1
@@ -271,7 +271,7 @@ GEM
|
||||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.2)
|
||||
jmespath (1.6.2)
|
||||
json (2.21.1)
|
||||
json (2.21.2)
|
||||
jwt (3.2.0)
|
||||
base64
|
||||
language_server-protocol (3.17.0.5)
|
||||
|
||||
@@ -8,6 +8,7 @@ class NotificationsSettingsController < ApplicationController
|
||||
|
||||
before_action :build_account_config, only: :create
|
||||
authorize_resource :account_config, only: :create
|
||||
before_action :authorize_email_reminders!, only: :create
|
||||
|
||||
def index; end
|
||||
|
||||
@@ -21,6 +22,14 @@ class NotificationsSettingsController < ApplicationController
|
||||
|
||||
private
|
||||
|
||||
def authorize_email_reminders!
|
||||
return unless Docuseal.multitenant?
|
||||
return if @account_config.key != AccountConfig::SUBMITTER_REMINDERS
|
||||
return if can?(:manage, :email_reminders)
|
||||
|
||||
redirect_back fallback_location: settings_notifications_path, alert: I18n.t('unlock_with_docuseal_pro')
|
||||
end
|
||||
|
||||
def build_account_config
|
||||
@account_config =
|
||||
AccountConfig.find_or_initialize_by(account: current_account, key: email_config_params[:key])
|
||||
|
||||
@@ -5,6 +5,10 @@ class PreviewDocumentPageController < ActionController::API
|
||||
|
||||
FORMAT = Templates::ProcessDocument::FORMAT
|
||||
|
||||
TMPFILE_PREFIX = 'attachment-'
|
||||
TMPFILE_TTL = 5.minutes
|
||||
TMPFILE_MAX_TOTAL_SIZE = Docuseal.multitenant? ? 400.megabytes : 1.gigabyte
|
||||
|
||||
def show
|
||||
result_data =
|
||||
ApplicationRecord.signed_id_verifier.verified(params[:signed_key], purpose: :attachment)
|
||||
@@ -30,34 +34,63 @@ class PreviewDocumentPageController < ActionController::API
|
||||
allow_other_host: true
|
||||
end
|
||||
|
||||
file_path =
|
||||
if attachment.service.name == :disk
|
||||
ActiveStorage::Blob.service.path_for(attachment.key)
|
||||
else
|
||||
find_or_create_document_tempfile_path(attachment)
|
||||
end
|
||||
|
||||
preview_image =
|
||||
Templates::ProcessDocument.generate_pdf_preview_from_file(attachment, file_path, params[:id].to_i)
|
||||
open_attachment_io(attachment) do |io|
|
||||
Templates::ProcessDocument.generate_pdf_preview_from_io(attachment, io, params[:id].to_i)
|
||||
end
|
||||
|
||||
redirect_to preview_image.url(time: ActiveStorage::Attachment.service_url_time), allow_other_host: true
|
||||
end
|
||||
|
||||
def find_or_create_document_tempfile_path(attachment)
|
||||
file_path = "#{Dir.tmpdir}/attachment-#{Digest::SHA1.hexdigest("#{attachment.id}-#{attachment.uuid}")}"
|
||||
def open_attachment_io(attachment, &)
|
||||
return File.open(ActiveStorage::Blob.service.path_for(attachment.key), 'rb', &) if attachment.service.name == :disk
|
||||
|
||||
File.open(file_path, File::RDWR | File::CREAT, 0o644) do |f|
|
||||
f.flock(File::LOCK_EX)
|
||||
file_name = "#{TMPFILE_PREFIX}#{Digest::SHA1.hexdigest("#{attachment.id}-#{attachment.uuid}")}"
|
||||
|
||||
file_path = File.join(Dir.tmpdir, file_name)
|
||||
|
||||
File.open(file_path, File::RDWR | File::CREAT, 0o644) do |file|
|
||||
file.flock(File::LOCK_EX)
|
||||
|
||||
# rubocop:disable Style/ZeroLengthPredicate
|
||||
if f.size.zero?
|
||||
f.binmode
|
||||
if file.size.zero?
|
||||
cleanup_stale_tempfiles
|
||||
|
||||
f.write(attachment.download)
|
||||
file.binmode
|
||||
|
||||
file.write(attachment.download)
|
||||
else
|
||||
FileUtils.touch(file_path)
|
||||
end
|
||||
# rubocop:enable Style/ZeroLengthPredicate
|
||||
end
|
||||
|
||||
file_path
|
||||
file.flock(File::LOCK_UN)
|
||||
|
||||
yield file
|
||||
end
|
||||
end
|
||||
|
||||
def cleanup_stale_tempfiles
|
||||
entries =
|
||||
Dir.glob(File.join(Dir.tmpdir, "#{TMPFILE_PREFIX}*")).filter_map do |path|
|
||||
stat = File.stat(path)
|
||||
|
||||
[path, stat.mtime, stat.size]
|
||||
rescue Errno::ENOENT
|
||||
nil
|
||||
end
|
||||
|
||||
total_size = entries.sum(&:last)
|
||||
stale_time = TMPFILE_TTL.ago
|
||||
|
||||
entries.sort_by(&:second).each do |path, mtime, size|
|
||||
break if mtime > stale_time && total_size <= TMPFILE_MAX_TOTAL_SIZE
|
||||
|
||||
File.unlink(path)
|
||||
|
||||
total_size -= size
|
||||
rescue Errno::ENOENT
|
||||
nil
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -6,9 +6,10 @@ class StartFormEmail2faSendController < ApplicationController
|
||||
skip_before_action :authenticate_user!
|
||||
skip_authorization_check
|
||||
|
||||
def create
|
||||
@template = Template.find_by!(slug: params[:slug])
|
||||
before_action :load_template
|
||||
before_action :authorize_start!
|
||||
|
||||
def create
|
||||
@submitter = @template.submissions.new(account_id: @template.account_id)
|
||||
.submitters.new(**submitter_params, account_id: @template.account_id)
|
||||
|
||||
@@ -25,6 +26,23 @@ class StartFormEmail2faSendController < ApplicationController
|
||||
|
||||
private
|
||||
|
||||
def load_template
|
||||
@template = Template.find_by!(slug: params[:slug])
|
||||
end
|
||||
|
||||
def authorize_start!
|
||||
is_archived = @template.archived_at? || @template.account.archived_at?
|
||||
|
||||
return redirect_to start_form_path(@template.slug) if is_archived
|
||||
|
||||
return if (@template.shared_link? || (current_user && current_ability.can?(:read, @template))) &&
|
||||
@template.preferences['shared_link_2fa'] == true
|
||||
|
||||
Rollbar.warning("Not shared template: #{@template.id}") if defined?(Rollbar)
|
||||
|
||||
redirect_to start_form_path(@template.slug)
|
||||
end
|
||||
|
||||
def submitter_params
|
||||
params.require(:submitter).permit(:name, :email, :phone)
|
||||
end
|
||||
|
||||
@@ -40,7 +40,7 @@ class SubmissionsController < ApplicationController
|
||||
def create
|
||||
return redirect_to template_path(@template), alert: I18n.t('template_has_been_archived') if @template.archived_at?
|
||||
|
||||
save_template_message(@template, params) if params[:save_message] == '1'
|
||||
save_template_message(@template, params) if params[:save_message] == '1' && can?(:update, @template)
|
||||
|
||||
[params.delete(:subject), params.delete(:body)] if params[:is_custom_message] != '1'
|
||||
|
||||
|
||||
@@ -10,18 +10,19 @@ class SubmitFormCompletedDownloadController < ApplicationController
|
||||
def index
|
||||
@submitter = Submitter.find_signed(params[:sig], purpose: :download_completed) if params[:sig].present?
|
||||
|
||||
signature_valid =
|
||||
if @submitter&.slug == submitter_slug
|
||||
true
|
||||
else
|
||||
@submitter = nil
|
||||
end
|
||||
signature_valid = @submitter&.slug == submitter_slug
|
||||
|
||||
@submitter ||= Submitter.find_by!(slug: submitter_slug)
|
||||
@submitter = Submitter.find_by!(slug: submitter_slug) unless signature_valid
|
||||
|
||||
unless completed_submitter?(@submitter)
|
||||
Rollbar.error("Not completed: #{@submitter.id}") if defined?(Rollbar)
|
||||
|
||||
return head :not_found
|
||||
end
|
||||
|
||||
Submissions::EnsureResultGenerated.call(@submitter) if @submitter.completed_at?
|
||||
|
||||
last_submitter = @submitter.submission.submitters.where.not(completed_at: nil).order(:completed_at).last
|
||||
last_submitter = @submitter.submission.submitters.completed.order(:completed_at).last
|
||||
|
||||
return head :not_found unless last_submitter
|
||||
|
||||
@@ -60,6 +61,10 @@ class SubmitFormCompletedDownloadController < ApplicationController
|
||||
end
|
||||
end
|
||||
|
||||
def completed_submitter?(submitter)
|
||||
submitter.completed_at? || (submitter.viewer? && submitter.submission.completed_at?)
|
||||
end
|
||||
|
||||
def current_user_submitter?(submitter)
|
||||
current_user && current_ability.can?(:read, submitter)
|
||||
end
|
||||
|
||||
@@ -36,7 +36,7 @@ class SubmittersAutocompleteController < ApplicationController
|
||||
else
|
||||
column = Submitter.arel_table[field.to_sym]
|
||||
|
||||
term = "#{params[:q].downcase}%"
|
||||
term = "#{ActiveRecord::Base.sanitize_sql_like(params[:q].downcase)}%"
|
||||
|
||||
submitters.where(column.matches(term))
|
||||
end
|
||||
|
||||
@@ -6,7 +6,9 @@ class TemplatesUploadsController < ApplicationController
|
||||
layout 'plain'
|
||||
|
||||
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
|
||||
|
||||
def create
|
||||
|
||||
@@ -75,6 +75,8 @@ class UsersController < ApplicationController
|
||||
authorize!(:manage, account)
|
||||
|
||||
@user.account = account
|
||||
|
||||
authorize!(:create, @user)
|
||||
end
|
||||
|
||||
if @user.update(attrs.except(*(current_user == @user ? %i[password otp_required_for_login role] : %i[password])))
|
||||
|
||||
@@ -4,16 +4,16 @@ class VerifyPdfSignatureController < ApplicationController
|
||||
skip_authorization_check
|
||||
|
||||
def create
|
||||
pdfs =
|
||||
params[:files].map do |file|
|
||||
HexaPDF::Document.new(io: file.open)
|
||||
end
|
||||
|
||||
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',
|
||||
locals: { pdfs:, files: params[:files], trusted_certs: })
|
||||
rescue HexaPDF::MalformedPDFError
|
||||
locals: { signatures:, files: params[:files] })
|
||||
rescue Pdfium::PdfiumError
|
||||
render turbo_stream: turbo_stream.replace('result', html: helpers.tag.div(I18n.t('invalid_pdf'), id: 'result'))
|
||||
end
|
||||
end
|
||||
@@ -101,7 +101,7 @@
|
||||
</template>
|
||||
</div>
|
||||
<div>
|
||||
{{ new Date(signature.created_at).toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: 'numeric', timeZoneName: 'short' }) }}
|
||||
{{ new Date(signature.created_at).toLocaleString(undefined, { year: 'numeric', month: 'long', day: 'numeric', hour: 'numeric', minute: 'numeric', timeZoneName: 'short' }) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -417,10 +417,8 @@ function buildOperators (tags) {
|
||||
break
|
||||
}
|
||||
case 'end': {
|
||||
const popped = stack.pop()
|
||||
|
||||
if (popped.operator) {
|
||||
popped.operator.endTag = tag
|
||||
if (stack.length > 1) {
|
||||
stack.pop().operator.endTag = tag
|
||||
}
|
||||
|
||||
break
|
||||
@@ -434,7 +432,13 @@ function buildOperators (tags) {
|
||||
return operators
|
||||
}
|
||||
|
||||
function hasBlankKey (keyString) {
|
||||
return !keyString || keyString.split('.').some((key) => !key.trim())
|
||||
}
|
||||
|
||||
function assignNestedSchema (propertiesHash, parentProperties, keyString, value) {
|
||||
if (hasBlankKey(keyString)) return
|
||||
|
||||
const keys = keyString.split('.')
|
||||
const lastKey = keys.pop()
|
||||
|
||||
@@ -460,6 +464,8 @@ function assignNestedSchema (propertiesHash, parentProperties, keyString, value)
|
||||
}
|
||||
|
||||
function assignNestedSchemaWithPriority (propertiesHash, parentProperties, keyString, newType) {
|
||||
if (hasBlankKey(keyString)) return
|
||||
|
||||
const keys = keyString.split('.')
|
||||
const lastKey = keys.pop()
|
||||
|
||||
@@ -523,6 +529,8 @@ function processOperators (operators, propertiesHash = {}, parentProperties = {}
|
||||
processOperators(op.elseChildren, propertiesHash, parentProperties)
|
||||
break
|
||||
case 'for': {
|
||||
if (hasBlankKey(op.variableName)) break
|
||||
|
||||
const parts = op.variableName.split('.')
|
||||
const singularKey = singularize(parts[parts.length - 1])
|
||||
|
||||
|
||||
@@ -123,13 +123,18 @@ class ProcessSubmitterCompletionJob
|
||||
|
||||
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 &&
|
||||
(!template || template.preferences['completed_notification_email_enabled'] != false)
|
||||
user_submitter = submission.submitters.find { |s| s.email == user.email }
|
||||
|
||||
is_sent_to_user =
|
||||
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
|
||||
SubmitterMailer.completed_email(submitter, user).deliver_later!
|
||||
|
||||
@@ -139,11 +144,11 @@ class ProcessSubmitterCompletionJob
|
||||
enqueue_bcc_completed_emails(submitter, user, is_sent_to_user)
|
||||
end
|
||||
|
||||
maybe_enqueue_copy_emails(submitter)
|
||||
enqueue_copy_emails(submitter, copy_email_configs) if is_copy_email_enabled
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
@@ -154,14 +159,13 @@ class ProcessSubmitterCompletionJob
|
||||
end
|
||||
end
|
||||
|
||||
def maybe_enqueue_copy_emails(submitter)
|
||||
return if submitter.template&.preferences&.dig('documents_copy_email_enabled') == false
|
||||
def documents_copy_email_enabled?(submitter, configs)
|
||||
return false if submitter.template&.preferences&.dig('documents_copy_email_enabled') == false
|
||||
|
||||
configs = AccountConfigs.find_or_initialize_for_key(submitter.account,
|
||||
AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY)
|
||||
|
||||
return if configs.value['enabled'] == false
|
||||
configs.value['enabled'] != false
|
||||
end
|
||||
|
||||
def enqueue_copy_emails(submitter, configs)
|
||||
to = submitter.submission.submitters.reject { |e| e.preferences['send_email'] == false }
|
||||
.sort_by { |e| e.completed_at || Time.current }.select(&:email?).map(&:friendly_name)
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
<%= f.label :message, t('body'), class: 'label' %>
|
||||
<% body_variables = AccountConfig::EMAIL_VARIABLES[AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY] %>
|
||||
<%= render 'personalization_settings/email_body_editor', name: f.field_name(:body), value: local_assigns[:submitter_email_message]&.normalized_body.presence || (is_edit_viewer ? view_template_body : nil) || submitter_preferences_index.dig(local_assigns[:submitter]&.uuid, 'request_email_body').presence || (is_edit_viewer ? view_config.value['body'] : default_body), variables: body_variables %>
|
||||
<% if !local_assigns.fetch(:disable_save_as_default_template_option, false) && config.value['body_type'] != 'html' %>
|
||||
<% if !local_assigns.fetch(:disable_save_as_default_template_option, false) && config.value['body_type'] != 'html' && template && can?(:update, template) %>
|
||||
<label for="<%= uuid = SecureRandom.uuid %>" class="flex items-center cursor-pointer">
|
||||
<%= check_box_tag :save_message, id: uuid, class: 'base-checkbox', checked: false %>
|
||||
<span class="label"><%= t('save_as_default_template_message') %></span>
|
||||
|
||||
@@ -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 %>
|
||||
<%= 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? %>
|
||||
<% local_assigns[:default_params].each do |key, value| %>
|
||||
<%= hidden_field_tag(key, value) if value.present? %>
|
||||
@@ -10,7 +12,7 @@
|
||||
</div>
|
||||
<% if params[:with_remove] %>
|
||||
<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>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<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">
|
||||
<% if pdf.signatures.to_a.size == 0 %>
|
||||
<% if file_signatures.size == 0 %>
|
||||
<div class="text-sm">
|
||||
<%= file.original_filename %>
|
||||
</div>
|
||||
@@ -11,42 +11,29 @@
|
||||
<% else %>
|
||||
<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') %>
|
||||
<span><%= file.original_filename %> - <%= pluralize(pdf.signatures.to_a.size, 'Signature') %></span>
|
||||
<span><%= file.original_filename %> - <%= pluralize(file_signatures.size, 'Signature') %></span>
|
||||
</div>
|
||||
<% pdf.signatures.to_a.each do |signature| %>
|
||||
<% file_signatures.each do |signature| %>
|
||||
<div class="mt-3">
|
||||
<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">
|
||||
<% if message == 'Signature verification failed' %>
|
||||
<% if message.status == :error %>
|
||||
<%= 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') %>
|
||||
<% elsif message.status == :warning %>
|
||||
<%= svg_icon('alert_circle', class: 'w-6 h-6 text-yellow-500') %>
|
||||
<% end %>
|
||||
<span>
|
||||
<%= message %>
|
||||
<%= message.text %>
|
||||
</span>
|
||||
</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 %>
|
||||
</div>
|
||||
<div class="flex items-center space-x-1">
|
||||
<%= svg_icon('user', class: 'w-5 h-5 inline') %>
|
||||
<span><%= signature.signing_reason %></span>
|
||||
<span><%= signature.reason %></span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-1">
|
||||
<%= svg_icon('calendar', class: 'w-5 h-5 inline') %>
|
||||
@@ -54,11 +41,11 @@
|
||||
</div>
|
||||
<div class="flex items-center space-x-1">
|
||||
<%= 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 class="flex items-center space-x-1">
|
||||
<%= svg_icon('lock_access', class: 'w-5 h-5 inline') %>
|
||||
<span><%= signature.signature_type %></span>
|
||||
<span><%= signature.type %></span>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -19,6 +19,8 @@ module DocuSeal
|
||||
class Application < Rails::Application
|
||||
config.load_defaults 8.1
|
||||
|
||||
config.active_support.message_serializer = :json
|
||||
|
||||
config.autoload_lib(ignore: %w[assets tasks puma])
|
||||
|
||||
config.active_storage.routes_prefix = ''
|
||||
|
||||
@@ -110,4 +110,65 @@ module HexaPDF
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
module CycleSafeInheritedValue
|
||||
def inherited_value(field, name)
|
||||
seen = Set.new.compare_by_identity
|
||||
seen << field.value
|
||||
|
||||
while field.value[name].nil? && (parent = field[:Parent]) && seen.add?(parent.value)
|
||||
field = parent
|
||||
end
|
||||
|
||||
field.value[name].nil? ? nil : field[name]
|
||||
end
|
||||
end
|
||||
|
||||
module CycleSafeEachField
|
||||
def each_field(terminal_only: true)
|
||||
return to_enum(__method__, terminal_only:) unless block_given?
|
||||
|
||||
seen = Set.new.compare_by_identity
|
||||
|
||||
process_field_array = lambda do |array|
|
||||
array.each_with_index do |field, index|
|
||||
next if field.nil?
|
||||
|
||||
unless field.respond_to?(:type) && field.type == :XXAcroFormField
|
||||
array[index] = field = HexaPDF::Type::AcroForm::Field.wrap(document, field)
|
||||
end
|
||||
|
||||
next unless seen.add?(field.value)
|
||||
|
||||
if field.terminal_field?
|
||||
yield(field)
|
||||
else
|
||||
yield(field) unless terminal_only
|
||||
|
||||
process_field_array.call(field[:Kids])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
process_field_array.call(root_fields)
|
||||
|
||||
self
|
||||
end
|
||||
end
|
||||
|
||||
module CycleSafeFullFieldName
|
||||
def full_field_name(seen = Set.new.compare_by_identity)
|
||||
return field_name unless seen.add?(value)
|
||||
|
||||
if key?(:Parent)
|
||||
[self[:Parent].full_field_name(seen), field_name].compact.join('.')
|
||||
else
|
||||
field_name
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
HexaPDF::Type::AcroForm::Field.singleton_class.prepend(HexaPDF::CycleSafeInheritedValue)
|
||||
HexaPDF::Type::AcroForm::Field.prepend(HexaPDF::CycleSafeFullFieldName)
|
||||
HexaPDF::Type::AcroForm::Form.prepend(HexaPDF::CycleSafeEachField)
|
||||
+340
-28
@@ -57,8 +57,11 @@ en: &en
|
||||
paypal_account_has_been_connected: PayPal account has been connected.
|
||||
re_connect_paypal: Re-connect PayPal
|
||||
disconnect: Disconnect
|
||||
failed_to_connect_paypal: Failed to connect PayPal. Please try again.
|
||||
paypal_connect_not_completed: PayPal connection was not completed.
|
||||
failed_to_connect_paypal_please_try_again: Failed to connect PayPal. Please try again.
|
||||
paypal_connection_was_not_completed: PayPal connection was not completed.
|
||||
disconnecting_your_paypal_account_will_prevent_you_from_offering_paypal_services_and_products_on_your_website_do_you_wish_to_continue: Disconnecting your PayPal account will prevent you from offering PayPal services and products on your website. Do you wish to continue?
|
||||
attention_you_currently_cannot_receive_payments_due_to_restriction_on_your_paypal_account_please_reach_out_to_paypal_customer_support_or_connect_to_paypal_com_for_more_information: 'Attention: You currently cannot receive payments due to restriction on your PayPal account. Please reach out to PayPal Customer Support or connect to https://www.paypal.com for more information.'
|
||||
attention_please_confirm_your_email_address_on_paypal_com_in_order_to_receive_payments_you_currently_cannot_receive_payments: 'Attention: Please confirm your email address on https://www.paypal.com/businessprofile/settings in order to receive payments! You currently cannot receive payments.'
|
||||
payment_receipt: Payment Receipt
|
||||
amount_paid: Amount paid
|
||||
transaction: Transaction
|
||||
@@ -225,6 +228,7 @@ en: &en
|
||||
profile_details: Profile Details
|
||||
sign_up_with_google: Sign up with Google
|
||||
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>.'
|
||||
enter_email_to_continue: Enter email to continue
|
||||
account: Account
|
||||
@@ -593,6 +597,9 @@ en: &en
|
||||
there_are_no_signatures: There are no signatures
|
||||
signed_with_trusted_certificate: Signed with trusted 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.
|
||||
create_a_new_account: Create a new account.
|
||||
you_are_already_signed_in: You are already signed in.
|
||||
@@ -1113,9 +1120,16 @@ en: &en
|
||||
submission_submitters: Submitters list
|
||||
submission_link: Submission link
|
||||
documents_link: Documents link
|
||||
date:
|
||||
formats:
|
||||
default: "%m/%d/%Y"
|
||||
short: "%b %-d"
|
||||
long: "%B %-d, %Y"
|
||||
time:
|
||||
formats:
|
||||
detailed: "%B %d, %Y %H:%M:%S"
|
||||
short: "%b %-d, %-I:%M %p"
|
||||
long: "%B %-d, %Y at %-I:%M %p"
|
||||
detailed: "%B %-d, %Y at %-I:%M:%S %p"
|
||||
|
||||
es: &es
|
||||
knowledge_based_authentication: Autenticación basada en el conocimiento
|
||||
@@ -1147,8 +1161,11 @@ es: &es
|
||||
paypal_integration: Integración con PayPal
|
||||
paypal_account_has_been_connected: La cuenta de PayPal ha sido conectada.
|
||||
re_connect_paypal: Volver a conectar PayPal
|
||||
failed_to_connect_paypal: No se pudo conectar PayPal. Por favor, inténtalo de nuevo.
|
||||
paypal_connect_not_completed: La conexión con PayPal no se completó.
|
||||
failed_to_connect_paypal_please_try_again: No se pudo conectar PayPal. Por favor, inténtalo de nuevo.
|
||||
paypal_connection_was_not_completed: La conexión con PayPal no se completó.
|
||||
disconnecting_your_paypal_account_will_prevent_you_from_offering_paypal_services_and_products_on_your_website_do_you_wish_to_continue: Desconectar tu cuenta de PayPal impedirá que ofrezcas servicios y productos de PayPal en tu sitio web. ¿Deseas continuar?
|
||||
attention_you_currently_cannot_receive_payments_due_to_restriction_on_your_paypal_account_please_reach_out_to_paypal_customer_support_or_connect_to_paypal_com_for_more_information: 'Atención: Actualmente no puedes recibir pagos debido a una restricción en tu cuenta de PayPal. Por favor, contacta con el Soporte al Cliente de PayPal o visita https://www.paypal.com para más información.'
|
||||
attention_please_confirm_your_email_address_on_paypal_com_in_order_to_receive_payments_you_currently_cannot_receive_payments: 'Atención: Por favor, confirma tu dirección de correo electrónico en https://www.paypal.com/businessprofile/settings para poder recibir pagos. Actualmente no puedes recibir pagos.'
|
||||
payment_receipt: Recibo de pago
|
||||
amount_paid: Importe pagado
|
||||
transaction: Transacción
|
||||
@@ -1325,6 +1342,7 @@ es: &es
|
||||
profile_details: Detalles del perfil
|
||||
sign_up_with_google: Registrarse con Google
|
||||
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>.'
|
||||
enter_email_to_continue: Ingresa tu correo electrónico para continuar
|
||||
account: Cuenta
|
||||
@@ -1693,6 +1711,9 @@ es: &es
|
||||
there_are_no_signatures: No hay firmas
|
||||
signed_with_trusted_certificate: Firmado con certificado de confianza
|
||||
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.
|
||||
create_a_new_account: Crear una nueva cuenta.
|
||||
you_are_already_signed_in: Ya has iniciado sesión.
|
||||
@@ -2210,9 +2231,30 @@ es: &es
|
||||
submission_submitters: Lista de firmantes
|
||||
submission_link: Enlace del envío
|
||||
documents_link: Enlace de los documentos
|
||||
date:
|
||||
abbr_month_names:
|
||||
-
|
||||
- ene
|
||||
- feb
|
||||
- mar
|
||||
- abr
|
||||
- may
|
||||
- jun
|
||||
- jul
|
||||
- ago
|
||||
- sept
|
||||
- oct
|
||||
- nov
|
||||
- dic
|
||||
formats:
|
||||
default: "%d/%m/%Y"
|
||||
short: "%-d %b"
|
||||
long: "%-d de %B de %Y"
|
||||
time:
|
||||
formats:
|
||||
detailed: "%-d de %B de %Y %H:%M:%S"
|
||||
short: "%-d %b, %-H:%M"
|
||||
long: "%-d de %B de %Y a las %-H:%M"
|
||||
detailed: "%-d de %B de %Y a las %-H:%M:%S"
|
||||
|
||||
it: &it
|
||||
knowledge_based_authentication: Autenticazione basata sulla conoscenza
|
||||
@@ -2244,8 +2286,11 @@ it: &it
|
||||
paypal_integration: Integrazione PayPal
|
||||
paypal_account_has_been_connected: L'account PayPal è stato collegato.
|
||||
re_connect_paypal: Ricollega PayPal
|
||||
failed_to_connect_paypal: Impossibile collegare PayPal. Riprova.
|
||||
paypal_connect_not_completed: La connessione con PayPal non è stata completata.
|
||||
failed_to_connect_paypal_please_try_again: Impossibile collegare PayPal. Riprova.
|
||||
paypal_connection_was_not_completed: La connessione con PayPal non è stata completata.
|
||||
disconnecting_your_paypal_account_will_prevent_you_from_offering_paypal_services_and_products_on_your_website_do_you_wish_to_continue: La disconnessione del tuo account PayPal ti impedirà di offrire servizi e prodotti PayPal sul tuo sito web. Vuoi continuare?
|
||||
attention_you_currently_cannot_receive_payments_due_to_restriction_on_your_paypal_account_please_reach_out_to_paypal_customer_support_or_connect_to_paypal_com_for_more_information: 'Attenzione: Al momento non puoi ricevere pagamenti a causa di una restrizione sul tuo account PayPal. Contatta l''Assistenza Clienti PayPal o visita https://www.paypal.com per maggiori informazioni.'
|
||||
attention_please_confirm_your_email_address_on_paypal_com_in_order_to_receive_payments_you_currently_cannot_receive_payments: 'Attenzione: Conferma il tuo indirizzo email su https://www.paypal.com/businessprofile/settings per poter ricevere pagamenti! Al momento non puoi ricevere pagamenti.'
|
||||
payment_receipt: Ricevuta di pagamento
|
||||
amount_paid: Importo pagato
|
||||
transaction: Transazione
|
||||
@@ -2422,6 +2467,7 @@ it: &it
|
||||
profile_details: Dettagli del profilo
|
||||
sign_up_with_google: Registrati con Google
|
||||
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>.'
|
||||
enter_email_to_continue: "Inserisci l'email per continuare"
|
||||
account: Account
|
||||
@@ -2790,6 +2836,9 @@ it: &it
|
||||
there_are_no_signatures: Non ci sono firme
|
||||
signed_with_trusted_certificate: Firmato con certificato affidabile
|
||||
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 l’ultima firma
|
||||
setup_2fa_to_continue: Configura 2FA per continuare.
|
||||
create_a_new_account: Crea un nuovo account.
|
||||
you_are_already_signed_in: "Hai già effettuato l'accesso."
|
||||
@@ -3307,9 +3356,16 @@ it: &it
|
||||
submission_submitters: Lista dei firmatari
|
||||
submission_link: Link dell'invio
|
||||
documents_link: Link dei documenti
|
||||
date:
|
||||
formats:
|
||||
default: "%d/%m/%Y"
|
||||
short: "%-d %b"
|
||||
long: "%-d %B %Y"
|
||||
time:
|
||||
formats:
|
||||
detailed: "%d %B %Y %H:%M:%S"
|
||||
short: "%-d %b, %H:%M"
|
||||
long: "%-d %B %Y alle ore %H:%M"
|
||||
detailed: "%-d %B %Y alle ore %H:%M:%S"
|
||||
|
||||
fr: &fr
|
||||
knowledge_based_authentication: Authentification basée sur la connaissance
|
||||
@@ -3341,8 +3397,11 @@ fr: &fr
|
||||
paypal_integration: Intégration PayPal
|
||||
paypal_account_has_been_connected: Le compte PayPal a été connecté.
|
||||
re_connect_paypal: Reconnecter PayPal
|
||||
failed_to_connect_paypal: Échec de la connexion à PayPal. Veuillez réessayer.
|
||||
paypal_connect_not_completed: La connexion à PayPal n'a pas été complétée.
|
||||
failed_to_connect_paypal_please_try_again: Échec de la connexion à PayPal. Veuillez réessayer.
|
||||
paypal_connection_was_not_completed: La connexion à PayPal n'a pas été complétée.
|
||||
disconnecting_your_paypal_account_will_prevent_you_from_offering_paypal_services_and_products_on_your_website_do_you_wish_to_continue: Déconnecter votre compte PayPal vous empêchera de proposer des services et produits PayPal sur votre site web. Voulez-vous continuer ?
|
||||
attention_you_currently_cannot_receive_payments_due_to_restriction_on_your_paypal_account_please_reach_out_to_paypal_customer_support_or_connect_to_paypal_com_for_more_information: 'Attention : Vous ne pouvez actuellement pas recevoir de paiements en raison d''une restriction sur votre compte PayPal. Veuillez contacter le service client PayPal ou visiter https://www.paypal.com pour plus d''informations.'
|
||||
attention_please_confirm_your_email_address_on_paypal_com_in_order_to_receive_payments_you_currently_cannot_receive_payments: 'Attention : Veuillez confirmer votre adresse e-mail sur https://www.paypal.com/businessprofile/settings afin de pouvoir recevoir des paiements ! Vous ne pouvez actuellement pas recevoir de paiements.'
|
||||
payment_receipt: Reçu de paiement
|
||||
amount_paid: Montant payé
|
||||
transaction: Transaction
|
||||
@@ -3519,6 +3578,7 @@ fr: &fr
|
||||
profile_details: Détails du profil
|
||||
sign_up_with_google: S’inscrire avec Google
|
||||
sign_up_with_microsoft: S’inscrire 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 d’utilisation</a>.
|
||||
enter_email_to_continue: Entrez votre e‑mail pour continuer
|
||||
account: Compte
|
||||
@@ -3887,6 +3947,9 @@ fr: &fr
|
||||
there_are_no_signatures: Il n’y a pas de signatures
|
||||
signed_with_trusted_certificate: Signé avec un certificat de confiance
|
||||
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.
|
||||
create_a_new_account: Créer un nouveau compte.
|
||||
you_are_already_signed_in: Vous êtes déjà connecté.
|
||||
@@ -4401,9 +4464,29 @@ fr: &fr
|
||||
submission_submitters: Liste des signataires
|
||||
submission_link: Lien de la soumission
|
||||
documents_link: Lien des documents
|
||||
date:
|
||||
abbr_month_names:
|
||||
-
|
||||
- janv.
|
||||
- févr.
|
||||
- mars
|
||||
- avr.
|
||||
- mai
|
||||
- juin
|
||||
- juil.
|
||||
- août
|
||||
- sept.
|
||||
- oct.
|
||||
- nov.
|
||||
- déc.
|
||||
formats:
|
||||
short: "%-d %b"
|
||||
long: "%-d %B %Y"
|
||||
time:
|
||||
formats:
|
||||
detailed: "%A %d %B %Y %Hh%Mm%Ss"
|
||||
short: "%-d %b, %H:%M"
|
||||
long: "%-d %B %Y à %H:%M"
|
||||
detailed: "%-d %B %Y à %H:%M:%S"
|
||||
|
||||
pt: &pt
|
||||
knowledge_based_authentication: Autenticação baseada em conhecimento
|
||||
@@ -4435,8 +4518,11 @@ pt: &pt
|
||||
paypal_integration: Integração com PayPal
|
||||
paypal_account_has_been_connected: Conta PayPal foi conectada.
|
||||
re_connect_paypal: Reconectar PayPal
|
||||
failed_to_connect_paypal: Falha ao conectar PayPal. Tente novamente.
|
||||
paypal_connect_not_completed: A conexão com PayPal não foi concluída.
|
||||
failed_to_connect_paypal_please_try_again: Falha ao conectar PayPal. Tente novamente.
|
||||
paypal_connection_was_not_completed: A conexão com PayPal não foi concluída.
|
||||
disconnecting_your_paypal_account_will_prevent_you_from_offering_paypal_services_and_products_on_your_website_do_you_wish_to_continue: Desconectar sua conta PayPal impedirá que você ofereça serviços e produtos PayPal no seu site. Deseja continuar?
|
||||
attention_you_currently_cannot_receive_payments_due_to_restriction_on_your_paypal_account_please_reach_out_to_paypal_customer_support_or_connect_to_paypal_com_for_more_information: 'Atenção: No momento você não pode receber pagamentos devido a uma restrição na sua conta PayPal. Entre em contato com o Suporte ao Cliente PayPal ou acesse https://www.paypal.com para mais informações.'
|
||||
attention_please_confirm_your_email_address_on_paypal_com_in_order_to_receive_payments_you_currently_cannot_receive_payments: 'Atenção: Por favor, confirme seu endereço de e-mail em https://www.paypal.com/businessprofile/settings para poder receber pagamentos! No momento você não pode receber pagamentos.'
|
||||
payment_receipt: Recibo de pagamento
|
||||
amount_paid: Valor pago
|
||||
transaction: Transação
|
||||
@@ -4613,6 +4699,7 @@ pt: &pt
|
||||
profile_details: Detalhes do perfil
|
||||
sign_up_with_google: Inscrever-se com Google
|
||||
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>.'
|
||||
enter_email_to_continue: Insira o e-mail para continuar
|
||||
account: Conta
|
||||
@@ -4981,6 +5068,9 @@ pt: &pt
|
||||
there_are_no_signatures: Não há assinaturas
|
||||
signed_with_trusted_certificate: Assinado com certificado confiável
|
||||
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.
|
||||
create_a_new_account: Criar uma nova conta.
|
||||
you_are_already_signed_in: Você já está conectado.
|
||||
@@ -5498,9 +5588,29 @@ pt: &pt
|
||||
submission_submitters: Lista de signatários
|
||||
submission_link: Link da submissão
|
||||
documents_link: Link dos documentos
|
||||
date:
|
||||
abbr_month_names:
|
||||
-
|
||||
- jan.
|
||||
- fev.
|
||||
- mar.
|
||||
- abr.
|
||||
- mai.
|
||||
- jun.
|
||||
- jul.
|
||||
- ago.
|
||||
- set.
|
||||
- out.
|
||||
- nov.
|
||||
- dez.
|
||||
formats:
|
||||
short: "%-d de %b"
|
||||
long: "%-d de %B de %Y"
|
||||
time:
|
||||
formats:
|
||||
detailed: "%A, %d de %B de %Y, %H:%M:%Sh"
|
||||
short: "%-d de %b, %H:%M"
|
||||
long: "%-d de %B de %Y às %H:%M"
|
||||
detailed: "%-d de %B de %Y às %H:%M:%S"
|
||||
|
||||
de: &de
|
||||
knowledge_based_authentication: Wissensbasierte Authentifizierung
|
||||
@@ -5542,8 +5652,11 @@ de: &de
|
||||
paypal_integration: PayPal-Integration
|
||||
paypal_account_has_been_connected: PayPal-Konto wurde verbunden.
|
||||
re_connect_paypal: PayPal erneut verbinden
|
||||
failed_to_connect_paypal: PayPal konnte nicht verbunden werden. Bitte erneut versuchen.
|
||||
paypal_connect_not_completed: Die PayPal-Verbindung wurde nicht abgeschlossen.
|
||||
failed_to_connect_paypal_please_try_again: PayPal konnte nicht verbunden werden. Bitte erneut versuchen.
|
||||
paypal_connection_was_not_completed: Die PayPal-Verbindung wurde nicht abgeschlossen.
|
||||
disconnecting_your_paypal_account_will_prevent_you_from_offering_paypal_services_and_products_on_your_website_do_you_wish_to_continue: Das Trennen Ihres PayPal-Kontos verhindert, dass Sie PayPal-Dienste und -Produkte auf Ihrer Website anbieten können. Möchten Sie fortfahren?
|
||||
attention_you_currently_cannot_receive_payments_due_to_restriction_on_your_paypal_account_please_reach_out_to_paypal_customer_support_or_connect_to_paypal_com_for_more_information: 'Achtung: Sie können derzeit aufgrund einer Einschränkung Ihres PayPal-Kontos keine Zahlungen empfangen. Bitte wenden Sie sich an den PayPal-Kundenservice oder besuchen Sie https://www.paypal.com für weitere Informationen.'
|
||||
attention_please_confirm_your_email_address_on_paypal_com_in_order_to_receive_payments_you_currently_cannot_receive_payments: 'Achtung: Bitte bestätigen Sie Ihre E-Mail-Adresse auf https://www.paypal.com/businessprofile/settings, um Zahlungen empfangen zu können! Sie können derzeit keine Zahlungen empfangen.'
|
||||
payment_receipt: Zahlungsbeleg
|
||||
amount_paid: Gezahlter Betrag
|
||||
transaction: Transaktion
|
||||
@@ -5710,6 +5823,7 @@ de: &de
|
||||
profile_details: Profildetails
|
||||
sign_up_with_google: Mit Google 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.'
|
||||
enter_email_to_continue: E-Mail eingeben, um fortzufahren
|
||||
account: Konto
|
||||
@@ -6078,6 +6192,9 @@ de: &de
|
||||
there_are_no_signatures: Es gibt keine Unterschriften
|
||||
signed_with_trusted_certificate: Signiert mit vertrauenswürdigem 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.
|
||||
create_a_new_account: Neues Konto erstellen.
|
||||
you_are_already_signed_in: Sie sind bereits angemeldet.
|
||||
@@ -6595,11 +6712,42 @@ de: &de
|
||||
submission_submitters: Liste der Unterzeichner
|
||||
submission_link: Link der Einreichung
|
||||
documents_link: Link der Dokumente
|
||||
date:
|
||||
abbr_month_names:
|
||||
-
|
||||
- Jan.
|
||||
- Feb.
|
||||
- März
|
||||
- Apr.
|
||||
- Mai
|
||||
- Juni
|
||||
- Juli
|
||||
- Aug.
|
||||
- Sept.
|
||||
- Okt.
|
||||
- Nov.
|
||||
- Dez.
|
||||
formats:
|
||||
default: "%d.%m.%Y"
|
||||
short: "%-d. %b"
|
||||
long: "%-d. %B %Y"
|
||||
time:
|
||||
formats:
|
||||
detailed: "%A, %d. %B %Y, %H:%M:%S Uhr"
|
||||
short: "%-d. %b, %H:%M"
|
||||
long: "%-d. %B %Y um %H:%M"
|
||||
detailed: "%-d. %B %Y um %H:%M:%S"
|
||||
|
||||
pl:
|
||||
date:
|
||||
formats:
|
||||
default: "%d.%m.%Y"
|
||||
short: "%-d %b"
|
||||
long: "%-d %B %Y"
|
||||
time:
|
||||
formats:
|
||||
short: "%-d %b, %H:%M"
|
||||
long: "%-d %B %Y %H:%M"
|
||||
detailed: "%-d %B %Y %H:%M:%S"
|
||||
require_phone_2fa_to_open: Wymagaj uwierzytelniania telefonicznego 2FA do otwarcia
|
||||
the_sender_has_requested_a_two_factor_authentication_via_one_time_password_sent_to_your_html: Nadawca zażądał uwierzytelnienia dwuskładnikowego poprzez jednorazowe hasło wysłane na Twój numer telefonu <b>%{phone}</b>.
|
||||
send_verification_code: Wyślij kod weryfikacyjny
|
||||
@@ -6666,6 +6814,7 @@ pl:
|
||||
profile_details: Szczegóły profilu
|
||||
sign_up_with_google: Zarejestruj się przez Google
|
||||
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>.'
|
||||
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.
|
||||
@@ -6701,6 +6850,44 @@ pl:
|
||||
too_many_requests_try_again_later: Zbyt wiele żądań. Spróbuj ponownie później.
|
||||
|
||||
uk:
|
||||
date:
|
||||
month_names:
|
||||
-
|
||||
- січня
|
||||
- лютого
|
||||
- березня
|
||||
- квітня
|
||||
- травня
|
||||
- червня
|
||||
- липня
|
||||
- серпня
|
||||
- вересня
|
||||
- жовтня
|
||||
- листопада
|
||||
- грудня
|
||||
abbr_month_names:
|
||||
-
|
||||
- січ.
|
||||
- лют.
|
||||
- бер.
|
||||
- квіт.
|
||||
- трав.
|
||||
- черв.
|
||||
- лип.
|
||||
- серп.
|
||||
- вер.
|
||||
- жовт.
|
||||
- лист.
|
||||
- груд.
|
||||
formats:
|
||||
default: "%d.%m.%Y"
|
||||
short: "%-d %b"
|
||||
long: "%-d %B %Y р."
|
||||
time:
|
||||
formats:
|
||||
short: "%-d %b, %H:%M"
|
||||
long: "%-d %B %Y р. о %H:%M"
|
||||
detailed: "%-d %B %Y р. о %H:%M:%S"
|
||||
require_phone_2fa_to_open: Вимагати двофакторну автентифікацію через телефон для відкриття
|
||||
the_sender_has_requested_a_two_factor_authentication_via_one_time_password_sent_to_your_html: Відправник запитав двофакторну автентифікацію за допомогою одноразового пароля, надісланого на ваш номер телефону <b>%{phone}</b>.
|
||||
send_verification_code: Надіслати код перевірки
|
||||
@@ -6767,6 +6954,7 @@ uk:
|
||||
profile_details: Дані профілю
|
||||
sign_up_with_google: Зареєструватися через Google
|
||||
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>.'
|
||||
enter_email_to_continue: Введіть електронну пошту, щоб продовжити
|
||||
the_code_has_been_sent_to_your_email: Код було надіслано на вашу електронну пошту.
|
||||
@@ -6802,6 +6990,44 @@ uk:
|
||||
too_many_requests_try_again_later: Забагато запитів. Спробуйте пізніше.
|
||||
|
||||
cs:
|
||||
date:
|
||||
month_names:
|
||||
-
|
||||
- ledna
|
||||
- února
|
||||
- března
|
||||
- dubna
|
||||
- května
|
||||
- června
|
||||
- července
|
||||
- srpna
|
||||
- září
|
||||
- října
|
||||
- listopadu
|
||||
- prosince
|
||||
abbr_month_names:
|
||||
-
|
||||
- led
|
||||
- úno
|
||||
- bře
|
||||
- dub
|
||||
- kvě
|
||||
- čvn
|
||||
- čvc
|
||||
- srp
|
||||
- zář
|
||||
- říj
|
||||
- lis
|
||||
- pro
|
||||
formats:
|
||||
default: "%d. %m. %Y"
|
||||
short: "%-d. %-m."
|
||||
long: "%-d. %B %Y"
|
||||
time:
|
||||
formats:
|
||||
short: "%-d. %-m. %-H:%M"
|
||||
long: "%-d. %B %Y v %-H:%M"
|
||||
detailed: "%-d. %B %Y v %-H:%M:%S"
|
||||
require_phone_2fa_to_open: Vyžadovat otevření pomocí telefonního 2FA
|
||||
the_sender_has_requested_a_two_factor_authentication_via_one_time_password_sent_to_your_html: Odesílatel požádal o dvoufaktorové ověření pomocí jednorázového hesla zaslaného na vaše telefonní číslo <b>%{phone}</b>.
|
||||
send_verification_code: Odeslat ověřovací kód
|
||||
@@ -6868,6 +7094,7 @@ cs:
|
||||
profile_details: Detaily profilu
|
||||
sign_up_with_google: Zaregistrovat se pomocí Googlu
|
||||
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>.'
|
||||
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.
|
||||
@@ -6903,6 +7130,30 @@ cs:
|
||||
too_many_requests_try_again_later: Příliš mnoho požadavků. Zkuste to později.
|
||||
|
||||
he:
|
||||
date:
|
||||
abbr_month_names:
|
||||
-
|
||||
- ינו׳
|
||||
- פבר׳
|
||||
- מרץ
|
||||
- אפר׳
|
||||
- מאי
|
||||
- יוני
|
||||
- יולי
|
||||
- אוג׳
|
||||
- ספט׳
|
||||
- אוק׳
|
||||
- נוב׳
|
||||
- דצמ׳
|
||||
formats:
|
||||
default: "%d.%m.%Y"
|
||||
short: "%-d ב%b"
|
||||
long: "%-d ב%B %Y"
|
||||
time:
|
||||
formats:
|
||||
short: "%-d ב%b, %-H:%M"
|
||||
long: "%-d ב%B %Y בשעה %-H:%M"
|
||||
detailed: "%-d ב%B %Y בשעה %-H:%M:%S"
|
||||
require_phone_2fa_to_open: דרוש אימות דו-שלבי באמצעות טלפון לפתיחה
|
||||
the_sender_has_requested_a_two_factor_authentication_via_one_time_password_sent_to_your_html: השולח ביקש אימות דו-שלבי באמצעות סיסמה חד פעמית שנשלחה למספר הטלפון שלך <b>%{phone}</b>.
|
||||
send_verification_code: שלח קוד אימות
|
||||
@@ -6969,6 +7220,7 @@ he:
|
||||
profile_details: פרטי הפרופיל
|
||||
sign_up_with_google: הירשם עם גוגל
|
||||
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> שלנו.'
|
||||
enter_email_to_continue: הכנס דוא"ל כדי להמשיך
|
||||
the_code_has_been_sent_to_your_email: הקוד נשלח לדוא"ל שלך.
|
||||
@@ -7043,8 +7295,11 @@ nl: &nl
|
||||
paypal_integration: PayPal-integratie
|
||||
paypal_account_has_been_connected: PayPal-account is verbonden.
|
||||
re_connect_paypal: PayPal opnieuw verbinden
|
||||
failed_to_connect_paypal: PayPal kon niet worden verbonden. Probeer het opnieuw.
|
||||
paypal_connect_not_completed: De verbinding met PayPal is niet voltooid.
|
||||
failed_to_connect_paypal_please_try_again: PayPal kon niet worden verbonden. Probeer het opnieuw.
|
||||
paypal_connection_was_not_completed: De verbinding met PayPal is niet voltooid.
|
||||
disconnecting_your_paypal_account_will_prevent_you_from_offering_paypal_services_and_products_on_your_website_do_you_wish_to_continue: Het loskoppelen van uw PayPal-account voorkomt dat u PayPal-diensten en -producten op uw website kunt aanbieden. Wilt u doorgaan?
|
||||
attention_you_currently_cannot_receive_payments_due_to_restriction_on_your_paypal_account_please_reach_out_to_paypal_customer_support_or_connect_to_paypal_com_for_more_information: 'Let op: U kunt momenteel geen betalingen ontvangen vanwege een beperking op uw PayPal-account. Neem contact op met de PayPal-klantenservice of ga naar https://www.paypal.com voor meer informatie.'
|
||||
attention_please_confirm_your_email_address_on_paypal_com_in_order_to_receive_payments_you_currently_cannot_receive_payments: 'Let op: Bevestig uw e-mailadres op https://www.paypal.com/businessprofile/settings om betalingen te kunnen ontvangen! U kunt momenteel geen betalingen ontvangen.'
|
||||
payment_receipt: Betalingsbewijs
|
||||
amount_paid: Betaald bedrag
|
||||
transaction: Transactie
|
||||
@@ -7212,6 +7467,7 @@ nl: &nl
|
||||
profile_details: Profielgegevens
|
||||
sign_up_with_google: Registreren met Google
|
||||
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>.
|
||||
enter_email_to_continue: Voer e-mailadres in om door te gaan
|
||||
account: Account
|
||||
@@ -7580,6 +7836,9 @@ nl: &nl
|
||||
there_are_no_signatures: Er zijn geen handtekeningen
|
||||
signed_with_trusted_certificate: Ondertekend met vertrouwd 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.
|
||||
create_a_new_account: Maak een nieuw account aan.
|
||||
you_are_already_signed_in: U bent al aangemeld.
|
||||
@@ -8093,11 +8352,29 @@ nl: &nl
|
||||
submission_submitters: Lijst van ondertekenaars
|
||||
submission_link: Link van de inzending
|
||||
documents_link: Link van de documenten
|
||||
date:
|
||||
formats:
|
||||
short: "%-d %b"
|
||||
long: "%-d %B %Y"
|
||||
time:
|
||||
formats:
|
||||
detailed: "%d %B %Y %H:%M:%S"
|
||||
short: "%-d %b, %H:%M"
|
||||
long: "%-d %B %Y om %H:%M"
|
||||
detailed: "%-d %B %Y om %H:%M:%S"
|
||||
|
||||
ar:
|
||||
date:
|
||||
formats:
|
||||
default: "%d/%m/%Y"
|
||||
short: "%-d %b"
|
||||
long: "%-d %B %Y"
|
||||
time:
|
||||
am: "ص"
|
||||
pm: "م"
|
||||
formats:
|
||||
short: "%-d %b، %-I:%M %p"
|
||||
long: "%-d %B %Y في %-I:%M %p"
|
||||
detailed: "%-d %B %Y في %-I:%M:%S %p"
|
||||
require_phone_2fa_to_open: "يتطلب المصادقة الثنائية عبر الهاتف للفتح"
|
||||
the_sender_has_requested_a_two_factor_authentication_via_one_time_password_sent_to_your_html: "طلب المرسل المصادقة الثنائية عبر كلمة مرور لمرة واحدة مرسلة إلى رقم هاتفك <b>%{phone}</b>."
|
||||
send_verification_code: "إرسال رمز التحقق"
|
||||
@@ -8164,6 +8441,7 @@ ar:
|
||||
profile_details: تفاصيل الملف الشخصي
|
||||
sign_up_with_google: الاشتراك باستخدام جوجل
|
||||
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> الخاصة بنا.'
|
||||
enter_email_to_continue: أدخل البريد الإلكتروني للمتابعة
|
||||
the_code_has_been_sent_to_your_email: تم إرسال الرمز إلى بريدك الإلكتروني.
|
||||
@@ -8199,6 +8477,16 @@ ar:
|
||||
too_many_requests_try_again_later: طلبات كثيرة جدًا. حاول مرة أخرى لاحقًا.
|
||||
|
||||
ko:
|
||||
date:
|
||||
formats:
|
||||
default: "%Y. %m. %d."
|
||||
short: "%b %-d일"
|
||||
long: "%Y년 %B %-d일"
|
||||
time:
|
||||
formats:
|
||||
short: "%b %-d일 %p %-I:%M"
|
||||
long: "%Y년 %B %-d일 %p %-I:%M"
|
||||
detailed: "%Y년 %B %-d일 %p %-I:%M:%S"
|
||||
require_phone_2fa_to_open: 열려면 휴대폰 2FA 요구
|
||||
the_sender_has_requested_a_two_factor_authentication_via_one_time_password_sent_to_your_html: 발신자가 <b>%{phone}</b> 전화번호로 전송된 일회용 비밀번호를 통해 2단계 인증을 요청했습니다.
|
||||
send_verification_code: 인증 코드 보내기
|
||||
@@ -8265,6 +8553,7 @@ ko:
|
||||
profile_details: 프로필 세부 정보
|
||||
sign_up_with_google: Google로 가입
|
||||
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>에 동의하게 됩니다.'
|
||||
enter_email_to_continue: 계속하려면 이메일을 입력하세요
|
||||
the_code_has_been_sent_to_your_email: 코드가 이메일로 전송되었습니다.
|
||||
@@ -8300,6 +8589,16 @@ ko:
|
||||
too_many_requests_try_again_later: 요청이 너무 많습니다. 나중에 다시 시도하세요.
|
||||
|
||||
ja:
|
||||
date:
|
||||
formats:
|
||||
default: "%Y/%m/%d"
|
||||
short: "%-m月%-d日"
|
||||
long: "%Y年%-m月%-d日"
|
||||
time:
|
||||
formats:
|
||||
short: "%-m月%-d日 %-H:%M"
|
||||
long: "%Y年%-m月%-d日 %-H:%M"
|
||||
detailed: "%Y年%-m月%-d日 %-H:%M:%S"
|
||||
require_phone_2fa_to_open: 電話による2段階認証が必要です
|
||||
the_sender_has_requested_a_two_factor_authentication_via_one_time_password_sent_to_your_html: 送信者は、<b>%{phone}</b> に送信されたワンタイムパスワードによる2段階認証を要求しました。
|
||||
send_verification_code: 認証コードを送信
|
||||
@@ -8366,6 +8665,7 @@ ja:
|
||||
profile_details: プロフィールの詳細
|
||||
sign_up_with_google: Googleで登録
|
||||
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>に同意の上、アカウントを作成します。'
|
||||
enter_email_to_continue: 続行するにはメールを入力してください
|
||||
the_code_has_been_sent_to_your_email: コードがあなたのメールに送信されました
|
||||
@@ -8402,21 +8702,33 @@ ja:
|
||||
|
||||
en-US:
|
||||
<<: *en
|
||||
date:
|
||||
formats:
|
||||
default: "%m/%d/%Y"
|
||||
time:
|
||||
formats:
|
||||
detailed: "%B %d, %Y %I:%M:%S %p"
|
||||
|
||||
en-GB:
|
||||
<<: *en
|
||||
date:
|
||||
abbr_month_names:
|
||||
-
|
||||
- Jan
|
||||
- Feb
|
||||
- Mar
|
||||
- Apr
|
||||
- May
|
||||
- Jun
|
||||
- Jul
|
||||
- Aug
|
||||
- Sept
|
||||
- Oct
|
||||
- Nov
|
||||
- Dec
|
||||
formats:
|
||||
default: "%d/%m/%Y"
|
||||
short: "%-d %b"
|
||||
long: "%-d %B %Y"
|
||||
time:
|
||||
formats:
|
||||
detailed: "%d %B, %Y %H:%M:%S"
|
||||
short: "%-d %b, %H:%M"
|
||||
long: "%-d %B %Y at %H:%M"
|
||||
detailed: "%-d %B %Y at %H:%M:%S"
|
||||
|
||||
es-ES:
|
||||
<<: *es
|
||||
|
||||
@@ -6,7 +6,8 @@ class CreateLockEvents < ActiveRecord::Migration[8.0]
|
||||
t.string :key, index: true, null: false
|
||||
t.string :event_name, null: false
|
||||
|
||||
t.index %i[event_name key], unique: adapter_name != 'Mysql2', where: "event_name IN ('start', 'complete')"
|
||||
t.index %i[event_name key], unique: connection.supports_partial_index?,
|
||||
where: "event_name IN ('start', 'complete')"
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
@@ -10,7 +10,7 @@ class AddIsFirstToCompletedSubmitters < ActiveRecord::Migration[8.0]
|
||||
where: 'is_first = TRUE',
|
||||
name: 'index_completed_submitters_account_id_completed_at_is_first'
|
||||
|
||||
add_index :completed_submitters, :submission_id, unique: adapter_name != 'Mysql2',
|
||||
add_index :completed_submitters, :submission_id, unique: connection.supports_partial_index?,
|
||||
where: 'is_first = TRUE'
|
||||
end
|
||||
end
|
||||
+14
-20
@@ -15,34 +15,28 @@ module PdfUtils
|
||||
end
|
||||
|
||||
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_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
|
||||
end
|
||||
|
||||
def merge(files)
|
||||
merged_pdf = HexaPDF::Document.new
|
||||
def merge(io_files)
|
||||
merged_content = StringIO.new
|
||||
|
||||
files.each do |file|
|
||||
pdf = HexaPDF::Document.new(io: file)
|
||||
pdf.pages.each { |page| merged_pdf.pages << merged_pdf.import(page) }
|
||||
Pdfium.with_instance do
|
||||
Pdfium::Document.create do |merged_pdf|
|
||||
io_files.each do |io|
|
||||
Pdfium::Document.open_io(io) { |pdf| merged_pdf.import_pages(pdf) }
|
||||
end
|
||||
|
||||
merged_pdf.save(merged_content)
|
||||
end
|
||||
end
|
||||
|
||||
merged_content = StringIO.new
|
||||
merged_pdf.validate(auto_correct: true)
|
||||
merged_pdf.write(merged_content, validate: false)
|
||||
merged_content.rewind
|
||||
|
||||
merged_content
|
||||
merged_content.tap(&:rewind)
|
||||
end
|
||||
end
|
||||
+893
-17
File diff suppressed because it is too large.
Load diff
+2
-1
@@ -93,7 +93,8 @@ module Submitters
|
||||
def plain_search(submitters, keyword)
|
||||
return submitters if keyword.blank?
|
||||
|
||||
term = "%#{keyword.downcase}%"
|
||||
sanitized = ActiveRecord::Base.sanitize_sql_like(keyword.downcase)
|
||||
term = "%#{sanitized}%"
|
||||
|
||||
arel_table = Submitter.arel_table
|
||||
|
||||
|
||||
@@ -114,7 +114,7 @@ module Submitters
|
||||
end
|
||||
|
||||
reason_field = submitter.submission.template_fields.find do |e|
|
||||
e['uuid'] == reason_field_uuid && e['submitter_uuid'] == submitter.uuid
|
||||
e['uuid'] == reason_field_uuid
|
||||
end
|
||||
|
||||
if reason_field
|
||||
|
||||
@@ -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
|
||||
@@ -39,6 +39,8 @@ module Templates
|
||||
end
|
||||
|
||||
def handle_pdf_or_image(template, file, document_data = nil, params = {}, extract_fields: false, metadata: {})
|
||||
return handle_pdf_or_image_v2(template, file, document_data, params, extract_fields:, metadata:) if v2?
|
||||
|
||||
document_data ||= file.read
|
||||
|
||||
if file.content_type == PDF_CONTENT_TYPE
|
||||
@@ -48,6 +50,55 @@ module Templates
|
||||
document_data.size < ANNOTATIONS_SIZE_LIMIT ? Templates::BuildAnnotations.call(document_data) : []
|
||||
end
|
||||
|
||||
document = create_document(template, file, document_data, metadata, annotations)
|
||||
|
||||
Templates::ProcessDocument.call(document, document_data, extract_fields:)
|
||||
end
|
||||
|
||||
def handle_pdf_or_image_v2(template, file, document_data = nil, params = {}, extract_fields: false, metadata: {})
|
||||
document_data ||= file.read
|
||||
|
||||
unless file.content_type == PDF_CONTENT_TYPE
|
||||
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))
|
||||
|
||||
blob = ActiveStorage::Blob.create_and_upload!(
|
||||
@@ -62,19 +113,7 @@ module Templates
|
||||
content_type: file.content_type
|
||||
)
|
||||
|
||||
document = 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
|
||||
template.documents.create!(blob:)
|
||||
end
|
||||
|
||||
def extract_zip_files(files)
|
||||
@@ -124,5 +163,9 @@ module Templates
|
||||
|
||||
raise InvalidFileType, "#{file.content_type}/#{dynamic}"
|
||||
end
|
||||
|
||||
def v2?
|
||||
true
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -173,20 +173,20 @@ module Templates
|
||||
**attrs,
|
||||
type: '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?
|
||||
{
|
||||
**attrs,
|
||||
type: '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
|
||||
{
|
||||
**attrs,
|
||||
type: 'cells',
|
||||
default_value: field.field_value
|
||||
default_value: field.field_value.presence
|
||||
}
|
||||
elsif field.field_type == :Tx
|
||||
if field[:AA] && ((field[:AA][:F] && field[:AA][:F][:JS].include?('AFDate_')) ||
|
||||
@@ -199,13 +199,13 @@ module Templates
|
||||
{
|
||||
**attrs,
|
||||
type: 'date',
|
||||
default_value: field.field_value
|
||||
default_value: field.field_value.presence
|
||||
}
|
||||
else
|
||||
{
|
||||
**attrs,
|
||||
type: 'text',
|
||||
default_value: field.field_value
|
||||
default_value: field.field_value.presence
|
||||
}
|
||||
end
|
||||
elsif field.field_type == :Sig
|
||||
@@ -234,7 +234,7 @@ module Templates
|
||||
|
||||
{
|
||||
uuid: SecureRandom.uuid,
|
||||
value: is_option_number || is_skip_single_value ? '' : option
|
||||
value: is_option_number || is_skip_single_value ? '' : option.presence
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -251,7 +251,9 @@ module Templates
|
||||
fields_index[annot.hash] ||= HexaPDF::Type::AcroForm::Field.wrap(pdf, annot)
|
||||
elsif annot.key?(:Parent)
|
||||
field = annot[:Parent]
|
||||
field = field[:Parent] while field[:Parent]
|
||||
seen = Set.new.compare_by_identity
|
||||
|
||||
field = field[:Parent] while field[:Parent] && seen.add?(field.value)
|
||||
|
||||
annots_index[field.hash] ||= page
|
||||
fields_index[field.hash] ||= HexaPDF::Type::AcroForm::Field.wrap(pdf, field)
|
||||
@@ -262,7 +264,7 @@ module Templates
|
||||
[process_fields_array(pdf, fields_index.values), annots_index]
|
||||
end
|
||||
|
||||
def process_fields_array(pdf, array, acc = [])
|
||||
def process_fields_array(pdf, array, acc = [], seen = Set.new.compare_by_identity)
|
||||
array.each_with_index do |field, index|
|
||||
next if field.nil?
|
||||
|
||||
@@ -270,10 +272,12 @@ module Templates
|
||||
array[index] = field = HexaPDF::Type::AcroForm::Field.wrap(pdf, field)
|
||||
end
|
||||
|
||||
next unless seen.add?(field.value)
|
||||
|
||||
if field.terminal_field?
|
||||
acc << field
|
||||
else
|
||||
process_fields_array(pdf, field[:Kids], acc)
|
||||
process_fields_array(pdf, field[:Kids], acc, seen)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -19,15 +19,19 @@ module Templates
|
||||
|
||||
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 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
|
||||
|
||||
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
|
||||
elsif attachment.image?
|
||||
@@ -76,13 +80,17 @@ module Templates
|
||||
)
|
||||
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
|
||||
|
||||
pdf ||= HexaPDF::Document.new(io: StringIO.new(data))
|
||||
number_of_pages = pdf.pages.size
|
||||
if doc
|
||||
number_of_pages = doc.page_count
|
||||
else
|
||||
pdf ||= HexaPDF::Document.new(io: StringIO.new(data))
|
||||
number_of_pages = pdf.pages.size
|
||||
|
||||
data = maybe_flatten_form(data, pdf)
|
||||
data = maybe_flatten_form(data, pdf)
|
||||
end
|
||||
|
||||
attachment.metadata['pdf'] ||= {}
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
def generate_document_preview_images(attachment, data, range, concurrency: CONCURRENCY)
|
||||
doc = Pdfium::Document.open_bytes(data)
|
||||
def generate_document_preview_images(attachment, data, range, concurrency: CONCURRENCY, doc: nil)
|
||||
flatten_pages = doc&.form?
|
||||
pdfium_doc = doc || Pdfium::Document.open_bytes(data)
|
||||
|
||||
pool = Concurrent::FixedThreadPool.new(concurrency)
|
||||
|
||||
promises =
|
||||
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)
|
||||
|
||||
@@ -126,10 +138,31 @@ module Templates
|
||||
end
|
||||
end
|
||||
ensure
|
||||
doc&.close
|
||||
pdfium_doc&.close if doc.nil?
|
||||
pool&.kill
|
||||
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)
|
||||
image = image.copy(interpretation: :srgb)
|
||||
|
||||
@@ -192,8 +225,8 @@ module Templates
|
||||
end
|
||||
end
|
||||
|
||||
def generate_pdf_preview_from_file(attachment, file_path, page_number)
|
||||
doc = Pdfium::Document.open_file(file_path)
|
||||
def generate_pdf_preview_from_io(attachment, io, page_number)
|
||||
doc = Pdfium::Document.open_io(io)
|
||||
|
||||
doc_page = doc.get_page(page_number)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -1153,9 +1153,10 @@ RSpec.describe 'Signing Form' do
|
||||
click_on 'next'
|
||||
draw_canvas
|
||||
|
||||
expect do
|
||||
click_on 'Sign and Complete'
|
||||
end.to change(ProcessSubmitterCompletionJob.jobs, :size).by(1)
|
||||
click_on 'Sign and Complete'
|
||||
|
||||
expect(page).to have_content('Document has been signed!')
|
||||
expect(ProcessSubmitterCompletionJob.jobs.size).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
Reference in new issue
Block a user