Compare commits

..

45 Commits

Author SHA1 Message Date
Alex Turchyn 1f89accac3 Merge from docusealco/wip 2026-06-01 14:58:06 +03:00
Pete Matsyburka 9fcaef4cf7 use new_from_memory_copy 2026-05-31 14:28:35 +03:00
Pete Matsyburka d5738a0631 disable variant_processor 2026-05-31 12:51:52 +03:00
Pete Matsyburka 8bf7a1f95a adjust image detect fields 2026-05-31 09:20:51 +03:00
Pete Matsyburka 04cf36891e fix complete button press enter 2026-05-31 09:09:57 +03:00
Pete Matsyburka b2d9948c30 remove active storage analyzers 2026-05-31 08:06:52 +03:00
Pete Matsyburka a2c9ac1707 remove image_processing 2026-05-30 21:37:00 +03:00
Pete Matsyburka 46a6bd0108 fix stamp 2026-05-30 12:04:23 +03:00
Pete Matsyburka 5f069e7a40 use load vips 2026-05-30 07:49:40 +03:00
Pete Matsyburka ff57e5c6ae fix pdfa 2026-05-29 15:31:32 +03:00
Alex Turchyn f65b6e2d76 add confirm prompt for template upload via URL 2026-05-29 09:29:21 +03:00
Pete Matsyburka 58fd180ae0 update gem 2026-05-29 08:04:53 +03:00
Pete Matsyburka cd6503c4c3 adjust image size 2026-05-28 15:37:20 +03:00
Pete Matsyburka e8b36c2b6d update gh stars 2026-05-28 14:38:34 +03:00
Pete Matsyburka 51743f1359 fix spec 2026-05-28 10:18:45 +03:00
Pete Matsyburka 504c42646b refactor template builder data 2026-05-28 10:06:47 +03:00
Pete Matsyburka c61e84d1b4 adjust archive 2026-05-28 08:40:05 +03:00
Pete Matsyburka b8ab01c46c add dynamic documents to template response 2026-05-28 08:29:10 +03:00
Pete Matsyburka 9558060bde check resubmit config 2026-05-28 08:29:09 +03:00
Pete Matsyburka 0741c879f1 use expires_at with file links 2026-05-27 22:19:18 +03:00
Pete Matsyburka 2e6dd2867e file url ttl env var 2026-05-26 21:54:59 +03:00
Pete Matsyburka 9f3fcccb61 optimize build 2026-05-26 16:50:10 +03:00
Pete Matsyburka ae08d7abad adjust opacity_layer 2026-05-26 13:11:15 +03:00
Pete Matsyburka 16808a7ec6 use pngload_buffer 2026-05-26 13:11:15 +03:00
Pete Matsyburka b1dfcd6283 fix email typo 2026-05-26 13:11:15 +03:00
Pete Matsyburka 369f4c0f08 sync prefillable field changes 2026-05-26 13:11:15 +03:00
Pete Matsyburka 827de03579 fix result generatrion 2026-05-26 08:57:30 +03:00
Alex Turchyn 0c7f4e5b43 fix sidekiq embedded redis connection 2026-05-25 21:52:34 +03:00
Alex Turchyn 9c700a3fb6 Merge from docusealco/wip 2026-05-25 12:28:05 +03:00
Pete Matsyburka 89bf83febb adjust autorot 2026-05-23 11:43:28 +03:00
Pete Matsyburka d44b35cf0a convert images on upload 2026-05-23 08:25:04 +03:00
Pete Matsyburka 891833e273 refactor process doc 2026-05-22 14:06:04 +03:00
Pete Matsyburka 29d3a80d3f fix rubocop 2026-05-21 21:55:30 +03:00
Pete Matsyburka 90fef58684 ruby 4.0.5 2026-05-21 21:46:27 +03:00
Pete Matsyburka 07841722ac adjust image load 2026-05-21 21:44:13 +03:00
Pete Matsyburka 9e4da5948b convert images on upload 2026-05-21 14:50:30 +03:00
Pete Matsyburka dc6e4313a1 fix ci 2026-05-20 16:15:15 +03:00
Pete Matsyburka a68cc0b689 convert images on upload 2026-05-20 15:23:42 +03:00
Pete Matsyburka d057fb0f67 content type priority 2026-05-19 13:27:16 +03:00
Pete Matsyburka 7e8f045a29 cleanup 2026-05-19 11:42:14 +03:00
Pete Matsyburka 5249d8d18d update gem 2026-05-19 11:02:15 +03:00
Pete Matsyburka f0fafbadd4 isolate editor nodes 2026-05-19 10:52:48 +03:00
Pete Matsyburka 8b51079dcc update gem 2026-05-18 18:42:04 +03:00
Pete Matsyburka 354bccd6e8 handle dangerous extensions 2026-05-18 18:05:16 +03:00
Pete Matsyburka 6806772346 add security headers 2026-05-18 16:47:19 +03:00
62 changed files with 716 additions and 280 deletions
+5 -5
View File
@@ -12,7 +12,7 @@ jobs:
- name: Install Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: 4.0.1
ruby-version: 4.0.5
- name: Cache gems
uses: actions/cache@v4
with:
@@ -37,7 +37,7 @@ jobs:
- name: Install Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: 4.0.1
ruby-version: 4.0.5
- name: Cache gems
uses: actions/cache@v4
with:
@@ -89,7 +89,7 @@ jobs:
- name: Install Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: 4.0.1
ruby-version: 4.0.5
- name: Cache gems
uses: actions/cache@v4
with:
@@ -132,7 +132,7 @@ jobs:
- name: Install Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: 4.0.1
ruby-version: 4.0.5
- name: Set up Node
uses: actions/setup-node@v1
with:
@@ -163,7 +163,7 @@ jobs:
yarn install
sudo apt-get update
sudo apt-get install -y libvips
wget -O pdfium-linux.tgz "https://github.com/docusealco/pdfium-binaries/releases/latest/download/pdfium-linux-$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/').tgz"
wget -O pdfium-linux.tgz "https://github.com/bblanchon/pdfium-binaries/releases/latest/download/pdfium-linux-$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/').tgz"
sudo tar -xzf pdfium-linux.tgz --strip-components=1 -C /usr/lib lib/libpdfium.so
rm -f pdfium-linux.tgz
- name: Run
+6 -4
View File
@@ -1,4 +1,4 @@
FROM ruby:4.0.1-alpine AS download
FROM ruby:4.0.5-alpine AS download
WORKDIR /fonts
@@ -13,7 +13,7 @@ RUN apk --no-cache add wget && \
mkdir -p /pdfium-linux && \
tar -xzf pdfium-linux.tgz -C /pdfium-linux
FROM ruby:4.0.1-alpine AS webpack
FROM ruby:4.0.5-alpine AS webpack
ENV RAILS_ENV=production
ENV NODE_ENV=production
@@ -40,7 +40,7 @@ COPY ./app/views ./app/views
RUN echo "gem 'shakapacker'" > Gemfile && ./bin/shakapacker
FROM ruby:4.0.1-alpine AS app
FROM ruby:4.0.5-alpine AS app
ENV RAILS_ENV=production
ENV BUNDLE_WITHOUT="development:test"
@@ -48,7 +48,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"]
+1 -2
View File
@@ -2,7 +2,7 @@
source 'https://rubygems.org'
ruby '4.0.1'
ruby '4.0.5'
gem 'addressable'
gem 'arabic-letter-connector', require: false
@@ -21,7 +21,6 @@ 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
+7 -13
View File
@@ -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.7)
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)
@@ -434,7 +429,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 +511,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,7 +619,6 @@ DEPENDENCIES
foreman
google-cloud-storage
hexapdf
image_processing
jwt
letter_opener_web
lograge
@@ -662,7 +656,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)
+37 -14
View File
@@ -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
+8 -1
View File
@@ -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 -17
View File
@@ -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)
+33 -4
View File
@@ -21,6 +21,7 @@ import SubmittersAutocomplete from './elements/submitter_autocomplete'
import FolderAutocomplete from './elements/folder_autocomplete'
import SignatureForm from './elements/signature_form'
import SubmitForm from './elements/submit_form'
import ConvertUpload from './elements/convert_upload'
import PromptPassword from './elements/prompt_password'
import EmailsTextarea from './elements/emails_textarea'
import ToggleSubmit from './elements/toggle_submit'
@@ -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
}
})
})
}
}
}
+27
View File
@@ -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')
}
})
+73
View File
@@ -0,0 +1,73 @@
export function convertImage (sourceFile, targetType, quality) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = function (event) {
const img = new Image()
img.onload = function () {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
canvas.width = img.width
canvas.height = img.height
ctx.drawImage(img, 0, 0)
canvas.toBlob(function (blob) {
const ext = targetType === 'image/jpeg' ? '.jpg' : '.png'
const newFile = new File([blob], sourceFile.name.replace(/\.\w+$/, ext), { type: targetType })
resolve(newFile)
}, targetType, quality)
}
img.onerror = () => reject(new Error(`browser cannot decode ${sourceFile.type || sourceFile.name}`))
img.src = event.target.result
}
reader.onerror = reject
reader.readAsDataURL(sourceFile)
})
}
export async function convertImagesInInput (input) {
if (!input.files || input.files.length === 0) return
const dt = new DataTransfer()
let didConvert = false
for (const file of Array.from(input.files)) {
let converted = file
try {
if (['image/bmp', 'image/vnd.microsoft.icon', 'image/svg+xml', 'image/gif'].includes(file.type)) {
converted = await convertImage(file, 'image/png')
didConvert = true
} else if (['image/heic', 'image/heif', 'image/heic-sequence', 'image/heif-sequence', 'image/avif', 'image/avif-sequence', 'image/webp'].includes(file.type)) {
converted = await convertImage(file, 'image/jpeg', 0.9)
didConvert = true
}
} catch (e) {
alert(e.message)
}
dt.items.add(converted)
}
if (didConvert) {
input.files = dt.files
}
}
export default class extends HTMLElement {
connectedCallback () {
const input = this.querySelector('input[type="file"]')
const form = input.form
input.addEventListener('change', async () => {
await convertImagesInInput(input)
form.querySelector('[type="submit"]')?.setAttribute('disabled', true)
form.requestSubmit()
})
}
}
@@ -1,4 +1,5 @@
import { target, targets, targetable } from '@github/catalyst/lib/targetable'
import { convertImagesInInput } from './convert_upload'
const loadingIconHtml = `<svg xmlns="http://www.w3.org/2000/svg" class="animate-spin" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
@@ -150,12 +151,16 @@ export default targetable(class extends HTMLElement {
if (!this.isLoading) this.hideDraghover()
}
uploadFiles (files, url) {
async uploadFiles (files, url) {
this.isLoading = true
this.form.action = url
this.form.querySelector('[type="file"]').files = files
const input = this.form.querySelector('[type="file"]')
input.files = files
await convertImagesInInput(input)
this.form.querySelector('[type="submit"]').click()
}
+8 -3
View File
@@ -1,5 +1,6 @@
import { actionable } from '@github/catalyst/lib/actionable'
import { target, targetable } from '@github/catalyst/lib/targetable'
import { convertImagesInInput } from './convert_upload'
export default actionable(targetable(class extends HTMLElement {
static [target.static] = [
@@ -38,17 +39,21 @@ export default actionable(targetable(class extends HTMLElement {
this.classList.add('border-base-300', 'hover:bg-base-200/30')
}
onDrop (e) {
async onDrop (e) {
e.preventDefault()
this.input.files = e.dataTransfer.files
this.uploadFiles(e.dataTransfer.files)
await convertImagesInInput(this.input)
this.uploadFiles(this.input.files)
}
onSelectFiles (e) {
async onSelectFiles (e) {
e.preventDefault()
await convertImagesInInput(this.input)
this.uploadFiles(this.input.files)
}
@@ -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')]"
>
+38 -28
View File
@@ -51,6 +51,36 @@
<script>
import { IconCloudUpload, IconInnerShadowTop } from '@tabler/icons-vue'
function convertImage (sourceFile, targetType, quality) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = function (event) {
const img = new Image()
img.onload = function () {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
canvas.width = img.width
canvas.height = img.height
ctx.drawImage(img, 0, 0)
canvas.toBlob(function (blob) {
const ext = targetType === 'image/jpeg' ? '.jpg' : '.png'
const newFile = new File([blob], sourceFile.name.replace(/\.\w+$/, ext), { type: targetType })
resolve(newFile)
}, targetType, quality)
}
img.onerror = () => reject(new Error(`browser cannot decode ${sourceFile.type || sourceFile.name}`))
img.src = event.target.result
}
reader.onerror = reject
reader.readAsDataURL(sourceFile)
})
}
export default {
name: 'FileDropzone',
components: {
@@ -153,8 +183,14 @@ export default {
}
})
} else {
if (file.type === 'image/bmp' || file.type === 'image/vnd.microsoft.icon') {
file = await this.convertBmpToPng(file)
try {
if (['image/bmp', 'image/vnd.microsoft.icon', 'image/svg+xml', 'image/gif'].includes(file.type)) {
file = await convertImage(file, 'image/png')
} else if (['image/heic', 'image/heif', 'image/heic-sequence', 'image/heif-sequence', 'image/avif', 'image/avif-sequence', 'image/webp'].includes(file.type)) {
file = await convertImage(file, 'image/jpeg', 0.9)
}
} catch (e) {
alert(e.message)
}
formData.append('file', file)
@@ -181,32 +217,6 @@ export default {
}).finally(() => {
this.isLoading = false
})
},
convertBmpToPng (bmpFile) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = function (event) {
const img = new Image()
img.onload = function () {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
canvas.width = img.width
canvas.height = img.height
ctx.drawImage(img, 0, 0)
canvas.toBlob(function (blob) {
const newFile = new File([blob], bmpFile.name.replace(/\.\w+$/, '.png'), { type: 'image/png' })
resolve(newFile)
}, 'image/png')
}
img.src = event.target.result
}
reader.onerror = reject
reader.readAsDataURL(bmpFile)
})
}
}
}
+10 -2
View File
@@ -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"
@@ -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')
@@ -168,6 +168,65 @@
<script>
import { IconUpload, IconInnerShadowTop, IconChevronDown, IconBrandGoogleDrive } from '@tabler/icons-vue'
function convertImage (sourceFile, targetType, quality) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = function (event) {
const img = new Image()
img.onload = function () {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
canvas.width = img.width
canvas.height = img.height
ctx.drawImage(img, 0, 0)
canvas.toBlob(function (blob) {
const ext = targetType === 'image/jpeg' ? '.jpg' : '.png'
const newFile = new File([blob], sourceFile.name.replace(/\.\w+$/, ext), { type: targetType })
resolve(newFile)
}, targetType, quality)
}
img.onerror = () => reject(new Error(`browser cannot decode ${sourceFile.type || sourceFile.name}`))
img.src = event.target.result
}
reader.onerror = reject
reader.readAsDataURL(sourceFile)
})
}
async function convertImagesInInput (input) {
if (!input.files || input.files.length === 0) return
const dt = new DataTransfer()
let didConvert = false
for (const file of Array.from(input.files)) {
let converted = file
try {
if (['image/bmp', 'image/vnd.microsoft.icon', 'image/svg+xml', 'image/gif'].includes(file.type)) {
converted = await convertImage(file, 'image/png')
didConvert = true
} else if (['image/heic', 'image/heif', 'image/heic-sequence', 'image/heif-sequence', 'image/avif', 'image/avif-sequence', 'image/webp'].includes(file.type)) {
converted = await convertImage(file, 'image/jpeg', 0.9)
didConvert = true
}
} catch (e) {
alert(e.message)
}
dt.items.add(converted)
}
if (didConvert) {
input.files = dt.files
}
}
export default {
name: 'DocumentsUpload',
components: {
@@ -282,6 +341,10 @@ export default {
async upload ({ path } = {}) {
this.isLoading = true
if (this.$refs.input) {
await convertImagesInInput(this.$refs.input)
}
return this.baseFetch(path || this.uploadUrl, {
method: 'POST',
headers: { Accept: 'application/json' },
-2
View File
@@ -3,8 +3,6 @@
class ReindexSearchEntryJob
include Sidekiq::Job
InvalidFormat = Class.new(StandardError)
def perform(params = {})
entry = SearchEntry.find_or_initialize_by(params.slice('record_type', 'record_id'))
+6
View File
@@ -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
+1 -1
View File
@@ -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) }
+2 -2
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
<a target="_blank" href="<%= Docuseal::GITHUB_URL %>" rel="noopener noreferrer nofollow" class="relative flex items-center rounded-full px-2 py-0.5 text-xs leading-4 mt-1 text-base-content border border-base-300 tooltip tooltip-bottom" data-tip="Give a star on GitHub">
<span class="flex items-center justify-between space-x-0.5 font-medium">
<%= svg_icon('start', class: 'h-3 w-3') %>
<span>16k</span>
<span>17k</span>
</span>
</a>
+4 -2
View File
@@ -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| %>
+2 -2
View File
@@ -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">
+2 -2
View File
@@ -15,8 +15,8 @@
</span>
</label>
<input type="hidden" name="form_id" value="<%= form_id %>">
<submit-form data-on="change" data-disable="true">
<convert-upload>
<input id="upload_template" name="files[]" class="hidden" type="file" accept="image/*, application/pdf, application/zip, application/json<%= ", #{Templates::CreateAttachments::DOCUMENT_EXTENSIONS.join(', ')}" if Docuseal.advanced_formats? %>" multiple>
</submit-form>
</convert-upload>
<input hidden name="folder_name" value="<%= local_assigns[:folder_name] %>">
<% end %>
+1 -1
View File
@@ -6,4 +6,4 @@
<%= button_to nil, user_configs_path, method: :post, params: { user_config: { key: UserConfig::SHOW_APP_TOUR, value: true } }, class: 'hidden', id: 'start_tour_button' %>
<% end %>
<% end %>
<template-builder class="grid" data-template="<%= @template_data %>" data-custom-fields="<%= (current_account.account_configs.find_or_initialize_by(key: AccountConfig::TEMPLATE_CUSTOM_FIELDS_KEY).value || []).to_json %>" data-with-sign-yourself-button="<%= !@template.archived_at? %>" data-with-fields-detection="true" data-with-send-button="<%= !@template.archived_at? && can?(:create, @template.submissions.new(account: current_account)) %>" data-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
View File
@@ -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>
+28 -16
View File
@@ -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>
+12
View File
@@ -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]
+15
View File
@@ -0,0 +1,15 @@
# frozen_string_literal: true
priority = %w[application/pdf image/jpeg image/png]
indexes = Marcel::MAGIC.each_with_index.with_object({}) do |((type, _), i), acc|
acc[type] = i if priority.include?(type)
break acc if acc.size == priority.size
end
pdf_index, jpg_index, png_index = indexes.values_at(*priority)
Marcel::MAGIC[0], Marcel::MAGIC[pdf_index] = Marcel::MAGIC[pdf_index], Marcel::MAGIC[0]
Marcel::MAGIC[1], Marcel::MAGIC[jpg_index] = Marcel::MAGIC[jpg_index], Marcel::MAGIC[1]
Marcel::MAGIC[2], Marcel::MAGIC[png_index] = Marcel::MAGIC[png_index], Marcel::MAGIC[2]
+21
View File
@@ -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
View File
@@ -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
+17
View File
@@ -1,8 +1,25 @@
# frozen_string_literal: true
module ImageUtils
ICO_REGEXP = %r{\Aimage/(?:x-icon|vnd\.microsoft\.icon)\z}
BMP_REGEXP = %r{\Aimage/(?:bmp|x-bmp|x-ms-bmp)\z}
module_function
def load_vips(data, content_type: nil, autorot: false)
content_type ||= Marcel::MimeType.for(data)
if ICO_REGEXP.match?(content_type)
LoadIco.call(data)
elsif BMP_REGEXP.match?(content_type)
LoadBmp.call(data)
else
image = Vips::Image.new_from_buffer(data, '')
autorot ? image.autorot : image
end
end
def blank?(image)
stats = image.stats
+1 -1
View File
@@ -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
View File
@@ -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
+2 -1
View File
@@ -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
View File
@@ -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
+5 -3
View File
@@ -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
@@ -363,7 +365,7 @@ module Submissions
image =
begin
Submissions::GenerateResultAttachments.load_vips_image(attachment).autorot
ImageUtils.load_vips(attachment.download, content_type: attachment.content_type, autorot: true)
rescue Vips::Error
next unless attachment.content_type.starts_with?('image/')
next if attachment.byte_size.zero?
@@ -379,7 +381,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
@@ -408,7 +410,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
@@ -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
+25 -28
View File
@@ -11,9 +11,6 @@ module Submissions
'Helvetica'
end
ICO_REGEXP = %r{\Aimage/(?:x-icon|vnd\.microsoft\.icon)\z}
BMP_REGEXP = %r{\Aimage/(?:bmp|x-bmp|x-ms-bmp)\z}
FONT_BOLD_NAME = if File.exist?(FONT_BOLD_PATH)
FONT_BOLD_PATH
else
@@ -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
@@ -313,7 +314,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 +362,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 +430,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 +457,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 +477,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 +520,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 +747,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 +763,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 +1032,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
View File
@@ -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 -11
View File
@@ -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)
+3 -1
View File
@@ -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
+31 -13
View File
@@ -212,8 +212,8 @@ module Submitters
elsif type.in?(%w[signature initials]) && value.length < 60
find_or_create_blob_from_text(account, value, type)
elsif (data = Base64.decode64(value.sub(BASE64_PREFIX_REGEXP, ''))) &&
Marcel::MimeType.for(data).exclude?('octet-stream')
find_or_create_blob_from_base64(account, data, type)
(mime_type = Marcel::MimeType.for(data)).exclude?('octet-stream')
find_or_create_blob_from_base64(account, data, type, mime_type:)
elsif type == 'image' && (value.starts_with?('<html>') || value.starts_with?('<!DOCTYPE'))
raise InvalidDefaultValue, "Invalid #{type} value" unless purpose == :api
@@ -236,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)
+16
View File
@@ -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
+2 -2
View File
@@ -77,7 +77,7 @@ module Templates
split_page: false, aspect_ratio: false, padding: nil, page_number: nil)
return [[], nil] if page_number && page_number != 0
image = Vips::Image.new_from_buffer(io.read, '')
image = ImageUtils.load_vips(io.read)
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)
+28 -31
View File
@@ -7,7 +7,6 @@ module Templates
PREVIEW_FORMAT = '.jpg'
ATTACHMENT_NAME = 'preview_images'
BMP_REGEXP = %r{\Aimage/(?:bmp|x-bmp|x-ms-bmp)\z}
PDF_CONTENT_TYPE = 'application/pdf'
CONCURRENCY = 2
Q = 95
@@ -59,19 +58,13 @@ module Templates
def generate_preview_image(attachment, data)
ActiveStorage::Attachment.where(name: ATTACHMENT_NAME, record: attachment).destroy_all
image =
if BMP_REGEXP.match?(attachment.content_type)
LoadBmp.call(data)
else
Vips::Image.new_from_buffer(data, '')
end
image = image.autorot.resize(MAX_WIDTH / image.width.to_f)
image = ImageUtils.load_vips(data, content_type: attachment.content_type, autorot: true)
image = image.resize(MAX_WIDTH / image.width.to_f)
bitdepth = 2**image.stats.to_a[1..3].pluck(2).uniq.size
io = StringIO.new(image.write_to_buffer(FORMAT, compression: 6, filter: 0, bitdepth:,
palette: true, Q: Q, dither: 0))
palette: true, Q: Q, dither: 0, strip: true))
ActiveStorage::Attachment.create!(
blob: ActiveStorage::Blob.create_and_upload!(
@@ -110,7 +103,15 @@ module Templates
promises =
range.map do |page_number|
Concurrent::Promise.execute(executor: pool) { build_and_upload_blob(doc, page_number) }
doc_page = doc.get_page(page_number)
bytes, width, height = doc_page.render_to_bitmap(width: MAX_WIDTH)
image = Vips::Image.new_from_memory_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!(
+33 -14
View File
@@ -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
+1 -3
View File
@@ -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')
+40
View File
@@ -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