mirror of
https://github.com/docusealco/docuseal.git
synced 2026-08-07 07:14:43 +00:00
Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ed46af8418 | |||
| 095f25a6e9 | |||
| 3cdab30dc8 | |||
| 8e462047ba | |||
| b7f8b65d9d | |||
| da21c37254 | |||
| a89aef4a89 | |||
| dd6516fd11 | |||
| 344a3ce198 | |||
| 62f8283587 | |||
| 1f89accac3 | |||
| 9fcaef4cf7 | |||
| d5738a0631 | |||
| 8bf7a1f95a | |||
| 04cf36891e | |||
| b2d9948c30 | |||
| a2c9ac1707 | |||
| 46a6bd0108 | |||
| 5f069e7a40 | |||
| ff57e5c6ae | |||
| f65b6e2d76 | |||
| 58fd180ae0 | |||
| cd6503c4c3 | |||
| e8b36c2b6d | |||
| 51743f1359 | |||
| 504c42646b | |||
| c61e84d1b4 | |||
| b8ab01c46c | |||
| 9558060bde | |||
| 0741c879f1 | |||
| 2e6dd2867e | |||
| 9f3fcccb61 | |||
| ae08d7abad | |||
| 16808a7ec6 | |||
| b1dfcd6283 | |||
| 369f4c0f08 | |||
| 827de03579 | |||
| 0c7f4e5b43 | |||
| 9c700a3fb6 | |||
| 89bf83febb | |||
| d44b35cf0a | |||
| 891833e273 | |||
| 29d3a80d3f | |||
| 90fef58684 | |||
| 07841722ac | |||
| 9e4da5948b | |||
| dc6e4313a1 | |||
| a68cc0b689 | |||
| d057fb0f67 | |||
| 7e8f045a29 | |||
| 5249d8d18d | |||
| f0fafbadd4 | |||
| 8b51079dcc | |||
| 354bccd6e8 | |||
| 6806772346 |
@@ -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
|
||||
|
||||
+6
-4
@@ -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,8 @@ 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 && \
|
||||
rm -f /usr/bin/onnx_test_runner /usr/bin/onnxruntime_test
|
||||
|
||||
RUN addgroup -g 2000 docuseal && adduser -u 2000 -G docuseal -s /bin/sh -D -h /home/docuseal docuseal
|
||||
|
||||
@@ -94,6 +95,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"]
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
source 'https://rubygems.org'
|
||||
|
||||
ruby '4.0.1'
|
||||
ruby '4.0.5'
|
||||
|
||||
gem 'addressable'
|
||||
gem 'arabic-letter-connector', require: false
|
||||
@@ -21,11 +21,9 @@ gem 'faraday'
|
||||
gem 'faraday-follow_redirects'
|
||||
gem 'google-cloud-storage', require: false
|
||||
gem 'hexapdf'
|
||||
gem 'image_processing'
|
||||
gem 'jwt', require: false
|
||||
gem 'lograge'
|
||||
gem 'numo-narray-alt', require: false
|
||||
gem 'oj'
|
||||
gem 'onnxruntime', require: false
|
||||
gem 'pagy'
|
||||
gem 'pg', require: false
|
||||
|
||||
+7
-18
@@ -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
|
||||
@@ -264,9 +264,6 @@ GEM
|
||||
strscan (>= 3.1.2)
|
||||
i18n (1.14.8)
|
||||
concurrent-ruby (~> 1.0)
|
||||
image_processing (1.14.0)
|
||||
mini_magick (>= 4.9.5, < 6)
|
||||
ruby-vips (>= 2.0.17, < 3)
|
||||
io-console (0.8.2)
|
||||
irb (1.18.0)
|
||||
pp (>= 0.6.0)
|
||||
@@ -274,8 +271,8 @@ GEM
|
||||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.2)
|
||||
jmespath (1.6.2)
|
||||
json (2.19.5)
|
||||
jwt (3.1.2)
|
||||
json (2.19.8)
|
||||
jwt (3.2.0)
|
||||
base64
|
||||
language_server-protocol (3.17.0.5)
|
||||
launchy (3.1.1)
|
||||
@@ -308,8 +305,6 @@ GEM
|
||||
marcel (1.1.0)
|
||||
matrix (0.4.3)
|
||||
method_source (1.1.0)
|
||||
mini_magick (5.3.1)
|
||||
logger
|
||||
mini_mime (1.1.5)
|
||||
minitest (6.0.6)
|
||||
drb (~> 2.0)
|
||||
@@ -339,9 +334,6 @@ GEM
|
||||
nokogiri (1.19.3-x86_64-linux-musl)
|
||||
racc (~> 1.4)
|
||||
numo-narray-alt (0.10.3)
|
||||
oj (3.16.16)
|
||||
bigdecimal (>= 3.0)
|
||||
ostruct (>= 0.2)
|
||||
onnxruntime (0.10.1-aarch64-linux)
|
||||
ffi
|
||||
onnxruntime (0.10.1-arm64-darwin)
|
||||
@@ -351,7 +343,6 @@ GEM
|
||||
openssl (4.0.1)
|
||||
orm_adapter (0.5.0)
|
||||
os (1.1.4)
|
||||
ostruct (0.6.3)
|
||||
package_json (0.2.0)
|
||||
pagy (43.4.4)
|
||||
json
|
||||
@@ -434,7 +425,7 @@ GEM
|
||||
erb
|
||||
psych (>= 4.0.0)
|
||||
tsort
|
||||
redis-client (0.28.0)
|
||||
redis-client (0.29.0)
|
||||
connection_pool
|
||||
regexp_parser (2.11.3)
|
||||
reline (0.6.3)
|
||||
@@ -516,12 +507,12 @@ GEM
|
||||
rack-proxy (>= 0.6.1)
|
||||
railties (>= 5.2)
|
||||
semantic_range (>= 2.3.0)
|
||||
sidekiq (8.1.2)
|
||||
sidekiq (8.1.6)
|
||||
connection_pool (>= 3.0.0)
|
||||
json (>= 2.16.0)
|
||||
logger (>= 1.7.0)
|
||||
rack (>= 3.2.0)
|
||||
redis-client (>= 0.26.0)
|
||||
redis-client (>= 0.29.0)
|
||||
signet (0.21.0)
|
||||
addressable (~> 2.8)
|
||||
faraday (>= 0.17.5, < 3.a)
|
||||
@@ -624,12 +615,10 @@ DEPENDENCIES
|
||||
foreman
|
||||
google-cloud-storage
|
||||
hexapdf
|
||||
image_processing
|
||||
jwt
|
||||
letter_opener_web
|
||||
lograge
|
||||
numo-narray-alt
|
||||
oj
|
||||
onnxruntime
|
||||
pagy
|
||||
pg
|
||||
@@ -662,7 +651,7 @@ DEPENDENCIES
|
||||
webmock
|
||||
|
||||
RUBY VERSION
|
||||
ruby 4.0.1
|
||||
ruby 4.0.5
|
||||
|
||||
BUNDLED WITH
|
||||
4.0.3
|
||||
|
||||
@@ -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,13 +11,13 @@ module Api
|
||||
@submitter = Submitter.find_by!(slug: params[:submitter_slug])
|
||||
|
||||
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)
|
||||
@@ -31,9 +31,11 @@ module Api
|
||||
return render json: { error: "#{params[:type]} error, try to sign on another device" },
|
||||
status: :unprocessable_content
|
||||
end
|
||||
|
||||
metadata = { analyzed: true, identified: true, width: image.width, height: image.height }
|
||||
end
|
||||
|
||||
attachment = Submitters.create_attachment!(@submitter, params)
|
||||
attachment = Submitters.create_attachment!(@submitter, file, metadata:)
|
||||
|
||||
if params[:remember_signature] == 'true' && @submitter.email.present?
|
||||
cookies.encrypted[:signature_uuids] = build_new_cookie_signatures_json(@submitter, attachment)
|
||||
|
||||
@@ -9,20 +9,7 @@ module Api
|
||||
|
||||
templates = paginate(templates.preload(:author, folder: :parent_folder))
|
||||
|
||||
schema_documents =
|
||||
ActiveStorage::Attachment.where(record_id: templates.map(&:id),
|
||||
record_type: 'Template',
|
||||
name: :documents,
|
||||
uuid: templates.flat_map { |t| t.schema.pluck('attachment_uuid') })
|
||||
.preload(:blob)
|
||||
|
||||
preview_image_attachments =
|
||||
ActiveStorage::Attachment.joins(:blob)
|
||||
.where(blob: { filename: ['0.png', '0.jpg'] })
|
||||
.where(record_id: schema_documents.map(&:id),
|
||||
record_type: 'ActiveStorage::Attachment',
|
||||
name: :preview_images)
|
||||
.preload(:blob)
|
||||
schema_documents, dynamic_documents, preview_image_attachments = preload_relations(templates)
|
||||
|
||||
expires_at = Accounts.link_expires_at(current_account)
|
||||
|
||||
@@ -30,6 +17,7 @@ module Api
|
||||
data: templates.map do |t|
|
||||
Templates::SerializeForApi.call(t,
|
||||
schema_documents: schema_documents.select { |e| e.record_id == t.id },
|
||||
dynamic_documents:,
|
||||
preview_image_attachments:,
|
||||
expires_at:)
|
||||
end,
|
||||
@@ -88,6 +76,41 @@ module Api
|
||||
|
||||
private
|
||||
|
||||
def preload_relations(templates)
|
||||
schema_documents =
|
||||
ActiveStorage::Attachment.where(record_id: templates.map(&:id),
|
||||
record_type: 'Template',
|
||||
name: :documents,
|
||||
uuid: templates.flat_map { |t| t.schema.pluck('attachment_uuid') })
|
||||
.preload(:blob)
|
||||
|
||||
dynamic_document_uuids =
|
||||
templates.flat_map { |t| t.schema.select { |item| item['dynamic'] }.pluck('attachment_uuid') }
|
||||
|
||||
dynamic_documents =
|
||||
if dynamic_document_uuids.present?
|
||||
DynamicDocument.where(template: templates.map(&:id))
|
||||
.where(uuid: dynamic_document_uuids)
|
||||
.preload(current_version: { document_attachment: :blob })
|
||||
.select(:id, :uuid, :template_id, :sha1, :created_at, :updated_at)
|
||||
else
|
||||
DynamicDocument.none
|
||||
end
|
||||
|
||||
preview_attachment_ids =
|
||||
schema_documents.map(&:id) + dynamic_documents.filter_map { |d| d.current_version&.document_attachment&.id }
|
||||
|
||||
preview_image_attachments =
|
||||
ActiveStorage::Attachment.joins(:blob)
|
||||
.where(blob: { filename: ['0.png', '0.jpg'] })
|
||||
.where(record_id: preview_attachment_ids,
|
||||
record_type: 'ActiveStorage::Attachment',
|
||||
name: :preview_images)
|
||||
.preload(:blob)
|
||||
|
||||
[schema_documents, dynamic_documents, preview_image_attachments]
|
||||
end
|
||||
|
||||
def filter_templates(templates, params)
|
||||
templates = Templates.search(current_user, templates, params[:q])
|
||||
templates = params[:archived].in?(['true', true]) ? templates.archived : templates.active
|
||||
|
||||
@@ -101,11 +101,18 @@ class StartFormController < ApplicationController
|
||||
def load_resubmit_submitter
|
||||
@resubmit_submitter =
|
||||
if params[:resubmit].present? && !params[:resubmit].in?([true, 'true'])
|
||||
Submitter.find_by(slug: params[:resubmit])
|
||||
submitter = Submitter.find_by(slug: params[:resubmit])
|
||||
|
||||
submitter if submitter && can_resubmit?(submitter)
|
||||
end
|
||||
end
|
||||
|
||||
def can_resubmit?(submitter)
|
||||
submitter.account.account_configs.find_or_initialize_by(key: AccountConfig::ALLOW_TO_RESUBMIT).value != false
|
||||
end
|
||||
|
||||
def authorize_start!
|
||||
return redirect_to submit_form_path(@resubmit_submitter.slug) if @resubmit_submitter && @template.archived_at?
|
||||
return redirect_to start_form_path(@template.slug) if @template.archived_at?
|
||||
|
||||
return if @resubmit_submitter
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class TemplatesController < ApplicationController
|
||||
TEMPLATE_FIELDS = %i[id author_id folder_id external_id name slug
|
||||
schema fields submitters variables_schema preferences
|
||||
shared_link source archived_at created_at updated_at].freeze
|
||||
|
||||
load_and_authorize_resource :template
|
||||
|
||||
def show
|
||||
@@ -31,19 +27,7 @@ class TemplatesController < ApplicationController
|
||||
def new; end
|
||||
|
||||
def edit
|
||||
ActiveRecord::Associations::Preloader.new(
|
||||
records: [@template],
|
||||
associations: [{ schema_documents: [:blob, { preview_images_attachments: :blob }] }]
|
||||
).call
|
||||
|
||||
@template_data =
|
||||
@template.as_json(only: TEMPLATE_FIELDS).merge(
|
||||
documents: @template.schema_documents.as_json(
|
||||
only: %i[id uuid],
|
||||
methods: %i[metadata signed_key],
|
||||
include: { preview_images: { only: %i[id], methods: %i[url metadata filename] } }
|
||||
)
|
||||
).to_json
|
||||
@template_data = Templates.serialize_for_builder(@template)
|
||||
|
||||
render :edit, layout: 'plain'
|
||||
end
|
||||
|
||||
@@ -16,7 +16,12 @@ class TemplatesDetectFieldsController < ApplicationController
|
||||
page_number = params[:page].presence&.to_i
|
||||
|
||||
documents.each do |document|
|
||||
io = StringIO.new(document.download)
|
||||
io =
|
||||
if document.image?
|
||||
StringIO.new(document.preview_images.joins(:blob).find_by(blob: { filename: ['0.png', '0.jpg'] }).download)
|
||||
else
|
||||
StringIO.new(document.download)
|
||||
end
|
||||
|
||||
Templates::DetectFields.call(io, attachment: document, page_number:) do |(attachment_uuid, page, fields)|
|
||||
sse.write({ attachment_uuid:, page:, fields: })
|
||||
|
||||
@@ -4,18 +4,7 @@ class TemplatesPreviewController < ApplicationController
|
||||
load_and_authorize_resource :template
|
||||
|
||||
def show
|
||||
ActiveRecord::Associations::Preloader.new(
|
||||
records: [@template],
|
||||
associations: [{ schema_documents: { preview_images_attachments: :blob } }]
|
||||
).call
|
||||
|
||||
@template_data =
|
||||
@template.as_json.merge(
|
||||
documents: @template.schema_documents.as_json(
|
||||
methods: %i[metadata signed_key],
|
||||
include: { preview_images: { methods: %i[url metadata filename] } }
|
||||
)
|
||||
).to_json
|
||||
@template_data = Templates.serialize_for_builder(@template)
|
||||
|
||||
render :show, layout: 'plain'
|
||||
end
|
||||
|
||||
@@ -5,7 +5,9 @@ class TemplatesUploadsController < ApplicationController
|
||||
|
||||
layout 'plain'
|
||||
|
||||
def show; end
|
||||
def show
|
||||
redirect_to root_path if params[:url].blank?
|
||||
end
|
||||
|
||||
def create
|
||||
url_params = create_file_params_from_url if params[:url].present?
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
@@ -53,6 +54,7 @@ import GoogleDriveFilePicker from './elements/google_drive_file_picker'
|
||||
import OpenModal from './elements/open_modal'
|
||||
import BarChart from './elements/bar_chart'
|
||||
import FieldCondition from './elements/field_condition'
|
||||
import ConfirmUpload from './elements/confirm_upload'
|
||||
|
||||
import * as TurboInstantClick from './lib/turbo_instant_click'
|
||||
|
||||
@@ -111,6 +113,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)
|
||||
@@ -144,6 +147,7 @@ safeRegisterElement('google-drive-file-picker', GoogleDriveFilePicker)
|
||||
safeRegisterElement('open-modal', OpenModal)
|
||||
safeRegisterElement('bar-chart', BarChart)
|
||||
safeRegisterElement('field-condition', FieldCondition)
|
||||
safeRegisterElement('confirm-upload', ConfirmUpload)
|
||||
|
||||
safeRegisterElement('template-builder', class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
@@ -195,10 +199,35 @@ safeRegisterElement('template-builder', class extends HTMLElement {
|
||||
}
|
||||
|
||||
onSubmit = (e) => {
|
||||
if (e.detail.success && e.detail?.formSubmission?.formElement?.id === 'submitters_form') {
|
||||
e.detail.fetchResponse.response.json().then((data) => {
|
||||
this.component.template.submitters = data.submitters
|
||||
})
|
||||
if (e.detail.success) {
|
||||
if (e.detail?.formSubmission?.formElement?.id === 'submitters_form') {
|
||||
e.detail.fetchResponse.response.json().then((data) => {
|
||||
this.component.template.submitters = data.submitters
|
||||
})
|
||||
}
|
||||
|
||||
if (e.detail?.formSubmission?.formElement?.action?.endsWith('/prefillable_fields')) {
|
||||
e.detail.fetchResponse.response.text().then((data) => {
|
||||
const doc = new DOMParser().parseFromString(data, 'text/html')
|
||||
const fragment = doc.querySelector('turbo-stream template').content
|
||||
|
||||
const prefillableUuidsIndex = {}
|
||||
|
||||
fragment.querySelectorAll('[name="field_uuid"]').forEach((field) => {
|
||||
prefillableUuidsIndex[field.value] = true
|
||||
})
|
||||
|
||||
this.component.template.fields.forEach((field) => {
|
||||
if (prefillableUuidsIndex[field.uuid]) {
|
||||
field.prefillable = true
|
||||
field.readonly = true
|
||||
} else if (field.prefillable) {
|
||||
delete field.prefillable
|
||||
delete field.readonly
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { target, targetable } from '@github/catalyst/lib/targetable'
|
||||
|
||||
export default targetable(class extends HTMLElement {
|
||||
static [target.static] = [
|
||||
'prompt',
|
||||
'processing',
|
||||
'logo'
|
||||
]
|
||||
|
||||
connectedCallback () {
|
||||
this.form.addEventListener('submit', this.onSubmit)
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
this.form.removeEventListener('submit', this.onSubmit)
|
||||
}
|
||||
|
||||
onSubmit = () => {
|
||||
this.prompt.classList.add('hidden')
|
||||
this.processing.classList.remove('hidden')
|
||||
this.logo.classList.add('animate-bounce')
|
||||
}
|
||||
|
||||
get form () {
|
||||
return this.querySelector('form')
|
||||
}
|
||||
})
|
||||
@@ -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,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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
</label>
|
||||
<button
|
||||
v-if="withToday"
|
||||
type="button"
|
||||
class="btn btn-outline btn-sm !normal-case font-normal set-current-date-button"
|
||||
@click.prevent="[setCurrentDate(), $emit('focus')]"
|
||||
>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@
|
||||
v-else
|
||||
id="complete_form_button"
|
||||
class="btn btn-sm btn-neutral text-white px-4 w-full flex justify-center"
|
||||
form="steps_form"
|
||||
form="complete_form"
|
||||
type="submit"
|
||||
name="completed"
|
||||
value="true"
|
||||
@@ -120,7 +120,7 @@
|
||||
>
|
||||
<button
|
||||
class="complete-button btn btn-sm btn-neutral text-white px-4"
|
||||
form="steps_form"
|
||||
form="complete_form"
|
||||
type="submit"
|
||||
name="completed"
|
||||
value="true"
|
||||
@@ -138,6 +138,14 @@
|
||||
</span>
|
||||
</button>
|
||||
</Teleport>
|
||||
<form
|
||||
v-if="!isCompleted && !isInvite"
|
||||
id="complete_form"
|
||||
class="hidden"
|
||||
:action="submitPath"
|
||||
method="post"
|
||||
@submit.prevent="submitStep"
|
||||
/>
|
||||
<button
|
||||
v-if="!isFormVisible"
|
||||
id="expand_form_button"
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
</template>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline btn-sm reupload-button"
|
||||
@click.prevent="remove"
|
||||
>
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
{{ error }}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="base-button w-full flex justify-center submit-form-button"
|
||||
@click="restartKba"
|
||||
>
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
<div v-else>
|
||||
<button
|
||||
v-if="sessionId"
|
||||
type="button"
|
||||
disabled
|
||||
class="base-button w-full modal-save-button"
|
||||
>
|
||||
@@ -47,6 +48,7 @@
|
||||
<button
|
||||
v-else
|
||||
:id="field.uuid"
|
||||
type="button"
|
||||
class="btn bg-[#7B73FF] text-white hover:bg-[#0A2540] text-lg w-full"
|
||||
:class="{ disabled: isCreatingCheckout }"
|
||||
:disabled="isCreatingCheckout"
|
||||
|
||||
@@ -197,7 +197,8 @@
|
||||
v-else-if="!isCheckboxInput"
|
||||
width="100%"
|
||||
height="100%"
|
||||
class="max-h-10 opacity-50"
|
||||
class="opacity-50"
|
||||
:style="{ maxHeight: isInlineSize ? '4.4cqmin' : '40px' }"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -309,6 +309,8 @@
|
||||
:data-document-uuid="item.attachment_uuid"
|
||||
:accept-file-types="acceptFileTypes"
|
||||
:with-replace-button="withUploadButton"
|
||||
:with-google-drive="withGoogleDrive"
|
||||
:authenticity-token="authenticityToken"
|
||||
:editable="editable"
|
||||
:dynamic-documents="dynamicDocuments"
|
||||
:with-dynamic-documents="withDynamicDocuments"
|
||||
@@ -355,12 +357,17 @@
|
||||
</div>
|
||||
<div
|
||||
id="pages_container"
|
||||
class="w-full overflow-x-hidden mt-0.5 pt-0.5"
|
||||
:class="isMobile ? 'overflow-y-auto' : 'overflow-y-hidden md:overflow-y-auto'"
|
||||
ref="pagesContainer"
|
||||
class="w-full mt-0.5 pt-0.5"
|
||||
:class="[
|
||||
isMobile ? 'overflow-y-auto' : 'overflow-y-hidden md:overflow-y-auto',
|
||||
zoomLevel > 1 ? 'overflow-x-auto' : 'overflow-x-hidden'
|
||||
]"
|
||||
>
|
||||
<div
|
||||
ref="documents"
|
||||
class="pr-3.5 pl-0.5"
|
||||
:style="zoomLevel > 1 ? { width: `${zoomLevel * 100}%` } : null"
|
||||
>
|
||||
<template v-if="!sortedDocuments.length && (withUploadButton || withAddPageButton)">
|
||||
<Dropzone
|
||||
@@ -451,6 +458,8 @@
|
||||
:with-arrows="template.schema.length > 1"
|
||||
:item="template.schema.find((item) => item.attachment_uuid === document.uuid)"
|
||||
:with-replace-button="withUploadButton"
|
||||
:with-google-drive="withGoogleDrive"
|
||||
:authenticity-token="authenticityToken"
|
||||
:accept-file-types="acceptFileTypes"
|
||||
:document="document"
|
||||
:template="template"
|
||||
@@ -633,6 +642,36 @@
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-200 ease-out"
|
||||
enter-from-class="translate-y-4 opacity-0"
|
||||
enter-to-class="translate-y-0 opacity-100"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="translate-y-0 opacity-100"
|
||||
leave-to-class="translate-y-4 opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="zoomLevel > 1"
|
||||
class="sticky bottom-0 z-40 pointer-events-none"
|
||||
>
|
||||
<div class="absolute left-0 right-0 bottom-4 flex justify-center">
|
||||
<div class="join shadow pointer-events-auto">
|
||||
<span class="join-item bg-base-content text-white pl-2 pr-2.5 h-9 items-center text-sm font-medium cursor-default w-16 flex justify-end">
|
||||
<span>
|
||||
{{ Math.round(zoomLevel * 100) }}%
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="join-item bg-base-content text-white h-9 pl-2 pr-3 inline-flex items-center justify-center cursor-pointer hover:opacity-90 border-l border-white/20"
|
||||
@click="zoomLevel = 1"
|
||||
>
|
||||
<IconX class="w-4 h-4 stroke-2" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
<div
|
||||
id="docuseal_modal_container"
|
||||
class="modal-container"
|
||||
@@ -664,7 +703,7 @@ import DocumentControls from './controls'
|
||||
import MobileFields from './mobile_fields'
|
||||
import FieldSubmitter from './field_submitter'
|
||||
import RevisionsModal from './revisions_modal'
|
||||
import { IconPlus, IconUsersPlus, IconDeviceFloppy, IconChevronDown, IconEye, IconWritingSign, IconInnerShadowTop, IconInfoCircle, IconAdjustments, IconDownload, IconHistory } from '@tabler/icons-vue'
|
||||
import { IconPlus, IconUsersPlus, IconDeviceFloppy, IconChevronDown, IconEye, IconWritingSign, IconInnerShadowTop, IconInfoCircle, IconAdjustments, IconDownload, IconHistory, IconX } from '@tabler/icons-vue'
|
||||
import { v4 } from 'uuid'
|
||||
import { ref, computed, toRaw, defineAsyncComponent } from 'vue'
|
||||
import * as i18n from './i18n'
|
||||
@@ -706,6 +745,7 @@ export default {
|
||||
IconEye,
|
||||
IconHistory,
|
||||
IconDeviceFloppy,
|
||||
IconX,
|
||||
RevisionsModal
|
||||
},
|
||||
provide () {
|
||||
@@ -1063,7 +1103,8 @@ export default {
|
||||
isMathLoaded: false,
|
||||
isRevisionsModalOpen: false,
|
||||
revisions: [],
|
||||
beforeRevisionSnapshot: null
|
||||
beforeRevisionSnapshot: null,
|
||||
zoomLevel: 1
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -1313,6 +1354,8 @@ export default {
|
||||
this.pendingFieldAttachmentUuids.push(item.attachment_uuid)
|
||||
}
|
||||
})
|
||||
|
||||
this.$refs.pagesContainer.addEventListener('wheel', this.onPagesWheel, { passive: false })
|
||||
},
|
||||
unmounted () {
|
||||
document.removeEventListener('keyup', this.onKeyUp)
|
||||
@@ -1320,6 +1363,8 @@ export default {
|
||||
|
||||
window.removeEventListener('resize', this.onWindowResize)
|
||||
window.removeEventListener('dragleave', this.onWindowDragLeave)
|
||||
|
||||
this.$refs.pagesContainer.removeEventListener('wheel', this.onPagesWheel)
|
||||
},
|
||||
beforeUpdate () {
|
||||
this.documentRefs = []
|
||||
@@ -2018,6 +2063,30 @@ export default {
|
||||
|
||||
this.isBreakpointLg = this.$el.getRootNode().querySelector('div[data-v-app]').offsetWidth < breakpointLg
|
||||
},
|
||||
onPagesWheel (event) {
|
||||
if (!event.ctrlKey && !event.metaKey) return
|
||||
|
||||
event.preventDefault()
|
||||
|
||||
const oldZoom = this.zoomLevel
|
||||
const nextZoom = Math.max(1, Math.min(3, oldZoom - event.deltaY * 0.006))
|
||||
|
||||
if (nextZoom === oldZoom) return
|
||||
|
||||
const rect = this.$refs.pagesContainer.getBoundingClientRect()
|
||||
const cursorX = event.clientX - rect.left
|
||||
const cursorY = event.clientY - rect.top
|
||||
const ratio = nextZoom / oldZoom
|
||||
const nextScrollLeft = (this.$refs.pagesContainer.scrollLeft + cursorX) * ratio - cursorX
|
||||
const nextScrollTop = (this.$refs.pagesContainer.scrollTop + cursorY) * ratio - cursorY
|
||||
|
||||
this.zoomLevel = nextZoom
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.$refs.pagesContainer.scrollLeft = nextScrollLeft
|
||||
this.$refs.pagesContainer.scrollTop = nextScrollTop
|
||||
})
|
||||
},
|
||||
setDocumentRefs (el) {
|
||||
if (el) {
|
||||
this.documentRefs.push(el)
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
v-if="withReplaceButton"
|
||||
:template-id="template.id"
|
||||
:accept-file-types="acceptFileTypes"
|
||||
:authenticity-token="authenticityToken"
|
||||
:with-google-drive="withGoogleDrive"
|
||||
:google-drive-file-id="item.google_drive_file_id"
|
||||
@click.stop
|
||||
@success="$emit('replace', { replaceSchemaItem: item, ...$event })"
|
||||
/>
|
||||
@@ -73,6 +76,16 @@ export default {
|
||||
required: true,
|
||||
default: true
|
||||
},
|
||||
withGoogleDrive: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
authenticityToken: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
withArrows: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
name="files[]"
|
||||
:accept="acceptFileTypes"
|
||||
multiple
|
||||
@change="upload"
|
||||
@change="upload()"
|
||||
>
|
||||
</form>
|
||||
</label>
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
<template>
|
||||
<Teleport :to="modalContainerEl">
|
||||
<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 px-6 mt-20 max-h-none w-full max-w-xl">
|
||||
<div class="flex justify-between items-center border-b pb-2 mb-2 font-medium">
|
||||
<span class="modal-title">
|
||||
Google Drive
|
||||
</span>
|
||||
<a
|
||||
href="#"
|
||||
class="text-xl modal-close-button"
|
||||
@click.prevent="$emit('close')"
|
||||
>×</a>
|
||||
</div>
|
||||
<div>
|
||||
<form
|
||||
v-if="showOauthButton"
|
||||
method="post"
|
||||
:action="oauthPath"
|
||||
@submit="isConnectClicked = true"
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name="authenticity_token"
|
||||
:value="authenticityToken"
|
||||
autocomplete="off"
|
||||
>
|
||||
<button
|
||||
class="btn bg-white btn-outline w-full text-base font-medium mt-4"
|
||||
data-turbo="false"
|
||||
type="submit"
|
||||
:disabled="isConnectClicked"
|
||||
>
|
||||
<span v-if="isConnectClicked">
|
||||
<span class="flex items-center justify-center space-x-2">
|
||||
<IconInnerShadowTop class="animate-spin" />
|
||||
<span>{{ t('submitting') }}...</span>
|
||||
</span>
|
||||
</span>
|
||||
<span v-else>
|
||||
<span class="flex items-center justify-center space-x-2">
|
||||
<IconBrandGoogleDrive />
|
||||
<span>{{ t('connect_google_drive') }}</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
<div
|
||||
v-else
|
||||
class="relative"
|
||||
>
|
||||
<iframe
|
||||
class="border border-base-300 rounded-lg"
|
||||
style="width: 100%; height: 440px; background: white;"
|
||||
src="/template_google_drive"
|
||||
/>
|
||||
<div v-if="loading">
|
||||
<div
|
||||
class="bg-white absolute top-0 bottom-0 left-0 right-0 opacity-80 rounded-lg"
|
||||
style="margin: 1px"
|
||||
/>
|
||||
<div class="absolute top-0 bottom-0 left-0 right-0 flex items-center justify-center">
|
||||
<IconInnerShadowTop class="animate-spin" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { IconBrandGoogleDrive, IconInnerShadowTop } from '@tabler/icons-vue'
|
||||
|
||||
export default {
|
||||
name: 'GoogleDrivePickerModal',
|
||||
components: {
|
||||
IconBrandGoogleDrive,
|
||||
IconInnerShadowTop
|
||||
},
|
||||
inject: ['t'],
|
||||
props: {
|
||||
templateId: {
|
||||
type: [Number, String],
|
||||
required: true
|
||||
},
|
||||
authenticityToken: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
reopenAfterAuth: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
modalContainerEl: {
|
||||
type: [Object, String],
|
||||
required: true
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
}
|
||||
},
|
||||
emits: ['close', 'picked', 'update:loading'],
|
||||
data () {
|
||||
return {
|
||||
isConnectClicked: false,
|
||||
showOauthButton: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
oauthPath () {
|
||||
const redir = this.reopenAfterAuth
|
||||
? `/templates/${this.templateId}/edit?google_drive_open=1`
|
||||
: `/templates/${this.templateId}/edit`
|
||||
|
||||
const params = {
|
||||
access_type: 'offline',
|
||||
include_granted_scopes: 'true',
|
||||
prompt: 'consent',
|
||||
scope: [
|
||||
'https://www.googleapis.com/auth/userinfo.email',
|
||||
'https://www.googleapis.com/auth/drive.file'
|
||||
].join(' '),
|
||||
oauth_data: new URLSearchParams({ redir }).toString()
|
||||
}
|
||||
|
||||
return `/auth/google_oauth2?${new URLSearchParams(params).toString()}`
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
window.addEventListener('message', this.messageHandler)
|
||||
},
|
||||
beforeUnmount () {
|
||||
window.removeEventListener('message', this.messageHandler)
|
||||
},
|
||||
methods: {
|
||||
messageHandler (event) {
|
||||
if (event.data.type === 'google-drive-files-picked') {
|
||||
const files = event.data.files || []
|
||||
|
||||
if (!files.length) return
|
||||
|
||||
this.$emit('update:loading', true)
|
||||
this.$emit('picked', files)
|
||||
} else if (event.data.type === 'google-drive-picker-loaded') {
|
||||
this.$emit('update:loading', false)
|
||||
} else if (event.data.type === 'google-drive-picker-request-oauth') {
|
||||
this.showOauthButton = true
|
||||
this.$emit('update:loading', false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -219,7 +219,9 @@ const en = {
|
||||
revisions: 'Revisions',
|
||||
apply: 'Apply',
|
||||
no_revisions_yet: 'No revisions yet',
|
||||
viewing_revision_from: 'Viewing revision from {date}'
|
||||
viewing_revision_from: 'Viewing revision from {date}',
|
||||
connect_google_drive: 'Connect Google Drive',
|
||||
submitting: 'Submitting'
|
||||
}
|
||||
|
||||
const es = {
|
||||
@@ -443,7 +445,9 @@ const es = {
|
||||
revisions: 'Revisiones',
|
||||
apply: 'Aplicar',
|
||||
no_revisions_yet: 'Aún no hay revisiones',
|
||||
viewing_revision_from: 'Viendo revisión del {date}'
|
||||
viewing_revision_from: 'Viendo revisión del {date}',
|
||||
connect_google_drive: 'Conectar Google Drive',
|
||||
submitting: 'Enviando'
|
||||
}
|
||||
|
||||
const it = {
|
||||
@@ -667,7 +671,9 @@ const it = {
|
||||
revisions: 'Revisioni',
|
||||
apply: 'Applica',
|
||||
no_revisions_yet: 'Nessuna revisione ancora',
|
||||
viewing_revision_from: 'Visualizzazione revisione del {date}'
|
||||
viewing_revision_from: 'Visualizzazione revisione del {date}',
|
||||
connect_google_drive: 'Connetti Google Drive',
|
||||
submitting: 'Invio'
|
||||
}
|
||||
|
||||
const pt = {
|
||||
@@ -891,7 +897,9 @@ const pt = {
|
||||
revisions: 'Revisões',
|
||||
apply: 'Aplicar',
|
||||
no_revisions_yet: 'Nenhuma revisão ainda',
|
||||
viewing_revision_from: 'Visualizando revisão de {date}'
|
||||
viewing_revision_from: 'Visualizando revisão de {date}',
|
||||
connect_google_drive: 'Conectar Google Drive',
|
||||
submitting: 'Enviando'
|
||||
}
|
||||
|
||||
const fr = {
|
||||
@@ -1115,7 +1123,9 @@ const fr = {
|
||||
revisions: 'Révisions',
|
||||
apply: 'Appliquer',
|
||||
no_revisions_yet: 'Aucune révision pour le moment',
|
||||
viewing_revision_from: 'Affichage de la révision du {date}'
|
||||
viewing_revision_from: 'Affichage de la révision du {date}',
|
||||
connect_google_drive: 'Connecter Google Drive',
|
||||
submitting: 'Soumission'
|
||||
}
|
||||
|
||||
const de = {
|
||||
@@ -1339,7 +1349,9 @@ const de = {
|
||||
revisions: 'Revisionen',
|
||||
apply: 'Anwenden',
|
||||
no_revisions_yet: 'Noch keine Revisionen',
|
||||
viewing_revision_from: 'Ansicht der Revision vom {date}'
|
||||
viewing_revision_from: 'Ansicht der Revision vom {date}',
|
||||
connect_google_drive: 'Google Drive verbinden',
|
||||
submitting: 'Übermittlung'
|
||||
}
|
||||
|
||||
const nl = {
|
||||
@@ -1563,7 +1575,9 @@ const nl = {
|
||||
revisions: 'Revisies',
|
||||
apply: 'Toepassen',
|
||||
no_revisions_yet: 'Nog geen revisies',
|
||||
viewing_revision_from: 'Revisie van {date} bekijken'
|
||||
viewing_revision_from: 'Revisie van {date} bekijken',
|
||||
connect_google_drive: 'Verbind Google Drive',
|
||||
submitting: 'Indienen'
|
||||
}
|
||||
|
||||
export { en, es, it, pt, fr, de, nl }
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
:src="image.url"
|
||||
:width="width"
|
||||
:height="height"
|
||||
class="rounded"
|
||||
class="w-full h-full rounded"
|
||||
@load="onImageLoad"
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -36,7 +36,10 @@
|
||||
v-if="withReplaceButton"
|
||||
:template-id="template.id"
|
||||
:accept-file-types="acceptFileTypes"
|
||||
class="opacity-0 group-hover:opacity-100"
|
||||
:authenticity-token="authenticityToken"
|
||||
:with-google-drive="withGoogleDrive"
|
||||
:google-drive-file-id="item.google_drive_file_id"
|
||||
class="opacity-0 group-hover:opacity-100 has-[label:focus]:opacity-100"
|
||||
@click.stop
|
||||
@success="$emit('replace', { replaceSchemaItem: item, ...$event })"
|
||||
/>
|
||||
@@ -232,6 +235,16 @@ export default {
|
||||
required: true,
|
||||
default: true
|
||||
},
|
||||
withGoogleDrive: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
authenticityToken: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
dynamicDocuments: {
|
||||
type: Array,
|
||||
required: true
|
||||
|
||||
@@ -1,37 +1,106 @@
|
||||
<template>
|
||||
<label
|
||||
:for="inputId"
|
||||
class="btn btn-neutral btn-xs text-white transition-none replace-document-button"
|
||||
:class="{ 'opacity-100': isLoading }"
|
||||
>
|
||||
{{ message }}
|
||||
<div class="inline-flex items-stretch replace-document-control">
|
||||
<label
|
||||
:for="inputId"
|
||||
class="btn btn-neutral btn-xs transition-none replace-document-button"
|
||||
:class="{ 'opacity-100': isLoading, 'pr-0': showGoogleDriveDropdown && !isLoading }"
|
||||
>
|
||||
<div class="flex items-center justify-between w-full h-full">
|
||||
<span class="flex items-center space-x-2 w-full justify-center text-white">
|
||||
{{ message }}
|
||||
</span>
|
||||
<span
|
||||
v-if="showGoogleDriveDropdown && !isLoading"
|
||||
class="dropdown dropdown-end inline h-full"
|
||||
style="width: 30px"
|
||||
>
|
||||
<label
|
||||
tabindex="0"
|
||||
class="flex items-center h-full cursor-pointer text-white"
|
||||
>
|
||||
<IconChevronDown class="w-4 h-4 flex-shrink-0" />
|
||||
</label>
|
||||
<ul
|
||||
tabindex="0"
|
||||
:style="{ backgroundColor }"
|
||||
class="dropdown-content p-2 mt-2 shadow menu text-base mb-1 rounded-box text-right !text-base-content"
|
||||
>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
@click.prevent="openGoogleDriveModal"
|
||||
>
|
||||
<IconBrandGoogleDrive class="w-4 h-4 flex-shrink-0" />
|
||||
<span class="whitespace-nowrap text-sm normal-case font-medium">Google Drive</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
<form
|
||||
ref="form"
|
||||
class="hidden"
|
||||
>
|
||||
<input
|
||||
v-if="googleDriveFile"
|
||||
name="google_drive_file_ids[]"
|
||||
:value="googleDriveFile.id"
|
||||
>
|
||||
<input
|
||||
:id="inputId"
|
||||
ref="input"
|
||||
name="files[]"
|
||||
type="file"
|
||||
:accept="acceptFileTypes"
|
||||
@change="upload"
|
||||
@change="upload()"
|
||||
>
|
||||
</form>
|
||||
</label>
|
||||
<GoogleDrivePickerModal
|
||||
v-if="showGoogleDriveModal"
|
||||
v-model:loading="isLoadingGoogleDrive"
|
||||
:template-id="templateId"
|
||||
:authenticity-token="authenticityToken"
|
||||
:modal-container-el="modalContainerEl"
|
||||
@close="showGoogleDriveModal = false"
|
||||
@picked="onGoogleDrivePicked"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Upload from './upload'
|
||||
import GoogleDrivePickerModal from './google_drive_picker_modal'
|
||||
import { IconChevronDown, IconBrandGoogleDrive } from '@tabler/icons-vue'
|
||||
|
||||
export default {
|
||||
name: 'ReplaceDocument',
|
||||
inject: ['baseFetch', 't'],
|
||||
components: {
|
||||
IconChevronDown,
|
||||
IconBrandGoogleDrive,
|
||||
GoogleDrivePickerModal
|
||||
},
|
||||
inject: ['baseFetch', 't', 'backgroundColor'],
|
||||
props: {
|
||||
templateId: {
|
||||
type: [Number, String],
|
||||
required: true
|
||||
},
|
||||
authenticityToken: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
googleDriveFileId: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
withGoogleDrive: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
acceptFileTypes: {
|
||||
type: String,
|
||||
required: false,
|
||||
@@ -41,16 +110,25 @@ export default {
|
||||
emits: ['success'],
|
||||
data () {
|
||||
return {
|
||||
isLoading: false
|
||||
isLoading: false,
|
||||
isLoadingGoogleDrive: true,
|
||||
googleDriveFile: null,
|
||||
showGoogleDriveModal: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
inputId () {
|
||||
return 'el' + Math.random().toString(32).split('.')[1]
|
||||
},
|
||||
showGoogleDriveDropdown () {
|
||||
return this.withGoogleDrive && !!this.googleDriveFileId
|
||||
},
|
||||
uploadUrl () {
|
||||
return `/templates/${this.templateId}/documents`
|
||||
},
|
||||
modalContainerEl () {
|
||||
return this.$el.getRootNode().querySelector('#docuseal_modal_container')
|
||||
},
|
||||
message () {
|
||||
if (this.isLoading) {
|
||||
return this.t('uploading_')
|
||||
@@ -60,7 +138,27 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
upload: Upload.methods.upload
|
||||
upload: Upload.methods.upload,
|
||||
openGoogleDriveModal () {
|
||||
this.showGoogleDriveModal = true
|
||||
this.isLoadingGoogleDrive = true
|
||||
this.googleDriveFile = null
|
||||
this.$el.getRootNode().activeElement?.blur()
|
||||
},
|
||||
onGoogleDrivePicked (files) {
|
||||
this.googleDriveFile = files[0]
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.upload({ path: `/templates/${this.templateId}/google_drive_documents` }).then((resp) => {
|
||||
if (resp.ok) {
|
||||
this.showGoogleDriveModal = false
|
||||
}
|
||||
}).finally(() => {
|
||||
this.isLoadingGoogleDrive = false
|
||||
this.googleDriveFile = null
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -61,87 +61,16 @@
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
<Teleport
|
||||
<GoogleDrivePickerModal
|
||||
v-if="showGoogleDriveModal"
|
||||
:to="modalContainerEl"
|
||||
>
|
||||
<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="showGoogleDriveModal = false"
|
||||
/>
|
||||
<div class="modal-box pt-4 pb-6 px-6 mt-20 max-h-none w-full max-w-xl">
|
||||
<div class="flex justify-between items-center border-b pb-2 mb-2 font-medium">
|
||||
<span class="modal-title">
|
||||
Google Drive
|
||||
</span>
|
||||
<a
|
||||
href="#"
|
||||
class="text-xl modal-close-button"
|
||||
@click.prevent="showGoogleDriveModal = false"
|
||||
>×</a>
|
||||
</div>
|
||||
<div>
|
||||
<form
|
||||
v-if="showGoogleDriveOauthButton"
|
||||
method="post"
|
||||
:action="googleDriveOauthPath"
|
||||
@submit="isConnectGoogleDriveClicked = true"
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name="authenticity_token"
|
||||
:value="authenticityToken"
|
||||
autocomplete="off"
|
||||
>
|
||||
<button
|
||||
id="gdrive_oauth_button"
|
||||
class="btn bg-white btn-outline w-full text-base font-medium mt-4"
|
||||
data-turbo="false"
|
||||
type="submit"
|
||||
:disabled="isConnectGoogleDriveClicked"
|
||||
>
|
||||
<span v-if="isConnectGoogleDriveClicked">
|
||||
<span class="flex items-center justify-center space-x-2">
|
||||
<IconInnerShadowTop class="animate-spin" />
|
||||
<span class="">Submitting...</span>
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
>
|
||||
<span class="flex items-center justify-center space-x-2">
|
||||
<IconBrandGoogleDrive />
|
||||
<span>Connect Google Drive</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
<div
|
||||
v-else
|
||||
class="relative"
|
||||
>
|
||||
<iframe
|
||||
class="border border-base-300 rounded-lg"
|
||||
style="width: 100%; height: 440px; background: white;"
|
||||
src="/template_google_drive"
|
||||
/>
|
||||
<div v-if="isLoadingGoogleDrive">
|
||||
<div
|
||||
class="bg-white absolute top-0 bottom-0 left-0 right-0 opacity-80 rounded-lg"
|
||||
style="margin: 1px"
|
||||
/>
|
||||
<div class="absolute top-0 bottom-0 left-0 right-0 flex items-center justify-center">
|
||||
<IconInnerShadowTop class="animate-spin" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
v-model:loading="isLoadingGoogleDrive"
|
||||
:template-id="templateId"
|
||||
:authenticity-token="authenticityToken"
|
||||
:modal-container-el="modalContainerEl"
|
||||
:reopen-after-auth="true"
|
||||
@close="showGoogleDriveModal = false"
|
||||
@picked="onGoogleDrivePicked"
|
||||
/>
|
||||
<form
|
||||
ref="form"
|
||||
class="hidden"
|
||||
@@ -159,7 +88,7 @@
|
||||
type="file"
|
||||
:accept="acceptFileTypes"
|
||||
multiple
|
||||
@change="upload"
|
||||
@change="upload()"
|
||||
>
|
||||
</form>
|
||||
</div>
|
||||
@@ -167,6 +96,66 @@
|
||||
|
||||
<script>
|
||||
import { IconUpload, IconInnerShadowTop, IconChevronDown, IconBrandGoogleDrive } from '@tabler/icons-vue'
|
||||
import GoogleDrivePickerModal from './google_drive_picker_modal'
|
||||
|
||||
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',
|
||||
@@ -174,7 +163,8 @@ export default {
|
||||
IconUpload,
|
||||
IconInnerShadowTop,
|
||||
IconChevronDown,
|
||||
IconBrandGoogleDrive
|
||||
IconBrandGoogleDrive,
|
||||
GoogleDrivePickerModal
|
||||
},
|
||||
inject: ['baseFetch', 't', 'backgroundColor'],
|
||||
props: {
|
||||
@@ -202,11 +192,9 @@ export default {
|
||||
data () {
|
||||
return {
|
||||
isLoading: false,
|
||||
isConnectGoogleDriveClicked: false,
|
||||
isLoadingGoogleDrive: false,
|
||||
isLoadingGoogleDrive: true,
|
||||
googleDriveFiles: [],
|
||||
showGoogleDriveModal: false,
|
||||
showGoogleDriveOauthButton: false
|
||||
showGoogleDriveModal: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -219,69 +207,43 @@ export default {
|
||||
uploadUrl () {
|
||||
return `/templates/${this.templateId}/documents`
|
||||
},
|
||||
googleDriveOauthPath () {
|
||||
const params = {
|
||||
access_type: 'offline',
|
||||
include_granted_scopes: 'true',
|
||||
prompt: 'consent',
|
||||
scope: [
|
||||
'https://www.googleapis.com/auth/userinfo.email',
|
||||
'https://www.googleapis.com/auth/drive.file'
|
||||
].join(' '),
|
||||
oauth_data: new URLSearchParams({
|
||||
redir: `/templates/${this.templateId}/edit?google_drive_open=1`
|
||||
}).toString()
|
||||
}
|
||||
|
||||
const query = new URLSearchParams(params).toString()
|
||||
|
||||
return `/auth/google_oauth2?${query}`
|
||||
},
|
||||
modalContainerEl () {
|
||||
return this.$el.getRootNode().querySelector('#docuseal_modal_container')
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
window.addEventListener('message', this.messageHandler)
|
||||
|
||||
if (this.queryParams.get('google_drive_open') === '1') {
|
||||
this.openGoogleDriveModal()
|
||||
|
||||
window.history.replaceState({}, document.title, window.location.pathname)
|
||||
}
|
||||
},
|
||||
beforeUnmount () {
|
||||
window.removeEventListener('message', this.messageHandler)
|
||||
},
|
||||
methods: {
|
||||
openGoogleDriveModal () {
|
||||
this.showGoogleDriveModal = true
|
||||
this.isLoadingGoogleDrive = true
|
||||
},
|
||||
messageHandler (event) {
|
||||
if (event.data.type === 'google-drive-files-picked') {
|
||||
this.googleDriveFiles = event.data.files || []
|
||||
onGoogleDrivePicked (files) {
|
||||
this.googleDriveFiles = files
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.isLoadingGoogleDrive = true
|
||||
|
||||
this.upload({ path: `/templates/${this.templateId}/google_drive_documents` }).then((resp) => {
|
||||
if (resp.ok) {
|
||||
this.showGoogleDriveModal = false
|
||||
}
|
||||
}).finally(() => {
|
||||
this.isLoadingGoogleDrive = false
|
||||
})
|
||||
this.$nextTick(() => {
|
||||
this.upload({ path: `/templates/${this.templateId}/google_drive_documents` }).then((resp) => {
|
||||
if (resp.ok) {
|
||||
this.showGoogleDriveModal = false
|
||||
}
|
||||
}).finally(() => {
|
||||
this.isLoadingGoogleDrive = false
|
||||
this.googleDriveFiles = []
|
||||
})
|
||||
} else if (event.data.type === 'google-drive-picker-loaded') {
|
||||
this.isLoadingGoogleDrive = false
|
||||
} else if (event.data.type === 'google-drive-picker-request-oauth') {
|
||||
this.showGoogleDriveOauthButton = true
|
||||
}
|
||||
})
|
||||
},
|
||||
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' },
|
||||
|
||||
@@ -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'))
|
||||
|
||||
|
||||
@@ -28,6 +28,12 @@ class DynamicDocument < ApplicationRecord
|
||||
|
||||
has_many :versions, class_name: 'DynamicDocumentVersion', dependent: :destroy
|
||||
|
||||
has_one :current_version, class_name: 'DynamicDocumentVersion',
|
||||
primary_key: %i[id sha1],
|
||||
foreign_key: %i[dynamic_document_id sha1],
|
||||
dependent: :destroy,
|
||||
inverse_of: :dynamic_document
|
||||
|
||||
attribute :fields, :json
|
||||
|
||||
before_validation :set_sha1
|
||||
|
||||
@@ -75,7 +75,7 @@ class Template < ApplicationRecord
|
||||
has_many :dynamic_document_versions, through: :dynamic_documents, source: :versions
|
||||
|
||||
has_many :schema_dynamic_documents, lambda { |e|
|
||||
where(uuid: e.schema.select { |e| e['dynamic'] }.pluck('attachment_uuid'))
|
||||
where(uuid: e.schema.select { |item| item['dynamic'] }.pluck('attachment_uuid'))
|
||||
}, class_name: 'DynamicDocument', dependent: :destroy, inverse_of: :template
|
||||
|
||||
scope :active, -> { where(archived_at: nil) }
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
<% if signature %>
|
||||
<div class="flex justify-center mb-4 relative">
|
||||
<%= button_to button_title(title: t('remove'), disabled_with: t('removing')), user_signature_path, method: :delete, class: 'right-0 top-0 absolute link' %>
|
||||
<img src="<%= signature.url %>" style="max-height: 200px; width: auto" width="<%= signature.metadata['width'] %>" height="<%= signature.metadata['height'] %>">
|
||||
<img src="<%= signature.url %>" style="height: 130px; width: 100%; object-fit: contain;">
|
||||
</div>
|
||||
<% end %>
|
||||
<a href="<%= edit_user_signature_path %>" data-turbo-frame="modal" class="base-button w-full">
|
||||
@@ -49,7 +49,7 @@
|
||||
<% if initials %>
|
||||
<div class="flex justify-center mb-4 relative">
|
||||
<%= button_to button_title(title: t('remove'), disabled_with: t('removing')), user_initials_path, method: :delete, class: 'right-0 top-0 absolute link' %>
|
||||
<img src="<%= initials.url %>" style="max-height: 200px; width: auto" width="<%= initials.metadata['width'] %>" height="<%= initials.metadata['height'] %>">
|
||||
<img src="<%= initials.url %>" style="height: 130px; width: 100%; object-fit: contain;">
|
||||
</div>
|
||||
<% end %>
|
||||
<a href="<%= edit_user_initials_path %>" data-turbo-frame="modal" class="base-button w-full">
|
||||
|
||||
@@ -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>16k</span>
|
||||
<span>17k</span>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
@@ -227,7 +227,7 @@
|
||||
<div class="flex items-center space-x-1 mt-1">
|
||||
<span>
|
||||
<%= t('reason') %>:
|
||||
<%= simple_format(h(submitter.submission_events.find_by(event_type: :decline_form).data['reason'])) %>
|
||||
<%= simple_format(h(submitter.submission_events.find_by(event_type: :decline_form).data['reason']), {}, sanitize: false) %>
|
||||
</span>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -266,10 +266,12 @@
|
||||
<div dir="auto">
|
||||
<% if field['type'].in?(%w[signature initials]) %>
|
||||
<div class="w-full bg-base-300 py-1">
|
||||
<img class="object-contain mx-auto" style="max-height: <%= field['type'] == 'signature' ? 100 : 50 %>px" height="<%= attachments_index[value].metadata['height'] %>" width="<%= attachments_index[value].metadata['width'] %>" src="<%= attachments_index[value].url %>" loading="lazy" alt="<%= field['name'] || field['title'] || field['type'] %>">
|
||||
<% img_height = attachments_index[value].metadata['height'] %>
|
||||
<img class="object-contain mx-auto" style="<%= 'max-' if img_height %>height: <%= field['type'] == 'signature' ? 100 : 50 %>px" height="<%= img_height %>" width="<%= attachments_index[value].metadata['width'] %>" src="<%= attachments_index[value].url %>" loading="lazy" alt="<%= field['name'] || field['title'] || field['type'] %>">
|
||||
</div>
|
||||
<% elsif field['type'].in?(['image', 'stamp', 'kba']) && attachments_index[value].image? %>
|
||||
<img class="object-contain mx-auto max-h-28" style="max-height: 200px" height="<%= attachments_index[value].metadata['height'] %>" width="<%= attachments_index[value].metadata['width'] %>" src="<%= attachments_index[value].url %>" loading="lazy" alt="<%= field['name'] || field['title'] || field['type'] %>">
|
||||
<% img_height = attachments_index[value].metadata['height'] %>
|
||||
<img class="object-contain mx-auto" style="<%= 'max-' if img_height %>height: 200px" height="<%= img_height %>" width="<%= attachments_index[value].metadata['width'] %>" src="<%= attachments_index[value].url %>" loading="lazy" alt="<%= field['name'] || field['title'] || field['type'] %>">
|
||||
<% elsif field['type'].in?(['file', 'payment', 'image']) %>
|
||||
<div class="flex flex-col justify-center">
|
||||
<% Array.wrap(value).each do |val| %>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<p><%= t('hi_there') %>,</p>
|
||||
<p><%= t('name_declined_by_submitter_with_the_following_reason', name: @submitter.submission.name || @submitter.submission.template.name, submitter: @submitter.name || @submitter.email || @submitter.phone) %></p>
|
||||
<%= simple_format(h(@submitter.submission_events.find_by(event_type: :decline_form).data['reason'])) %>
|
||||
<%= simple_format(h(@submitter.submission_events.find_by(event_type: :decline_form).data['reason']), {}, sanitize: false) %>
|
||||
<p><%= link_to submission_url(@submitter.submission), submission_url(@submitter.submission) %></p>
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2 w-full md:w-fit md:justify-between md:flex-none md:pt-1">
|
||||
<% if !template.archived_at? && can?(:destroy, template) %>
|
||||
<%= button_to button_title(title: t('archive'), disabled_with: t('archiving'), title_class: 'inline', icon: svg_icon('archive', class: 'w-6 h-6')), template_path(template), class: 'btn btn-outline btn-sm w-full', form_class: 'flex-1', method: :delete, data: { turbo_confirm: t('are_you_sure_') } %>
|
||||
<%= button_to button_title(title: t('archive'), disabled_with: t('archiving')[..-4], title_class: 'inline', icon: svg_icon('archive', class: 'w-6 h-6')), template_path(template), class: 'btn btn-outline btn-sm w-full', form_class: 'flex-1', method: :delete %>
|
||||
<% end %>
|
||||
<% if can?(:create, current_account.templates.new(author: current_user)) %>
|
||||
<%= link_to new_template_clone_path(template), class: 'btn btn-outline btn-sm flex-1', data: { turbo_frame: :modal } do %>
|
||||
@@ -89,7 +89,7 @@
|
||||
<% end %>
|
||||
<% if template.archived_at? %>
|
||||
<% if can?(:create, template) %>
|
||||
<%= button_to button_title(title: t('restore'), disabled_with: t('restoring'), icon: svg_icon('rotate', class: 'w-6 h-6')), template_restore_index_path(template), class: 'btn btn-outline btn-sm flex-1' %>
|
||||
<%= button_to button_title(title: t('restore'), disabled_with: t('restoring')[..-4], icon: svg_icon('rotate', class: 'w-6 h-6')), template_restore_index_path(template), class: 'btn btn-outline btn-sm flex-1' %>
|
||||
<% end %>
|
||||
<%= link_to template_preview_path(template), class: 'btn btn-outline btn-sm flex-1' do %>
|
||||
<span class="flex items-center justify-center space-x-2">
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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-with-revisions-menu="<%= @template.template_versions.exists? %>" data-locale="<%= I18n.locale %>" data-show-tour-start-form="<%= @show_tour_start_form %>"></template-builder>
|
||||
<template-builder class="grid" data-template="<%= @template_data.to_json %>" 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 @@
|
||||
<template-builder class="grid" data-editable="false" data-with-sign-yourself-button="<%= !@template.archived_at? %>" data-with-send-button="<%= !@template.archived_at? && can?(:create, @template.submissions.new(account: current_account)) %>" data-template="<%= @template_data %>"></template-builder>
|
||||
<template-builder class="grid" data-editable="false" data-with-sign-yourself-button="<%= !@template.archived_at? %>" data-with-send-button="<%= !@template.archived_at? && can?(:create, @template.submissions.new(account: current_account)) %>" data-template="<%= @template_data.to_json %>"></template-builder>
|
||||
|
||||
@@ -1,18 +1,30 @@
|
||||
<div class="h-screen">
|
||||
<div
|
||||
class="text-center p-8 h-full flex items-center justify-center">
|
||||
<div>
|
||||
<%= render 'shared/logo', width: 50, height: 50, class: 'mx-auto animate-bounce' %>
|
||||
<span>
|
||||
<%= t('processing') %>...
|
||||
</span>
|
||||
<confirm-upload>
|
||||
<div class="h-screen">
|
||||
<div class="text-center p-8 h-full flex items-center justify-center">
|
||||
<div class="space-y-4">
|
||||
<div data-target="confirm-upload.logo" class="mx-auto">
|
||||
<%= render 'shared/logo', width: 50, height: 50, class: 'mx-auto' %>
|
||||
</div>
|
||||
<div data-target="confirm-upload.processing" class="hidden">
|
||||
<span><%= t('processing') %>...</span>
|
||||
</div>
|
||||
<div data-target="confirm-upload.prompt">
|
||||
<p class="text-lg">
|
||||
<%= t('open_file_from') %>
|
||||
<a href="<%= params[:url] %>" target="_blank" class="link" rel="noopener noreferrer nofollow">
|
||||
<%= params[:filename].presence || params[:url] %>
|
||||
</a>
|
||||
</p>
|
||||
<div class="flex items-center justify-center gap-2 mt-4">
|
||||
<%= link_to t('cancel'), root_path, class: 'white-button w-44' %>
|
||||
<%= form_for '', url: templates_upload_path, method: :post do |f| %>
|
||||
<input type="hidden" name="url" value="<%= params[:url] %>">
|
||||
<input type="hidden" name="filename" value="<%= params[:filename] %>">
|
||||
<%= f.button button_title(title: t('open')), class: 'base-button w-44' %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<submit-form>
|
||||
<%= form_for '', url: templates_upload_path, method: :post, class: 'hidden' do %>
|
||||
<button type="submit"></button>
|
||||
<input name="url" value="<%= params[:url] %>">
|
||||
<input name="filename" value="<%= params[:filename] %>">
|
||||
<% end %>
|
||||
</submit-form>
|
||||
</confirm-upload>
|
||||
|
||||
@@ -25,6 +25,18 @@ module DocuSeal
|
||||
|
||||
config.active_storage.draw_routes = ENV['MULTITENANT'] != 'true'
|
||||
|
||||
config.active_storage.analyzers = []
|
||||
|
||||
config.active_storage.variant_processor = :disabled
|
||||
|
||||
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]
|
||||
|
||||
@@ -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]
|
||||
@@ -1,3 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
Oj.optimize_rails
|
||||
@@ -937,6 +937,9 @@ en: &en
|
||||
your_email_address_has_been_changed: Your email address has been changed
|
||||
the_email_address_for_your_account_has_been_changed_to_new_email: The email address for your account has been changed to %{new_email}.
|
||||
if_you_did_not_make_this_change_please_contact_us_by_replying_to_this_email: If you did not make this change, please contact us by replying to this email.
|
||||
open_file_from: 'Open file from'
|
||||
cancel: Cancel
|
||||
open: Open
|
||||
devise:
|
||||
confirmations:
|
||||
confirmed: Your email address has been successfully confirmed.
|
||||
@@ -1987,6 +1990,9 @@ es: &es
|
||||
your_email_address_has_been_changed: Tu dirección de correo electrónico ha sido cambiada
|
||||
the_email_address_for_your_account_has_been_changed_to_new_email: La dirección de correo electrónico de tu cuenta ha sido cambiada a %{new_email}.
|
||||
if_you_did_not_make_this_change_please_contact_us_by_replying_to_this_email: Si no realizaste este cambio, contáctanos respondiendo a este correo electrónico.
|
||||
open_file_from: 'Abrir archivo desde'
|
||||
cancel: Cancelar
|
||||
open: Abrir
|
||||
devise:
|
||||
confirmations:
|
||||
confirmed: Tu dirección de correo electrónico ha sido confirmada correctamente.
|
||||
@@ -3037,6 +3043,9 @@ it: &it
|
||||
your_email_address_has_been_changed: Il tuo indirizzo email è stato modificato
|
||||
the_email_address_for_your_account_has_been_changed_to_new_email: L'indirizzo email del tuo account è stato modificato in %{new_email}.
|
||||
if_you_did_not_make_this_change_please_contact_us_by_replying_to_this_email: Se non hai effettuato questa modifica, contattaci rispondendo a questa email.
|
||||
open_file_from: 'Aprire file da'
|
||||
cancel: Annulla
|
||||
open: Apri
|
||||
devise:
|
||||
confirmations:
|
||||
confirmed: Il tuo indirizzo email è stato confermato con successo.
|
||||
@@ -4084,6 +4093,9 @@ fr: &fr
|
||||
your_email_address_has_been_changed: Votre adresse e-mail a été modifiée
|
||||
the_email_address_for_your_account_has_been_changed_to_new_email: "L'adresse e-mail de votre compte a été modifiée en %{new_email}."
|
||||
if_you_did_not_make_this_change_please_contact_us_by_replying_to_this_email: Si vous n'avez pas effectué ce changement, veuillez nous contacter en répondant à cet e-mail.
|
||||
open_file_from: 'Ouvrir le fichier depuis'
|
||||
cancel: Annuler
|
||||
open: Ouvrir
|
||||
devise:
|
||||
confirmations:
|
||||
confirmed: Votre adresse e-mail a été confirmée avec succès.
|
||||
@@ -5134,6 +5146,9 @@ pt: &pt
|
||||
your_email_address_has_been_changed: Seu endereço de e-mail foi alterado
|
||||
the_email_address_for_your_account_has_been_changed_to_new_email: O endereço de e-mail da sua conta foi alterado para %{new_email}.
|
||||
if_you_did_not_make_this_change_please_contact_us_by_replying_to_this_email: Se você não fez essa alteração, entre em contato conosco respondendo a este e-mail.
|
||||
open_file_from: 'Abrir arquivo de'
|
||||
cancel: Cancelar
|
||||
open: Abrir
|
||||
devise:
|
||||
confirmations:
|
||||
confirmed: Seu endereço de e-mail foi confirmado com sucesso.
|
||||
@@ -6184,6 +6199,9 @@ de: &de
|
||||
your_email_address_has_been_changed: Ihre E-Mail-Adresse wurde geändert
|
||||
the_email_address_for_your_account_has_been_changed_to_new_email: Die E-Mail-Adresse Ihres Kontos wurde in %{new_email} geändert.
|
||||
if_you_did_not_make_this_change_please_contact_us_by_replying_to_this_email: Wenn Sie diese Änderung nicht vorgenommen haben, kontaktieren Sie uns bitte, indem Sie auf diese E-Mail antworten.
|
||||
open_file_from: 'Datei öffnen von'
|
||||
cancel: Abbrechen
|
||||
open: Öffnen
|
||||
devise:
|
||||
confirmations:
|
||||
confirmed: Ihre E-Mail-Adresse wurde erfolgreich bestätigt.
|
||||
@@ -7635,6 +7653,9 @@ nl: &nl
|
||||
your_email_address_has_been_changed: Uw e-mailadres is gewijzigd
|
||||
the_email_address_for_your_account_has_been_changed_to_new_email: Het e-mailadres van uw account is gewijzigd naar %{new_email}.
|
||||
if_you_did_not_make_this_change_please_contact_us_by_replying_to_this_email: Als u deze wijziging niet hebt aangebracht, neem dan contact met ons op door op deze e-mail te antwoorden.
|
||||
open_file_from: 'Bestand openen van'
|
||||
cancel: Annuleren
|
||||
open: Openen
|
||||
devise:
|
||||
confirmations:
|
||||
confirmed: Uw e-mailadres is succesvol bevestigd.
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Accounts
|
||||
LINK_EXPIRES_AT = 40.minutes
|
||||
LINK_EXPIRES_AT = ENV.fetch('FILE_URLS_EXPIRE_MINUTES', '40').to_i.minutes
|
||||
|
||||
module_function
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ module HtmlToPlainText
|
||||
|
||||
doc = Nokogiri::HTML.fragment(cleaned)
|
||||
|
||||
doc.css('script').each(&:remove)
|
||||
doc.xpath('.//script').each(&:remove)
|
||||
|
||||
result = process_nodes(doc, line_length)
|
||||
|
||||
@@ -33,12 +33,9 @@ module HtmlToPlainText
|
||||
result = +''
|
||||
|
||||
node.children.each do |child|
|
||||
case child
|
||||
when Nokogiri::XML::Text
|
||||
if child.text? || child.cdata?
|
||||
result << child.text
|
||||
when Nokogiri::XML::Comment
|
||||
next
|
||||
when Nokogiri::XML::Element
|
||||
elsif child.element?
|
||||
result << process_element(child, line_length)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+1
-1
@@ -39,7 +39,7 @@ module LoadBmp
|
||||
bands = header_data[:bpp] / 8
|
||||
end
|
||||
|
||||
image = Vips::Image.new_from_memory(final_pixel_data, header_data[:width], header_data[:height], bands, :uchar)
|
||||
image = Vips::Image.new_from_memory_copy(final_pixel_data, header_data[:width], header_data[:height], bands, :uchar)
|
||||
|
||||
image = image.flip(:vertical) if header_data[:orientation] == -1
|
||||
|
||||
|
||||
+5
-3
@@ -43,7 +43,9 @@ module LoadIco
|
||||
|
||||
raise ArgumentError, 'Unable to load' unless image_data_bytes && image_data_bytes.bytesize == best_entry[:size]
|
||||
|
||||
return Vips::Image.new_from_buffer(image_data_bytes, '') if image_data_bytes.start_with?(PNG_SIGNATURE)
|
||||
if image_data_bytes.start_with?(PNG_SIGNATURE)
|
||||
return ImageUtils.load_vips(image_data_bytes, content_type: 'image/png')
|
||||
end
|
||||
|
||||
image = load_image_entry(image_data_bytes, best_entry[:width], best_entry[:height])
|
||||
|
||||
@@ -200,13 +202,13 @@ module LoadIco
|
||||
|
||||
return nil unless pixel_data_string.bytesize == expected_bytes && expected_bytes.positive?
|
||||
|
||||
Vips::Image.new_from_memory(
|
||||
Vips::Image.new_from_memory_copy(
|
||||
pixel_data_string,
|
||||
dib_width,
|
||||
image_pixel_height,
|
||||
4,
|
||||
:uchar
|
||||
)
|
||||
).copy(interpretation: :srgb)
|
||||
end
|
||||
# rubocop:enable Metrics
|
||||
end
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'puma/plugin'
|
||||
require 'redis_client'
|
||||
|
||||
# rubocop:disable Metrics
|
||||
Puma::Plugin.create do
|
||||
@@ -68,7 +69,7 @@ Puma::Plugin.create do
|
||||
|
||||
break
|
||||
rescue RedisClient::CannotConnectError
|
||||
raise('Unable to connect to redis') if attempt > 10
|
||||
raise('Unable to connect to redis') if attempt > 30
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
+4
-2
@@ -162,7 +162,7 @@ module Submissions
|
||||
return email.downcase.sub(/@gmail?\z/i, '@gmail.com') if email.match?(/@gmail?\z/i)
|
||||
|
||||
return email.downcase if email.include?(',') ||
|
||||
email.match?(/\.(?:gob|om|mm|cm|et|mo|nz|za|ie|ed\.jp)\z/i) ||
|
||||
email.match?(/\.(?:gob(?:\.\w+)?|om|mm|cm|et|mo|nz|za|ie|ed\.jp)\z/i) ||
|
||||
email.exclude?('.')
|
||||
|
||||
fixed_email = EmailTypo.call(email.delete_prefix('<'))
|
||||
@@ -175,7 +175,9 @@ module Submissions
|
||||
return email.downcase if domain == fixed_domain
|
||||
return email.downcase if fixed_domain.match?(/\Agmail\.(?!com\z)/i)
|
||||
|
||||
if DidYouMean::Levenshtein.distance(domain, fixed_domain) > 3
|
||||
threshold = fixed_domain.start_with?('hotmail.') ? 2 : 3
|
||||
|
||||
if DidYouMean::Levenshtein.distance(domain, fixed_domain) > threshold
|
||||
Rails.logger.info("Skipped email fix #{domain}")
|
||||
|
||||
return email.downcase
|
||||
|
||||
@@ -129,6 +129,8 @@ module Submissions
|
||||
with_submitter_timezone = configs.find { |c| c.key == AccountConfig::WITH_SUBMITTER_TIMEZONE_KEY }&.value == true
|
||||
with_timestamp_seconds = configs.find { |c| c.key == AccountConfig::WITH_TIMESTAMP_SECONDS_KEY }&.value == true
|
||||
|
||||
file_links_expire_at = Accounts.link_expires_at(submission.account) if with_file_links
|
||||
|
||||
timezone = account.timezone
|
||||
timezone = last_submitter.timezone || account.timezone if with_submitter_timezone
|
||||
|
||||
@@ -345,6 +347,13 @@ module Submissions
|
||||
|
||||
field_name = grouped_value_field_names[value].presence || field['title'].presence || field['name'].to_s
|
||||
|
||||
field_type = field['type']
|
||||
|
||||
if field_type == 'image' &&
|
||||
submitter.attachments.find { |a| a.uuid == value }.then { |a| !a.image? || a.content_type == 'image/heic' }
|
||||
field_type = 'file'
|
||||
end
|
||||
|
||||
[
|
||||
composer.formatted_text_box(
|
||||
[
|
||||
@@ -357,13 +366,13 @@ module Submissions
|
||||
text_align: field_name.to_s.match?(RTL_REGEXP) ? :right : :left,
|
||||
line_spacing: 1.3, padding: [0, 0, 2, 0]
|
||||
),
|
||||
if field['type'].in?(%w[image signature initials stamp kba]) &&
|
||||
if field_type.in?(%w[image signature initials stamp kba]) &&
|
||||
(attachment = submitter.attachments.find { |a| a.uuid == value }) &&
|
||||
attachment.image?
|
||||
|
||||
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?
|
||||
@@ -379,7 +388,7 @@ module Submissions
|
||||
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'))
|
||||
StringIO.new(resized_image.write_to_buffer('.png', strip: true))
|
||||
end
|
||||
|
||||
width = field['type'] == 'initials' ? 50 : 200
|
||||
@@ -392,7 +401,7 @@ module Submissions
|
||||
|
||||
composer.image(io, width:, height:, margin: [5, 0, 10, 0])
|
||||
composer.formatted_text_box([{ text: '' }])
|
||||
elsif field['type'].in?(%w[file payment image])
|
||||
elsif field_type.in?(%w[file payment image])
|
||||
if field['type'] == 'payment'
|
||||
unit = CURRENCY_SYMBOLS[field['preferences']['currency']] || field['preferences']['currency']
|
||||
|
||||
@@ -408,7 +417,7 @@ module Submissions
|
||||
|
||||
link =
|
||||
if with_file_links
|
||||
ActiveStorage::Blob.proxy_url(attachment.blob)
|
||||
ActiveStorage::Blob.proxy_url(attachment.blob, expires_at: file_links_expire_at)
|
||||
else
|
||||
r.submissions_preview_url(submission.slug, **Docuseal.default_url_options)
|
||||
end
|
||||
@@ -417,7 +426,7 @@ module Submissions
|
||||
end,
|
||||
padding: [0, 0, 10, 0]
|
||||
)
|
||||
elsif field['type'] == 'checkbox'
|
||||
elsif field_type == 'checkbox'
|
||||
composer.formatted_text_box([{ text: value.to_s.titleize }], padding: [0, 0, 10, 0])
|
||||
else
|
||||
if field['type'] == 'date'
|
||||
|
||||
@@ -29,6 +29,8 @@ module Submissions
|
||||
with_signature_id_reason =
|
||||
configs.find { |c| c.key == AccountConfig::WITH_SIGNATURE_ID_REASON_KEY }&.value != false
|
||||
|
||||
file_links_expire_at = Accounts.link_expires_at(submission.account) if with_file_links
|
||||
|
||||
pdfs_index = GenerateResultAttachments.build_pdfs_index(submission, flatten: is_flatten,
|
||||
incremental: is_rotate_incremental)
|
||||
|
||||
@@ -42,7 +44,8 @@ module Submissions
|
||||
GenerateResultAttachments.fill_submitter_fields(s, submission.account, pdfs_index,
|
||||
with_signature_id:, is_flatten:, with_headings: index.zero?,
|
||||
with_submitter_timezone:, with_file_links:,
|
||||
with_signature_id_reason:, with_timestamp_seconds:)
|
||||
file_links_expire_at:, with_signature_id_reason:,
|
||||
with_timestamp_seconds:)
|
||||
end
|
||||
|
||||
template = submission.template
|
||||
|
||||
@@ -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
|
||||
@@ -154,6 +151,8 @@ module Submissions
|
||||
with_signature_id_reason =
|
||||
configs.find { |c| c.key == AccountConfig::WITH_SIGNATURE_ID_REASON_KEY }&.value != false
|
||||
|
||||
file_links_expire_at = Accounts.link_expires_at(submitter.account) if with_file_links
|
||||
|
||||
pdfs_index = build_pdfs_index(submitter.submission, submitter:, flatten: is_flatten,
|
||||
incremental: is_rotate_incremental)
|
||||
|
||||
@@ -201,12 +200,14 @@ module Submissions
|
||||
with_submitter_timezone:,
|
||||
with_file_links:,
|
||||
with_timestamp_seconds:,
|
||||
with_signature_id_reason:)
|
||||
with_signature_id_reason:,
|
||||
file_links_expire_at:)
|
||||
end
|
||||
|
||||
def fill_submitter_fields(submitter, account, pdfs_index, with_signature_id:, is_flatten:, with_headings: nil,
|
||||
with_submitter_timezone: false, with_signature_id_reason: true,
|
||||
with_timestamp_seconds: false, with_file_links: nil)
|
||||
with_timestamp_seconds: false, with_file_links: nil,
|
||||
file_links_expire_at: Accounts.link_expires_at(account))
|
||||
cell_layouters = Hash.new do |hash, valign|
|
||||
hash[valign] = HexaPDF::Layout::TextLayouter.new(text_valign: valign.to_sym, text_align: :center)
|
||||
end
|
||||
@@ -293,8 +294,11 @@ module Submissions
|
||||
canvas.font(FONT_NAME, size: font_size)
|
||||
|
||||
field_type = field['type']
|
||||
field_type = 'file' if field_type == 'image' &&
|
||||
!submitter.attachments.find { |a| a.uuid == value }.image?
|
||||
|
||||
if field_type == 'image' &&
|
||||
submitter.attachments.find { |a| a.uuid == value }.then { |a| !a.image? || a.content_type == 'image/heic' }
|
||||
field_type = 'file'
|
||||
end
|
||||
|
||||
if field_type == 'signature' && field.dig('preferences', 'with_signature_id').in?([true, false])
|
||||
with_signature_id = field['preferences']['with_signature_id']
|
||||
@@ -313,7 +317,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 +365,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 +433,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 +460,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?
|
||||
@@ -468,7 +480,7 @@ module Submissions
|
||||
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'))
|
||||
StringIO.new(resized_image.write_to_buffer('.png', strip: true))
|
||||
end
|
||||
|
||||
canvas.image(
|
||||
@@ -511,7 +523,7 @@ module Submissions
|
||||
|
||||
url =
|
||||
if with_file_links
|
||||
ActiveStorage::Blob.proxy_url(attachment.blob)
|
||||
ActiveStorage::Blob.proxy_url(attachment.blob, expires_at: file_links_expire_at)
|
||||
else
|
||||
r.submissions_preview_url(submission.slug, **Docuseal.default_url_options)
|
||||
end
|
||||
@@ -738,7 +750,7 @@ module Submissions
|
||||
pdf.trailer.info[:Creator] = info_creator
|
||||
|
||||
if Docuseal.pdf_format == 'pdf/a-3b'
|
||||
pdf.task(:pdfa, level: '3b')
|
||||
pdfa_listener = pdf.task(:pdfa, level: '3b')
|
||||
pdf.config['font.map'] = PDFA_FONT_MAP
|
||||
end
|
||||
|
||||
@@ -754,12 +766,14 @@ module Submissions
|
||||
|
||||
begin
|
||||
pdf.sign(io, write_options: { validate: false }, **sign_params)
|
||||
rescue HexaPDF::Error, NoMethodError => e
|
||||
rescue HexaPDF::Error, NoMethodError, TypeError => e
|
||||
Rollbar.error(e) if defined?(Rollbar)
|
||||
|
||||
pdf.instance_variable_get(:@listeners)[:complete_objects].delete(pdfa_listener) if pdfa_listener
|
||||
|
||||
begin
|
||||
pdf.sign(io, write_options: { validate: false, incremental: false }, **sign_params)
|
||||
rescue HexaPDF::Error
|
||||
rescue HexaPDF::Error, TypeError
|
||||
pdf.validate(auto_correct: true)
|
||||
pdf.sign(io, write_options: { validate: false, incremental: false }, **sign_params)
|
||||
end
|
||||
@@ -1021,20 +1035,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
|
||||
|
||||
+12
-18
@@ -122,27 +122,21 @@ 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, metadata: {})
|
||||
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,
|
||||
metadata:)
|
||||
|
||||
ActiveStorage::Attachment.create!(blob:, name: 'attachments', record: submitter)
|
||||
end
|
||||
|
||||
def normalize_preferences(account, user, params)
|
||||
|
||||
@@ -6,12 +6,6 @@ module Submitters
|
||||
HEIGHT = 200
|
||||
LRM = "\u200E"
|
||||
|
||||
TRANSPARENT_PIXEL = "\x89PNG\r\n\u001A\n\u0000\u0000\u0000\rIHDR\u0000" \
|
||||
"\u0000\u0000\u0001\u0000\u0000\u0000\u0001\b\u0004" \
|
||||
"\u0000\u0000\u0000\xB5\u001C\f\u0002\u0000\u0000\u0000" \
|
||||
"\vIDATx\xDAc\xFC_\u000F\u0000\u0002\x83\u0001\x804\xC3ڨ" \
|
||||
"\u0000\u0000\u0000\u0000IEND\xAEB`\x82"
|
||||
|
||||
module_function
|
||||
|
||||
def call(submitter, with_logo: true)
|
||||
@@ -25,7 +19,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,16 +34,17 @@ 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)
|
||||
Vips::Image.black(WIDTH, WIDTH, bands: 4).copy(interpretation: :srgb)
|
||||
end
|
||||
|
||||
logo = logo.resize([WIDTH / logo.width.to_f, HEIGHT / logo.height.to_f].min)
|
||||
logo = logo.copy(interpretation: :srgb) if logo.interpretation == :multiband
|
||||
|
||||
base_layer = Vips::Image.black(WIDTH, HEIGHT).new_from_image([255, 255, 255]).copy(interpretation: :srgb)
|
||||
base_layer = Vips::Image.black(WIDTH, HEIGHT).new_from_image([255, 255, 255, 255]).copy(interpretation: :srgb)
|
||||
|
||||
opacity_layer = Vips::Image.new_from_buffer(TRANSPARENT_PIXEL, '').resize(WIDTH)
|
||||
opacity_layer = Vips::Image.black(WIDTH, HEIGHT).new_from_image([255, 255, 255, 127]).copy(interpretation: :srgb)
|
||||
|
||||
text = build_text_image(submitter)
|
||||
|
||||
|
||||
@@ -27,7 +27,9 @@ module Submitters
|
||||
|
||||
text_mask = Vips::Image.black(text_image.width, text_image.height)
|
||||
|
||||
text_mask.bandjoin(text_image).copy(interpretation: :b_w).write_to_buffer('.png')
|
||||
image = text_mask.bandjoin(text_image).copy(interpretation: :b_w)
|
||||
|
||||
[image.write_to_buffer('.png'), image.width, image.height]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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,19 +236,31 @@ 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)
|
||||
data = Submitters::GenerateFontImage.call(text, font: type)
|
||||
data, width, height = Submitters::GenerateFontImage.call(text, font: type)
|
||||
|
||||
checksum = Digest::MD5.base64digest(data)
|
||||
|
||||
@@ -256,11 +268,20 @@ module Submitters
|
||||
|
||||
blob || ActiveStorage::Blob.create_and_upload!(
|
||||
io: StringIO.new(data),
|
||||
filename: "#{type}.png"
|
||||
filename: "#{type}.png",
|
||||
content_type: 'image/png',
|
||||
metadata: { analyzed: true, identified: true, width:, height: }
|
||||
)
|
||||
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 +297,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)
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
module Templates
|
||||
COLOR_REGEXP = /\A(#(?:[0-9a-f]{3}|[0-9a-f]{6})|[a-z]+)\z/i
|
||||
|
||||
TEMPLATE_BUILDER_FIELDS = %i[id author_id folder_id external_id name slug
|
||||
schema fields submitters variables_schema preferences
|
||||
shared_link source archived_at created_at updated_at].freeze
|
||||
|
||||
EXPIRATION_DURATIONS = {
|
||||
one_day: 1.day,
|
||||
two_days: 2.days,
|
||||
@@ -91,4 +95,16 @@ module Templates
|
||||
Time.current + EXPIRATION_DURATIONS[default_expire_at_duration]
|
||||
end
|
||||
end
|
||||
|
||||
def serialize_for_builder(template)
|
||||
data = template.as_json(only: TEMPLATE_BUILDER_FIELDS)
|
||||
|
||||
data['documents'] = template.schema_documents.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] } }
|
||||
)
|
||||
|
||||
data
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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)
|
||||
|
||||
fields = inference.call(image, confidence:, nms:, nmm:, split_page:,
|
||||
temperature:, aspect_ratio:, padding:)
|
||||
@@ -193,7 +193,7 @@ module Templates
|
||||
|
||||
data, width, height = page.render_to_bitmap(size_key => size)
|
||||
|
||||
Vips::Image.new_from_memory(data, width, height, 4, :uchar)
|
||||
Vips::Image.new_from_memory_copy(data, width, height, 4, :uchar)
|
||||
end
|
||||
|
||||
def sort_fields(fields, y_threshold: 0.01)
|
||||
|
||||
@@ -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_copy(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_copy(bytes, width, height, 4, :uchar)
|
||||
|
||||
blob = build_and_upload_blob(image, page_number, PREVIEW_FORMAT)
|
||||
|
||||
ApplicationRecord.no_touching do
|
||||
ActiveStorage::Attachment.create!(
|
||||
|
||||
@@ -14,27 +14,25 @@ module Templates
|
||||
|
||||
module_function
|
||||
|
||||
def call(template, schema_documents: template.schema_documents.preload(:blob), preview_image_attachments: nil,
|
||||
expires_at: Accounts.link_expires_at(Account.new(id: template.account_id)))
|
||||
def call(template, schema_documents: template.schema_documents.preload(:blob), dynamic_documents: nil,
|
||||
preview_image_attachments: nil, expires_at: Accounts.link_expires_at(Account.new(id: template.account_id)))
|
||||
json = template.as_json(SERIALIZE_PARAMS)
|
||||
|
||||
preview_image_attachments ||=
|
||||
ActiveStorage::Attachment.joins(:blob)
|
||||
.where(blob: { filename: ['0.jpg', '0.png'] })
|
||||
.where(record_id: schema_documents.map(&:id),
|
||||
record_type: 'ActiveStorage::Attachment',
|
||||
name: :preview_images)
|
||||
.preload(:blob)
|
||||
dynamic_documents ||= preload_dynamic_documents(template)
|
||||
|
||||
preview_image_attachments ||= preload_preview_image_attachments(schema_documents, dynamic_documents)
|
||||
|
||||
json['documents'] = template.schema.filter_map do |item|
|
||||
attachment = schema_documents.find { |e| e.uuid == item['attachment_uuid'] }
|
||||
if item['dynamic']
|
||||
dynamic_document = dynamic_documents.find { |e| e.uuid == item['attachment_uuid'] }
|
||||
|
||||
unless attachment
|
||||
Rollbar.error("Documents missing: #{template.id}") if defined?(Rollbar)
|
||||
|
||||
next
|
||||
attachment = dynamic_document.current_version&.document_attachment
|
||||
end
|
||||
|
||||
attachment ||= schema_documents.find { |e| e.uuid == item['attachment_uuid'] }
|
||||
|
||||
next unless attachment
|
||||
|
||||
first_page_blob = preview_image_attachments.find { |e| e.record_id == attachment.id }&.blob
|
||||
first_page_blob ||= attachment.preview_images.joins(:blob).find_by(blob: { filename: ['0.jpg', '0.png'] })&.blob
|
||||
|
||||
@@ -49,5 +47,26 @@ module Templates
|
||||
|
||||
json
|
||||
end
|
||||
|
||||
def preload_dynamic_documents(template)
|
||||
return DynamicDocument.none if template.schema.none? { |item| item['dynamic'] }
|
||||
|
||||
template.schema_dynamic_documents
|
||||
.preload(current_version: { document_attachment: :blob })
|
||||
.select(:id, :uuid, :template_id, :sha1, :created_at, :updated_at)
|
||||
end
|
||||
|
||||
def preload_preview_image_attachments(schema_documents, dynamic_documents)
|
||||
record_ids =
|
||||
schema_documents.map(&:id) +
|
||||
dynamic_documents.filter_map { |d| d.current_version&.document_attachment&.id }
|
||||
|
||||
ActiveStorage::Attachment.joins(:blob)
|
||||
.where(blob: { filename: ['0.jpg', '0.png'] })
|
||||
.where(record_id: record_ids,
|
||||
record_type: 'ActiveStorage::Attachment',
|
||||
name: :preview_images)
|
||||
.preload(:blob)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -20,7 +20,7 @@ RSpec.describe 'Template Builder' do
|
||||
|
||||
expect do
|
||||
doc.find('.replace-document-button').click
|
||||
doc.find('.replace-document-button input[type="file"]', visible: false)
|
||||
doc.find('.replace-document-control input[type="file"]', visible: false)
|
||||
.attach_file(Rails.root.join('spec/fixtures/sample-image.png'))
|
||||
|
||||
page.driver.wait_for_network_idle
|
||||
|
||||
@@ -42,9 +42,7 @@ RSpec.describe 'Template' do
|
||||
|
||||
it 'archives a template' do
|
||||
expect do
|
||||
accept_confirm('Are you sure?') do
|
||||
click_button 'Archive'
|
||||
end
|
||||
click_button 'Archive'
|
||||
end.to change { Template.active.count }.by(-1)
|
||||
|
||||
expect(page).to have_content('Template has been archived')
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
RSpec.describe 'Templates Upload' do
|
||||
let!(:account) { create(:account) }
|
||||
let!(:user) { create(:user, account:) }
|
||||
|
||||
before do
|
||||
sign_in(user)
|
||||
end
|
||||
|
||||
context 'when url param is present' do
|
||||
let(:file_url) { 'https://example.com/document.pdf' }
|
||||
|
||||
before do
|
||||
stub_request(:get, file_url).to_return(
|
||||
body: Rails.root.join('spec/fixtures/sample-document.pdf').read,
|
||||
headers: { 'Content-Type' => 'application/pdf' }
|
||||
)
|
||||
end
|
||||
|
||||
it 'shows a confirm page and creates template on submit' do
|
||||
visit "/new?url=#{CGI.escape(file_url)}"
|
||||
|
||||
expect(page).to have_text('Open file from')
|
||||
expect(page).to have_link('example.com/document.pdf')
|
||||
|
||||
click_button 'Open'
|
||||
|
||||
expect(Template.last.name).to eq('document')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when url param is missing' do
|
||||
it 'redirects to root' do
|
||||
visit '/new'
|
||||
|
||||
expect(page).to have_current_path(root_path)
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user