Compare commits

..

56 Commits

Author SHA1 Message Date
Alex Turchyn 9c700a3fb6 Merge from docusealco/wip 2026-05-25 12:28:05 +03:00
Pete Matsyburka 89bf83febb adjust autorot 2026-05-23 11:43:28 +03:00
Pete Matsyburka d44b35cf0a convert images on upload 2026-05-23 08:25:04 +03:00
Pete Matsyburka 891833e273 refactor process doc 2026-05-22 14:06:04 +03:00
Pete Matsyburka 29d3a80d3f fix rubocop 2026-05-21 21:55:30 +03:00
Pete Matsyburka 90fef58684 ruby 4.0.5 2026-05-21 21:46:27 +03:00
Pete Matsyburka 07841722ac adjust image load 2026-05-21 21:44:13 +03:00
Pete Matsyburka 9e4da5948b convert images on upload 2026-05-21 14:50:30 +03:00
Pete Matsyburka dc6e4313a1 fix ci 2026-05-20 16:15:15 +03:00
Pete Matsyburka a68cc0b689 convert images on upload 2026-05-20 15:23:42 +03:00
Pete Matsyburka d057fb0f67 content type priority 2026-05-19 13:27:16 +03:00
Pete Matsyburka 7e8f045a29 cleanup 2026-05-19 11:42:14 +03:00
Pete Matsyburka 5249d8d18d update gem 2026-05-19 11:02:15 +03:00
Pete Matsyburka f0fafbadd4 isolate editor nodes 2026-05-19 10:52:48 +03:00
Pete Matsyburka 8b51079dcc update gem 2026-05-18 18:42:04 +03:00
Pete Matsyburka 354bccd6e8 handle dangerous extensions 2026-05-18 18:05:16 +03:00
Pete Matsyburka 6806772346 add security headers 2026-05-18 16:47:19 +03:00
Alex Turchyn 60082655d4 Merge from docusealco/wip 2026-05-18 13:45:07 +03:00
Pete Matsyburka 7fe56941fd optimize pdf images 2026-05-17 19:55:03 +03:00
Pete Matsyburka abd498dd33 adjust validation message 2026-05-16 08:15:02 +03:00
Pete Matsyburka 99ca0136ed add percent format 2026-05-16 08:15:02 +03:00
Pete Matsyburka e52830c9b4 add authorization checks 2026-05-16 08:15:01 +03:00
Pete Matsyburka 755decca27 adjust capture revision 2026-05-15 17:50:18 +03:00
Pete Matsyburka 41604008d1 clamp area box 2026-05-15 17:20:29 +03:00
Pete Matsyburka 5bddce8969 fix i18n 2026-05-14 12:16:36 +03:00
Pete Matsyburka b25503f141 fix i18n 2026-05-13 10:08:13 +03:00
Pete Matsyburka bdf1850448 resend emails 2026-05-13 09:59:06 +03:00
Pete Matsyburka 230d3ccf69 fix with download config 2026-05-13 08:52:41 +03:00
Pete Matsyburka e378025a2d fix refactor complete button 2026-05-12 22:24:09 +03:00
Alex Turchyn 04129ded90 add complete button to signing form header 2026-05-12 19:19:49 +03:00
Pete Matsyburka a7891f89f8 do not override custom webhook header 2026-05-11 17:13:26 +03:00
Pete Matsyburka 37d4a8e834 update gem 2026-05-11 14:06:17 +03:00
Alex Turchyn 528a1216f8 Merge from docusealco/wip 2026-05-11 13:52:58 +03:00
Pete Matsyburka f2479bd259 adjust open revisions 2026-05-11 11:11:16 +03:00
Pete Matsyburka 135f0826d2 fix style 2026-05-09 21:17:18 +03:00
Pete Matsyburka 5710f0177b update gh stars 2026-05-09 19:25:42 +03:00
Pete Matsyburka 1a3a0528ba fix i18n 2026-05-09 10:34:20 +03:00
Pete Matsyburka 59793ff374 adjust builder 2026-05-08 19:35:46 +03:00
Pete Matsyburka 3c4ed42419 fix i18n 2026-05-08 18:08:35 +03:00
Pete Matsyburka 6a17b61550 update gem 2026-05-08 16:25:11 +03:00
Pete Matsyburka 76659497b7 typos 2026-05-08 16:12:09 +03:00
Pete Matsyburka ee50c957cb update gem 2026-05-08 15:43:34 +03:00
Pete Matsyburka a9dd200919 add pdfium rotate 2026-05-08 15:43:34 +03:00
Pete Matsyburka 1fa953104a update gh stars 2026-05-08 15:43:34 +03:00
Pete Matsyburka 50eb5b070e fix typo 2026-05-08 15:43:34 +03:00
Pete Matsyburka 10fd624bec add revisions 2026-05-08 15:43:34 +03:00
Alex Turchyn 04ec2f8260 allow permanent delete submissions in archived templates 2026-05-08 15:43:34 +03:00
Pete Matsyburka d828d79574 improve log 2026-05-08 15:43:34 +03:00
Pete Matsyburka 01dd3fefe5 retry webhooks in test mode 2026-05-08 15:43:34 +03:00
Pete Matsyburka 45ae954c0c add hmac webhook secret 2026-05-08 15:43:34 +03:00
Pete Matsyburka 1304849b55 update gh stars 2026-05-08 15:43:34 +03:00
Pete Matsyburka 0875faa079 fix date preview 2026-05-08 15:43:34 +03:00
Pete Matsyburka 3aa15d6ea6 update gem 2026-05-08 15:43:34 +03:00
Pete Matsyburka 6557329e97 gh 13k 2026-05-08 15:43:34 +03:00
Pete Matsyburka be27ce4161 Merge pull request #666 from aqilaziz/docs-fix-readme-typos
docs: fix README typos
2026-05-08 14:02:33 +03:00
aqilaziz a3391a970e docs: fix README typos 2026-05-08 05:23:28 +07:00
105 changed files with 2163 additions and 743 deletions
+5 -5
View File
@@ -12,7 +12,7 @@ jobs:
- name: Install Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: 4.0.1
ruby-version: 4.0.5
- name: Cache gems
uses: actions/cache@v4
with:
@@ -37,7 +37,7 @@ jobs:
- name: Install Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: 4.0.1
ruby-version: 4.0.5
- name: Cache gems
uses: actions/cache@v4
with:
@@ -89,7 +89,7 @@ jobs:
- name: Install Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: 4.0.1
ruby-version: 4.0.5
- name: Cache gems
uses: actions/cache@v4
with:
@@ -132,7 +132,7 @@ jobs:
- name: Install Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: 4.0.1
ruby-version: 4.0.5
- name: Set up Node
uses: actions/setup-node@v1
with:
@@ -163,7 +163,7 @@ jobs:
yarn install
sudo apt-get update
sudo apt-get install -y libvips
wget -O pdfium-linux.tgz "https://github.com/docusealco/pdfium-binaries/releases/latest/download/pdfium-linux-$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/').tgz"
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
- name: Run
+5 -4
View File
@@ -1,4 +1,4 @@
FROM ruby:4.0.1-alpine AS download
FROM ruby:4.0.5-alpine AS download
WORKDIR /fonts
@@ -13,7 +13,7 @@ RUN apk --no-cache add wget && \
mkdir -p /pdfium-linux && \
tar -xzf pdfium-linux.tgz -C /pdfium-linux
FROM ruby:4.0.1-alpine AS webpack
FROM ruby:4.0.5-alpine AS webpack
ENV RAILS_ENV=production
ENV NODE_ENV=production
@@ -40,7 +40,7 @@ COPY ./app/views ./app/views
RUN echo "gem 'shakapacker'" > Gemfile && ./bin/shakapacker
FROM ruby:4.0.1-alpine AS app
FROM ruby:4.0.5-alpine AS app
ENV RAILS_ENV=production
ENV BUNDLE_WITHOUT="development:test"
@@ -48,7 +48,7 @@ ENV OPENSSL_CONF=/etc/openssl_legacy.cnf
WORKDIR /app
RUN apk add --no-cache libpq vips redis vips-heif onnxruntime
RUN apk add --no-cache libpq vips redis onnxruntime
RUN addgroup -g 2000 docuseal && adduser -u 2000 -G docuseal -s /bin/sh -D -h /home/docuseal docuseal
@@ -94,6 +94,7 @@ WORKDIR /data/docuseal
ENV HOME=/home/docuseal
ENV WORKDIR=/data/docuseal
ENV VIPS_MAX_COORD=17000
ENV VIPS_BLOCK_UNTRUSTED=1
EXPOSE 3000
CMD ["/app/bin/bundle", "exec", "puma", "-C", "/app/config/puma.rb", "--dir", "/app"]
+1 -1
View File
@@ -2,7 +2,7 @@
source 'https://rubygems.org'
ruby '4.0.1'
ruby '4.0.5'
gem 'addressable'
gem 'arabic-letter-connector', require: false
+16 -16
View File
@@ -116,7 +116,7 @@ GEM
erubi (~> 1.4)
parser (>= 2.4)
smart_properties
bigdecimal (4.1.0)
bigdecimal (4.1.2)
bindex (0.8.1)
bootsnap (1.23.0)
msgpack (~> 1.2)
@@ -161,7 +161,7 @@ GEM
irb (~> 1.10)
reline (>= 0.3.8)
declarative (0.0.20)
devise (5.0.3)
devise (5.0.4)
bcrypt (~> 3.0)
orm_adapter (~> 0.1)
railties (>= 7.0)
@@ -195,7 +195,7 @@ GEM
railties (>= 6.1.0)
faker (3.6.1)
i18n (>= 1.8.11, < 2)
faraday (2.14.1)
faraday (2.14.2)
faraday-net_http (>= 2.0, < 3.5)
json
logger
@@ -257,7 +257,7 @@ GEM
os (>= 0.9, < 2.0)
signet (>= 0.16, < 2.a)
hashdiff (1.2.1)
hexapdf (1.6.0)
hexapdf (1.7.0)
cmdparse (~> 3.0, >= 3.0.3)
geom2d (~> 0.4, >= 0.4.1)
openssl (>= 2.2.1)
@@ -268,14 +268,14 @@ GEM
mini_magick (>= 4.9.5, < 6)
ruby-vips (>= 2.0.17, < 3)
io-console (0.8.2)
irb (1.17.0)
irb (1.18.0)
pp (>= 0.6.0)
prism (>= 1.3.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
jmespath (1.6.2)
json (2.19.3)
jwt (3.1.2)
json (2.19.5)
jwt (3.2.0)
base64
language_server-protocol (3.17.0.5)
launchy (3.1.1)
@@ -311,14 +311,14 @@ GEM
mini_magick (5.3.1)
logger
mini_mime (1.1.5)
minitest (6.0.3)
minitest (6.0.6)
drb (~> 2.0)
prism (~> 1.5)
msgpack (1.8.0)
multi_json (1.19.1)
net-http (0.9.1)
uri (>= 0.11.1)
net-imap (0.6.3)
net-imap (0.6.4)
date
net-protocol
net-pop (0.1.2)
@@ -328,15 +328,15 @@ GEM
net-smtp (0.5.1)
net-protocol
nio4r (2.7.5)
nokogiri (1.19.2-aarch64-linux-gnu)
nokogiri (1.19.3-aarch64-linux-gnu)
racc (~> 1.4)
nokogiri (1.19.2-aarch64-linux-musl)
nokogiri (1.19.3-aarch64-linux-musl)
racc (~> 1.4)
nokogiri (1.19.2-arm64-darwin)
nokogiri (1.19.3-arm64-darwin)
racc (~> 1.4)
nokogiri (1.19.2-x86_64-linux-gnu)
nokogiri (1.19.3-x86_64-linux-gnu)
racc (~> 1.4)
nokogiri (1.19.2-x86_64-linux-musl)
nokogiri (1.19.3-x86_64-linux-musl)
racc (~> 1.4)
numo-narray-alt (0.10.3)
oj (3.16.16)
@@ -429,7 +429,7 @@ GEM
tsort (>= 0.2)
zeitwerk (~> 2.6)
rainbow (3.1.1)
rake (13.3.1)
rake (13.4.2)
rdoc (7.2.0)
erb
psych (>= 4.0.0)
@@ -662,7 +662,7 @@ DEPENDENCIES
webmock
RUBY VERSION
ruby 4.0.1
ruby 4.0.5
BUNDLED WITH
4.0.3
+2 -2
View File
@@ -50,7 +50,7 @@ DocuSeal is an open source platform that provides secure and efficient digital d
- Company logo and white-label
- User roles
- Automated reminders
- Invitation and identify verification via SMS
- Invitation and identity verification via SMS
- Conditional fields and formulas
- Bulk send with CSV, XLSX spreadsheet import
- SSO / SAML
@@ -74,7 +74,7 @@ DocuSeal is an open source platform that provides secure and efficient digital d
docker run --name docuseal -p 3000:3000 -v.:/data docuseal/docuseal
```
By default DocuSeal docker container uses an SQLite database to store data and configurations. Alternatively, it is possible use PostgreSQL or MySQL databases by specifying the `DATABASE_URL` env variable.
By default DocuSeal docker container uses an SQLite database to store data and configurations. Alternatively, it is possible to use PostgreSQL or MySQL databases by specifying the `DATABASE_URL` env variable.
#### Docker Compose
@@ -9,7 +9,9 @@ module Api
before_action :set_cors_headers
before_action :set_noindex_headers
before_action :set_security_headers
# rubocop:disable Metrics
def show
blob_uuid, purp, exp = ApplicationRecord.signed_id_verifier.verified(params[:signed_uuid])
@@ -21,6 +23,12 @@ module Api
blob = ActiveStorage::Blob.find_by!(uuid: blob_uuid)
if Submitters::DANGEROUS_EXTENSIONS.include?(blob.filename.extension.to_s.downcase)
Rollbar.error('Dangerous extension') if defined?(Rollbar)
return head :unprocessable_content
end
attachment = blob.attachments.take
@record = attachment.record
@@ -45,6 +53,7 @@ module Api
end
end
end
# rubocop:enable Metrics
private
@@ -9,6 +9,7 @@ module Api
before_action :set_cors_headers
before_action :set_noindex_headers
before_action :set_security_headers
# rubocop:disable Metrics
def show
@@ -18,6 +19,12 @@ module Api
return head :not_found unless blob
if Submitters::DANGEROUS_EXTENSIONS.include?(blob.filename.extension.to_s.downcase)
Rollbar.error('Dangerous extension') if defined?(Rollbar)
return head :unprocessable_content
end
is_permitted = blob.attachments.any? do |a|
(current_user && a.record.account.id == current_user.account_id) ||
a.record.account.account_configs.any? { |e| e.key == 'legacy_blob_proxy' } ||
@@ -102,6 +102,10 @@ module Api
headers['X-Robots-Tag'] = 'noindex'
end
def set_security_headers
response.headers['X-Content-Type-Options'] = 'nosniff'
end
def set_cors_headers
headers['Access-Control-Allow-Origin'] = '*'
headers['Access-Control-Allow-Methods'] = 'POST, GET, PUT, PATCH, DELETE, OPTIONS'
+11 -9
View File
@@ -8,35 +8,37 @@ module Api
COOKIE_STORE_LIMIT = 10
def create
submitter = Submitter.find_by!(slug: params[:submitter_slug])
@submitter = Submitter.find_by!(slug: params[:submitter_slug])
unless can_upload?(submitter)
Rollbar.error("Can't upload: #{submitter.id}") if defined?(Rollbar)
unless can_upload?(@submitter)
Rollbar.error("Can't upload: #{@submitter.id}") if defined?(Rollbar)
return render json: { error: I18n.t('form_has_been_archived') }, status: :unprocessable_content
end
file = params[:file]
if params[:type].in?(%w[initials signature])
image = Vips::Image.new_from_file(params[:file].path)
image = ImageUtils.load_vips(file.read, content_type: file.content_type)
if ImageUtils.blank?(image)
Rollbar.error("Empty signature: #{submitter.id}") if defined?(Rollbar)
Rollbar.error("Empty signature: #{@submitter.id}") if defined?(Rollbar)
return render json: { error: "#{params[:type]} is empty" }, status: :unprocessable_content
end
if ImageUtils.error?(image)
Rollbar.error("Error signature: #{submitter.id}") if defined?(Rollbar)
Rollbar.error("Error signature: #{@submitter.id}") if defined?(Rollbar)
return render json: { error: "#{params[:type]} error, try to sign on another device" },
status: :unprocessable_content
end
end
attachment = Submitters.create_attachment!(submitter, params)
attachment = Submitters.create_attachment!(@submitter, file)
if params[:remember_signature] == 'true' && submitter.email.present?
cookies.encrypted[:signature_uuids] = build_new_cookie_signatures_json(submitter, attachment)
if params[:remember_signature] == 'true' && @submitter.email.present?
cookies.encrypted[:signature_uuids] = build_new_cookie_signatures_json(@submitter, attachment)
end
render json: attachment.as_json(only: %i[uuid created_at], methods: %i[url filename content_type])
@@ -0,0 +1,39 @@
# frozen_string_literal: true
class SubmissionsResendEmailController < ApplicationController
load_and_authorize_resource :submission
before_action do
authorize!(:manage, :resend_all)
authorize!(:update, @submission)
end
def create
submitters = @submission.submitters.reject(&:completed_at?).select { |s| s.email.present? && !s.declined_at? }
if Docuseal.multitenant?
recent_submitter_ids =
SubmissionEvent.where(submitter_id: submitters.map(&:id),
event_type: 'send_email',
created_at: 10.hours.ago..Time.current).pluck(:submitter_id).to_set
submitters = submitters.reject { |s| recent_submitter_ids.include?(s.id) }
end
submitters.each do |submitter|
SendSubmitterInvitationEmailJob.perform_async('submitter_id' => submitter.id)
submitter.sent_at ||= Time.current
submitter.save!
end
notice =
if submitters.empty?
I18n.t('email_has_been_sent_already')
else
I18n.t('emails_have_been_sent_to_n_recipients', count: submitters.size)
end
redirect_back(fallback_location: submission_path(@submission), notice:)
end
end
@@ -4,6 +4,8 @@ class SubmissionsUnarchiveController < ApplicationController
load_and_authorize_resource :submission
def create
authorize!(:update, @submission)
@submission.update!(archived_at: nil)
redirect_to submission_path(@submission), notice: I18n.t('submission_has_been_unarchived')
@@ -5,12 +5,12 @@ class SubmitFormInviteController < ApplicationController
skip_authorization_check
def create
submitter = Submitter.find_by!(slug: params[:submit_form_slug])
@submitter = Submitter.find_by!(slug: params[:submit_form_slug])
return head :unprocessable_content unless can_invite?(submitter)
return head :unprocessable_content unless can_invite?(@submitter)
invite_submitters = filter_invite_submitters(submitter, 'invite_by_uuid')
optional_invite_submitters = filter_invite_submitters(submitter, 'optional_invite_by_uuid')
invite_submitters = filter_invite_submitters(@submitter, 'invite_by_uuid')
optional_invite_submitters = filter_invite_submitters(@submitter, 'optional_invite_by_uuid')
ApplicationRecord.transaction do
(invite_submitters + optional_invite_submitters).each do |item|
@@ -21,18 +21,18 @@ class SubmitFormInviteController < ApplicationController
email = Submissions.normalize_email(attrs[:email])
submitter.submission.submitters.create!(uuid: attrs[:uuid], email:, account_id: submitter.account_id)
@submitter.submission.submitters.create!(uuid: attrs[:uuid], email:, account_id: @submitter.account_id)
SubmissionEvents.create_with_tracking_data(submitter, 'invite_party', request, { uuid: submitter.uuid })
SubmissionEvents.create_with_tracking_data(@submitter, 'invite_party', request, { uuid: @submitter.uuid })
end
submitter.submission.update!(submitters_order: :preserved)
@submitter.submission.update!(submitters_order: :preserved)
end
submitter.submission.submitters.reload
@submitter.submission.submitters.reload
if invite_submitters.all? { |s| submitter.submission.submitters.any? { |e| e.uuid == s['uuid'] } }
Submitters::SubmitValues.call(submitter, ActionController::Parameters.new(completed: 'true'), request)
if invite_submitters.all? { |s| @submitter.submission.submitters.any? { |e| e.uuid == s['uuid'] } }
Submitters::SubmitValues.call(@submitter, ActionController::Parameters.new(completed: 'true'), request)
head :ok
else
@@ -5,19 +5,19 @@ class SubmitFormMetadataController < ApplicationController
skip_authorization_check
def index
submitter = Submitter.find_by!(slug: params[:submit_form_slug])
@submitter = Submitter.find_by!(slug: params[:submit_form_slug])
return head :not_found if submitter.declined_at? ||
submitter.completed_at? ||
submitter.submission.archived_at? ||
submitter.submission.expired? ||
submitter.submission.template&.archived_at? ||
submitter.account.archived_at? ||
!Submitters::AuthorizedForForm.call(submitter, current_user, request)
return head :not_found if @submitter.declined_at? ||
@submitter.completed_at? ||
@submitter.submission.archived_at? ||
@submitter.submission.expired? ||
@submitter.submission.template&.archived_at? ||
@submitter.account.archived_at? ||
!Submitters::AuthorizedForForm.call(@submitter, current_user, request)
submission = submitter.submission
submission = @submitter.submission
values = submission.submitters.reduce({}) { |acc, sub| acc.merge(sub.values) }
schema = Submissions.filtered_conditions_schema(submission, values:, include_submitter_uuid: submitter.uuid)
schema = Submissions.filtered_conditions_schema(submission, values:, include_submitter_uuid: @submitter.uuid)
documents = schema.filter_map do |item|
submission.schema_documents.find { |a| a.uuid == item['attachment_uuid'] }
@@ -5,21 +5,21 @@ class SubmitFormValuesController < ApplicationController
skip_authorization_check
def index
submitter = Submitter.find_by!(slug: params[:submit_form_slug])
@submitter = Submitter.find_by!(slug: params[:submit_form_slug])
return render json: {} if submitter.completed_at? ||
submitter.declined_at? ||
submitter.submission.template&.archived_at? ||
submitter.submission.archived_at? ||
submitter.submission.expired? ||
!Submitters::AuthorizedForForm.call(submitter, current_user, request)
return render json: {} if @submitter.completed_at? ||
@submitter.declined_at? ||
@submitter.submission.template&.archived_at? ||
@submitter.submission.archived_at? ||
@submitter.submission.expired? ||
!Submitters::AuthorizedForForm.call(@submitter, current_user, request)
value = submitter.values[params['field_uuid']]
attachment = submitter.attachments.where(created_at: params[:after]..).find_by(uuid: value) if value.present?
value = @submitter.values[params['field_uuid']]
attachment = @submitter.attachments.where(created_at: params[:after]..).find_by(uuid: value) if value.present?
render json: {
value:,
attachment: attachment&.as_json(only: %i[uuid created_at], methods: %i[url filename content_type])
}, head: :ok
}
end
end
@@ -4,6 +4,8 @@ class SubmittersSendEmailController < ApplicationController
load_and_authorize_resource :submitter
def create
authorize!(:update, @submitter)
if Docuseal.multitenant? && SubmissionEvent.exists?(submitter: @submitter,
event_type: 'send_email',
created_at: 10.hours.ago..Time.current)
@@ -10,6 +10,8 @@ class TemplateDocumentsController < ApplicationController
end
def create
authorize!(:update, @template)
if params[:blobs].blank? && params[:files].blank?
return render json: { error: I18n.t('file_is_missing') }, status: :unprocessable_content
end
@@ -13,6 +13,9 @@ class TemplatesCloneAndReplaceController < ApplicationController
cloned_template = Templates::Clone.call(@template, author: current_user)
cloned_template.name = File.basename(params[:files].first.original_filename, '.*')
authorize!(:create, cloned_template)
cloned_template.save!
documents = Templates::ReplaceAttachments.call(cloned_template, params, extract_fields: true)
@@ -6,6 +6,8 @@ class TemplatesFoldersController < ApplicationController
def edit; end
def update
authorize!(:update, @template)
name = [params[:parent_name], params[:name]].compact_blank.join(' / ')
@template.folder = TemplateFolders.find_or_create_by_name(current_user, name)
@@ -4,6 +4,8 @@ class TemplatesRestoreController < ApplicationController
load_and_authorize_resource :template
def create
authorize!(:update, @template)
@template.update!(archived_at: nil)
WebhookUrls.enqueue_events(@template, 'template.updated')
@@ -0,0 +1,25 @@
# frozen_string_literal: true
class TemplatesVersionsController < ApplicationController
load_and_authorize_resource :template
def index
versions = @template.template_versions.order(id: :desc).preload(:author)
render json: versions.as_json(TemplateVersions::SERIALIZE_PARAMS)
end
def show
version = @template.template_versions.find(params[:id])
render json: TemplateVersions.serialize(version)
end
def create
authorize!(:update, @template)
TemplateVersions.find_or_create_for(@template, author: current_user)
head :ok
end
end
@@ -11,6 +11,12 @@ class UserInitialsController < ApplicationController
return redirect_to settings_profile_index_path, notice: I18n.t('unable_to_save_initials') if file.blank?
extension = File.extname(file.original_filename).delete_prefix('.').downcase
if Submitters::DANGEROUS_EXTENSIONS.include?(extension)
raise Submitters::MaliciousFileExtension, "File type '.#{extension}' is not allowed."
end
blob = ActiveStorage::Blob.create_and_upload!(io: file.open,
filename: file.original_filename,
content_type: file.content_type)
@@ -11,6 +11,12 @@ class UserSignaturesController < ApplicationController
return redirect_to settings_profile_index_path, notice: I18n.t('unable_to_save_signature') if file.blank?
extension = File.extname(file.original_filename).delete_prefix('.').downcase
if Submitters::DANGEROUS_EXTENSIONS.include?(extension)
raise Submitters::MaliciousFileExtension, "File type '.#{extension}' is not allowed."
end
blob = ActiveStorage::Blob.create_and_upload!(io: file.open,
filename: file.original_filename,
content_type: file.content_type)
@@ -0,0 +1,7 @@
# frozen_string_literal: true
class WebhookHmacController < ApplicationController
load_and_authorize_resource :webhook_url, parent: false
def show; end
end
+4 -2
View File
@@ -21,6 +21,7 @@ import SubmittersAutocomplete from './elements/submitter_autocomplete'
import FolderAutocomplete from './elements/folder_autocomplete'
import SignatureForm from './elements/signature_form'
import SubmitForm from './elements/submit_form'
import ConvertUpload from './elements/convert_upload'
import PromptPassword from './elements/prompt_password'
import EmailsTextarea from './elements/emails_textarea'
import ToggleSubmit from './elements/toggle_submit'
@@ -47,7 +48,6 @@ import ScrollTo from './elements/scroll_to'
import SetValue from './elements/set_value'
import ReviewForm from './elements/review_form'
import ShowOnValue from './elements/show_on_value'
import CustomValidation from './elements/custom_validation'
import ToggleClasses from './elements/toggle_classes'
import AutosizeField from './elements/autosize_field'
import GoogleDriveFilePicker from './elements/google_drive_file_picker'
@@ -112,6 +112,7 @@ safeRegisterElement('submitters-autocomplete', SubmittersAutocomplete)
safeRegisterElement('folder-autocomplete', FolderAutocomplete)
safeRegisterElement('signature-form', SignatureForm)
safeRegisterElement('submit-form', SubmitForm)
safeRegisterElement('convert-upload', ConvertUpload)
safeRegisterElement('prompt-password', PromptPassword)
safeRegisterElement('emails-textarea', EmailsTextarea)
safeRegisterElement('toggle-cookies', ToggleCookies)
@@ -139,7 +140,6 @@ safeRegisterElement('scroll-to', ScrollTo)
safeRegisterElement('set-value', SetValue)
safeRegisterElement('review-form', ReviewForm)
safeRegisterElement('show-on-value', ShowOnValue)
safeRegisterElement('custom-validation', CustomValidation)
safeRegisterElement('toggle-classes', ToggleClasses)
safeRegisterElement('autosize-field', AutosizeField)
safeRegisterElement('google-drive-file-picker', GoogleDriveFilePicker)
@@ -170,6 +170,8 @@ safeRegisterElement('template-builder', class extends HTMLElement {
withLogo: this.dataset.withLogo !== 'false',
withFieldsDetection: this.dataset.withFieldsDetection === 'true',
withDetectExistingFields: this.dataset.withDetectExistingFields === 'true',
withRevisions: true,
withRevisionsMenu: this.dataset.withRevisionsMenu === 'true',
editable: this.dataset.editable !== 'false',
authenticityToken: document.querySelector('meta[name="csrf-token"]')?.content,
withCustomFields: true,
+73
View File
@@ -0,0 +1,73 @@
export function convertImage (sourceFile, targetType, quality) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = function (event) {
const img = new Image()
img.onload = function () {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
canvas.width = img.width
canvas.height = img.height
ctx.drawImage(img, 0, 0)
canvas.toBlob(function (blob) {
const ext = targetType === 'image/jpeg' ? '.jpg' : '.png'
const newFile = new File([blob], sourceFile.name.replace(/\.\w+$/, ext), { type: targetType })
resolve(newFile)
}, targetType, quality)
}
img.onerror = () => reject(new Error(`browser cannot decode ${sourceFile.type || sourceFile.name}`))
img.src = event.target.result
}
reader.onerror = reject
reader.readAsDataURL(sourceFile)
})
}
export async function convertImagesInInput (input) {
if (!input.files || input.files.length === 0) return
const dt = new DataTransfer()
let didConvert = false
for (const file of Array.from(input.files)) {
let converted = file
try {
if (['image/bmp', 'image/vnd.microsoft.icon', 'image/svg+xml', 'image/gif'].includes(file.type)) {
converted = await convertImage(file, 'image/png')
didConvert = true
} else if (['image/heic', 'image/heif', 'image/heic-sequence', 'image/heif-sequence', 'image/avif', 'image/avif-sequence', 'image/webp'].includes(file.type)) {
converted = await convertImage(file, 'image/jpeg', 0.9)
didConvert = true
}
} catch (e) {
alert(e.message)
}
dt.items.add(converted)
}
if (didConvert) {
input.files = dt.files
}
}
export default class extends HTMLElement {
connectedCallback () {
const input = this.querySelector('input[type="file"]')
const form = input.form
input.addEventListener('change', async () => {
await convertImagesInInput(input)
form.querySelector('[type="submit"]')?.setAttribute('disabled', true)
form.requestSubmit()
})
}
}
@@ -1,14 +0,0 @@
export default class extends HTMLElement {
connectedCallback () {
const input = this.querySelector('input')
const invalidMessage = this.dataset.invalidMessage || ''
input.addEventListener('invalid', () => {
input.setCustomValidity(input.value ? invalidMessage : '')
})
input.addEventListener('input', () => {
input.setCustomValidity('')
})
}
}
@@ -1,4 +1,5 @@
import { target, targets, targetable } from '@github/catalyst/lib/targetable'
import { convertImagesInInput } from './convert_upload'
const loadingIconHtml = `<svg xmlns="http://www.w3.org/2000/svg" class="animate-spin" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
@@ -150,12 +151,16 @@ export default targetable(class extends HTMLElement {
if (!this.isLoading) this.hideDraghover()
}
uploadFiles (files, url) {
async uploadFiles (files, url) {
this.isLoading = true
this.form.action = url
this.form.querySelector('[type="file"]').files = files
const input = this.form.querySelector('[type="file"]')
input.files = files
await convertImagesInInput(input)
this.form.querySelector('[type="submit"]').click()
}
+8 -3
View File
@@ -1,5 +1,6 @@
import { actionable } from '@github/catalyst/lib/actionable'
import { target, targetable } from '@github/catalyst/lib/targetable'
import { convertImagesInInput } from './convert_upload'
export default actionable(targetable(class extends HTMLElement {
static [target.static] = [
@@ -38,17 +39,21 @@ export default actionable(targetable(class extends HTMLElement {
this.classList.add('border-base-300', 'hover:bg-base-200/30')
}
onDrop (e) {
async onDrop (e) {
e.preventDefault()
this.input.files = e.dataTransfer.files
this.uploadFiles(e.dataTransfer.files)
await convertImagesInInput(this.input)
this.uploadFiles(this.input.files)
}
onSelectFiles (e) {
async onSelectFiles (e) {
e.preventDefault()
await convertImagesInInput(this.input)
this.uploadFiles(this.input.files)
}
+3 -1
View File
@@ -52,7 +52,9 @@ safeRegisterElement('submission-form', class extends HTMLElement {
completedMessage: JSON.parse(this.dataset.completedMessage || '{}'),
completedRedirectUrl: this.dataset.completedRedirectUrl,
attachments: reactive(JSON.parse(this.dataset.attachments)),
fields: JSON.parse(this.dataset.fields)
fields: JSON.parse(this.dataset.fields),
completeButtonContainer: document.getElementById('complete_button_container'),
completeButtonScrollContainer: document.getElementById('complete_button_container_scroll')
})
this.app.mount(this.appElem)
+4
View File
@@ -643,6 +643,10 @@ export default {
return new Intl.NumberFormat('de-DE').format(number)
} else if (format === 'space') {
return new Intl.NumberFormat('fr-FR').format(number)
} else if (format === 'percent') {
return `${number}%`
} else if (format === 'percent_space') {
return `${String(number).replace('.', ',')} %`
} else {
return number
}
+38 -28
View File
@@ -51,6 +51,36 @@
<script>
import { IconCloudUpload, IconInnerShadowTop } from '@tabler/icons-vue'
function convertImage (sourceFile, targetType, quality) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = function (event) {
const img = new Image()
img.onload = function () {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
canvas.width = img.width
canvas.height = img.height
ctx.drawImage(img, 0, 0)
canvas.toBlob(function (blob) {
const ext = targetType === 'image/jpeg' ? '.jpg' : '.png'
const newFile = new File([blob], sourceFile.name.replace(/\.\w+$/, ext), { type: targetType })
resolve(newFile)
}, targetType, quality)
}
img.onerror = () => reject(new Error(`browser cannot decode ${sourceFile.type || sourceFile.name}`))
img.src = event.target.result
}
reader.onerror = reject
reader.readAsDataURL(sourceFile)
})
}
export default {
name: 'FileDropzone',
components: {
@@ -153,8 +183,14 @@ export default {
}
})
} else {
if (file.type === 'image/bmp' || file.type === 'image/vnd.microsoft.icon') {
file = await this.convertBmpToPng(file)
try {
if (['image/bmp', 'image/vnd.microsoft.icon', 'image/svg+xml', 'image/gif'].includes(file.type)) {
file = await convertImage(file, 'image/png')
} else if (['image/heic', 'image/heif', 'image/heic-sequence', 'image/heif-sequence', 'image/avif', 'image/avif-sequence', 'image/webp'].includes(file.type)) {
file = await convertImage(file, 'image/jpeg', 0.9)
}
} catch (e) {
alert(e.message)
}
formData.append('file', file)
@@ -181,32 +217,6 @@ export default {
}).finally(() => {
this.isLoading = false
})
},
convertBmpToPng (bmpFile) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = function (event) {
const img = new Image()
img.onload = function () {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
canvas.width = img.width
canvas.height = img.height
ctx.drawImage(img, 0, 0)
canvas.toBlob(function (blob) {
const newFile = new File([blob], bmpFile.name.replace(/\.\w+$/, '.png'), { type: 'image/png' })
resolve(newFile)
}, 'image/png')
}
img.src = event.target.result
}
reader.onerror = reject
reader.readAsDataURL(bmpFile)
})
}
}
}
+43
View File
@@ -113,6 +113,31 @@
</span>
</button>
</Teleport>
<Teleport
v-for="ref in (showCompleteButton ? [completeButtonContainer, completeButtonScrollContainer].filter(Boolean) : [])"
:key="ref"
:to="ref"
>
<button
class="complete-button btn btn-sm btn-neutral text-white px-4"
form="steps_form"
type="submit"
name="completed"
value="true"
:disabled="isSubmittingComplete"
>
<span class="flex items-center">
<IconInnerShadowTop
v-if="isSubmittingComplete"
class="mr-1 animate-spin w-5 h-5"
aria-hidden="true"
/>
<span>
{{ t('complete') }}
</span>
</span>
</button>
</Teleport>
<button
v-if="!isFormVisible"
id="expand_form_button"
@@ -791,6 +816,16 @@ export default {
required: false,
default: null
},
completeButtonContainer: {
type: Object,
required: false,
default: null
},
completeButtonScrollContainer: {
type: Object,
required: false,
default: null
},
schema: {
type: Array,
required: false,
@@ -1008,6 +1043,7 @@ export default {
isSubmitting: false,
isSubmittingComplete: false,
submittedValues: {},
isFormStarted: false,
recalculateButtonDisabledKey: '',
isAccessibilityMode: false
}
@@ -1056,6 +1092,10 @@ export default {
})
})
},
showCompleteButton () {
return this.completeButtonContainer && !this.isCompleted && !this.isInvite && this.isFormStarted &&
!this.stepFields.find((fields) => fields.some((f) => f.required && isEmpty(this.submittedValues[f.uuid])))
},
submitButtonText () {
if (this.alwaysMinimize) {
return this.t('submit')
@@ -1657,6 +1697,8 @@ export default {
return Promise.reject(new Error(data.error))
}
this.isFormStarted = true
const nextStep = (isLastStep && emptyRequiredField) || (forceComplete ? null : this.findNextStep(submitStepIndex))
if (nextStep) {
@@ -1693,6 +1735,7 @@ export default {
},
async performComplete (resp) {
this.isCompleted = true
this.isFormVisible = true
if (resp?.text) {
const respData = await resp.text()
+136 -38
View File
@@ -7,6 +7,13 @@ const en = {
kba: 'KBA',
please_upload_an_image_file: 'Please upload an image file',
must_be_characters_length: 'Must be {number} characters long',
must_be_valid_ssn: 'Must be a valid SSN (XXX-XX-XXXX)',
must_be_valid_ein: 'Must be a valid EIN (XX-XXXXXXX)',
must_be_valid_email: 'Must be a valid email address',
must_be_valid_url: 'Must be a valid URL (starting with http:// or https://)',
must_be_valid_zip: 'Must be a valid ZIP code (XXXXX or XXXXX-XXXX)',
must_contain_numbers_only: 'Must contain numbers only',
must_contain_letters_only: 'Must contain letters only',
complete_all_required_fields_to_proceed_with_identity_verification: 'Complete all required fields to proceed with identity verification.',
verify_id: 'Verify ID',
identity_verification: 'Identity verification',
@@ -116,6 +123,13 @@ const es = {
kba: 'KBA',
please_upload_an_image_file: 'Por favor, sube un archivo de imagen',
must_be_characters_length: 'Debe tener {number} caracteres de longitud',
must_be_valid_ssn: 'Debe ser un SSN válido (XXX-XX-XXXX)',
must_be_valid_ein: 'Debe ser un EIN válido (XX-XXXXXXX)',
must_be_valid_email: 'Debe ser una dirección de correo electrónico válida',
must_be_valid_url: 'Debe ser una URL válida (que comience con http:// o https://)',
must_be_valid_zip: 'Debe ser un ZIP válido (XXXXX o XXXXX-XXXX)',
must_contain_numbers_only: 'Debe contener solo números',
must_contain_letters_only: 'Debe contener solo letras',
complete_all_required_fields_to_proceed_with_identity_verification: 'Complete todos los campos requeridos para continuar con la verificación de identidad.',
verify_id: 'Verificar ID',
identity_verification: 'Verificación de identidad',
@@ -136,7 +150,7 @@ const es = {
verification_code_is_invalid: 'El código de verificación no es válido',
sign_on_the_touchscreen: 'Firmar en pantalla táctil',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Escanea el código QR con la aplicación de la cámara para abrir el formulario en el móvil y dibujar tu firma',
by_clicking_you_agree_to_the: 'Al hacer clic en "{button}", usted acepta el',
by_clicking_you_agree_to_the: 'Al hacer clic en "{button}", usted acepta la',
electronic_signature_disclosure: 'Divulgación de Firma Electrónica',
esignature_disclosure: 'Divulgación de eFirma',
already_paid: 'Ya pagado',
@@ -225,6 +239,13 @@ const it = {
kba: 'KBA',
please_upload_an_image_file: 'Per favore carica un file immagine',
must_be_characters_length: 'Deve essere lungo {number} caratteri',
must_be_valid_ssn: 'Deve essere un SSN valido (XXX-XX-XXXX)',
must_be_valid_ein: 'Deve essere un EIN valido (XX-XXXXXXX)',
must_be_valid_email: 'Deve essere un indirizzo email valido',
must_be_valid_url: 'Deve essere un URL valido (che inizia con http:// o https://)',
must_be_valid_zip: 'Deve essere uno ZIP valido (XXXXX o XXXXX-XXXX)',
must_contain_numbers_only: 'Deve contenere solo numeri',
must_contain_letters_only: 'Deve contenere solo lettere',
complete_all_required_fields_to_proceed_with_identity_verification: "Compila tutti i campi obbligatori per procedere con la verifica dell'identità.",
verify_id: 'Verifica ID',
identity_verification: "Verifica dell'identità",
@@ -245,7 +266,7 @@ const it = {
verification_code_is_invalid: 'Il codice di verifica non è valido',
sign_on_the_touchscreen: 'Firma su schermo tattile',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: "Scansiona il codice QR con l'app della fotocamera per aprire il modulo sul cellulare e disegnare la tua firma",
by_clicking_you_agree_to_the: 'Cliccando su "{button}", accetti il',
by_clicking_you_agree_to_the: 'Cliccando su "{button}", accetti la',
electronic_signature_disclosure: 'Divulgazione della Firma Elettronica',
esignature_disclosure: 'Divulgazione della eFirma',
minimize: 'Minimizza',
@@ -258,7 +279,7 @@ const it = {
date: 'Data',
number: 'Numero',
image: 'Immagine',
pay: 'Pagamento',
pay: 'Paga',
file: 'File',
select: 'Seleziona',
checkbox: 'Checkbox',
@@ -334,6 +355,13 @@ const de = {
kba: 'KBA',
please_upload_an_image_file: 'Bitte laden Sie eine Bilddatei hoch',
must_be_characters_length: 'Muss {number} Zeichen lang sein',
must_be_valid_ssn: 'Muss eine gültige SSN sein (XXX-XX-XXXX)',
must_be_valid_ein: 'Muss eine gültige EIN sein (XX-XXXXXXX)',
must_be_valid_email: 'Muss eine gültige E-Mail-Adresse sein',
must_be_valid_url: 'Muss eine gültige URL sein (beginnend mit http:// oder https://)',
must_be_valid_zip: 'Muss eine gültige ZIP sein (XXXXX oder XXXXX-XXXX)',
must_contain_numbers_only: 'Darf nur Zahlen enthalten',
must_contain_letters_only: 'Darf nur Buchstaben enthalten',
complete_all_required_fields_to_proceed_with_identity_verification: 'Füllen Sie alle Pflichtfelder aus, um mit der Identitätsprüfung fortzufahren.',
verify_id: 'ID überprüfen',
identity_verification: 'Identitätsprüfung',
@@ -443,6 +471,13 @@ const fr = {
kba: 'KBA',
please_upload_an_image_file: 'Veuillez téléverser un fichier image',
must_be_characters_length: 'Doit comporter {number} caractères',
must_be_valid_ssn: 'Doit être un SSN valide (XXX-XX-XXXX)',
must_be_valid_ein: 'Doit être un EIN valide (XX-XXXXXXX)',
must_be_valid_email: 'Doit être une adresse e-mail valide',
must_be_valid_url: 'Doit être une URL valide (commençant par http:// ou https://)',
must_be_valid_zip: 'Doit être un ZIP valide (XXXXX ou XXXXX-XXXX)',
must_contain_numbers_only: 'Doit contenir uniquement des chiffres',
must_contain_letters_only: 'Doit contenir uniquement des lettres',
complete_all_required_fields_to_proceed_with_identity_verification: "Veuillez remplir tous les champs obligatoires pour poursuivre la vérification d'identité.",
verify_id: "Vérifier l'ID",
identity_verification: "Vérification d'identité",
@@ -552,6 +587,13 @@ const pl = {
kba: 'KBA',
please_upload_an_image_file: 'Proszę przesłać plik obrazu',
must_be_characters_length: 'Musi mieć długość {number} znaków',
must_be_valid_ssn: 'Musi być prawidłowym numerem SSN (XXX-XX-XXXX)',
must_be_valid_ein: 'Musi być prawidłowym numerem EIN (XX-XXXXXXX)',
must_be_valid_email: 'Musi być prawidłowym adresem e-mail',
must_be_valid_url: 'Musi być prawidłowym adresem URL (zaczynającym się od http:// lub https://)',
must_be_valid_zip: 'Musi być prawidłowym ZIP (XXXXX lub XXXXX-XXXX)',
must_contain_numbers_only: 'Może zawierać tylko cyfry',
must_contain_letters_only: 'Może zawierać tylko litery',
complete_all_required_fields_to_proceed_with_identity_verification: 'Uzupełnij wszystkie wymagane pola, aby kontynuować weryfikację tożsamości.',
verify_id: 'Zweryfikuj ID',
identity_verification: 'Weryfikacja tożsamości',
@@ -574,7 +616,7 @@ const pl = {
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Zeskanuj kod QR za pomocą aplikacji aparatu, aby otworzyć formularz na telefonie i narysować swój podpis',
by_clicking_you_agree_to_the: 'Klikając na "{button}", zgadzasz się na',
electronic_signature_disclosure: 'Ujawnienie Elektronicznej Sygnatury',
esignature_disclosure: 'Ujawnienie ePodpisu',
esignature_disclosure: 'informację o e-podpisie',
minimize: 'Zminimalizuj',
text: 'Tekst',
already_paid: 'Już zapłacono',
@@ -642,7 +684,7 @@ const pl = {
toggle_multiline_text: 'Przełącz Tekst Wielolinijkowy',
email_has_been_sent: 'E-mail został wysłany',
processing: 'Przetwarzanie',
pay_with_stripe: 'Płatność za pomocą Stripe',
pay_with_stripe: 'Zapłać za pomocą Stripe',
reupload: 'Ponowne przesłanie',
upload: 'Przesyłanie',
files: 'Pliki',
@@ -661,6 +703,13 @@ const uk = {
kba: 'KBA',
please_upload_an_image_file: 'Будь ласка, завантажте файл зображення',
must_be_characters_length: 'Має містити {number} символів',
must_be_valid_ssn: 'Має бути дійсним SSN (XXX-XX-XXXX)',
must_be_valid_ein: 'Має бути дійсним EIN (XX-XXXXXXX)',
must_be_valid_email: 'Має бути дійсною адресою електронної пошти',
must_be_valid_url: 'Має бути дійсним URL (починаючи з http:// або https://)',
must_be_valid_zip: 'Має бути дійсним ZIP (XXXXX або XXXXX-XXXX)',
must_contain_numbers_only: 'Має містити лише цифри',
must_contain_letters_only: 'Має містити лише літери',
complete_all_required_fields_to_proceed_with_identity_verification: "Заповніть всі обов'язкові поля, щоб продовжити перевірку особи.",
verify_id: 'Підтвердження ідентичності',
identity_verification: 'Ідентифікація особи',
@@ -678,12 +727,12 @@ const uk = {
authored_by: 'Автор',
select_a_reason: 'Виберіть причину',
value_is_invalid: 'Значення є неправильним',
verification_code_is_invalid: 'Код підтвердження є неправильним',
verification_code_is_invalid: 'Невірний код підтвердження',
sign_on_the_touchscreen: 'Підписати на сенсорному екрані',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Скануйте QR-код за допомогою програми камери, щоб відкрити форму на мобільному пристрої та намалювати свій підпис',
by_clicking_you_agree_to_the: 'Натиснувши на "{button}", ви погоджуєтеся з',
electronic_signature_disclosure: 'Розголошення Електронного Підпису',
esignature_disclosure: 'Розголошення еПідпису',
electronic_signature_disclosure: 'інформацією про е-підпис',
esignature_disclosure: 'інформацією про е-підпис',
minimize: 'Зменшити',
text: 'Текст',
already_paid: 'Вже оплачено',
@@ -751,7 +800,7 @@ const uk = {
toggle_multiline_text: 'Перемкнути Багаторядковий Текст',
email_has_been_sent: 'Електронний лист був відправлений',
processing: 'Обробка',
pay_with_strip: 'Сплатити за допомогою Stripe',
pay_with_stripe: 'Сплатити за допомогою Stripe',
reupload: 'Перезавантажити',
upload: 'Завантажити',
files: 'Файли',
@@ -770,11 +819,18 @@ const cs = {
kba: 'KBA',
please_upload_an_image_file: 'Nahrajte prosím obrázkový soubor',
must_be_characters_length: 'Musí mít délku {number} znaků',
must_be_valid_ssn: 'Musí být platné SSN (XXX-XX-XXXX)',
must_be_valid_ein: 'Musí být platné EIN (XX-XXXXXXX)',
must_be_valid_email: 'Musí být platná e-mailová adresa',
must_be_valid_url: 'Musí být platná URL (začínající http:// nebo https://)',
must_be_valid_zip: 'Musí být platný ZIP (XXXXX nebo XXXXX-XXXX)',
must_contain_numbers_only: 'Musí obsahovat pouze čísla',
must_contain_letters_only: 'Musí obsahovat pouze písmena',
complete_all_required_fields_to_proceed_with_identity_verification: 'Vyplňte všechna povinná pole, abyste mohli pokračovat v ověření identity.',
verify_id: 'Ověřit ID',
identity_verification: 'Ověření identity',
complete: 'Dokončit',
fill_all_required_fields_to_complete: 'Please complete all mandatory fields',
fill_all_required_fields_to_complete: 'Vyplňte povinná pole pro dokončení',
sign_and_complete: 'Podepsat a dokončit',
invite: 'Pozvat',
email: 'E-mail',
@@ -782,8 +838,8 @@ const cs = {
reviewed: 'Zkontrolováno',
other: 'Jiné',
authored_by_me: 'Autorem jsem já',
approved_by: 'Schváleno kým',
reviewed_by: 'Zkontrolováno kým',
approved_by: 'Schválil(a)',
reviewed_by: 'Zkontroloval(a)',
authored_by: 'Autorem',
select_a_reason: 'Vyberte důvod',
value_is_invalid: 'Hodnota je neplatná',
@@ -792,7 +848,7 @@ const cs = {
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Naskenujte QR kód pomocí aplikace fotoaparátu, abyste otevřeli formulář na mobilním zařízení a nakreslili svůj podpis',
by_clicking_you_agree_to_the: 'Kliknutím na "{button}" souhlasíte s',
electronic_signature_disclosure: 'Zveřejněním Elektronického Podpisu',
esignature_disclosure: 'Zveřejnění ePodpisu',
esignature_disclosure: 'informací o e-podpisu',
already_paid: 'Už zaplaceno',
minimize: 'Minimalizovat',
text: 'Text',
@@ -860,7 +916,7 @@ const cs = {
toggle_multiline_text: 'Přepnout Víceřádkový Text',
email_has_been_sent: 'E-mail byl odeslán',
processing: 'Zpracování',
pay_with_stripe: 'Zaplacení přes Stripe',
pay_with_stripe: 'Zaplatit přes Stripe',
reupload: 'Znovu nahrát',
upload: 'Nahrát',
files: 'Soubory',
@@ -879,6 +935,13 @@ const pt = {
kba: 'KBA',
please_upload_an_image_file: 'Por favor, envie um arquivo de imagem',
must_be_characters_length: 'Deve ter {number} caracteres',
must_be_valid_ssn: 'Deve ser um SSN válido (XXX-XX-XXXX)',
must_be_valid_ein: 'Deve ser um EIN válido (XX-XXXXXXX)',
must_be_valid_email: 'Deve ser um endereço de e-mail válido',
must_be_valid_url: 'Deve ser uma URL válida (começando com http:// ou https://)',
must_be_valid_zip: 'Deve ser um ZIP válido (XXXXX ou XXXXX-XXXX)',
must_contain_numbers_only: 'Deve conter apenas números',
must_contain_letters_only: 'Deve conter apenas letras',
complete_all_required_fields_to_proceed_with_identity_verification: 'Preencha todos os campos obrigatórios para prosseguir com a verificação de identidade.',
verify_id: 'Verificar ID',
identity_verification: 'Verificação de identidade',
@@ -890,18 +953,18 @@ const pt = {
approved: 'Aprovado',
reviewed: 'Revisado',
other: 'Outro',
authored_by_me: 'Autorizado por mim',
authored_by_me: 'Escrito por mim',
approved_by: 'Aprovado por',
reviewed_by: 'Revisado por',
authored_by: 'Autorizado por',
authored_by: 'Escrito por',
select_a_reason: 'Selecione um motivo',
value_is_invalid: 'Valor é inválido',
verification_code_is_invalid: 'Código de verificação é inválido',
sign_on_the_touchscreen: 'Assinar na tela sensível',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Escaneie o código QR com o aplicativo da câmera para abrir o formulário no celular e desenhar sua assinatura',
by_clicking_you_agree_to_the: 'Ao clicar em "{button}", você concorda com o',
by_clicking_you_agree_to_the: 'Ao clicar em "{button}", você concorda com a',
electronic_signature_disclosure: 'Divulgação de Assinatura Eletrônica',
esignature_disclosure: 'Divulgação da eAssinatura',
esignature_disclosure: 'Informação sobre eAssinatura',
already_paid: 'Já pago',
minimize: 'Minimizar',
text: 'Texto',
@@ -970,7 +1033,7 @@ const pt = {
email_has_been_sent: 'Email enviado',
processing: 'Processamento',
pay_with_stripe: 'Pagar com Stripe',
reupload: 'Reenviar',
reupload: 'Recarregar',
upload: 'Carregar',
files: 'Arquivos',
signature_is_too_small_or_simple_please_redraw: 'A assinatura é muito pequena ou simples. Por favor, redesenhe.',
@@ -988,6 +1051,13 @@ const he = {
kba: 'KBA',
please_upload_an_image_file: 'אנא העלה קובץ תמונה',
must_be_characters_length: 'חייב להיות באורך של {number} תווים',
must_be_valid_ssn: 'חייב להיות SSN חוקי (XXX-XX-XXXX)',
must_be_valid_ein: 'חייב להיות EIN חוקי (XX-XXXXXXX)',
must_be_valid_email: 'חייבת להיות כתובת אימייל חוקית',
must_be_valid_url: 'חייבת להיות כתובת URL חוקית (המתחילה ב-http:// או https://)',
must_be_valid_zip: 'חייב להיות ZIP חוקי (XXXXX או XXXXX-XXXX)',
must_contain_numbers_only: 'חייב להכיל מספרים בלבד',
must_contain_letters_only: 'חייב להכיל אותיות בלבד',
complete_all_required_fields_to_proceed_with_identity_verification: 'מלא את כל השדות הנדרשים כדי להמשיך עם אימות זהות.',
verify_id: 'אמת מזהה',
identity_verification: 'אימות זהות',
@@ -1010,8 +1080,8 @@ const he = {
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'סרוק את קוד ה-QR באמצעות אפליקציית המצלמה כדי לפתוח את הטופס במובייל ולצייר את החתימה שלך',
by_clicking_you_agree_to_the: 'על ידי לחיצה על "{button}", אתה מסכים ל',
electronic_signature_disclosure: 'חשיפת חתימה אלקטרונית',
esignature_disclosure: 'חשיפת ה-eחתימה',
minimize: 'לקטן',
esignature_disclosure: 'חשיפת חתימה אלקטרונית',
minimize: 'מזער',
text: 'טקסט',
already_paid: 'כבר שולם',
signature: 'חתימה',
@@ -1022,9 +1092,9 @@ const he = {
number: 'מספר',
image: 'תמונה',
file: 'קובץ',
pay: 'תשלום',
pay: 'שלם',
select: 'בחר',
checkbox: 'תיק בחירה',
checkbox: 'סימון',
multiple: 'רב ערכים',
radio: 'רדיו',
cells: 'תאים',
@@ -1051,9 +1121,9 @@ const he = {
download: 'הורדה',
clear: 'נקה',
redraw: 'צייר מחדש',
draw_initials: 'צייר ציוני ראשי תיבות',
draw_initials: 'צייר ראשי תיבות',
type_signature_here: 'הקלד חתימה כאן',
type_initial_here: 'הקלד ציוני ראשי תיבות כאן',
type_initial_here: 'הקלד ראשי תיבות כאן',
form_has_been_completed: 'הטופס הושלם',
document_has_been_signed: 'המסמך נחתם!',
documents_have_been_signed: 'המסמכים נחתמו!',
@@ -1063,7 +1133,7 @@ const he = {
open_source_documents_software: 'תוכנה פתוחה למסמכים',
verified_phone_number: 'אימות מספר טלפון',
use_international_format: 'השתמש בפורמט בינלאומי: +1xxx',
six_digits_code: 'קוד משתמש שש ספרות',
six_digits_code: 'קוד שש ספרות',
change_phone_number: 'שינוי מספר טלפון',
sending: 'שולח',
resend_code: 'שלח מחדש קוד',
@@ -1072,7 +1142,7 @@ const he = {
set_today: 'קבע היום',
toggle_multiline_text: 'שנה בין טקסט במספר שורות לטקסט בשורה אחת',
draw_signature: 'צייר חתימה',
type_initial: 'הקלד ציוני ראשי תיבות',
type_initial: 'הקלד ראשי תיבות',
draw: 'צייר',
type: 'הקלד',
type_text: 'הקלד טקסט',
@@ -1097,8 +1167,15 @@ const nl = {
kba: 'KBA',
please_upload_an_image_file: 'Upload alstublieft een afbeeldingsbestand',
must_be_characters_length: 'Moet {number} tekens lang zijn',
must_be_valid_ssn: 'Moet een geldig SSN zijn (XXX-XX-XXXX)',
must_be_valid_ein: 'Moet een geldig EIN zijn (XX-XXXXXXX)',
must_be_valid_email: 'Moet een geldig e-mailadres zijn',
must_be_valid_url: 'Moet een geldige URL zijn (beginnend met http:// of https://)',
must_be_valid_zip: 'Moet een geldige ZIP zijn (XXXXX of XXXXX-XXXX)',
must_contain_numbers_only: 'Mag alleen cijfers bevatten',
must_contain_letters_only: 'Mag alleen letters bevatten',
complete_all_required_fields_to_proceed_with_identity_verification: 'Vul alle verplichte velden in om door te gaan met de identiteitsverificatie.',
verify_id: 'Verifiëren ID',
verify_id: 'ID verifiëren',
identity_verification: 'Identiteitsverificatie',
complete: 'Voltooien',
fill_all_required_fields_to_complete: 'Vul alle verplichte velden in om te voltooien',
@@ -1146,7 +1223,7 @@ const nl = {
continue: 'Doorgaan',
sign_now: 'Nu ondertekenen',
type_here_: 'Typ hier...',
optional: 'Optioneel',
optional: 'optioneel',
option: 'Optie',
appears_on: 'Verschijnt op',
page: 'Pagina',
@@ -1170,11 +1247,11 @@ const nl = {
powered_by: 'Aangedreven door',
please_check_the_box_to_continue: 'Vink het vakje aan om door te gaan.',
open_source_documents_software: 'Open source documenten software',
verified_phone_number: 'Geverifieerd telefoonnummer',
verified_phone_number: 'Telefoonnummer verifiëren',
use_international_format: 'Gebruik internationaal formaat: +1xxx',
six_digits_code: '6-cijferige code',
change_phone_number: 'Wijzig telefoonnummer',
sending: 'Voltooien...',
sending: 'Verzenden...',
resend_code: 'Code opnieuw verzenden',
verification_code_has_been_resent: 'Verificatiecode is opnieuw verzonden via SMS',
please_fill_all_required_fields: 'Vul alle verplichte velden in',
@@ -1206,6 +1283,13 @@ const ar = {
kba: 'KBA',
please_upload_an_image_file: 'يرجى تحميل ملف صورة',
must_be_characters_length: 'يجب أن يكون الطول {number} حرفًا',
must_be_valid_ssn: 'يجب أن يكون SSN صالحًا (XXX-XX-XXXX)',
must_be_valid_ein: 'يجب أن يكون EIN صالحًا (XX-XXXXXXX)',
must_be_valid_email: 'يجب أن يكون عنوان بريد إلكتروني صالحًا',
must_be_valid_url: 'يجب أن يكون عنوان URL صالحًا (يبدأ بـ http:// أو https://)',
must_be_valid_zip: 'يجب أن يكون ZIP صالحًا (XXXXX أو XXXXX-XXXX)',
must_contain_numbers_only: 'يجب أن يحتوي على أرقام فقط',
must_contain_letters_only: 'يجب أن يحتوي على أحرف فقط',
complete_all_required_fields_to_proceed_with_identity_verification: 'أكمل جميع الحقول المطلوبة للمتابعة في التحقق من الهوية.',
verify_id: 'تحقق من الهوية',
identity_verification: 'التحقق من الهوية',
@@ -1232,7 +1316,7 @@ const ar = {
esignature_disclosure: 'كشف التوقيع الإلكتروني',
text: 'نص',
signature: 'توقيع',
initials: 'الاختصارات',
initials: 'حروف أولى',
date: 'تاريخ',
number: 'رقم',
digitally_signed_by: 'تم التوقيع رقميًا بواسطة',
@@ -1269,9 +1353,9 @@ const ar = {
download: 'تحميل',
clear: 'مسح',
redraw: 'إعادة الرسم',
draw_initials: 'ارسم الاختصارات',
draw_initials: 'ارسم حروف أولى',
type_signature_here: 'اكتب التوقيع هنا',
type_initial_here: 'اكتب الاختصارات هنا',
type_initial_here: 'اكتب حروف أولى هنا',
form_has_been_completed: 'تم إكمال النموذج!',
document_has_been_signed: 'تم توقيع الوثيقة!',
documents_have_been_signed: 'تم توقيع الوثائق!',
@@ -1290,13 +1374,13 @@ const ar = {
set_today: 'تعيين اليوم',
toggle_multiline_text: 'تبديل النصوص متعددة الأسطر',
draw_signature: 'ارسم التوقيع',
type_initial: 'اكتب الاختصارات',
type_initial: 'اكتب حروف أولى',
draw: 'ارسم',
type: 'اكتب',
type_text: 'اكتب نصًا',
email_has_been_sent: 'تم إرسال البريد الإلكتروني',
processing: 'جارٍ المعالجة',
pay_with_strip: 'الدفع بواسطة Stripe',
pay_with_stripe: 'الدفع بواسطة Stripe',
reupload: 'إعادة التحميل',
upload: 'تحميل',
files: 'الملفات',
@@ -1315,6 +1399,13 @@ const ko = {
kba: 'KBA',
please_upload_an_image_file: '이미지 파일을 업로드해 주세요',
must_be_characters_length: '{number}자여야 합니다',
must_be_valid_ssn: '유효한 SSN이어야 합니다 (XXX-XX-XXXX)',
must_be_valid_ein: '유효한 EIN이어야 합니다 (XX-XXXXXXX)',
must_be_valid_email: '유효한 이메일 주소여야 합니다',
must_be_valid_url: '유효한 URL이어야 합니다 (http:// 또는 https://로 시작)',
must_be_valid_zip: '유효한 ZIP이어야 합니다 (XXXXX 또는 XXXXX-XXXX)',
must_contain_numbers_only: '숫자만 포함해야 합니다',
must_contain_letters_only: '문자만 포함해야 합니다',
complete_all_required_fields_to_proceed_with_identity_verification: '신원 확인을 진행하려면 모든 필수 필드를 작성하십시오.',
verify_id: '아이디 확인',
identity_verification: '신원 확인',
@@ -1343,10 +1434,10 @@ const ko = {
signature: '서명',
initials: '이니셜',
date: '날짜',
digitally_signed_by: '디지털 서명이 완료되었습니다',
digitally_signed_by: '디지털 서명자:',
reason: '이유',
number: '숫자',
pay: '급여',
pay: '결제',
image: '이미지',
take_photo: '사진 찍기',
file: '파일',
@@ -1424,6 +1515,13 @@ const ja = {
kba: 'KBA',
please_upload_an_image_file: '画像ファイルをアップロードしてください',
must_be_characters_length: '{number}文字でなければなりません',
must_be_valid_ssn: '有効なSSNである必要があります (XXX-XX-XXXX)',
must_be_valid_ein: '有効なEINである必要があります (XX-XXXXXXX)',
must_be_valid_email: '有効なメールアドレスである必要があります',
must_be_valid_url: '有効なURLである必要があります (http://またはhttps://で始まる)',
must_be_valid_zip: '有効なZIPである必要があります (XXXXX または XXXXX-XXXX)',
must_contain_numbers_only: '数字のみを含む必要があります',
must_contain_letters_only: '文字のみを含む必要があります',
complete_all_required_fields_to_proceed_with_identity_verification: '本人確認を進めるには、すべての必須項目を入力してください。',
verify_id: '本人確認',
identity_verification: '本人確認',
+16 -2
View File
@@ -41,12 +41,11 @@
:class="{ '!pr-11 -mr-10': !field.validation?.pattern }"
:required="field.required"
:pattern="field.validation?.pattern"
:title="validationMessage"
:aria-describedby="field.description ? field.uuid + '-desc' : undefined"
:placeholder="`${t('type_here_')}${field.required ? '' : ` (${t('optional')})`}`"
type="text"
:name="`values[${field.uuid}]`"
@invalid="validationMessage ? $event.target.setCustomValidity(validationMessage) : ''"
@input="validationMessage ? $event.target.setCustomValidity('') : ''"
@focus="$emit('focus')"
>
<textarea
@@ -138,6 +137,17 @@ export default {
return null
}
},
patternMessageKeys () {
return {
'^[0-9]{3}-[0-9]{2}-[0-9]{4}$': 'must_be_valid_ssn',
'^[0-9]{2}-[0-9]{7}$': 'must_be_valid_ein',
'^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$': 'must_be_valid_email',
'^https?://.*': 'must_be_valid_url',
'^[0-9]{5}(?:-[0-9]{4})?$': 'must_be_valid_zip',
'^[0-9]+$': 'must_contain_numbers_only',
'^[a-zA-Z]+$': 'must_contain_letters_only'
}
},
validationMessage () {
if (this.field.validation?.message) {
return this.field.validation.message
@@ -149,6 +159,10 @@ export default {
.join('-')
return this.t('must_be_characters_length').replace('{number}', number)
} else if (this.field.validation?.pattern) {
const key = this.patternMessageKeys[this.field.validation.pattern]
return key ? this.t(key) : ''
} else {
return ''
}
+20 -21
View File
@@ -50,6 +50,7 @@
@remove="$emit('remove')"
@scroll-to="$emit('scroll-to', $event)"
@add-custom-field="$emit('add-custom-field')"
@click-title="$emit('click-title')"
/>
<div
ref="touchValueTarget"
@@ -317,7 +318,7 @@ export default {
default: false
}
},
emits: ['start-resize', 'stop-resize', 'start-drag', 'stop-drag', 'remove', 'scroll-to', 'add-custom-field'],
emits: ['start-resize', 'stop-resize', 'start-drag', 'stop-drag', 'remove', 'scroll-to', 'add-custom-field', 'click-title'],
data () {
return {
isContenteditable: false,
@@ -619,6 +620,10 @@ export default {
return new Intl.NumberFormat('de-DE').format(number)
} else if (format === 'space') {
return new Intl.NumberFormat('fr-FR').format(number)
} else if (format === 'percent') {
return `${number}%`
} else if (format === 'percent_space') {
return `${String(number).replace('.', ',')} %`
} else {
return number
}
@@ -715,14 +720,6 @@ export default {
}
}
},
drag (e) {
if (e.target.id === 'mask' && this.editable) {
this.isDragged = true
this.area.x = (e.offsetX - this.dragFrom.x) / e.target.clientWidth
this.area.y = (e.offsetY - this.dragFrom.y) / e.target.clientHeight
}
},
startTouchDrag (e) {
if (e.target !== this.$refs.touchTarget && e.target !== this.$refs.touchValueTarget) {
return
@@ -768,6 +765,8 @@ export default {
this.maybeChangeAreaPage(this.area)
this.clampAreaBounds(this.area)
if (this.isDragged) {
this.save()
}
@@ -835,6 +834,8 @@ export default {
this.maybeChangeAreaPage(this.area)
this.clampAreaBounds(this.area)
if (this.isMoved) {
this.save()
}
@@ -859,18 +860,6 @@ export default {
area.y = area.y - 1 - (16.0 / this.$parent.$refs.mask.previousSibling.offsetHeight)
}
},
stopDrag () {
this.$el.getRootNode().removeEventListener('mousemove', this.drag)
this.$el.getRootNode().removeEventListener('mouseup', this.stopDrag)
if (this.isDragged) {
this.save()
}
this.isDragged = false
this.$emit('stop-drag')
},
startResize () {
if (!this.selectedAreasRef.value.includes(this.area)) {
this.selectedAreasRef.value = [this.area]
@@ -885,6 +874,8 @@ export default {
this.$el.getRootNode().removeEventListener('mousemove', this.resize)
this.$el.getRootNode().removeEventListener('mouseup', this.stopResize)
this.clampAreaBounds(this.area)
this.$emit('stop-resize')
this.save()
@@ -923,9 +914,17 @@ export default {
this.$el.getRootNode().removeEventListener('touchmove', this.touchResize)
this.$el.getRootNode().removeEventListener('touchend', this.stopTouchResize)
this.clampAreaBounds(this.area)
this.$emit('stop-resize')
this.save()
},
clampAreaBounds (area) {
area.x = Math.min(Math.max(area.x, 0), 1)
area.y = Math.min(Math.max(area.y, 0), 1)
area.w = Math.min(Math.max(area.w, 0), 1)
area.h = Math.min(Math.max(area.h, 0), 1)
}
}
}
@@ -3,7 +3,7 @@
v-if="field?.type && (isSelected || isNameFocus) && !isInMultiSelection"
class="absolute bg-white rounded-t border overflow-visible whitespace-nowrap flex z-10 field-area-controls"
style="top: -25px; height: 25px"
@mousedown.stop
@mousedown.stop="$emit('click-title')"
@pointerdown.stop
>
<FieldSubmitter
@@ -279,7 +279,7 @@ export default {
default: false
}
},
emits: ['remove', 'scroll-to', 'add-custom-field', 'change'],
emits: ['remove', 'scroll-to', 'add-custom-field', 'change', 'click-title'],
data () {
return {
isShowFormulaModal: false,
+153 -6
View File
@@ -51,6 +51,30 @@
</div>
</div>
</div>
<div
v-if="beforeRevisionSnapshot"
class="top-1.5 sticky h-0 z-20 max-w-2xl mx-auto"
>
<div class="alert border-base-content/30 py-2 px-2.5">
<IconInfoCircle class="stroke-info shrink-0 w-6 h-6" />
<span>{{ t('viewing_revision_from').replace('{date}', formatRevisionTime(beforeRevisionSnapshot.revision.created_at)) }}</span>
<div>
<button
class="btn btn-sm"
@click.prevent="cancelRevision"
>
{{ t('cancel') }}
</button>
<button
v-if="editable"
class="btn btn-sm btn-neutral text-white"
@click.prevent="applyRevision"
>
{{ t('apply') }}
</button>
</div>
</div>
</div>
<div
v-if="$slots.buttons || withTitle"
id="title_container"
@@ -213,6 +237,18 @@
<span class="whitespace-nowrap">{{ t('preferences') }}</span>
</a>
</li>
<li v-if="withRevisionsMenu">
<button
class="flex space-x-2"
@click.prevent="openRevisionsModal"
@mouseenter="preloadRevisions"
>
<span class="w-6 h-6 flex-shrink-0 flex items-center justify-center">
<IconHistory class="w-5 h-5" />
</span>
<span class="whitespace-nowrap">{{ t('revisions') }}</span>
</button>
</li>
<li v-if="withDownload">
<button
class="flex space-x-2"
@@ -600,7 +636,16 @@
<div
id="docuseal_modal_container"
class="modal-container"
/>
>
<RevisionsModal
v-if="isRevisionsModalOpen"
:template="template"
:revisions="revisions"
:locale="locale"
@close="isRevisionsModalOpen = false"
@apply="onRevisionApply"
/>
</div>
</div>
</template>
@@ -618,7 +663,8 @@ import DocumentPreview from './preview'
import DocumentControls from './controls'
import MobileFields from './mobile_fields'
import FieldSubmitter from './field_submitter'
import { IconPlus, IconUsersPlus, IconDeviceFloppy, IconChevronDown, IconEye, IconWritingSign, IconInnerShadowTop, IconInfoCircle, IconAdjustments, IconDownload } from '@tabler/icons-vue'
import RevisionsModal from './revisions_modal'
import { IconPlus, IconUsersPlus, IconDeviceFloppy, IconChevronDown, IconEye, IconWritingSign, IconInnerShadowTop, IconInfoCircle, IconAdjustments, IconDownload, IconHistory } from '@tabler/icons-vue'
import { v4 } from 'uuid'
import { ref, computed, toRaw, defineAsyncComponent } from 'vue'
import * as i18n from './i18n'
@@ -658,7 +704,9 @@ export default {
IconDownload,
IconAdjustments,
IconEye,
IconDeviceFloppy
IconHistory,
IconDeviceFloppy,
RevisionsModal
},
provide () {
return {
@@ -980,6 +1028,16 @@ export default {
type: Boolean,
required: false,
default: false
},
withRevisions: {
type: Boolean,
required: false,
default: false
},
withRevisionsMenu: {
type: Boolean,
required: false,
default: false
}
},
data () {
@@ -1002,7 +1060,10 @@ export default {
drawOption: null,
dragField: null,
isDragFile: false,
isMathLoaded: false
isMathLoaded: false,
isRevisionsModalOpen: false,
revisions: [],
beforeRevisionSnapshot: null
}
},
computed: {
@@ -1767,6 +1828,73 @@ export default {
closeDropdown () {
document.activeElement.blur()
},
preloadRevisions () {
this.loadRevisionsPromise ||= this.baseFetch(`/templates/${this.template.id}/versions`)
},
openRevisionsModal () {
this.closeDropdown()
this.loadRevisionsPromise ||= this.baseFetch(`/templates/${this.template.id}/versions`)
this.loadRevisionsPromise.then(async (resp) => {
this.revisions = await resp.json()
this.isRevisionsModalOpen = true
}).finally(() => {
this.loadRevisionsPromise = null
})
},
onRevisionApply (revision) {
this.beforeRevisionSnapshot = {
template: JSON.parse(JSON.stringify(this.template)),
dynamicDocuments: JSON.parse(JSON.stringify(this.dynamicDocuments)),
revision
}
const { dynamic_documents: nextDynamicDocs = [], ...nextTemplate } = revision.data
Object.assign(this.template, nextTemplate)
this.dynamicDocuments.splice(0, this.dynamicDocuments.length, ...nextDynamicDocs)
this.$nextTick(() => this.reloadDynamicDocumentContent())
this.isRevisionsModalOpen = false
},
cancelRevision () {
Object.assign(this.template, this.beforeRevisionSnapshot.template)
this.dynamicDocuments.splice(0, this.dynamicDocuments.length, ...this.beforeRevisionSnapshot.dynamicDocuments)
this.beforeRevisionSnapshot = null
this.$nextTick(() => this.reloadDynamicDocumentContent())
},
applyRevision () {
this.beforeRevisionSnapshot = null
const dynamicDocumentRefs = this.documentRefs.filter((ref) => ref.isDynamic)
dynamicDocumentRefs.forEach((ref) => ref.update())
this.rebuildVariablesSchema({ disable: false })
return Promise.all([this.save({ force: true }), ...dynamicDocumentRefs.map((ref) => ref.saveBody())])
},
reloadDynamicDocumentContent () {
this.documentRefs.forEach((ref) => {
if (ref.isDynamic) ref.reloadContent()
})
},
formatRevisionTime (string) {
return new Date(string).toLocaleString(this.locale || undefined, {
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: 'numeric',
minute: '2-digit'
})
},
t (key) {
return this.i18n[key] || i18n[this.language]?.[key] || i18n.en[key] || key
},
@@ -2384,6 +2512,11 @@ export default {
this.selectedAreasRef.value = [area]
area.x = Math.min(Math.max(area.x, 0), 1)
area.y = Math.min(Math.max(area.y, 0), 1)
area.w = Math.min(Math.max(area.w, 0), 1)
area.h = Math.min(Math.max(area.h, 0), 1)
this.save()
} else {
const documentRef = this.documentRefs.find((e) => e.document.uuid === area.attachment_uuid)
@@ -3013,7 +3146,11 @@ export default {
const dynamicDocumentSaves = dynamicDocumentRefs.map((ref) => ref.saveBody())
Promise.all([this.save(), ...dynamicDocumentSaves]).then(() => {
Promise.all([this.save({ force: true }), ...dynamicDocumentSaves]).then(() => {
if (this.withRevisions) {
this.captureRevision()
}
window.Turbo.visit(`/templates/${this.template.id}`)
}).finally(() => {
this.isSaving = false
@@ -3244,9 +3381,13 @@ export default {
}
})
},
save ({ force } = { force: false }) {
save ({ force = false } = {}) {
this.pendingFieldAttachmentUuids = []
if (this.beforeRevisionSnapshot) {
this.beforeRevisionSnapshot = null
}
if (this.onChange) {
this.onChange(this.template)
}
@@ -3281,6 +3422,12 @@ export default {
}
})
},
captureRevision () {
return this.baseFetch(`/templates/${this.template.id}/versions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
})
},
onDynamicDocumentUpdate () {
this.rebuildVariablesSchema()
@@ -6,7 +6,7 @@
class="absolute top-0 bottom-0 right-0 left-0"
@click.prevent="$emit('close')"
/>
<div class="modal-box pt-4 pb-6 px-6 mt-20 max-h-none w-full max-w-xl">
<div class="modal-box pt-4 pb-6 px-6 mt-20 w-full max-w-xl">
<div class="flex justify-between items-center border-b pb-2 mb-2 font-medium">
<span class="modal-title">
{{ t('condition') }} - {{ (defaultField ? (defaultField.title || item.title || item.name) : item.name) || buildDefaultName(item) }}
@@ -183,6 +183,9 @@ export default {
this.sectionRefs.push(ref)
}
},
reloadContent () {
this.sectionRefs.forEach((ref) => ref.reloadContent())
},
onBeforeUnload (event) {
if (this.saveTimer) {
event.preventDefault()
@@ -94,6 +94,8 @@ img.ProseMirror-separator {
}
dynamic-variable {
background-color: #fef3c7;
word-break: break-all;
overflow-wrap: anywhere;
}`)
function collectDomAttrs (dom) {
@@ -136,11 +138,12 @@ function collectSpanDomAttrs (dom) {
return result
}
function createBlockNode (name, tag, content) {
function createBlockNode (name, tag, content, extra = {}) {
return Node.create({
name,
group: 'block',
content: content || 'block+',
...extra,
addAttributes () {
return {
htmlAttrs: { default: {} }
@@ -194,9 +197,9 @@ const CustomHeading = Node.create({
})
const SectionNode = createBlockNode('section', 'section')
const ArticleNode = createBlockNode('article', 'article')
const HeaderNode = createBlockNode('header', 'header')
const FooterNode = createBlockNode('footer', 'footer')
const ArticleNode = createBlockNode('article', 'article', null, { isolating: true })
const HeaderNode = createBlockNode('header', 'header', null, { isolating: true })
const FooterNode = createBlockNode('footer', 'footer', null, { isolating: true })
const DivNode = createBlockNode('div', 'div')
const BlockquoteNode = createBlockNode('blockquote', 'blockquote')
const PreNode = createBlockNode('pre', 'pre')
@@ -281,6 +281,9 @@ export default {
}
},
methods: {
reloadContent () {
this.editor.commands.setContent(this.section.innerHTML, { emitUpdate: false })
},
findAreaNodePos (areaUuid) {
const el = this.editor.view.dom.querySelector(`[data-area-uuid="${areaUuid}"]`)
@@ -248,7 +248,7 @@ export default {
FieldType,
IconSettings
},
inject: ['t', 'save', 'backgroundColor', 'dateFormats'],
inject: ['t', 'save', 'backgroundColor', 'dateFormats', 'locale'],
provide () {
return {
fieldTypes: ['text', 'number', 'date', 'checkbox', 'radio', 'select']
@@ -308,15 +308,18 @@ export default {
return { text: 'string', number: 'number', date: 'date', checkbox: 'boolean', radio: 'string', select: 'string' }
},
numberFormats () {
return [
'none',
'usd',
'eur',
'gbp',
'comma',
'dot',
'space'
]
const formats = ['none', 'usd', 'eur', 'gbp', 'comma', 'dot', 'space']
const spaceLocales = ['fr-FR', 'es-ES', 'pt-PT', 'de-DE', 'it-IT', 'nl-NL']
formats.push(spaceLocales.includes(this.locale) ? 'percent_space' : 'percent')
const selectedFormat = this.schema.format
if (selectedFormat && !formats.includes(selectedFormat)) {
formats.push(selectedFormat)
}
return formats
},
availableDateFormats () {
const formats = this.dateFormats.length
@@ -397,6 +400,10 @@ export default {
return new Intl.NumberFormat('de-DE').format(number)
} else if (format === 'space') {
return new Intl.NumberFormat('fr-FR').format(number)
} else if (format === 'percent') {
return `${number}%`
} else if (format === 'percent_space') {
return `${String(number).replace('.', ',')} %`
} else {
return number
}
@@ -514,7 +514,7 @@ export default {
ContextSubmenu,
ContextModal
},
inject: ['t', 'getFieldTypeIndex', 'template', 'withCustomFields', 'currencies', 'dateFormats'],
inject: ['t', 'getFieldTypeIndex', 'template', 'withCustomFields', 'currencies', 'dateFormats', 'locale'],
props: {
contextMenu: {
type: Object,
@@ -610,7 +610,7 @@ export default {
IconTypography,
IconX
},
inject: ['template', 't', 'dateFormats'],
inject: ['template', 't', 'dateFormats', 'locale'],
props: {
field: {
type: Object,
@@ -691,15 +691,18 @@ export default {
}
},
numberFormats () {
return [
'none',
'usd',
'eur',
'gbp',
'comma',
'dot',
'space'
]
const formats = ['none', 'usd', 'eur', 'gbp', 'comma', 'dot', 'space']
const spaceLocales = ['fr-FR', 'es-ES', 'pt-PT', 'de-DE', 'it-IT', 'nl-NL']
formats.push(spaceLocales.includes(this.locale) ? 'percent_space' : 'percent')
const selectedFormat = this.field.preferences?.format
if (selectedFormat && !formats.includes(selectedFormat)) {
formats.push(selectedFormat)
}
return formats
},
availableDateFormats () {
const formats = this.dateFormats.length
@@ -824,6 +827,10 @@ export default {
return new Intl.NumberFormat('de-DE').format(number)
} else if (format === 'space') {
return new Intl.NumberFormat('fr-FR').format(number)
} else if (format === 'percent') {
return `${number}%`
} else if (format === 'percent_space') {
return `${String(number).replace('.', ',')} %`
} else {
return number
}
@@ -147,7 +147,7 @@
>
<a
href="#"
class="flex px-2 group justify-between items-center"
class="flex px-2 text-sm group justify-between items-center"
:class="{ 'active': submitter === selectedSubmitter, 'py-0.5': submitters.length > 8 }"
@click.prevent="selectSubmitter(submitter)"
>
@@ -198,7 +198,7 @@
>
<a
href="#"
class="flex px-2"
class="flex px-2 text-sm"
@click.prevent="addSubmitter"
>
<IconUserPlus
+1 -1
View File
@@ -383,7 +383,7 @@
class="rounded py-2 px-4 w-full border border-dashed border-base-300"
>
<div class="text-center text-sm">
{{ t('start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document') }}
{{ t('start_a_quick_tour_to_learn_how_to_create_and_send_your_first_document') }}
</div>
<div class="flex justify-center">
<label
+65 -37
View File
@@ -200,7 +200,7 @@ const en = {
learn_more: 'Learn more',
and: 'and',
or: 'or',
start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document: 'Start a quick tour to learn how to create and send your first document',
start_a_quick_tour_to_learn_how_to_create_and_send_your_first_document: 'Start a quick tour to learn how to create and send your first document',
start_tour: 'Start Tour',
or_add_from: 'Or add from',
sync: 'Sync',
@@ -215,7 +215,11 @@ const en = {
align_bottom: 'Align Bottom',
fields_selected: '{count} Fields Selected',
field_added: '{count} Field Added',
fields_added: '{count} Fields Added'
fields_added: '{count} Fields Added',
revisions: 'Revisions',
apply: 'Apply',
no_revisions_yet: 'No revisions yet',
viewing_revision_from: 'Viewing revision from {date}'
}
const es = {
@@ -239,7 +243,7 @@ const es = {
min: 'Mín',
max: 'Máx',
date_signed: 'Fecha actual',
fuente: 'Fuente',
font: 'Fuente',
party: 'Parte',
method: 'Método',
reorder_fields: 'Reordenar campos',
@@ -298,7 +302,7 @@ const es = {
set_signing_date: 'Establecer fecha de firma',
are_you_sure_: '¿Estás seguro?',
sign_yourself: 'Firma tú mismo',
checked: 'Seleccionado',
checked: 'Marcado',
current_date: 'Fecha actual',
send: 'Enviar',
remove: 'Eliminar',
@@ -336,16 +340,16 @@ const es = {
eighth_party: 'Octava Parte',
ninth_party: 'Novena Parte',
tenth_party: 'Décima Parte',
eleventh_party: 'Undécimo Partido',
twelfth_party: 'Duodécimo Partido',
thirteenth_party: 'Decimotercer Partido',
fourteenth_party: 'Catorceavo Partido',
fifteenth_party: 'Quinceavo Partido',
sixteenth_party: 'Dieciséisavo Partido',
seventeenth_party: 'Diecisieteavo Partido',
eighteenth_party: 'Dieciochoavo Partido',
nineteenth_party: 'Decimonovena Fiesta',
twentieth_party: 'Vigésima Fiesta',
eleventh_party: 'Undécima Parte',
twelfth_party: 'Duodécima Parte',
thirteenth_party: 'Decimotercera Parte',
fourteenth_party: 'Decimocuarta Parte',
fifteenth_party: 'Decimoquinta Parte',
sixteenth_party: 'Decimosexta Parte',
seventeenth_party: 'Decimoséptima Parte',
eighteenth_party: 'Decimoctava Parte',
nineteenth_party: 'Decimonovena Parte',
twentieth_party: 'Vigésima Parte',
draw: 'Dibujar',
add: 'Agregar',
text: 'Texto',
@@ -420,7 +424,7 @@ const es = {
learn_more: 'Aprende más',
and: 'y',
or: 'o',
start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document: 'Inicia una guía rápida para aprender a crear y enviar tu primer documento.',
start_a_quick_tour_to_learn_how_to_create_and_send_your_first_document: 'Inicia una guía rápida para aprender a crear y enviar tu primer documento.',
start_tour: 'Iniciar guía',
or_add_from: 'O agregar desde',
sync: 'Sincronizar',
@@ -435,7 +439,11 @@ const es = {
align_bottom: 'Alinear abajo',
fields_selected: '{count} Campos Seleccionados',
field_added: '{count} Campo Añadido',
fields_added: '{count} Campos Añadidos'
fields_added: '{count} Campos Añadidos',
revisions: 'Revisiones',
apply: 'Aplicar',
no_revisions_yet: 'Aún no hay revisiones',
viewing_revision_from: 'Viendo revisión del {date}'
}
const it = {
@@ -458,15 +466,15 @@ const it = {
length: 'Lunghezza',
min: 'Min',
max: 'Max',
date_signed: 'Data attuale',
date_signed: 'Data corrente',
font: 'Carattere',
party: 'Parte',
method: 'Metodo',
reorder_fields: 'Riordina i campi',
verify_id: 'Verifica ID',
obtain_qualified_electronic_signature_with_the_trusted_provider_click_to_learn_more: 'Ottieni una firma elettronica qualificata (QeS) con il fornitore di fiducia. Clicca per saperne di più.',
ricorrente: 'Ricorrente',
una_volta: 'Una volta',
recurrent: 'Ricorrente',
one_off: 'Una tantum',
editable: 'Modificabile',
search_field: 'Campo di ricerca',
field_not_found: 'Campo non trovato',
@@ -525,7 +533,7 @@ const it = {
drawn: 'Disegnato',
drawn_or_typed: 'Disegnato o Digitato',
drawn_or_upload: 'Disegnato o Caricato',
upload: 'Caricare',
upload: 'Carica',
formula: 'Formula',
typed: 'Digitato',
draw_field_on_the_document: 'Disegnare un campo sul documento',
@@ -640,7 +648,7 @@ const it = {
learn_more: 'Scopri di più',
and: 'e',
or: 'o',
start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document: 'Inizia un tour rapido per imparare a creare e inviare il tuo primo documento.',
start_a_quick_tour_to_learn_how_to_create_and_send_your_first_document: 'Inizia un tour rapido per imparare a creare e inviare il tuo primo documento.',
start_tour: 'Inizia il tour',
or_add_from: 'O aggiungi da',
sync: 'Sincronizza',
@@ -655,7 +663,11 @@ const it = {
align_bottom: 'Allinea in basso',
fields_selected: '{count} Campi Selezionati',
field_added: '{count} Campo Aggiunto',
fields_added: '{count} Campi Aggiunti'
fields_added: '{count} Campi Aggiunti',
revisions: 'Revisioni',
apply: 'Applica',
no_revisions_yet: 'Nessuna revisione ancora',
viewing_revision_from: 'Visualizzazione revisione del {date}'
}
const pt = {
@@ -667,7 +679,7 @@ const pt = {
analyzing_: 'Analisando...',
download: 'Baixar',
downloading_: 'Baixando...',
view: 'Visualizar',
view: 'Ver',
payment_link: 'Link de pagamento',
strikeout: 'Tachado',
draw_strikethrough_the_document: 'Desenhe uma linha de tachado no documento',
@@ -679,13 +691,13 @@ const pt = {
min: 'Mín',
max: 'Máx',
date_signed: 'Data atual',
fonte: 'Fonte',
font: 'Fonte',
party: 'Parte',
method: 'Método',
reorder_fields: 'Reorganizar campos',
verify_id: 'Verificar ID',
obtain_qualified_electronic_signature_with_the_trusted_provider_click_to_learn_more: 'Obtenha a assinatura eletrônica qualificada (QeS) com o provedor confiável. Clique para saber mais.',
recurrent: 'Recurrente',
recurrent: 'Recorrente',
one_off: 'Único',
editable: 'Editável',
search_field: 'Campo de busca',
@@ -752,7 +764,7 @@ const pt = {
processing_: 'Processando...',
add_pdf_documents_or_images: 'Adicionar documentos PDF ou imagens',
add_documents_or_images: 'Adicionar documentos ou imagens',
add_a_new_document: 'Ajouter un nouveau document',
add_a_new_document: 'Adicionar um novo documento',
replace_existing_document: 'Substituir documento existente',
clone_and_replace_documents: 'Clonar e substituir documentos',
required: 'Obrigatório',
@@ -860,7 +872,7 @@ const pt = {
learn_more: 'Saiba mais',
and: 'e',
or: 'ou',
start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document: 'Comece um tour rápido para aprender a criar e enviar seu primeiro documento.',
start_a_quick_tour_to_learn_how_to_create_and_send_your_first_document: 'Comece um tour rápido para aprender a criar e enviar seu primeiro documento.',
start_tour: 'Iniciar tour',
or_add_from: 'Ou adicionar de',
sync: 'Sincronizar',
@@ -875,7 +887,11 @@ const pt = {
align_bottom: 'Alinhar à parte inferior',
fields_selected: '{count} Campos Selecionados',
field_added: '{count} Campo Adicionado',
fields_added: '{count} Campos Adicionados'
fields_added: '{count} Campos Adicionados',
revisions: 'Revisões',
apply: 'Aplicar',
no_revisions_yet: 'Nenhuma revisão ainda',
viewing_revision_from: 'Visualizando revisão de {date}'
}
const fr = {
@@ -900,7 +916,7 @@ const fr = {
max: 'Max',
font: 'Police',
party: 'Partie',
date_signed: 'Date de signature',
date_signed: 'Date du jour',
method: 'Méthode',
reorder_fields: 'Réorganiser les champs',
verify_id: "Vérifier l'ID",
@@ -1080,7 +1096,7 @@ const fr = {
learn_more: 'En savoir plus',
and: 'et',
or: 'ou',
start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document: 'Commencez une visite rapide pour apprendre à créer et à envoyer votre premier document',
start_a_quick_tour_to_learn_how_to_create_and_send_your_first_document: 'Commencez une visite rapide pour apprendre à créer et à envoyer votre premier document',
start_tour: 'Démarrer',
or_add_from: 'Ou ajouter depuis',
sync: 'Synchroniser',
@@ -1095,7 +1111,11 @@ const fr = {
align_bottom: 'Aligner en bas',
fields_selected: '{count} Champs Sélectionnés',
field_added: '{count} Champ Ajouté',
fields_added: '{count} Champs Ajoutés'
fields_added: '{count} Champs Ajoutés',
revisions: 'Révisions',
apply: 'Appliquer',
no_revisions_yet: 'Aucune révision pour le moment',
viewing_revision_from: 'Affichage de la révision du {date}'
}
const de = {
@@ -1120,7 +1140,7 @@ const de = {
max: 'Max.',
font: 'Schriftart',
party: 'Partei',
date_signed: 'Unterzeichnungsdatum',
date_signed: 'Aktuelles Datum',
method: 'Methode',
reorder_fields: 'Felder neu anordnen',
verify_id: 'ID überprüfen',
@@ -1300,7 +1320,7 @@ const de = {
learn_more: 'Mehr erfahren',
and: 'und',
or: 'oder',
start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document: 'Starten Sie eine Kurztour, um zu lernen, wie Sie Ihr erstes Dokument erstellen und versenden.',
start_a_quick_tour_to_learn_how_to_create_and_send_your_first_document: 'Starten Sie eine Kurztour, um zu lernen, wie Sie Ihr erstes Dokument erstellen und versenden.',
start_tour: 'Tour starten',
or_add_from: 'Oder hinzufügen aus',
sync: 'Synchronisieren',
@@ -1315,7 +1335,11 @@ const de = {
align_bottom: 'Unten ausrichten',
fields_selected: '{count} Felder Ausgewählt',
field_added: '{count} Feld Hinzugefügt',
fields_added: '{count} Felder Hinzugefügt'
fields_added: '{count} Felder Hinzugefügt',
revisions: 'Revisionen',
apply: 'Anwenden',
no_revisions_yet: 'Noch keine Revisionen',
viewing_revision_from: 'Ansicht der Revision vom {date}'
}
const nl = {
@@ -1340,7 +1364,7 @@ const nl = {
max: 'Max',
font: 'Lettertype',
party: 'Partij',
date_signed: 'Datum ondertekend',
date_signed: 'Huidige datum',
method: 'Methode',
reorder_fields: 'Velden herschikken',
verify_id: 'ID verifiëren',
@@ -1520,7 +1544,7 @@ const nl = {
learn_more: 'Meer informatie',
and: 'en',
or: 'of',
start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document: 'Start een korte rondleiding om te leren hoe u uw eerste document maakt en verzendt',
start_a_quick_tour_to_learn_how_to_create_and_send_your_first_document: 'Start een korte rondleiding om te leren hoe u uw eerste document maakt en verzendt',
start_tour: 'Rondleiding starten',
or_add_from: 'Of toevoegen van',
sync: 'Synchroniseren',
@@ -1535,7 +1559,11 @@ const nl = {
align_bottom: 'Onder uitlijnen',
fields_selected: '{count} Velden Geselecteerd',
field_added: '{count} Veld Toegevoegd',
fields_added: '{count} Velden Toegevoegd'
fields_added: '{count} Velden Toegevoegd',
revisions: 'Revisies',
apply: 'Toepassen',
no_revisions_yet: 'Nog geen revisies',
viewing_revision_from: 'Revisie van {date} bekijken'
}
export { en, es, it, pt, fr, de, nl }
+1
View File
@@ -57,6 +57,7 @@
@scroll-to="$emit('scroll-to', $event)"
@add-custom-field="$emit('add-custom-field', $event)"
@contextmenu="openAreaContextMenu($event, item.area, item.field)"
@click-title="closeContextMenu"
/>
<FieldArea
v-for="(area, index) in newAreas"
@@ -0,0 +1,104 @@
<template>
<div class="modal modal-open items-start !animate-none overflow-y-auto">
<div
class="absolute top-0 bottom-0 right-0 left-0"
@click.prevent="$emit('close')"
/>
<div class="modal-box pt-4 pb-6 mt-20 w-full">
<div class="flex justify-between items-center border-b pb-2 mb-3 font-medium">
<span>{{ t('revisions') }}</span>
<a
href="#"
class="text-xl"
@click.prevent="$emit('close')"
>&times;</a>
</div>
<ul class="space-y-1.5">
<li
v-for="revision in revisions"
:key="revision.id"
>
<button
type="button"
class="w-full text-left rounded-lg p-3 border border-dashed border-base-200 transition-colors disabled:cursor-default hover:bg-base-200 hover:border-base-200"
:disabled="loadingId !== null"
@click="viewRevision(revision.id)"
>
<div class="flex justify-between items-center gap-2">
<div class="flex flex-col">
<span>{{ formatDate(revision.created_at) }}</span>
<span class="-ml-0.5 flex items-center space-x-1 text-xs text-base-content/60 mt-0.5">
<IconUser class="w-3.5 h-3.5 flex-shrink-0" />
<span class="truncate">{{ revision.author.full_name || revision.author.email }}</span>
</span>
</div>
<span class="btn btn-sm btn-neutral text-white pointer-events-none flex-shrink-0">
<IconInnerShadowTop
v-if="loadingId === revision.id"
class="w-4 h-4 animate-spin"
/>
<span v-else>{{ t('view') }}</span>
</span>
</div>
</button>
</li>
<li
v-if="!revisions.length"
class="py-4 text-center text-base-content/60"
>
{{ t('no_revisions_yet') }}
</li>
</ul>
</div>
</div>
</template>
<script>
import { IconUser, IconInnerShadowTop } from '@tabler/icons-vue'
export default {
name: 'RevisionsModal',
components: { IconUser, IconInnerShadowTop },
inject: ['t', 'baseFetch'],
props: {
template: {
type: Object,
required: true
},
revisions: {
type: Array,
required: true
},
locale: {
type: String,
required: true
}
},
emits: ['close', 'apply'],
data () {
return {
loadingId: null
}
},
methods: {
viewRevision (id) {
if (this.loadingId !== null) return
this.loadingId = id
this.baseFetch(`/templates/${this.template.id}/versions/${id}`)
.then((r) => r.json())
.then((revision) => { this.$emit('apply', revision) })
.finally(() => { this.loadingId = null })
},
formatDate (string) {
return new Date(string).toLocaleString(this.locale || undefined, {
month: 'long',
day: 'numeric',
hour: 'numeric',
minute: '2-digit'
})
}
}
}
</script>
@@ -168,6 +168,65 @@
<script>
import { IconUpload, IconInnerShadowTop, IconChevronDown, IconBrandGoogleDrive } from '@tabler/icons-vue'
function convertImage (sourceFile, targetType, quality) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = function (event) {
const img = new Image()
img.onload = function () {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
canvas.width = img.width
canvas.height = img.height
ctx.drawImage(img, 0, 0)
canvas.toBlob(function (blob) {
const ext = targetType === 'image/jpeg' ? '.jpg' : '.png'
const newFile = new File([blob], sourceFile.name.replace(/\.\w+$/, ext), { type: targetType })
resolve(newFile)
}, targetType, quality)
}
img.onerror = () => reject(new Error(`browser cannot decode ${sourceFile.type || sourceFile.name}`))
img.src = event.target.result
}
reader.onerror = reject
reader.readAsDataURL(sourceFile)
})
}
async function convertImagesInInput (input) {
if (!input.files || input.files.length === 0) return
const dt = new DataTransfer()
let didConvert = false
for (const file of Array.from(input.files)) {
let converted = file
try {
if (['image/bmp', 'image/vnd.microsoft.icon', 'image/svg+xml', 'image/gif'].includes(file.type)) {
converted = await convertImage(file, 'image/png')
didConvert = true
} else if (['image/heic', 'image/heif', 'image/heic-sequence', 'image/heif-sequence', 'image/avif', 'image/avif-sequence', 'image/webp'].includes(file.type)) {
converted = await convertImage(file, 'image/jpeg', 0.9)
didConvert = true
}
} catch (e) {
alert(e.message)
}
dt.items.add(converted)
}
if (didConvert) {
input.files = dt.files
}
}
export default {
name: 'DocumentsUpload',
components: {
@@ -282,6 +341,10 @@ export default {
async upload ({ path } = {}) {
this.isLoading = true
if (this.$refs.input) {
await convertImagesInInput(this.$refs.input)
}
return this.baseFetch(path || this.uploadUrl, {
method: 'POST',
headers: { Accept: 'application/json' },
-2
View File
@@ -3,8 +3,6 @@
class ReindexSearchEntryJob
include Sidekiq::Job
InvalidFormat = Class.new(StandardError)
def perform(params = {})
entry = SearchEntry.find_or_initialize_by(params.slice('record_type', 'record_id'))
@@ -30,13 +30,12 @@ class SendFormCompletedWebhookRequestJob
attempt:,
data: Submitters::SerializeForWebhook.call(submitter))
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan))
SendFormCompletedWebhookRequestJob.perform_in((2**attempt).minutes, {
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
end
return if attempt > MAX_ATTEMPTS || (resp && resp.status.to_i < 400)
SendFormCompletedWebhookRequestJob.perform_in((2**attempt).minutes, {
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
end
end
@@ -28,13 +28,12 @@ class SendFormDeclinedWebhookRequestJob
attempt:,
data: Submitters::SerializeForWebhook.call(submitter))
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan))
SendFormDeclinedWebhookRequestJob.perform_in((2**attempt).minutes, {
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
end
return if attempt > MAX_ATTEMPTS || (resp && resp.status.to_i < 400)
SendFormDeclinedWebhookRequestJob.perform_in((2**attempt).minutes, {
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
end
end
@@ -28,13 +28,12 @@ class SendFormStartedWebhookRequestJob
attempt:,
data: Submitters::SerializeForWebhook.call(submitter))
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan))
SendFormStartedWebhookRequestJob.perform_in((2**attempt).minutes, {
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
end
return if attempt > MAX_ATTEMPTS || (resp && resp.status.to_i < 400)
SendFormStartedWebhookRequestJob.perform_in((2**attempt).minutes, {
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
end
end
@@ -28,13 +28,12 @@ class SendFormViewedWebhookRequestJob
attempt:,
data: Submitters::SerializeForWebhook.call(submitter))
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan))
SendFormViewedWebhookRequestJob.perform_in((2**attempt).minutes, {
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
end
return if attempt > MAX_ATTEMPTS || (resp && resp.status.to_i < 400)
SendFormViewedWebhookRequestJob.perform_in((2**attempt).minutes, {
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
end
end
@@ -26,13 +26,12 @@ class SendSubmissionArchivedWebhookRequestJob
attempt:,
data: submission.as_json(only: %i[id archived_at]))
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || submission.account.account_configs.exists?(key: :plan))
SendSubmissionArchivedWebhookRequestJob.perform_in((2**attempt).minutes, {
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
end
return if attempt > MAX_ATTEMPTS || (resp && resp.status.to_i < 400)
SendSubmissionArchivedWebhookRequestJob.perform_in((2**attempt).minutes, {
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
end
end
@@ -26,13 +26,12 @@ class SendSubmissionCompletedWebhookRequestJob
attempt:,
data: Submissions::SerializeForApi.call(submission))
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || submission.account.account_configs.exists?(key: :plan))
SendSubmissionCompletedWebhookRequestJob.perform_in((2**attempt).minutes, {
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
end
return if attempt > MAX_ATTEMPTS || (resp && resp.status.to_i < 400)
SendSubmissionCompletedWebhookRequestJob.perform_in((2**attempt).minutes, {
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
end
end
@@ -26,13 +26,12 @@ class SendSubmissionCreatedWebhookRequestJob
attempt:,
data: Submissions::SerializeForApi.call(submission))
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || submission.account.account_configs.exists?(key: :plan))
SendSubmissionCreatedWebhookRequestJob.perform_in((2**attempt).minutes, {
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
end
return if attempt > MAX_ATTEMPTS || (resp && resp.status.to_i < 400)
SendSubmissionCreatedWebhookRequestJob.perform_in((2**attempt).minutes, {
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
end
end
@@ -26,13 +26,12 @@ class SendSubmissionExpiredWebhookRequestJob
attempt:,
data: Submissions::SerializeForApi.call(submission))
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || submission.account.account_configs.exists?(key: :plan))
SendSubmissionExpiredWebhookRequestJob.perform_in((2**attempt).minutes, {
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
end
return if attempt > MAX_ATTEMPTS || (resp && resp.status.to_i < 400)
SendSubmissionExpiredWebhookRequestJob.perform_in((2**attempt).minutes, {
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
end
end
@@ -26,13 +26,12 @@ class SendTemplateArchivedWebhookRequestJob
attempt:,
data: template.as_json(only: %i[id archived_at]))
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || template.account.account_configs.exists?(key: :plan))
SendTemplateArchivedWebhookRequestJob.perform_in((2**attempt).minutes, {
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
end
return if attempt > MAX_ATTEMPTS || (resp && resp.status.to_i < 400)
SendTemplateArchivedWebhookRequestJob.perform_in((2**attempt).minutes, {
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
end
end
@@ -26,13 +26,12 @@ class SendTemplateCreatedWebhookRequestJob
attempt:,
data: Templates::SerializeForApi.call(template))
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || template.account.account_configs.exists?(key: :plan))
SendTemplateCreatedWebhookRequestJob.perform_in((2**attempt).minutes, {
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
end
return if attempt > MAX_ATTEMPTS || (resp && resp.status.to_i < 400)
SendTemplateCreatedWebhookRequestJob.perform_in((2**attempt).minutes, {
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
end
end
@@ -26,13 +26,12 @@ class SendTemplateUpdatedWebhookRequestJob
attempt:,
data: Templates::SerializeForApi.call(template))
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || template.account.account_configs.exists?(key: :plan))
SendTemplateUpdatedWebhookRequestJob.perform_in((2**attempt).minutes, {
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
end
return if attempt > MAX_ATTEMPTS || (resp && resp.status.to_i < 400)
SendTemplateUpdatedWebhookRequestJob.perform_in((2**attempt).minutes, {
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
end
end
+1
View File
@@ -70,6 +70,7 @@ class Template < ApplicationRecord
has_many :submissions, dependent: :destroy
has_many :template_sharings, dependent: :destroy
has_many :template_accesses, dependent: :destroy
has_many :template_versions, dependent: :destroy
has_many :dynamic_documents, dependent: :destroy
has_many :dynamic_document_versions, through: :dynamic_documents, source: :versions
+44
View File
@@ -0,0 +1,44 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: template_versions
#
# id :bigint not null, primary key
# data :text not null
# sha1 :string not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# author_id :bigint not null
# template_id :bigint not null
#
# Indexes
#
# index_template_versions_on_account_id (account_id)
# index_template_versions_on_author_id (author_id)
# index_template_versions_on_template_id_and_sha1 (template_id,sha1) UNIQUE
#
# Foreign Keys
#
# fk_rails_... (account_id => accounts.id)
# fk_rails_... (author_id => users.id)
# fk_rails_... (template_id => templates.id)
#
class TemplateVersion < ApplicationRecord
belongs_to :template
belongs_to :account
belongs_to :author, class_name: 'User'
attribute :data, :string, default: -> { {} }
serialize :data, coder: JSON
before_validation :set_account, on: :create
private
def set_account
self.account ||= template.account
end
end
+15 -9
View File
@@ -4,14 +4,15 @@
#
# Table name: webhook_urls
#
# id :bigint not null, primary key
# events :text not null
# secret :text not null
# sha1 :string not null
# url :text not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# id :bigint not null, primary key
# events :text not null
# hmac_secret :text not null
# secret :text not null
# sha1 :string not null
# url :text not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
#
# Indexes
#
@@ -47,10 +48,15 @@ class WebhookUrl < ApplicationRecord
serialize :secret, coder: JSON
before_validation :set_sha1
before_validation :set_hmac_secret
encrypts :url, :secret
encrypts :url, :secret, :hmac_secret
def set_sha1
self.sha1 = Digest::SHA1.hexdigest(url)
end
def set_hmac_secret
self.hmac_secret ||= WebhookUrls::Signatures.generate_secret
end
end
+1 -1
View File
@@ -1,6 +1,6 @@
<a target="_blank" href="<%= Docuseal::GITHUB_URL %>" rel="noopener noreferrer nofollow" class="relative flex items-center rounded-full px-2 py-0.5 text-xs leading-4 mt-1 text-base-content border border-base-300 tooltip tooltip-bottom" data-tip="Give a star on GitHub">
<span class="flex items-center justify-between space-x-0.5 font-medium">
<%= svg_icon('start', class: 'h-3 w-3') %>
<span>12k</span>
<span>16k</span>
</span>
</a>
+1 -3
View File
@@ -52,9 +52,7 @@
<% if link_form_fields.include?('phone') %>
<div dir="auto" class="form-control !mt-0">
<%= f.label :phone, t('phone'), class: 'label' %>
<custom-validation data-invalid-message="<%= t('use_international_format_1xxx_') %>">
<%= f.telephone_field :phone, value: params[:phone] || @submitter.phone, pattern: '^\+[0-9\s\-]+$', required: true, class: 'base-input w-full', placeholder: t(multiple_fields ? 'provide_your_phone_in_international_format' : 'provide_your_phone_in_international_format_to_start') %>
</custom-validation>
<%= f.telephone_field :phone, value: params[:phone] || @submitter.phone, pattern: '^\+[0-9\s\-]+$', required: true, title: t('use_international_format_1xxx_'), class: 'base-input w-full', placeholder: t(multiple_fields ? 'provide_your_phone_in_international_format' : 'provide_your_phone_in_international_format_to_start') %>
</div>
<% end %>
<toggle-submit dir="auto" class="form-control">
+10 -14
View File
@@ -34,13 +34,11 @@
</linked-input>
</submitters-autocomplete>
<% has_phone_field = true %>
<custom-validation data-invalid-message="<%= t('use_international_format_1xxx_') %>">
<submitters-autocomplete data-field="phone">
<linked-input data-target-id="<%= "detailed_phone_#{item['linked_to_uuid']}" if item['linked_to_uuid'].present? %>">
<%= tag.input type: 'tel', pattern: '^\+[0-9\s\-]+$', name: 'submission[1][submitters][][phone]', autocomplete: 'off', class: 'base-input !h-10 mt-1.5 w-full', placeholder: local_assigns[:require_phone_2fa] == true || local_assigns[:prefillable_fields].present? ? t(:phone) : "#{t('phone')} (#{t('optional')})", id: "detailed_phone_#{item['uuid']}", required: local_assigns[:require_phone_2fa] == true && index.zero? %>
</linked-input>
</submitters-autocomplete>
</custom-validation>
<submitters-autocomplete data-field="phone">
<linked-input data-target-id="<%= "detailed_phone_#{item['linked_to_uuid']}" if item['linked_to_uuid'].present? %>">
<%= tag.input type: 'tel', pattern: '^\+[0-9\s\-]+$', name: 'submission[1][submitters][][phone]', autocomplete: 'off', title: t('use_international_format_1xxx_'), class: 'base-input !h-10 mt-1.5 w-full', placeholder: local_assigns[:require_phone_2fa] == true || local_assigns[:prefillable_fields].present? ? t(:phone) : "#{t('phone')} (#{t('optional')})", id: "detailed_phone_#{item['uuid']}", required: local_assigns[:require_phone_2fa] == true && index.zero? %>
</linked-input>
</submitters-autocomplete>
</div>
<% end %>
<% if prefillable_fields.present? %>
@@ -51,13 +49,11 @@
</submitters-autocomplete>
<% if local_assigns[:require_phone_2fa] == true || prefillable_fields.any? { |f| f['type'] == 'phone' } %>
<% has_phone_field = true %>
<custom-validation data-invalid-message="<%= t('use_international_format_1xxx_') %>">
<submitters-autocomplete data-field="phone">
<linked-input data-target-id="<%= "detailed_phone_#{item['linked_to_uuid']}" if item['linked_to_uuid'].present? %>">
<%= tag.input type: 'tel', pattern: '^\+[0-9\s\-]+$', name: 'submission[1][submitters][][phone]', autocomplete: 'off', class: 'base-input !h-10 mt-1.5 w-full', placeholder: t(:phone), id: "detailed_phone_#{item['uuid']}", required: true %>
</linked-input>
</submitters-autocomplete>
</custom-validation>
<submitters-autocomplete data-field="phone">
<linked-input data-target-id="<%= "detailed_phone_#{item['linked_to_uuid']}" if item['linked_to_uuid'].present? %>">
<%= tag.input type: 'tel', pattern: '^\+[0-9\s\-]+$', name: 'submission[1][submitters][][phone]', autocomplete: 'off', title: t('use_international_format_1xxx_'), class: 'base-input !h-10 mt-1.5 w-full', placeholder: t(:phone), id: "detailed_phone_#{item['uuid']}", required: true %>
</linked-input>
</submitters-autocomplete>
<% end %>
<% prefillable_fields.each do |field| %>
<% field_id = "detailed_field_#{index}_#{field['uuid'] || field['name'].parameterize}" %>
+5 -7
View File
@@ -19,13 +19,11 @@
</label>
<% end %>
<input type="hidden" name="submission[1][submitters][][uuid]" value="<%= item['uuid'] %>">
<custom-validation data-invalid-message="<%= t('use_international_format_1xxx_') %>">
<submitters-autocomplete data-field="phone">
<linked-input data-target-id="<%= "phone_phone_#{item['linked_to_uuid']}" if item['linked_to_uuid'].present? %>">
<%= tag.input type: 'tel', pattern: '^\+[0-9\s\-]+$', name: 'submission[1][submitters][][phone]', autocomplete: 'off', class: 'base-input !h-10 w-full', placeholder: t('phone'), required: index.zero? || template.preferences['require_all_submitters'], id: "phone_phone_#{item['uuid']}" %>
</linked-input>
</submitters-autocomplete>
</custom-validation>
<submitters-autocomplete data-field="phone">
<linked-input data-target-id="<%= "phone_phone_#{item['linked_to_uuid']}" if item['linked_to_uuid'].present? %>">
<%= tag.input type: 'tel', pattern: '^\+[0-9\s\-]+$', name: 'submission[1][submitters][][phone]', autocomplete: 'off', title: t('use_international_format_1xxx_'), class: 'base-input !h-10 w-full', placeholder: t('phone'), required: index.zero? || template.preferences['require_all_submitters'], id: "phone_phone_#{item['uuid']}" %>
</linked-input>
</submitters-autocomplete>
<% if submitters.size > 1 %>
<submitters-autocomplete data-field="name">
<linked-input data-target-id="<%= "phone_name_#{item['linked_to_uuid']}" if item['linked_to_uuid'].present? %>">
+6
View File
@@ -30,6 +30,12 @@
<span class="hidden md:block"><%= t('event_log') %></span>
<% end %>
<% end %>
<% if signed_in? && !is_all_completed && can?(:manage, :resend_all) && @submission.submitters.to_a.size > 3 && !@submission.archived_at? && !@submission.template&.archived_at? && !@submission.expired? && can?(:update, @submission) %>
<% pending_submitters_count = @submission.submitters.to_a.count { |s| !s.completed_at? && s.email.present? && !s.declined_at? } %>
<% if pending_submitters_count.positive? %>
<%= button_to button_title(title: t('re_send_emails'), title_class: 'hidden md:inline', disabled_with: t('sending'), icon: svg_icon('mail_forward', class: 'w-6 h-6')), submission_resend_email_index_path(@submission), class: 'white-button', data: { turbo_confirm: t('are_you_sure_you_want_to_re_send_email_to_n_recipients', count: pending_submitters_count) } %>
<% end %>
<% end %>
<% if @submission.submitters.to_a.any?(&:completed_at?) %>
<% if is_all_completed || !is_combined_enabled %>
<div class="join relative">
+32 -19
View File
@@ -21,20 +21,31 @@
<h1 class="text-xl md:text-2xl font-medium focus:text-clip" style="width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
<%= @submitter.submission.name || @submitter.submission.template.name %>
</h1>
<div class="flex items-center space-x-2" style="margin-left: 20px; flex-shrink: 0">
<div class="flex items-center gap-2 group" style="margin-left: 20px; flex-shrink: 0">
<% if @form_configs[:with_decline] %>
<modal-button data-target="<%= decline_modal_id = SecureRandom.uuid %>" class="hidden group-has-[.complete-button]:flex">
<button type="button" class="btn btn-sm md:!px-5 px-2" aria-label="<%= t(:decline) %>">
<span class="hidden md:inline"><%= t(:decline) %></span>
<span class="inline md:hidden">
<%= svg_icon('x', class: 'w-5 h-5') %>
</span>
</button>
</modal-button>
<% end %>
<span id="complete_button_container" class="peer contents"></span>
<% if @form_configs[:with_delegate] %>
<modal-button data-target="<%= delegate_modal_id = SecureRandom.uuid %>">
<modal-button data-target="<%= delegate_modal_id = SecureRandom.uuid %>" class="hidden peer-empty:flex">
<button id="delegate_button" type="button" class="btn btn-sm !px-5"><%= t(:delegate) %></button>
</modal-button>
<% if @form_configs[:with_decline] %>
<modal-button data-target="<%= decline_modal_id = SecureRandom.uuid %>">
<modal-button data-target="<%= decline_modal_id %>" class="hidden peer-empty:flex">
<button id="decline_button" type="button" class="btn btn-sm px-2" aria-label="<%= t(:decline) %>">
<%= svg_icon('x', class: 'w-5 h-5') %>
</button>
</modal-button>
<% end %>
<% if @form_configs[:with_partial_download] %>
<download-button role="button" tabindex="0" data-src="<%= submit_form_download_index_path(@submitter.slug) %>" class="btn btn-neutral text-white btn-sm px-2" title="<%= t('download') %>" aria-label="<%= t('download') %>">
<download-button role="button" tabindex="0" data-src="<%= submit_form_download_index_path(@submitter.slug) %>" class="btn btn-neutral text-white btn-sm px-2 hidden peer-empty:flex" title="<%= t('download') %>" aria-label="<%= t('download') %>">
<span data-target="download-button.defaultButton">
<%= svg_icon('download', class: 'w-5 h-5') %>
</span>
@@ -45,12 +56,12 @@
<% end %>
<% else %>
<% if @form_configs[:with_decline] %>
<modal-button data-target="<%= decline_modal_id = SecureRandom.uuid %>">
<modal-button data-target="<%= decline_modal_id %>" class="hidden peer-empty:flex">
<button id="decline_button" type="button" class="btn btn-sm !px-5"><%= t(:decline) %></button>
</modal-button>
<% end %>
<% if @form_configs[:with_partial_download] %>
<download-button role="button" tabindex="0" data-src="<%= submit_form_download_index_path(@submitter.slug) %>" class="btn btn-neutral text-white btn-sm !px-4" aria-label="<%= t('download') %>">
<download-button role="button" tabindex="0" data-src="<%= submit_form_download_index_path(@submitter.slug) %>" class="btn btn-neutral text-white btn-sm !px-4 hidden peer-empty:flex" aria-label="<%= t('download') %>">
<span class="flex items-center justify-center" data-target="download-button.defaultButton">
<%= svg_icon('download', class: 'w-6 h-6 inline md:hidden') %>
<span class="hidden md:inline"><%= t('download') %></span>
@@ -64,9 +75,17 @@
<% end %>
</div>
</header>
<scroll-buttons inert class="fixed right-5 top-2 hidden md:flex gap-1 z-50 ease-in-out opacity-0 -translate-y-10">
<scroll-buttons inert class="fixed right-5 top-2 hidden md:flex gap-1 z-50 ease-in-out opacity-0 -translate-y-10 group">
<% if @form_configs[:with_decline] %>
<modal-button data-target="<%= decline_modal_id %>" class="hidden group-has-[.complete-button]:flex">
<button type="button" class="btn btn-sm px-2" aria-label="<%= t(:decline) %>">
<%= svg_icon('x', class: 'w-5 h-5') %>
</button>
</modal-button>
<% end %>
<span id="complete_button_container_scroll" class="peer contents"></span>
<% if @form_configs[:with_delegate] %>
<modal-button data-target="<%= delegate_modal_id %>">
<modal-button data-target="<%= delegate_modal_id %>" class="hidden peer-empty:flex">
<button id="delegate_button_mobile" type="button" class="btn btn-sm px-0" aria-label="<%= t(:delegate) %>">
<span class="min-[1366px]:inline hidden px-3">
<%= t(:delegate) %>
@@ -77,23 +96,15 @@
</button>
</modal-button>
<% if @form_configs[:with_decline] %>
<modal-button data-target="<%= decline_modal_id %>">
<modal-button data-target="<%= decline_modal_id %>" class="hidden peer-empty:flex">
<button id="decline_button_mobile" type="button" class="btn btn-sm px-2" aria-label="<%= t(:decline) %>">
<%= svg_icon('x', class: 'w-5 h-5') %>
</button>
</modal-button>
<% end %>
<download-button role="button" tabindex="0" data-src="<%= submit_form_download_index_path(@submitter.slug) %>" class="btn btn-neutral text-white btn-sm px-2" aria-label="<%= t('download') %>">
<span data-target="download-button.defaultButton">
<%= svg_icon('download', class: 'w-5 h-5') %>
</span>
<span class="hidden" data-target="download-button.loadingButton">
<%= svg_icon('loader', class: 'w-5 h-5 animate-spin') %>
</span>
</download-button>
<% else %>
<% if @form_configs[:with_decline] %>
<modal-button data-target="<%= decline_modal_id %>">
<modal-button data-target="<%= decline_modal_id %>" class="hidden peer-empty:flex">
<button id="decline_button_mobile" type="button" class="btn btn-sm px-0" aria-label="<%= t(:decline) %>">
<span class="min-[1366px]:inline hidden px-3">
<%= t(:decline) %>
@@ -104,7 +115,9 @@
</button>
</modal-button>
<% end %>
<download-button role="button" tabindex="0" data-src="<%= submit_form_download_index_path(@submitter.slug) %>" class="btn btn-neutral text-white btn-sm px-2" aria-label="<%= t('download') %>">
<% end %>
<% if @form_configs[:with_partial_download] %>
<download-button role="button" tabindex="0" data-src="<%= submit_form_download_index_path(@submitter.slug) %>" class="btn btn-neutral text-white btn-sm px-2 hidden peer-empty:flex" aria-label="<%= t('download') %>">
<span data-target="download-button.defaultButton">
<%= svg_icon('download', class: 'w-5 h-5') %>
</span>
+3 -5
View File
@@ -10,11 +10,9 @@
<submitters-autocomplete data-field="email">
<%= email_field_tag 'submitter[email]', @submitter.email, autocomplete: 'off', class: 'base-input !h-10 mt-1.5 w-full', placeholder: "#{t('email')} (#{t('optional')})" %>
</submitters-autocomplete>
<custom-validation data-invalid-message="<%= t('use_international_format_1xxx_') %>">
<submitters-autocomplete data-field="phone">
<%= telephone_field_tag 'submitter[phone]', @submitter.phone, autocomplete: 'off', pattern: '^\+[0-9\s\-]+$', class: 'base-input !h-10 mt-1.5 w-full', placeholder: "#{t('phone')} (#{t('optional')})" %>
</submitters-autocomplete>
</custom-validation>
<submitters-autocomplete data-field="phone">
<%= telephone_field_tag 'submitter[phone]', @submitter.phone, autocomplete: 'off', pattern: '^\+[0-9\s\-]+$', title: t('use_international_format_1xxx_'), class: 'base-input !h-10 mt-1.5 w-full', placeholder: "#{t('phone')} (#{t('optional')})" %>
</submitters-autocomplete>
</div>
</submitter-item>
</div>
+2 -2
View File
@@ -15,8 +15,8 @@
</span>
</label>
<input type="hidden" name="form_id" value="<%= form_id %>">
<submit-form data-on="change" data-disable="true">
<convert-upload>
<input id="upload_template" name="files[]" class="hidden" type="file" accept="image/*, application/pdf, application/zip, application/json<%= ", #{Templates::CreateAttachments::DOCUMENT_EXTENSIONS.join(', ')}" if Docuseal.advanced_formats? %>" multiple>
</submit-form>
</convert-upload>
<input hidden name="folder_name" value="<%= local_assigns[:folder_name] %>">
<% end %>
+1 -1
View File
@@ -6,4 +6,4 @@
<%= button_to nil, user_configs_path, method: :post, params: { user_config: { key: UserConfig::SHOW_APP_TOUR, value: true } }, class: 'hidden', id: 'start_tour_button' %>
<% end %>
<% end %>
<template-builder class="grid" data-template="<%= @template_data %>" data-custom-fields="<%= (current_account.account_configs.find_or_initialize_by(key: AccountConfig::TEMPLATE_CUSTOM_FIELDS_KEY).value || []).to_json %>" data-with-sign-yourself-button="<%= !@template.archived_at? %>" data-with-fields-detection="true" data-with-send-button="<%= !@template.archived_at? && can?(:create, @template.submissions.new(account: current_account)) %>" data-locale="<%= I18n.locale %>" data-show-tour-start-form="<%= @show_tour_start_form %>"></template-builder>
<template-builder class="grid" data-template="<%= @template_data %>" data-custom-fields="<%= (current_account.account_configs.find_or_initialize_by(key: AccountConfig::TEMPLATE_CUSTOM_FIELDS_KEY).value || []).to_json %>" data-with-sign-yourself-button="<%= !@template.archived_at? %>" data-with-fields-detection="true" data-with-send-button="<%= !@template.archived_at? && can?(:create, @template.submissions.new(account: current_account)) %>" data-with-revisions-menu="<%= @template.template_versions.exists? %>" data-locale="<%= I18n.locale %>" data-show-tour-start-form="<%= @show_tour_start_form %>"></template-builder>
+1 -1
View File
@@ -75,7 +75,7 @@
<% end %>
<% if @submissions.present? %>
<div class="space-y-4">
<%= render partial: 'submission', collection: @submissions, locals: { template: @template } %>
<%= render partial: 'submission', collection: @submissions, locals: { template: @template, archived: @template.archived_at? } %>
</div>
<% else %>
<div class="card bg-base-200">
+1 -1
View File
@@ -68,7 +68,7 @@
<%= t('welcome_to_docuseal') %>
</div>
<div class="my-2 text-center text-xs text-base-content/70">
<%= t('start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document') %>
<%= t('start_a_quick_tour_to_learn_how_to_create_and_send_your_first_document') %>
</div>
<div class="flex gap-2 mt-3 w-full">
<remove-on-event data-on="submit" data-selector-id="app_tour_manager" class="block w-full">
@@ -3,13 +3,14 @@
<% webhook_attempts = webhook_event.webhook_attempts.sort_by { |e| -e.id } %>
<% if webhook_event.status == 'error' %>
<% last_attempt = webhook_attempts.select { |e| e.attempt < SendWebhookRequest::MANUAL_ATTEMPT }.max_by(&:attempt) %>
<% if webhook_event.webhook_attempts.none?(&:success?) && last_attempt.attempt <= 10 %>
<% next_attempt_at = last_attempt.created_at + (2**last_attempt.attempt).minutes %>
<% if webhook_event.webhook_attempts.none?(&:success?) && last_attempt.attempt <= 10 && next_attempt_at > 30.seconds.ago %>
<li class="ml-7">
<span class="btn btn-outline btn-xs btn-circle pointer-events-none absolute justify-center border-base-content-/60 text-base-content/60 bg-base-100" style="left: -12px;">
<%= svg_icon('clock', class: 'w-4 h-4 shrink-0') %>
</span>
<p class="leading-none text-base-content/90 pt-1">
<%= t('next_attempt_in_time_in_words', time_in_words: distance_of_time_in_words(Time.current, last_attempt.created_at + (2**last_attempt.attempt).minutes)) %>
<%= t('next_attempt_in_time_in_words', time_in_words: distance_of_time_in_words(Time.current, next_attempt_at)) %>
</p>
</li>
<% end %>
+20
View File
@@ -0,0 +1,20 @@
<%= render 'shared/turbo_modal', title: t('webhook_security') do %>
<div class="text-center mb-4">
<div class="inline-flex justify-center">
<%= link_to t('secret'), webhook_secret_path(@webhook_url), class: 'block bg-base-200 md:min-w-[112px] text-sm font-semibold whitespace-nowrap py-1.5 px-4 rounded-l-3xl', data: { turbo_frame: 'modal' } %>
<%= link_to t('hmac'), webhook_hmac_path(@webhook_url), class: 'block bg-base-300 md:min-w-[112px] text-sm font-semibold whitespace-nowrap py-1.5 px-4 rounded-r-3xl', data: { turbo_frame: 'modal' } %>
</div>
</div>
<div class="form-control">
<label class="label" for="hmac_secret"><%= t('hmac_signing_secret') %></label>
<% token = @webhook_url.hmac_secret %>
<% obscured = "#{token[0, 10]}#{'*' * [token.length - 10, 0].max}" %>
<div class="flex gap-2">
<masked-input class="block w-full" data-token="<%= token %>">
<input id="hmac_secret" type="text" value="<%= obscured %>" class="base-input font-mono w-full" autocomplete="off" readonly>
</masked-input>
<%= render 'shared/clipboard_copy', icon: 'copy', text: token, class: 'base-button', icon_class: 'w-6 h-6 text-white', copy_title: t('copy'), copied_title: t('copied') %>
</div>
<p class="text-sm mt-2 opacity-70"><%= t('hmac_signature_header_hint_html', header: '<code>X-Docuseal-Signature</code>'.html_safe) %></p>
</div>
<% end %>
+7 -1
View File
@@ -1,4 +1,10 @@
<%= render 'shared/turbo_modal', title: t('webhook_secret') do %>
<%= render 'shared/turbo_modal', title: t('webhook_security') do %>
<div class="text-center mb-4">
<div class="inline-flex justify-center">
<%= link_to t('secret'), webhook_secret_path(@webhook_url), class: 'block bg-base-300 md:min-w-[112px] text-sm font-semibold whitespace-nowrap py-1.5 px-4 rounded-l-3xl', data: { turbo_frame: 'modal' } %>
<%= link_to t('hmac'), webhook_hmac_path(@webhook_url), class: 'block bg-base-200 md:min-w-[112px] text-sm font-semibold whitespace-nowrap py-1.5 px-4 rounded-r-3xl', data: { turbo_frame: 'modal' } %>
</div>
</div>
<%= form_for @webhook_url, url: webhook_secret_path, method: :patch, html: { class: 'space-y-4' }, data: { turbo_frame: :_top } do |f| %>
<div class="space-y-2">
<%= f.fields_for :secret, Struct.new(:key, :value).new(*@webhook_url.secret.to_a.first) do |ff| %>
+1 -1
View File
@@ -1,7 +1,7 @@
<div class="flex flex-wrap space-y-4 md:flex-nowrap md:space-y-0 md:space-x-10">
<%= render 'shared/settings_nav' %>
<div class="flex-grow min-w-0">
<div class="flex flex-col gap-2 md:flex-row md:justify-between md:items-center mb-4">
<div class="flex flex-col gap-2 md:flex-row md:justify-between md:items-end mb-4">
<h1 class="text-4xl font-bold">Webhooks</h1>
<div class="flex flex-col gap-2 md:flex-row md:justify-between md:items-center">
<%= render 'shared/test_mode_toggle' %>
+2 -2
View File
@@ -1,7 +1,7 @@
<div class="flex flex-wrap space-y-4 md:flex-nowrap md:space-y-0 md:space-x-10">
<%= render 'shared/settings_nav' %>
<div class="flex-grow">
<div class="flex flex-col gap-2 md:flex-row md:justify-between md:items-center mb-4">
<div class="flex flex-col gap-2 md:flex-row md:justify-between md:items-end mb-4">
<h1 class="text-4xl font-bold">Webhook</h1>
<div class="flex flex-col gap-2 md:flex-row md:justify-between md:items-center">
<% if params[:action] == 'index' && (current_user == true_user || current_account.testing?) %>
@@ -23,7 +23,7 @@
<div class="flex items-center space-x-2 md:absolute md:right-0">
<%= link_to webhook_secret_path(@webhook_url), class: 'btn btn-outline btn-sm bg-white', data: { turbo_frame: 'modal' } do %>
<%= svg_icon('lock', class: 'w-4 h-4') %>
<span><%= @webhook_url.secret.present? ? t('edit_secret') : t('add_secret') %></span>
<span><%= t('security') %></span>
<% end %>
<div class="tooltip tooltip-left md:tooltip-top" data-tip="<%= t('delete_webhook') %>">
<%= button_to settings_webhook_path(@webhook_url), class: 'btn btn-warning btn-sm', method: :delete, data: { turbo_confirm: t('are_you_sure_') } do %>
+8
View File
@@ -25,6 +25,14 @@ module DocuSeal
config.active_storage.draw_routes = ENV['MULTITENANT'] != 'true'
config.active_storage.content_types_to_serve_as_binary += %w[
application/javascript
text/javascript
application/ecmascript
text/ecmascript
application/wasm
]
config.i18n.available_locales = %i[en en-US en-GB es-ES fr-FR pt-PT de-DE it-IT nl-NL
es it de fr nl pl uk cs pt he ar ko ja]
config.i18n.fallbacks = [:en]
+15
View File
@@ -0,0 +1,15 @@
# frozen_string_literal: true
priority = %w[application/pdf image/jpeg image/png]
indexes = Marcel::MAGIC.each_with_index.with_object({}) do |((type, _), i), acc|
acc[type] = i if priority.include?(type)
break acc if acc.size == priority.size
end
pdf_index, jpg_index, png_index = indexes.values_at(*priority)
Marcel::MAGIC[0], Marcel::MAGIC[pdf_index] = Marcel::MAGIC[pdf_index], Marcel::MAGIC[0]
Marcel::MAGIC[1], Marcel::MAGIC[jpg_index] = Marcel::MAGIC[jpg_index], Marcel::MAGIC[1]
Marcel::MAGIC[2], Marcel::MAGIC[png_index] = Marcel::MAGIC[png_index], Marcel::MAGIC[2]
+267 -205
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -72,6 +72,7 @@ Rails.application.routes.draw do
resources :unarchive, only: %i[create], controller: 'submissions_unarchive'
resources :events, only: %i[index], controller: 'submission_events'
resources :download, only: %i[index], controller: 'submissions_download'
resources :resend_email, only: %i[create], controller: 'submissions_resend_email'
end
resources :submitters, only: %i[edit update]
resources :console_redirect, only: %i[index]
@@ -83,6 +84,7 @@ Rails.application.routes.draw do
resources :submitters_resubmit, only: %i[update]
resources :template_folders_autocomplete, only: %i[index]
resources :webhook_secret, only: %i[show update]
resources :webhook_hmac, only: %i[show]
resources :webhook_preferences, only: %i[update]
resource :templates_upload, only: %i[create]
authenticated do
@@ -107,6 +109,7 @@ Rails.application.routes.draw do
resource :form, only: %i[show], controller: 'templates_form_preview'
resource :code_modal, only: %i[show], controller: 'templates_code_modal'
resource :preferences, only: %i[show create destroy], controller: 'templates_preferences'
resources :versions, only: %i[index show create], controller: 'templates_versions'
resource :share_link, only: %i[show create], controller: 'templates_share_link'
resource :share_link_qr, only: %i[show], controller: 'templates_share_link_qr'
resources :recipients, only: %i[create], controller: 'templates_recipients'
@@ -0,0 +1,23 @@
# frozen_string_literal: true
class AddHmacToWebhookUrls < ActiveRecord::Migration[8.1]
class MigrationWebhookUrl < ApplicationRecord
self.table_name = 'webhook_urls'
encrypts :hmac_secret
end
def up
add_column :webhook_urls, :hmac_secret, :text
MigrationWebhookUrl.find_each do |webhook_url|
webhook_url.update_columns(hmac_secret: WebhookUrls::Signatures.generate_secret)
end
change_column_null :webhook_urls, :hmac_secret, false
end
def down
remove_column :webhook_urls, :hmac_secret
end
end
@@ -0,0 +1,16 @@
# frozen_string_literal: true
class CreateTemplateVersions < ActiveRecord::Migration[8.1]
def change
create_table :template_versions do |t|
t.references :template, null: false, foreign_key: true, index: false
t.references :account, null: false, foreign_key: true, index: true
t.references :author, null: false, foreign_key: { to_table: :users }, index: true
t.text :data, null: false
t.string :sha1, null: false
t.timestamps
end
add_index :template_versions, %i[template_id sha1], unique: true
end
end
+18 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.1].define(version: 2026_04_16_100000) do
ActiveRecord::Schema[8.1].define(version: 2026_05_06_121640) do
# These are extensions that must be enabled in order to support this database
enable_extension "btree_gin"
enable_extension "pg_catalog.plpgsql"
@@ -444,6 +444,19 @@ ActiveRecord::Schema[8.1].define(version: 2026_04_16_100000) do
t.index ["template_id"], name: "index_template_sharings_on_template_id"
end
create_table "template_versions", force: :cascade do |t|
t.bigint "account_id", null: false
t.bigint "author_id", null: false
t.datetime "created_at", null: false
t.text "data", null: false
t.string "sha1", null: false
t.bigint "template_id", null: false
t.datetime "updated_at", null: false
t.index ["account_id"], name: "index_template_versions_on_account_id"
t.index ["author_id"], name: "index_template_versions_on_author_id"
t.index ["template_id", "sha1"], name: "index_template_versions_on_template_id_and_sha1", unique: true
end
create_table "templates", force: :cascade do |t|
t.bigint "account_id", null: false
t.datetime "archived_at"
@@ -545,6 +558,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_04_16_100000) do
t.bigint "account_id", null: false
t.datetime "created_at", null: false
t.text "events", null: false
t.text "hmac_secret", null: false
t.text "secret", null: false
t.string "sha1", null: false
t.datetime "updated_at", null: false
@@ -586,6 +600,9 @@ ActiveRecord::Schema[8.1].define(version: 2026_04_16_100000) do
add_foreign_key "template_folders", "template_folders", column: "parent_folder_id"
add_foreign_key "template_folders", "users", column: "author_id"
add_foreign_key "template_sharings", "templates"
add_foreign_key "template_versions", "accounts"
add_foreign_key "template_versions", "templates"
add_foreign_key "template_versions", "users", column: "author_id"
add_foreign_key "templates", "accounts"
add_foreign_key "templates", "template_folders", column: "folder_id"
add_foreign_key "templates", "users", column: "author_id"
+17
View File
@@ -1,8 +1,25 @@
# frozen_string_literal: true
module ImageUtils
ICO_REGEXP = %r{\Aimage/(?:x-icon|vnd\.microsoft\.icon)\z}
BMP_REGEXP = %r{\Aimage/(?:bmp|x-bmp|x-ms-bmp)\z}
module_function
def load_vips(data, content_type: nil, autorot: false)
content_type ||= Marcel::MimeType.for(data)
if ICO_REGEXP.match?(content_type)
LoadIco.call(data)
elsif BMP_REGEXP.match?(content_type)
LoadBmp.call(data)
else
image = Vips::Image.new_from_buffer(data, '')
autorot ? image.autorot : image
end
end
def blank?(image)
stats = image.stats
+4
View File
@@ -25,6 +25,10 @@ module NumberUtils
ApplicationController.helpers.number_to_currency(number, locale:, precision: 2, unit: CURRENCY_SYMBOLS[format])
elsif locale
ApplicationController.helpers.number_with_delimiter(number, locale:)
elsif format == 'percent'
"#{number}%"
elsif format == 'percent_space'
"#{number.to_s.tr('.', ',')} %"
else
number
end
+203
View File
@@ -192,6 +192,71 @@ class Pdfium
attach_function :FPDFDOC_ExitFormFillEnvironment, [:FPDF_FORMHANDLE], :void
attach_function :FPDF_FFLDraw, %i[FPDF_FORMHANDLE FPDF_BITMAP FPDF_PAGE int int int int int int], :void
attach_function :FPDFPage_Flatten, %i[FPDF_PAGE int], :int
FLAT_NORMALDISPLAY = 0
FLAT_PRINT = 1
FLATTEN_FAIL = 0
FLATTEN_SUCCESS = 1
FLATTEN_NOTHINGTODO = 2
# rubocop:disable Naming/ClassAndModuleCamelCase
class FS_MATRIX < FFI::Struct
layout :a, :float,
:b, :float,
:c, :float,
:d, :float,
:e, :float,
:f, :float
end
# rubocop:enable Naming/ClassAndModuleCamelCase
attach_function :FPDFPage_GetRotation, [:FPDF_PAGE], :int
attach_function :FPDFPage_SetRotation, %i[FPDF_PAGE int], :void
attach_function :FPDFPage_TransFormWithClip, %i[FPDF_PAGE pointer pointer], :int
attach_function :FPDFPage_TransformAnnots, %i[FPDF_PAGE double double double double double double], :void
attach_function :FPDFPage_GenerateContent, [:FPDF_PAGE], :int
attach_function :FPDFPage_GetMediaBox, %i[FPDF_PAGE pointer pointer pointer pointer], :int
attach_function :FPDFPage_SetMediaBox, %i[FPDF_PAGE float float float float], :void
attach_function :FPDFPage_GetCropBox, %i[FPDF_PAGE pointer pointer pointer pointer], :int
attach_function :FPDFPage_SetCropBox, %i[FPDF_PAGE float float float float], :void
attach_function :FPDFPage_GetBleedBox, %i[FPDF_PAGE pointer pointer pointer pointer], :int
attach_function :FPDFPage_SetBleedBox, %i[FPDF_PAGE float float float float], :void
attach_function :FPDFPage_GetTrimBox, %i[FPDF_PAGE pointer pointer pointer pointer], :int
attach_function :FPDFPage_SetTrimBox, %i[FPDF_PAGE float float float float], :void
attach_function :FPDFPage_GetArtBox, %i[FPDF_PAGE pointer pointer pointer pointer], :int
attach_function :FPDFPage_SetArtBox, %i[FPDF_PAGE float float float float], :void
PAGE_BOX_ACCESSORS = [
%i[FPDFPage_GetMediaBox FPDFPage_SetMediaBox],
%i[FPDFPage_GetCropBox FPDFPage_SetCropBox],
%i[FPDFPage_GetBleedBox FPDFPage_SetBleedBox],
%i[FPDFPage_GetTrimBox FPDFPage_SetTrimBox],
%i[FPDFPage_GetArtBox FPDFPage_SetArtBox]
].freeze
# rubocop:disable Naming/ClassAndModuleCamelCase
class FPDF_FILEWRITE < FFI::Struct
layout :version, :int,
:WriteBlock, :pointer
end
# rubocop:enable Naming/ClassAndModuleCamelCase
attach_function :FPDF_SaveAsCopy, %i[FPDF_DOCUMENT pointer ulong], :int
FPDF_INCREMENTAL = 1
FPDF_NO_INCREMENTAL = 2
FPDF_REMOVE_SECURITY = 3
attach_function :FPDF_CreateNewDocument, [], :FPDF_DOCUMENT
begin
attach_function :FPDF_ImportPages, %i[FPDF_DOCUMENT FPDF_DOCUMENT string int], :int
rescue FFI::NotFoundError
define_singleton_method(:FPDF_ImportPages) { |*| raise PdfiumError, 'FPDF_ImportPages is not available' } # rubocop:disable Naming/MethodName
end
FPDF_ERR_SUCCESS = 0
FPDF_ERR_UNKNOWN = 1
FPDF_ERR_FILE = 2
@@ -257,6 +322,38 @@ class Pdfium
@page_count ||= Pdfium.FPDF_GetPageCount(@document_ptr)
end
def import_pages(src_doc)
ensure_not_closed!
result = Pdfium.FPDF_ImportPages(@document_ptr, src_doc.document_ptr, nil, page_count)
raise PdfiumError, 'Failed to import pages' if result.zero?
@page_count = nil
result
end
def self.create
doc_ptr = Pdfium.FPDF_CreateNewDocument()
if doc_ptr.null?
Pdfium.check_last_error('Failed to create new document')
raise PdfiumError, 'Failed to create new document'
end
doc = new(doc_ptr)
return doc unless block_given?
begin
yield doc
ensure
doc.close
end
end
def self.open_file(file_path, password = nil)
doc_ptr = Pdfium.FPDF_LoadDocument(file_path, password)
@@ -318,6 +415,30 @@ class Pdfium
@pages[page_index] ||= Page.new(self, page_index)
end
def save(io, flags: Pdfium::FPDF_NO_INCREMENTAL)
ensure_not_closed!
file_write_mem = FFI::MemoryPointer.new(FPDF_FILEWRITE.size)
file_write_struct = FPDF_FILEWRITE.new(file_write_mem)
file_write_struct[:version] = 1
file_write_struct[:WriteBlock] = FFI::Function.new(:int, %i[pointer pointer ulong]) do |_, data, size|
io.write(data.read_bytes(size))
1
end
result = Pdfium.FPDF_SaveAsCopy(@document_ptr, file_write_mem, flags)
if result.zero?
Pdfium.check_last_error('Failed to save document')
raise PdfiumError, 'Failed to save document'
end
io
end
def close
return if closed?
@@ -680,6 +801,88 @@ class Pdfium
@line_nodes = @line_nodes.sort { |a, b| a.endy == b.endy ? a.x <=> b.x : a.endy <=> b.endy }
end
def rotate
ensure_not_closed!
rotation = Pdfium.FPDFPage_GetRotation(page_ptr)
return false if rotation.zero?
l_ptr = FFI::MemoryPointer.new(:float)
b_ptr = FFI::MemoryPointer.new(:float)
r_ptr = FFI::MemoryPointer.new(:float)
t_ptr = FFI::MemoryPointer.new(:float)
has_crop = !Pdfium.FPDFPage_GetCropBox(page_ptr, l_ptr, b_ptr, r_ptr, t_ptr).zero?
Pdfium.FPDFPage_GetMediaBox(page_ptr, l_ptr, b_ptr, r_ptr, t_ptr) unless has_crop
pl = l_ptr.read_float
pb = b_ptr.read_float
pr = r_ptr.read_float
pt = t_ptr.read_float
a, b, c, d, e, f =
case rotation
when 1 then [0, -1, 1, 0, -pb, pr]
when 2 then [-1, 0, 0, -1, pr, pt]
when 3 then [0, 1, -1, 0, pt, -pl]
end
Pdfium::PAGE_BOX_ACCESSORS.each do |getter, setter|
next if Pdfium.public_send(getter, page_ptr, l_ptr, b_ptr, r_ptr, t_ptr).zero?
bl = l_ptr.read_float
bb = b_ptr.read_float
br = r_ptr.read_float
bt = t_ptr.read_float
c1x, c1y, c2x, c2y =
case rotation
when 1 then [br, bb, bl, bt]
when 2 then [br, bt, bl, bb]
when 3 then [bl, bt, br, bb]
end
new_llx = (a * c1x) + (c * c1y) + e
new_lly = (b * c1x) + (d * c1y) + f
new_urx = (a * c2x) + (c * c2y) + e
new_ury = (b * c2x) + (d * c2y) + f
Pdfium.public_send(setter, page_ptr, new_llx, new_lly, new_urx, new_ury)
end
Pdfium.FPDFPage_TransformAnnots(page_ptr, a, b, c, d, e, f)
matrix_ptr = FFI::MemoryPointer.new(FS_MATRIX.size)
matrix_struct = FS_MATRIX.new(matrix_ptr)
matrix_struct[:a] = a
matrix_struct[:b] = b
matrix_struct[:c] = c
matrix_struct[:d] = d
matrix_struct[:e] = e
matrix_struct[:f] = f
Pdfium.FPDFPage_TransFormWithClip(page_ptr, matrix_ptr, FFI::Pointer::NULL)
Pdfium.FPDFPage_SetRotation(page_ptr, 0)
Pdfium.FPDFPage_GenerateContent(page_ptr)
true
end
def flatten(flag = Pdfium::FLAT_NORMALDISPLAY)
ensure_not_closed!
result = Pdfium.FPDFPage_Flatten(page_ptr, flag)
if result == Pdfium::FLATTEN_FAIL
Pdfium.check_last_error("Failed to flatten page #{page_index}")
raise PdfiumError, "Failed to flatten page #{page_index}"
end
result
end
def close
return if closed?
+11 -5
View File
@@ -15,11 +15,7 @@ module SendWebhookRequest
# rubocop:disable Metrics/AbcSize
def call(webhook_url, event_uuid:, event_type:, record:, data:, attempt: 0)
uri = begin
URI(webhook_url.url)
rescue URI::Error
Addressable::URI.parse(webhook_url.url).normalize
end
uri = parse_uri(webhook_url.url)
if Docuseal.multitenant?
raise HttpsError, 'Only HTTPS is allowed.' if (uri.scheme != 'https' || [443, nil].exclude?(uri.port)) &&
@@ -43,6 +39,10 @@ module SendWebhookRequest
data: data
}.to_json
if req.headers['X-Docuseal-Signature'].blank?
req.headers['X-Docuseal-Signature'] = WebhookUrls::Signatures.sign(webhook_url.hmac_secret, body: req.body)
end
req.options.read_timeout = 15
req.options.open_timeout = 8
end
@@ -55,6 +55,12 @@ module SendWebhookRequest
end
# rubocop:enable Metrics/AbcSize
def parse_uri(url)
URI(url)
rescue URI::Error
Addressable::URI.parse(url).normalize
end
def create_webhook_event(webhook_url, event_uuid:, event_type:, record:)
return if event_uuid.blank?
+8 -2
View File
@@ -363,7 +363,7 @@ module Submissions
image =
begin
Submissions::GenerateResultAttachments.load_vips_image(attachment).autorot
ImageUtils.load_vips(attachment.download, content_type: attachment.content_type, autorot: true)
rescue Vips::Error
next unless attachment.content_type.starts_with?('image/')
next if attachment.byte_size.zero?
@@ -374,7 +374,13 @@ module Submissions
scale = [600.0 / image.width, 600.0 / image.height].min
resized_image = image.resize([scale, 1].min)
io = StringIO.new(resized_image.write_to_buffer('.png'))
io =
if field['type'] == 'image' && !resized_image.has_alpha?
StringIO.new(resized_image.colourspace(:srgb).write_to_buffer('.jpg', strip: true))
else
StringIO.new(resized_image.write_to_buffer('.png', strip: true))
end
width = field['type'] == 'initials' ? 50 : 200
height = resized_image.height * (width.to_f / resized_image.width)
+64 -22
View File
@@ -11,9 +11,6 @@ module Submissions
'Helvetica'
end
ICO_REGEXP = %r{\Aimage/(?:x-icon|vnd\.microsoft\.icon)\z}
BMP_REGEXP = %r{\Aimage/(?:bmp|x-bmp|x-ms-bmp)\z}
FONT_BOLD_NAME = if File.exist?(FONT_BOLD_PATH)
FONT_BOLD_PATH
else
@@ -313,7 +310,10 @@ module Submissions
image =
begin
load_vips_image(attachment, attachments_data_cache).autorot
attachments_data_cache[attachment.uuid] ||= attachment.download
ImageUtils.load_vips(attachments_data_cache[attachment.uuid],
content_type: attachment.content_type, autorot: true)
rescue Vips::Error
next unless attachment.content_type.starts_with?('image/')
next if attachment.byte_size.zero?
@@ -358,7 +358,8 @@ module Submissions
image_x = area_x + ((half_width - image_width) / 2.0)
image_y = height - area_y - image_height
io = StringIO.new(image.resize([scale * 4, 1].select(&:positive?).min).write_to_buffer('.png'))
io =
StringIO.new(image.resize([scale * 4, 1].select(&:positive?).min).write_to_buffer('.png', strip: true))
canvas.image(io, at: [image_x, image_y], width: image_width, height: image_height)
@@ -425,7 +426,8 @@ module Submissions
scale = [area_w / image.width, image_height / image.height].min
io = StringIO.new(image.resize([scale * 4, 1].select(&:positive?).min).write_to_buffer('.png'))
io =
StringIO.new(image.resize([scale * 4, 1].select(&:positive?).min).write_to_buffer('.png', strip: true))
layouter.fit([text], area_w, base_font_size / 0.65)
.draw(canvas, area_x + TEXT_LEFT_MARGIN,
@@ -451,7 +453,10 @@ module Submissions
image =
begin
load_vips_image(attachment, attachments_data_cache).autorot
attachments_data_cache[attachment.uuid] ||= attachment.download
ImageUtils.load_vips(attachments_data_cache[attachment.uuid],
content_type: attachment.content_type, autorot: true)
rescue Vips::Error
next unless attachment.content_type.starts_with?('image/')
next if attachment.byte_size.zero?
@@ -462,7 +467,14 @@ module Submissions
scale = [(area['w'] * width) / image.width,
(area['h'] * height) / image.height].min
io = StringIO.new(image.resize([scale * 4, 1].select(&:positive?).min).write_to_buffer('.png'))
resized_image = image.resize([scale * 4, 1].select(&:positive?).min)
io =
if field_type == 'image' && !resized_image.has_alpha?
StringIO.new(resized_image.colourspace(:srgb).write_to_buffer('.jpg', strip: true))
else
StringIO.new(resized_image.write_to_buffer('.png', strip: true))
end
canvas.image(
io,
@@ -875,6 +887,50 @@ module Submissions
pdf
end
def maybe_rotate_pdfium(io)
pdf = HexaPDF::Document.new(io:)
return pdf if pdf.pages.size > MAX_PAGE_ROTATE
root_rotate = pdf.pages.root[:Rotate].to_i
rotated_indexes = pdf.pages.each_with_index.filter_map do |page, idx|
page_rotate = page[:Rotate]
effective = page_rotate.nil? ? root_rotate : page_rotate.to_i
idx if effective != 0
end
return pdf if rotated_indexes.blank?
has_widgets = pdf.acro_form && pdf.acro_form[:Fields].present?
io.rewind
out_io = StringIO.new
Pdfium::Document.open_bytes(io.string) do |doc|
rotated_indexes.each do |idx|
page = doc.get_page(idx)
page.flatten if has_widgets
page.rotate
end
doc.save(out_io)
end
pdf = HexaPDF::Document.new(io: out_io.tap(&:rewind))
pdf.pages.root[:Rotate] = 0
pdf
rescue StandardError => e
Rollbar.error(e) if defined?(Rollbar)
io.rewind
HexaPDF::Document.new(io:)
end
def on_missing_glyph(character, font_wrapper)
Rails.logger.info("Missing glyph: #{character}") if character.present? && defined?(Rollbar)
@@ -970,20 +1026,6 @@ module Submissions
[]
end
def load_vips_image(attachment, cache = {})
cache[attachment.uuid] ||= attachment.download
data = cache[attachment.uuid]
if ICO_REGEXP.match?(attachment.content_type)
LoadIco.call(data)
elsif BMP_REGEXP.match?(attachment.content_type)
LoadBmp.call(data)
else
Vips::Image.new_from_buffer(data, '')
end
end
def r
Rails.application.routes.url_helpers
end
+11 -18
View File
@@ -122,27 +122,20 @@ module Submitters
end
end
def create_attachment!(submitter, params)
blob =
if (file = params[:file])
extension = File.extname(file.original_filename).delete_prefix('.').downcase
def create_attachment!(submitter, file)
raise ParamsError, 'file param is missing' if file.blank?
if DANGEROUS_EXTENSIONS.include?(extension)
raise MaliciousFileExtension, "File type '.#{extension}' is not allowed."
end
extension = File.extname(file.original_filename).delete_prefix('.').downcase
ActiveStorage::Blob.create_and_upload!(io: file.open,
filename: file.original_filename,
content_type: file.content_type)
else
raise ParamsError, 'file param is missing'
end
if DANGEROUS_EXTENSIONS.include?(extension)
raise MaliciousFileExtension, "File type '.#{extension}' is not allowed."
end
ActiveStorage::Attachment.create!(
blob:,
name: 'attachments',
record: submitter
)
blob = ActiveStorage::Blob.create_and_upload!(io: file.tap(&:rewind).open,
filename: file.original_filename,
content_type: file.content_type)
ActiveStorage::Attachment.create!(blob:, name: 'attachments', record: submitter)
end
def normalize_preferences(account, user, params)
+2 -2
View File
@@ -25,7 +25,7 @@ module Submitters
def build_attachment(submitter, with_logo: true)
image = generate_stamp_image(submitter, with_logo:)
image_data = image.write_to_buffer('.png')
image_data = image.write_to_buffer('.png', strip: true)
checksum = Digest::MD5.base64digest(image_data)
@@ -40,7 +40,7 @@ module Submitters
def generate_stamp_image(submitter, with_logo: true)
logo =
if with_logo
Vips::Image.new_from_buffer(load_logo(submitter).read, '')
ImageUtils.load_vips(load_logo(submitter).read)
else
Vips::Image.new_from_buffer(TRANSPARENT_PIXEL, '').resize(WIDTH)
end
+27 -11
View File
@@ -212,8 +212,8 @@ module Submitters
elsif type.in?(%w[signature initials]) && value.length < 60
find_or_create_blob_from_text(account, value, type)
elsif (data = Base64.decode64(value.sub(BASE64_PREFIX_REGEXP, ''))) &&
Marcel::MimeType.for(data).exclude?('octet-stream')
find_or_create_blob_from_base64(account, data, type)
(mime_type = Marcel::MimeType.for(data)).exclude?('octet-stream')
find_or_create_blob_from_base64(account, data, type, mime_type:)
elsif type == 'image' && (value.starts_with?('<html>') || value.starts_with?('<!DOCTYPE'))
raise InvalidDefaultValue, "Invalid #{type} value" unless purpose == :api
@@ -236,15 +236,27 @@ module Submitters
raise InvalidDefaultValue, "HTML content is not allowed: #{value.first(200)}..."
end
def find_or_create_blob_from_base64(account, data, type)
def find_or_create_blob_from_base64(account, data, type, mime_type: nil)
checksum = Digest::MD5.base64digest(data)
blob = find_blob_by_checksum(checksum, account)
blob || ActiveStorage::Blob.create_and_upload!(
io: StringIO.new(data),
filename: "#{type}.png"
)
return blob if blob
mime_type ||= Marcel::MimeType.for(data)
detected_extensions = Marcel::TYPE_EXTS[mime_type].to_a.map(&:downcase)
if detected_extensions.any? { |e| Submitters::DANGEROUS_EXTENSIONS.include?(e) }
raise InvalidDefaultValue, "File type '.#{detected_extensions.first}' is not allowed."
end
extension = detected_extensions.first
extension = 'png' if extension.blank? && type.in?(%w[signature initials stamp image])
filename = extension.present? ? "#{type}.#{extension}" : type
ActiveStorage::Blob.create_and_upload!(io: StringIO.new(data), filename:)
end
def find_or_create_blob_from_text(account, text, type)
@@ -261,6 +273,13 @@ module Submitters
end
def find_or_create_blob_from_url(account, url)
filename = Addressable::URI.parse(url).path.split('/').last.to_s
extension = File.extname(filename).delete_prefix('.').downcase
if Submitters::DANGEROUS_EXTENSIONS.include?(extension)
raise InvalidDefaultValue, "File type '.#{extension}' is not allowed."
end
cache_key = [account.id, url].join(':')
checksum = CHECKSUM_CACHE_STORE.fetch(cache_key)
@@ -276,10 +295,7 @@ module Submitters
blob = find_blob_by_checksum(checksum, account)
blob || ActiveStorage::Blob.create_and_upload!(
io: StringIO.new(data),
filename: Addressable::URI.parse(url).path.split('/').last
)
blob || ActiveStorage::Blob.create_and_upload!(io: StringIO.new(data), filename:)
end
def find_blob_by_checksum(checksum, account)
+76
View File
@@ -0,0 +1,76 @@
# frozen_string_literal: true
module TemplateVersions
SERIALIZE_PARAMS = {
only: %i[id created_at],
include: { author: { only: %i[email], methods: %i[full_name] } }
}.freeze
DATA_FIELDS = %i[name schema submitters variables_schema fields].freeze
module_function
def find_or_create_for(template, author:)
data = build_data(template)
sha1 = Digest::SHA1.hexdigest(data.to_json)
version = template.template_versions.find_by(sha1:)
version ||= template.template_versions.create!(data:, sha1:, author:)
version
rescue ActiveRecord::RecordNotUnique
retry
end
def serialize(version)
data = version.data.dup
data['documents'] = serialize_documents(version.template, data['schema'].to_a)
data['dynamic_documents'] = serialize_dynamic_documents(version.template, data['dynamic_documents'].to_a)
version.as_json(SERIALIZE_PARAMS).merge('data' => data)
end
def build_data(template)
dynamic_uuids = template.schema.select { |e| e['dynamic'] }.pluck('attachment_uuid')
dynamic_documents =
if dynamic_uuids.present?
template.dynamic_documents.where(uuid: dynamic_uuids).as_json(only: %i[uuid body])
else
[]
end
template.as_json(only: DATA_FIELDS).merge('dynamic_documents' => dynamic_documents)
end
def serialize_documents(template, schema)
return [] if schema.blank?
template.documents_attachments
.where(uuid: schema.pluck('attachment_uuid'))
.preload(:blob, preview_images_attachments: :blob)
.as_json(
only: %i[id uuid],
methods: %i[metadata signed_key],
include: { preview_images: { only: %i[id], methods: %i[url metadata filename] } }
)
end
def serialize_dynamic_documents(template, dynamic_docs)
return [] if dynamic_docs.blank?
dynamic_docs_index = template.dynamic_documents
.where(uuid: dynamic_docs.pluck('uuid'))
.preload(attachments_attachments: :blob)
.index_by(&:uuid)
dynamic_docs.map do |attrs|
document = dynamic_docs_index[attrs['uuid']]
attachments_data = document.attachments_attachments.as_json(only: %i[uuid], methods: %i[url metadata filename])
attrs.merge('head' => document.head, 'attachments' => attachments_data)
end
end
end
+1 -1
View File
@@ -77,7 +77,7 @@ module Templates
split_page: false, aspect_ratio: false, padding: nil, page_number: nil)
return [[], nil] if page_number && page_number != 0
image = Vips::Image.new_from_buffer(io.read, '')
image = ImageUtils.load_vips(io.read, content_type: attachment.content_type)
fields = inference.call(image, confidence:, nms:, nmm:, split_page:,
temperature:, aspect_ratio:, padding:)
+28 -31
View File
@@ -7,7 +7,6 @@ module Templates
PREVIEW_FORMAT = '.jpg'
ATTACHMENT_NAME = 'preview_images'
BMP_REGEXP = %r{\Aimage/(?:bmp|x-bmp|x-ms-bmp)\z}
PDF_CONTENT_TYPE = 'application/pdf'
CONCURRENCY = 2
Q = 95
@@ -59,19 +58,13 @@ module Templates
def generate_preview_image(attachment, data)
ActiveStorage::Attachment.where(name: ATTACHMENT_NAME, record: attachment).destroy_all
image =
if BMP_REGEXP.match?(attachment.content_type)
LoadBmp.call(data)
else
Vips::Image.new_from_buffer(data, '')
end
image = image.autorot.resize(MAX_WIDTH / image.width.to_f)
image = ImageUtils.load_vips(data, content_type: attachment.content_type, autorot: true)
image = image.resize(MAX_WIDTH / image.width.to_f)
bitdepth = 2**image.stats.to_a[1..3].pluck(2).uniq.size
io = StringIO.new(image.write_to_buffer(FORMAT, compression: 6, filter: 0, bitdepth:,
palette: true, Q: Q, dither: 0))
palette: true, Q: Q, dither: 0, strip: true))
ActiveStorage::Attachment.create!(
blob: ActiveStorage::Blob.create_and_upload!(
@@ -110,7 +103,15 @@ module Templates
promises =
range.map do |page_number|
Concurrent::Promise.execute(executor: pool) { build_and_upload_blob(doc, page_number) }
doc_page = doc.get_page(page_number)
bytes, width, height = doc_page.render_to_bitmap(width: MAX_WIDTH)
image = Vips::Image.new_from_memory(bytes, width, height, 4, :uchar)
Concurrent::Promise.execute(executor: pool) { build_and_upload_blob(image, page_number) }
ensure
doc_page&.close
end
Concurrent::Promise.zip(*promises).value!.each do |blob|
@@ -129,39 +130,27 @@ module Templates
pool&.kill
end
def build_and_upload_blob(doc, page_number, format = FORMAT)
doc_page = doc.get_page(page_number)
data, width, height = doc_page.render_to_bitmap(width: MAX_WIDTH)
page = Vips::Image.new_from_memory(data, width, height, 4, :uchar)
page = page.copy(interpretation: :srgb)
def build_and_upload_blob(image, page_number, format = FORMAT)
image = image.copy(interpretation: :srgb)
data =
if format == FORMAT
bitdepth = 2**page.stats.to_a[1..3].pluck(2).uniq.size
bitdepth = 2**image.stats.to_a[1..3].pluck(2).uniq.size
page.write_to_buffer(format, compression: 6, filter: 0, bitdepth:,
palette: true, Q: Q, dither: 0)
image.write_to_buffer(format, compression: 6, filter: 0, bitdepth:,
palette: true, Q: Q, dither: 0)
else
page.write_to_buffer(format, interlace: true, Q: JPEG_Q)
image.write_to_buffer(format, interlace: true, Q: JPEG_Q)
end
blob = ActiveStorage::Blob.new(
filename: "#{page_number}#{format}",
metadata: { analyzed: true, identified: true, width: page.width, height: page.height }
metadata: { analyzed: true, identified: true, width: image.width, height: image.height }
)
blob.upload(StringIO.new(data))
blob
rescue Vips::Error, Pdfium::PdfiumError => e
Rollbar.warning(e) if defined?(Rollbar)
nil
ensure
doc_page&.close
end
def maybe_flatten_form(data, pdf)
@@ -206,7 +195,15 @@ module Templates
def generate_pdf_preview_from_file(attachment, file_path, page_number)
doc = Pdfium::Document.open_file(file_path)
blob = build_and_upload_blob(doc, page_number, PREVIEW_FORMAT)
doc_page = doc.get_page(page_number)
bytes, width, height = doc_page.render_to_bitmap(width: MAX_WIDTH)
doc_page.close
image = Vips::Image.new_from_memory(bytes, width, height, 4, :uchar)
blob = build_and_upload_blob(image, page_number, PREVIEW_FORMAT)
ApplicationRecord.no_touching do
ActiveStorage::Attachment.create!(

Some files were not shown because too many files have changed in this diff Show More