Compare commits

...
53 Commits
Author SHA1 Message Date
Alex Turchyn 4eb4e8c1f8 fix file link generation 2024-02-21 00:38:33 +02:00
Pete Matsyburka 984df15949 pass account local var in attribution 2024-02-20 21:30:00 +02:00
Pete Matsyburka aa3bd24332 add cors headers 2024-02-20 18:25:41 +02:00
Pete Matsyburka 250889623d sort field types 2024-02-20 12:00:16 +02:00
Alex Turchyn 862fa55986 delay signature rendering 2024-02-20 01:13:43 +02:00
Pete Matsyburka 0f2889904a allow to pass signature as base64 2024-02-20 00:22:24 +02:00
Pete Matsyburka 49f14c7929 adjust rollbar configs 2024-02-19 23:53:16 +02:00
Pete Matsyburka c3f3c5d5df add missing i18n 2024-02-19 23:52:47 +02:00
Pete Matsyburka 274259f305 adjust page preview loading 2024-02-19 23:46:24 +02:00
Pete Matsyburka a15d7a9cf3 adjust csv export 2024-02-19 22:53:49 +02:00
Pete Matsyburka 7d154fc28d use message verifier purpose 2024-02-19 22:36:48 +02:00
Pete Matsyburka 6727d3be83 adjust blob proxy 2024-02-19 17:07:37 +02:00
Alex Turchyn 4bbd5c3bcb fix signature re-uploading issue 2024-02-18 23:13:15 +02:00
Pete Matsyburka 5995f2e427 show multiple signatures toggle on cloud 2024-02-18 23:13:15 +02:00
Pete Matsyburka 0910c8e100 redirect on archived template 2024-02-18 23:13:15 +02:00
Pete Matsyburka 8df76dfa90 download completed ttl 2024-02-18 23:13:14 +02:00
Pete Matsyburka 78e9e66cb0 use uuid blobs controller 2024-02-18 16:38:43 +02:00
Pete Matsyburka 71c61fc4b3 allow to use separate encryption key via env 2024-02-18 12:32:08 +02:00
Pete Matsyburka a728aa8689 add blobs proxy controller 2024-02-18 11:36:50 +02:00
Pete Matsyburka 5a3174a720 add uuid to active storage blob 2024-02-18 10:42:11 +02:00
Pete Matsyburka 652072d660 cache page url 2024-02-18 10:04:56 +02:00
Pete Matsyburka 323f5c0c15 use signed uuid in previews 2024-02-17 22:32:52 +02:00
Pete Matsyburka 229d78c78b add security.md 2024-02-17 18:22:36 +02:00
Pete Matsyburka 9d9a955149 do not use image variants deletion 2024-02-17 13:36:58 +02:00
Pete Matsyburka b40a047190 update readme 2024-02-17 12:13:56 +02:00
Pete Matsyburka ff9e415823 generate default signature initials from font 2024-02-17 01:41:07 +02:00
Pete Matsyburka 2f7a4fb2b3 fix i18n 2024-02-16 20:01:21 +02:00
Pete Matsyburka f8a654a5b6 reduce error log 2024-02-16 19:44:59 +02:00
Pete Matsyburka 4449475467 fix audit log currency 2024-02-16 19:00:18 +02:00
Pete Matsyburka bcbbf0dd42 fix large images in audit log 2024-02-16 18:59:10 +02:00
Pete Matsyburka 19b9ba52d4 adjust webhook retries 2024-02-16 18:47:02 +02:00
Pete Matsyburka bdc30db2dc fix typed signature field 2024-02-16 18:22:40 +02:00
Pete Matsyburka 74634ba96b mark as opened on form view 2024-02-16 17:59:33 +02:00
Pete Matsyburka 77feb05bad change font 2024-02-16 11:34:30 +02:00
Pete Matsyburka a1f05d006e fix the same file in default values 2024-02-16 01:33:35 +02:00
Pete Matsyburka cc3dae1335 do not send completed email to self-signed 2024-02-16 01:02:39 +02:00
Pete Matsyburka d9debcfb0d process annotations 2024-02-15 23:20:29 +02:00
Pete Matsyburka 8770d16d52 update pdf gem 2024-02-15 22:26:05 +02:00
Pete Matsyburka 84953481e4 adjust form i18n 2024-02-15 22:13:14 +02:00
Pete Matsyburka df7aa59f6f allow to clone templates to linked accounts 2024-02-15 11:32:08 +02:00
Pete Matsyburka 008b83119d better invalid role error message 2024-02-15 10:34:10 +02:00
Pete Matsyburka 15dafcb6ff downcase email when sign in 2024-02-15 10:27:04 +02:00
Pete Matsyburka 7d291ed888 add linked_account_accounts 2024-02-15 09:40:57 +02:00
Pete Matsyburka a7221f0750 fix form field size 2024-02-15 00:58:47 +02:00
Pete Matsyburka 9a5b2a5d30 add receive completed email config 2024-02-14 22:13:07 +02:00
Pete Matsyburka 7361a00d42 use universal font 2024-02-14 20:00:45 +02:00
Pete Matsyburka e07d402782 fix testing account email message 2024-02-13 23:05:31 +02:00
Pete Matsyburka a34354bee1 fix checkmark 2024-02-13 22:23:42 +02:00
Pete Matsyburka 84b762b529 report errors 2024-02-13 22:02:15 +02:00
Pete Matsyburka 2a839951b4 clone tenant tempaltes 2024-02-13 18:24:51 +02:00
Pete Matsyburka 26d7c2f047 add signature format settings 2024-02-12 21:36:06 +02:00
Pete Matsyburka 151a5b85f3 configure rollbar 2024-02-12 17:36:47 +02:00
Pete Matsyburka 3f506f53b4 add encrypted user configs controller 2024-02-12 12:26:22 +02:00
72 changed files with 727 additions and 203 deletions

No files matched your search

+2 -4
View File
@@ -2,9 +2,7 @@ FROM ruby:3.2.2-alpine3.18 as fonts
WORKDIR /fonts
RUN apk --no-cache add fontforge wget ttf-liberation && cp /usr/share/fonts/liberation/LiberationSans-Regular.ttf /usr/share/fonts/liberation/LiberationSans-Bold.ttf . && wget https://cdn.jsdelivr.net/gh/notofonts/notofonts.github.io/fonts/NotoSansArabic/hinted/ttf/NotoSansArabic-Regular.ttf && wget https://github.com/impallari/DancingScript/raw/master/fonts/DancingScript-Regular.otf && wget https://github.com/impallari/DancingScript/raw/master/OFL.txt
RUN fontforge -lang=py -c 'font1 = fontforge.open("LiberationSans-Regular.ttf"); font2 = fontforge.open("NotoSansArabic-Regular.ttf"); font1.mergeFonts(font2); font1.generate("LiberationSans-Regular.ttf")'
RUN apk --no-cache add wget && wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Regular.ttf && wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Bold.ttf && wget https://github.com/impallari/DancingScript/raw/master/fonts/DancingScript-Regular.otf && wget https://github.com/impallari/DancingScript/raw/master/OFL.txt
FROM ruby:3.2.2-alpine3.18 as webpack
@@ -55,7 +53,7 @@ COPY ./public ./public
COPY ./tmp ./tmp
COPY LICENSE README.md Rakefile config.ru .version ./
COPY --from=fonts /fonts/LiberationSans-Regular.ttf /fonts/LiberationSans-Bold.ttf /fonts/DancingScript-Regular.otf /fonts/OFL.txt /fonts
COPY --from=fonts /fonts/GoNotoKurrent-Regular.ttf /fonts/GoNotoKurrent-Bold.ttf /fonts/DancingScript-Regular.otf /fonts/OFL.txt /fonts
COPY --from=webpack /app/public/packs ./public/packs
RUN ln -s /fonts /app/public/fonts
+1 -1
View File
@@ -261,7 +261,7 @@ GEM
signet (>= 0.16, < 2.a)
hashdiff (1.1.0)
hashery (2.1.2)
hexapdf (0.34.1)
hexapdf (0.37.1)
cmdparse (~> 3.0, >= 3.0.3)
geom2d (~> 0.4, >= 0.4.1)
openssl (>= 2.2.1)
+24 -12
View File
@@ -33,17 +33,29 @@ DocuSeal is an open source platform that provides secure and efficient digital d
[![Demo](https://github.com/docusealco/docuseal/assets/5418788/d8703ea3-361a-423f-8bfe-eff1bd9dbe14)](https://demo.docuseal.co)
## Features
- [x] PDF form fields builder (WYSIWYG)
- [x] 11 field types available (Signature, Date, File, Checkbox etc.)
- [x] Multiple submitters per document
- [x] Automated emails via SMTP
- [x] Files storage on disk or AWS S3, Google Storage, Azure Cloud
- [x] Automatic PDF eSignature
- [x] PDF signature verification
- [x] Users management
- [x] Mobile-optimized
- [x] API and Webhooks for integrations
- [x] Easy to deploy in minutes
- PDF form fields builder (WYSIWYG)
- 12 field types available (Signature, Date, File, Checkbox etc.)
- Multiple submitters per document
- Automated emails via SMTP
- Files storage on disk or AWS S3, Google Storage, Azure Cloud
- Automatic PDF eSignature
- PDF signature verification
- Users management
- Mobile-optimized
- API and Webhooks for integrations
- Easy to deploy in minutes
## Pro Features
- Company logo and white-label
- User roles
- Automated reminders
- Invitation and identify verification via SMS
- SSO / SAML
- Template creation with HTML API ([Guide](https://www.docuseal.co/guides/create-pdf-document-fillable-form-with-html-api))
- Template creation with PDF or DOCX and text tags API ([Guide](https://www.docuseal.co/guides/use-embedded-text-field-tags-in-the-pdf-to-create-a-fillable-form))
- Embedded signing form ([React](https://github.com/docusealco/docuseal-react), [Vue](https://github.com/docusealco/docuseal-vue) or [JavaScript](https://www.docuseal.co/docs/embedded))
- Embedded document form builder ([React](https://github.com/docusealco/docuseal-react), [Vue](https://github.com/docusealco/docuseal-vue) or [JavaScript](https://www.docuseal.co/docs/embedded))
- [Learn more](https://www.docuseal.co/pricing)
## Deploy
@@ -77,7 +89,7 @@ sudo HOST=your-domain-name.com docker compose up
```
## For Businesses
### Integrate seamless document signing into your web or mobile apps with DocuSeal!
### Integrate seamless document signing into your web or mobile apps with DocuSeal
At DocuSeal we have expertise and technologies to make documents creation, filling, signing and processing seamlessly integrated with your product. We specialize in working with various industries, including **Banking, Healthcare, Transport, Real Estate, eCommerce, KYC, CRM, and other software products** that require bulk document signing. By leveraging DocuSeal, we can assist in reducing the overall cost of developing and processing electronic documents while ensuring security and compliance with local electronic document laws.
+5
View File
@@ -0,0 +1,5 @@
# Reporting a Vulnerability
If you come across any security concern or vulnarability, please report the information via email to security@docuseal.co instead of opening a GitHub issue. We will promptly respond and will collaborate with you to validate the issue, and resolve it ASAP.
**We have a bug bounty program to reward security researchers.**
@@ -0,0 +1,35 @@
# frozen_string_literal: true
module Api
class ActiveStorageBlobsProxyController < ApiBaseController
include ActiveStorage::Streaming
skip_before_action :authenticate_user!
skip_authorization_check
before_action :set_cors_headers
def show
blob_uuid, = ApplicationRecord.signed_id_verifier.verified(params[:signed_uuid])
if blob_uuid.blank?
Rollbar.error('Blob not found') if defined?(Rollbar)
return head :not_found
end
blob = ActiveStorage::Blob.find_by!(uuid: blob_uuid)
if request.headers['Range'].present?
send_blob_byte_range_data blob, request.headers['Range']
else
http_cache_forever public: true do
response.headers['Accept-Ranges'] = 'bytes'
response.headers['Content-Length'] = blob.byte_size.to_s
send_blob_stream blob, disposition: params[:disposition]
end
end
end
end
end
@@ -0,0 +1,41 @@
# frozen_string_literal: true
module Api
class ActiveStorageBlobsProxyLegacyController < ApiBaseController
include ActiveStorage::Streaming
skip_before_action :authenticate_user!
skip_authorization_check
# rubocop:disable Metrics
def show
Rollbar.info('Blob legacy') if defined?(Rollbar)
blob = ActiveStorage::Blob.find_signed!(params[:signed_blob_id] || params[:signed_id])
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' } ||
a.name == 'logo'
end
unless is_permitted
Rollbar.error("Blob account not found: #{blob.id}") if defined?(Rollbar)
return render json: { error: 'Not authenticated' }, status: :unauthorized
end
if request.headers['Range'].present?
send_blob_byte_range_data blob, request.headers['Range']
else
http_cache_forever public: true do
response.headers['Accept-Ranges'] = 'bytes'
response.headers['Content-Length'] = blob.byte_size.to_s
send_blob_stream blob, disposition: params[:disposition]
end
end
end
# rubocop:enable Metrics
end
end
+10 -2
View File
@@ -21,13 +21,13 @@ module Api
if Rails.env.production?
rescue_from CanCan::AccessDenied do |e|
Rollbar.error(e) if defined?(Rollbar)
Rollbar.warning(e) if defined?(Rollbar)
render json: { error: e.message }, status: :forbidden
end
rescue_from JSON::ParserError do |e|
Rollbar.error(e) if defined?(Rollbar)
Rollbar.warning(e) if defined?(Rollbar)
render json: { error: "JSON parse error: #{e.message}" }, status: :unprocessable_entity
end
@@ -59,5 +59,13 @@ module Api
def current_account
current_user&.account
end
def set_cors_headers
headers['Access-Control-Allow-Origin'] = '*'
headers['Access-Control-Allow-Methods'] = 'POST, GET, PUT, PATCH, DELETE, OPTIONS'
headers['Access-Control-Allow-Headers'] = '*'
headers['Access-Control-Max-Age'] = '1728000'
headers['Access-Control-Allow-Credentials'] = true
end
end
end
@@ -74,7 +74,7 @@ module Api
return render json: { error: 'Template not found' }, status: :unprocessable_entity if @template.nil?
if @template.fields.blank?
Rollbar.error("Template does not contain fields: #{@template.id}") if defined?(Rollbar)
Rollbar.warning("Template does not contain fields: #{@template.id}") if defined?(Rollbar)
return render json: { error: 'Template does not contain fields' }, status: :unprocessable_entity
end
@@ -93,7 +93,9 @@ module Api
end
render json: submissions.flat_map(&:submitters)
rescue Submitters::NormalizeValues::UnknownFieldName, Submitters::NormalizeValues::UnknownSubmitterName => e
rescue Submitters::NormalizeValues::BaseError => e
Rollbar.warning(e) if defined?(Rollbar)
render json: { error: e.message }, status: :unprocessable_entity
end
@@ -8,6 +8,9 @@ module Api
def create
submitter = Submitter.find_by!(slug: params[:submitter_slug])
submitter.opened_at = Time.current
submitter.save
SubmissionEvents.create_with_tracking_data(submitter, 'view_form', request)
SendFormViewedWebhookRequestJob.perform_later(submitter)
@@ -16,7 +16,7 @@ module Api
render json: {
schema:,
documents: documents.as_json(
methods: [:metadata],
methods: %i[metadata signed_uuid],
include: {
preview_images: { methods: %i[url metadata filename] }
}
+1 -1
View File
@@ -24,7 +24,7 @@ class ApplicationController < ActionController::Base
if Rails.env.production?
rescue_from CanCan::AccessDenied do |e|
Rollbar.error(e) if defined?(Rollbar)
Rollbar.warning(e) if defined?(Rollbar)
redirect_to root_path, alert: e.message
end
@@ -0,0 +1,11 @@
# frozen_string_literal: true
class EncryptedUserConfigsController < ApplicationController
load_and_authorize_resource :encrypted_user_config
def destroy
@encrypted_user_config.destroy!
redirect_back(fallback_location: root_path)
end
end
@@ -6,7 +6,9 @@ class PreviewDocumentPageController < ActionController::API
FORMAT = Templates::ProcessDocument::FORMAT
def show
attachment = ActiveStorage::Attachment.find_by(uuid: params[:attachment_uuid])
attachment_uuid = ApplicationRecord.signed_id_verifier.verified(params[:signed_uuid], purpose: :attachment)
attachment = ActiveStorage::Attachment.find_by(uuid: attachment_uuid, name: :preview_images) if attachment_uuid
return head :not_found unless attachment
@@ -21,9 +23,10 @@ class PreviewDocumentPageController < ActionController::API
find_or_create_document_tempfile_path(attachment)
end
io = Templates::ProcessDocument.generate_pdf_preview_from_file(attachment, file_path, params[:id].to_i)
preview_image =
Templates::ProcessDocument.generate_pdf_preview_from_file(attachment, file_path, params[:id].to_i)
render plain: io.tap(&:rewind).read, content_type: 'image/jpeg'
redirect_to preview_image.url, allow_other_host: true
end
def find_or_create_document_tempfile_path(attachment)
+4 -2
View File
@@ -4,12 +4,14 @@ class SessionsController < Devise::SessionsController
before_action :configure_permitted_parameters
def create
if Docuseal.multitenant? && !User.exists?(email: sign_in_params[:email])
email = sign_in_params[:email].to_s.downcase
if Docuseal.multitenant? && !User.exists?(email:)
return redirect_to new_registration_path(sign_up: true, user: sign_in_params.slice(:email)),
notice: 'Create a new account'
end
if User.exists?(email: sign_in_params[:email], otp_required_for_login: true) && sign_in_params[:otp_attempt].blank?
if User.exists?(email:, otp_required_for_login: true) && sign_in_params[:otp_attempt].blank?
return render :otp, locals: { resource: User.new(sign_in_params) }, status: :unprocessable_entity
end
+2
View File
@@ -15,6 +15,8 @@ class StartFormController < ApplicationController
end
def update
return redirect_to start_form_path(@template.slug) if @template.archived_at?
@submitter = Submitter.where(submission: @template.submissions.where(archived_at: nil))
.order(id: :desc)
.then { |rel| params[:resubmit].present? ? rel.where(completed_at: nil) : rel }
@@ -4,8 +4,10 @@ class SubmissionsDownloadController < ApplicationController
skip_before_action :authenticate_user!
skip_authorization_check
TTL = 20.minutes
def index
submitter = Submitter.find_by(slug: params[:submitter_slug])
submitter = Submitter.find_by!(slug: params[:submitter_slug])
Submissions::EnsureResultGenerated.call(submitter)
@@ -13,9 +15,18 @@ class SubmissionsDownloadController < ApplicationController
Submissions::EnsureResultGenerated.call(last_submitter)
return head :not_found unless last_submitter.completed_at?
if last_submitter.completed_at < TTL.ago &&
(current_user.nil? || !current_user.account.submitters.exists?(id: last_submitter.id))
Rollbar.info("TTL: #{last_submitter.id}") if defined?(Rollbar)
return head :not_found
end
urls =
Submitters.select_attachments_for_download(last_submitter).map do |attachment|
helpers.rails_blob_url(attachment)
ActiveStorage::Blob.proxy_url(attachment.blob)
end
render json: urls
@@ -6,9 +6,21 @@ class SubmissionsPreviewController < ApplicationController
PRELOAD_ALL_PAGES_AMOUNT = 200
TTL = 20.minutes
def show
@submission = Submission.find_by!(slug: params[:slug])
if !@submission.submitters.all?(&:completed_at?) && current_user.blank?
raise ActionController::RoutingError, 'Not Found'
end
unless submission_valid_ttl?(@submission)
Rollbar.info("TTL: #{@submission.id}") if defined?(Rollbar)
return redirect_to submissions_preview_completed_path(@submission.slug)
end
ActiveRecord::Associations::Preloader.new(
records: [@submission],
associations: [:template, { template_schema_documents: :blob }]
@@ -26,4 +38,20 @@ class SubmissionsPreviewController < ApplicationController
render 'submissions/show', layout: 'plain'
end
def completed
@submission = Submission.find_by!(slug: params[:submissions_preview_slug])
render :completed, layout: 'plain'
end
private
def submission_valid_ttl?(submission)
return true if current_user && current_user.account.submissions.exists?(id: submission.id)
last_submitter = submission.submitters.select(&:completed_at?).max_by(&:completed_at)
last_submitter && last_submitter.completed_at > TTL.ago
end
end
+20 -3
View File
@@ -33,7 +33,7 @@ class TemplatesController < ApplicationController
@template_data =
@template.as_json.merge(
documents: @template.schema_documents.as_json(
methods: [:metadata],
methods: %i[metadata signed_uuid],
include: { preview_images: { methods: %i[url metadata filename] } }
)
).to_json
@@ -51,12 +51,17 @@ class TemplatesController < ApplicationController
@template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:folder_name])
end
@template.account = current_account
if params[:account_id].present? && authorized_clone_account_id?(params[:account_id])
@template.account_id = params[:account_id]
@template.folder = @template.account.default_template_folder
else
@template.account = current_account
end
if @template.save
Templates::CloneAttachments.call(template: @template, original_template: @base_template) if @base_template
redirect_to edit_template_path(@template)
maybe_redirect_to_template(@template)
else
render turbo_stream: turbo_stream.replace(:modal, template: 'templates/new'), status: :unprocessable_entity
end
@@ -81,6 +86,18 @@ class TemplatesController < ApplicationController
private
def authorized_clone_account_id?(account_id)
true_user.account_id.to_s == account_id.to_s || true_user.account.linked_accounts.exists?(id: account_id)
end
def maybe_redirect_to_template(template)
if template.account == current_account
redirect_to(edit_template_path(@template))
else
redirect_back(fallback_location: root_path, notice: 'Template has been clonned')
end
end
def template_params
params.require(:template).permit(:name)
end
@@ -12,7 +12,7 @@ class TemplatesPreviewController < ApplicationController
@template_data =
@template.as_json.merge(
documents: @template.schema_documents.as_json(
methods: [:metadata],
methods: %i[metadata signed_uuid],
include: { preview_images: { methods: %i[url metadata filename] } }
)
).to_json
+20 -10
View File
@@ -32,7 +32,7 @@
v-if="!isCompleted"
id="minimize_form_button"
class="absolute right-0 mr-2 mt-2 top-0 hidden md:block"
title="Minimize"
:title="t('minimize')"
@click.prevent="isFormVisible = false"
>
<IconArrowsDiagonalMinimize2
@@ -40,13 +40,16 @@
:height="20"
/>
</button>
<div>
<div
:class="{ 'md:px-4': isBreakpointMd }"
>
<form
v-if="!isCompleted"
ref="form"
:action="submitPath"
method="post"
:class="{ 'md:mx-16': isBreakpointMd }"
class="mx-auto"
:style="{ maxWidth: isBreakpointMd ? '582px' : '' }"
@submit.prevent="submitStep"
>
<input
@@ -254,7 +257,7 @@
:key="currentField.uuid"
v-model="values[currentField.uuid]"
:field="currentField"
:previous-value="previousSignatureValue"
:previous-value="previousSignatureValueFor(currentField)"
:is-direct-upload="isDirectUpload"
:with-typed-signature="withTypedSignature"
:attachments-index="attachmentsIndex"
@@ -518,6 +521,11 @@ export default {
required: false,
default: () => ({})
},
i18n: {
type: Object,
required: false,
default: () => ({})
},
withSendCopyButton: {
type: Boolean,
required: false,
@@ -566,11 +574,6 @@ export default {
submitterSlug () {
return this.submitter.slug
},
previousSignatureValue () {
const signatureField = [...this.fields].reverse().find((field) => field.type === 'signature' && !!this.values[field.uuid])
return this.values[signatureField?.uuid]
},
previousInitialsValue () {
const initialsField = [...this.fields].reverse().find((field) => field.type === 'initials' && !!this.values[field.uuid])
@@ -675,7 +678,7 @@ export default {
},
methods: {
t (key) {
return i18n[this.language?.toLowerCase()]?.[key] || i18n[this.browserLanguage]?.[key] || i18n.en[key] || key
return this.i18n[key] || i18n[this.language?.toLowerCase()]?.[key] || i18n[this.browserLanguage]?.[key] || i18n.en[key] || key
},
maybeTrackEmailClick () {
const { queryParams } = this
@@ -736,6 +739,13 @@ export default {
})
})
},
previousSignatureValueFor (field) {
const signatureField = [...this.fields].reverse().find((f) =>
f.type === 'signature' && field.preferences?.format === f.preferences?.format && !!this.values[f.uuid]
)
return this.values[signatureField?.uuid]
},
goToStep (step, scrollToArea = false, clickUpload = false) {
this.currentStep = this.stepFields.indexOf(step)
this.showFillAllRequiredFields = false
+11
View File
@@ -12,6 +12,7 @@ const en = {
radio: 'Radio',
cells: 'Cells',
stamp: 'Stamp',
minimize: 'Minimize',
payment: 'Payment',
phone: 'Phone',
submit_form: 'Submit Form',
@@ -62,6 +63,7 @@ const en = {
}
const es = {
minimize: 'Minimizar',
text: 'Texto',
signature: 'Firma',
initials: 'Iniciales',
@@ -124,6 +126,7 @@ const es = {
}
const it = {
minimize: 'Minimizza',
text: 'Testo',
signature: 'Firma',
initials: 'Iniziali',
@@ -186,6 +189,7 @@ const it = {
}
const de = {
minimize: 'Minimieren',
text: 'Text',
signature: 'Unterschrift',
initials: 'Initialen',
@@ -248,6 +252,7 @@ const de = {
}
const fr = {
minimize: 'Réduire',
text: 'Texte',
signature: 'Signature',
initials: 'Initiales',
@@ -310,6 +315,7 @@ const fr = {
}
const pl = {
minimize: 'Zminimalizuj',
text: 'Tekst',
signature: 'Podpis',
initials: 'Inicjały',
@@ -372,6 +378,7 @@ const pl = {
}
const uk = {
minimize: 'Зменшити',
text: 'Текст',
signature: 'Підпис',
initials: 'Ініціали',
@@ -434,6 +441,7 @@ const uk = {
}
const cs = {
minimize: 'Minimalizovat',
text: 'Text',
signature: 'Podpis',
initials: 'Iniciály',
@@ -496,6 +504,7 @@ const cs = {
}
const pt = {
minimize: 'Minimizar',
text: 'Texto',
signature: 'Assinatura',
initials: 'Iniciais',
@@ -558,6 +567,7 @@ const pt = {
}
const he = {
minimize: 'לקטן',
text: 'טקסט',
signature: 'חתימה',
initials: 'ראשי תיקיות',
@@ -621,6 +631,7 @@ const he = {
}
const nl = {
minimize: 'Minimaliseren',
text: 'Tekst',
signature: 'Handtekening',
initials: 'Initialen',
@@ -8,7 +8,7 @@
<span
v-if="isDrawInitials"
class="tooltip"
:data-tip="t('type_initials')"
:data-tip="t('type_initial')"
>
<a
id="type_text_button"
@@ -58,7 +58,7 @@
{{ t('clear') }}
</a>
<a
title="Minimize"
:title="t('minimize')"
href="#"
class="py-1.5 inline md:hidden"
@click.prevent="$emit('minimize')"
@@ -231,7 +231,7 @@ export default {
}
},
updateWrittenInitials (e) {
this.isInitialsStarted = true
this.isInitialsStarted = !!e.target.value
const canvas = this.$refs.canvas
const context = canvas.getContext('2d')
@@ -6,7 +6,7 @@
>{{ showFieldNames && field.name ? field.name : t('signature') }}</label>
<div class="space-x-2 flex">
<span
v-if="isTextSignature"
v-if="isTextSignature && field.preferences?.format !== 'typed'"
class="tooltip"
:data-tip="t('draw_signature')"
>
@@ -23,7 +23,7 @@
</a>
</span>
<span
v-else-if="withTypedSignature"
v-else-if="withTypedSignature && field.preferences?.format !== 'typed' && field.preferences?.format !== 'drawn'"
class="tooltip ml-2"
:data-tip="t('type_text')"
>
@@ -40,6 +40,7 @@
</a>
</span>
<span
v-if="field.preferences?.format !== 'typed'"
class="tooltip"
:data-tip="t('take_photo')"
>
@@ -48,6 +49,7 @@
>
<IconCamera :width="16" />
<input
:key="uploadImageInputKey"
type="file"
hidden
accept="image/*"
@@ -78,7 +80,7 @@
</a>
<a
href="#"
title="Minimize"
:title="t('minimize')"
class="py-1.5 inline md:hidden"
@click.prevent="$emit('minimize')"
>
@@ -111,7 +113,7 @@
id="signature_text_input"
ref="textInput"
class="base-input !text-2xl w-full mt-6"
:required="field.required"
:required="field.required && !isSignatureStarted"
:placeholder="`${t('type_signature_here')}...`"
type="text"
@input="updateWrittenSignature"
@@ -185,7 +187,8 @@ export default {
return {
isSignatureStarted: !!this.previousValue,
isUsePreviousValue: true,
isTextSignature: false
isTextSignature: this.field.preferences?.format === 'typed',
uploadImageInputKey: Math.random().toString()
}
},
computed: {
@@ -269,7 +272,7 @@ export default {
}
},
updateWrittenSignature (e) {
this.isSignatureStarted = true
this.isSignatureStarted = !!e.target.value
const canvas = this.$refs.canvas
const context = canvas.getContext('2d')
@@ -340,16 +343,18 @@ export default {
const x = (canvasWidth - targetWidth) / 2
const y = (canvasHeight - targetHeight) / 2
context.clearRect(0, 0, canvasWidth, canvasHeight)
context.drawImage(img, x, y, targetWidth, targetHeight)
setTimeout(() => {
context.clearRect(0, 0, canvasWidth, canvasHeight)
context.drawImage(img, x, y, targetWidth, targetHeight)
this.$emit('start')
this.$emit('start')
}, 50)
}
}
reader.readAsDataURL(file)
event.target.value = null
this.uploadImageInputKey = Math.random().toString()
}
},
async submit () {
+1 -1
View File
@@ -90,7 +90,7 @@
<button
v-else-if="editable"
class="pr-1"
title="Remove"
:title="t('remove')"
@click.prevent="$emit('remove')"
>
<IconX width="14" />
@@ -27,7 +27,7 @@
<IconPencil
class="cursor-pointer flex-none opacity-0 group-hover/contenteditable-container:opacity-100 group-hover/contenteditable:opacity-100 align-middle peer-focus:hidden"
:style="iconInline ? {} : { right: -(1.1 * iconWidth) + 'px' }"
title="Edit"
:title="t('edit')"
:class="{ invisible: !editable, 'ml-1': !withRequired, 'absolute': !iconInline, 'inline align-bottom': iconInline }"
:width="iconWidth"
:stroke-width="iconStrokeWidth"
@@ -44,6 +44,7 @@ export default {
components: {
IconPencil
},
inject: ['t'],
props: {
modelValue: {
type: String,
+1 -1
View File
@@ -102,7 +102,7 @@ export default {
return this.previewImagesIndex[i] || {
metadata: lazyloadMetadata,
id: Math.random().toString(),
url: this.basePreviewUrl + `/preview/${this.document.uuid}/${i}.jpg`
url: this.basePreviewUrl + `/preview/${this.document.signed_uuid || this.document.uuid}/${i}.jpg`
}
})
},
+41 -4
View File
@@ -56,7 +56,7 @@
>
<button
v-if="field && !field.areas.length"
title="Draw"
:title="t('draw')"
class="relative cursor-pointer text-transparent group-hover:text-base-content"
@click="$emit('set-draw', { field })"
>
@@ -77,7 +77,7 @@
>
<label
tabindex="0"
title="Settings"
:title="t('settings')"
class="cursor-pointer text-transparent group-hover:text-base-content"
>
<IconSettings
@@ -142,6 +142,43 @@
{{ t('format') }}
</label>
</div>
<div
v-if="field.type === 'signature'"
class="py-1.5 px-1 relative"
@click.stop
>
<select
:placeholder="t('format')"
class="select select-bordered select-xs font-normal w-full max-w-xs !h-7 !outline-0"
@change="[field.preferences.format = $event.target.value, save()]"
>
<option
value="any"
:selected="!field.preferences?.format || field.preferences.format === 'any'"
>
{{ t('any') }}
</option>
<option
value="drawn"
:selected="field.preferences?.format === 'drawn'"
>
{{ t('drawn') }}
</option>
<option
value="typed"
:selected="field.preferences?.format === 'typed'"
>
{{ t('typed') }}
</option>
</select>
<label
:style="{ backgroundColor: backgroundColor }"
class="absolute -top-1 left-2.5 px-1 h-4"
style="font-size: 8px"
>
{{ t('format') }}
</label>
</div>
<li
v-if="field.type != 'phone'"
@click.stop
@@ -245,7 +282,7 @@
</span>
<button
class="relative text-transparent group-hover:text-base-content pr-1"
title="Remove"
:title="t('remove')"
@click="$emit('remove', field)"
>
<IconTrashX
@@ -284,7 +321,7 @@
@blur="save"
>
<button
title="Draw"
:title="t('draw')"
tabindex="-1"
@click.prevent="$emit('set-draw', { field, option })"
>
@@ -163,7 +163,7 @@
>
<div class="flex-col pr-1 hidden group-hover:flex -mt-1 h-0">
<button
title="Up"
:title="t('up')"
class="relative w-2"
style="font-size: 10px; margin-bottom: -4px"
@click.prevent.stop="[move(submitter, -1), $refs.label.focus()] "
@@ -171,7 +171,7 @@
▲
</button>
<button
title="Down"
:title="t('down')"
class="relative w-2"
style="font-size: 10px; margin-top: -4px"
@click.prevent.stop="[move(submitter, 1), $refs.label.focus()] "
+12 -1
View File
@@ -26,7 +26,7 @@
@click="closeDropdown"
>
<template
v-for="(icon, type) in fieldIcons"
v-for="(icon, type) in fieldIconsSorted"
:key="type"
>
<li v-if="(fieldTypes.length === 0 || fieldTypes.includes(type)) && (withPhone || type != 'phone') && (withPayment || type != 'payment')">
@@ -123,6 +123,17 @@ export default {
payment: IconCreditCard,
phone: IconPhoneCheck
}
},
fieldIconsSorted () {
if (this.fieldTypes.length) {
return this.fieldTypes.reduce((acc, type) => {
acc[type] = this.fieldIcons[type]
return acc
}, {})
} else {
return this.fieldIcons
}
}
},
methods: {
+12 -1
View File
@@ -68,7 +68,7 @@
class="grid grid-cols-3 gap-1 pb-2"
>
<template
v-for="(icon, type) in fieldIcons"
v-for="(icon, type) in fieldIconsSorted"
:key="type"
>
<button
@@ -213,6 +213,17 @@ export default {
computed: {
fieldNames: FieldType.computed.fieldNames,
fieldIcons: FieldType.computed.fieldIcons,
fieldIconsSorted () {
if (this.fieldTypes.length) {
return this.fieldTypes.reduce((acc, type) => {
acc[type] = this.fieldIcons[type]
return acc
}, {})
} else {
return this.fieldIcons
}
},
submitterFields () {
return this.fields.filter((f) => f.submitter_uuid === this.selectedSubmitter.uuid)
},
+9
View File
@@ -3,9 +3,17 @@ const en = {
sign_yourself: 'Sign Yourself',
set_signing_date: 'Set signing date',
send: 'Send',
remove: 'Remove',
edit: 'Edit',
settings: 'Settings',
up: 'Up',
down: 'Down',
checked: 'Checked',
save: 'Save',
cancel: 'Cancel',
any: 'Any',
drawn: 'Drawn',
typed: 'Typed',
draw_field_on_the_document: 'Draw {field} field on the document',
click_to_upload: 'Click to upload',
or_drag_and_drop_files: 'or drag and drop files',
@@ -42,6 +50,7 @@ const en = {
eighteenth_party: 'Eighteenth Party',
nineteenth_party: 'Nineteenth Party',
twentieth_party: 'Twentieth Party',
draw: 'Draw',
add: 'Add',
or_add_field_without_drawing: 'Or add field without drawing',
text: 'Text',
@@ -5,7 +5,7 @@
>
<label
tabindex="0"
title="Settings"
:title="t('settings')"
class="cursor-pointer text-transparent group-hover:text-base-content"
>
<IconSettings
@@ -167,7 +167,7 @@ export default {
IconInnerShadowTop,
IconBrandStripe
},
inject: ['backgroundColor', 'save', 'currencies'],
inject: ['backgroundColor', 'save', 'currencies', 't'],
props: {
field: {
type: Object,
+6 -4
View File
@@ -26,12 +26,14 @@ class ProcessSubmitterCompletionJob < ApplicationJob
user = submission.created_by_user || submitter.template.author
if submitter.template.account.users.exists?(id: user.id) &&
submission.preferences['send_email'] != false
SubmitterMailer.completed_email(submitter, user).deliver_later!
if submitter.account.users.exists?(id: user.id) && submission.preferences['send_email'] != false
if submitter.submission.submitters.map(&:email).exclude?(user.email) &&
user.user_configs.find_by(key: UserConfig::RECEIVE_COMPLETED_EMAIL)&.value != false
SubmitterMailer.completed_email(submitter, user).deliver_later!
end
bcc = submission.preferences['bcc_completed'].presence ||
submission.template.account.account_configs
submission.account.account_configs
.find_by(key: AccountConfig::BCC_EMAILS)&.value.presence
bcc.to_s.scan(User::EMAIL_REGEXP).each do |to|
@@ -3,9 +3,10 @@
class SendFormCompletedWebhookRequestJob < ApplicationJob
USER_AGENT = 'DocuSeal.co Webhook'
NotSuccessStatus = Class.new(StandardError)
MAX_ATTEMPTS = 10
def perform(submitter)
def perform(submitter, params = {})
attempt = params[:attempt].to_i
config = Accounts.load_webhook_configs(submitter.submission.account)
return if config.blank? || config.value.blank?
@@ -23,8 +24,13 @@ class SendFormCompletedWebhookRequestJob < ApplicationJob
'Content-Type' => 'application/json',
'User-Agent' => USER_AGENT)
if resp.status.to_i >= 400 && (!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan))
raise NotSuccessStatus, resp.status.to_s
if resp.status.to_i >= 400 && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan))
SendFormCompletedWebhookRequestJob.set(wait: (2**attempt).minutes)
.perform_later(submitter, {
attempt: attempt + 1,
last_status: resp.status.to_i
})
end
end
end
@@ -3,9 +3,10 @@
class SendFormStartedWebhookRequestJob < ApplicationJob
USER_AGENT = 'DocuSeal.co Webhook'
NotSuccessStatus = Class.new(StandardError)
MAX_ATTEMPTS = 10
def perform(submitter)
def perform(submitter, params = {})
attempt = params[:attempt].to_i
config = Accounts.load_webhook_configs(submitter.submission.account)
return if config.blank? || config.value.blank?
@@ -21,8 +22,13 @@ class SendFormStartedWebhookRequestJob < ApplicationJob
'Content-Type' => 'application/json',
'User-Agent' => USER_AGENT)
if resp.status.to_i >= 400 && (!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan))
raise NotSuccessStatus, resp.status.to_s
if resp.status.to_i >= 400 && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan))
SendFormStartedWebhookRequestJob.set(wait: (2**attempt).minutes)
.perform_later(submitter, {
attempt: attempt + 1,
last_status: resp.status.to_i
})
end
end
end
@@ -3,9 +3,10 @@
class SendFormViewedWebhookRequestJob < ApplicationJob
USER_AGENT = 'DocuSeal.co Webhook'
NotSuccessStatus = Class.new(StandardError)
MAX_ATTEMPTS = 10
def perform(submitter)
def perform(submitter, params = {})
attempt = params[:attempt].to_i
config = Accounts.load_webhook_configs(submitter.submission.account)
return if config.blank? || config.value.blank?
@@ -21,8 +22,13 @@ class SendFormViewedWebhookRequestJob < ApplicationJob
'Content-Type' => 'application/json',
'User-Agent' => USER_AGENT)
if resp.status.to_i >= 400 && (!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan))
raise NotSuccessStatus, resp.status.to_s
if resp.status.to_i >= 400 && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan))
SendFormViewedWebhookRequestJob.set(wait: (2**attempt).minutes)
.perform_later(submitter, {
attempt: attempt + 1,
last_status: resp.status.to_i
})
end
end
end
+4
View File
@@ -30,6 +30,10 @@ class Account < ApplicationRecord
foreign_key: :linked_account_id,
class_name: 'AccountLinkedAccount',
inverse_of: :linked_account
has_many :linked_account_accounts, dependent: :destroy,
foreign_key: :linked_account_id,
class_name: 'AccountLinkedAccount',
inverse_of: :linked_account
has_many :linked_accounts, through: :account_linked_accounts
has_many :testing_accounts, through: :account_testing_accounts, source: :linked_account
has_many :active_users, -> { active }, dependent: :destroy,
+1 -1
View File
@@ -76,7 +76,7 @@ class Submission < ApplicationRecord
def audit_trail_url
return if audit_trail.blank?
Rails.application.routes.url_helpers.rails_storage_proxy_url(audit_trail, **Docuseal.default_url_options)
ActiveStorage::Blob.proxy_url(audit_trail.blob)
end
alias audit_log_url audit_trail_url
end
+1
View File
@@ -22,6 +22,7 @@
#
class UserConfig < ApplicationRecord
SIGNATURE_KEY = 'signature'
RECEIVE_COMPLETED_EMAIL = 'receive_completed_email'
belongs_to :user
+17 -19
View File
@@ -123,26 +123,24 @@
<% end %>
</div>
<% end %>
<% if !Docuseal.multitenant? %>
<% account_config = AccountConfig.where(account: current_account, key: AccountConfig::ESIGNING_PREFERENCE_KEY).first_or_initialize(value: 'multiple') %>
<% if can?(:manage, account_config) %>
<div class="px-1 mt-8 max-w-xl">
<div class="flex justify-between items-end mb-4 mt-8">
<h2 class="text-3xl font-bold">Preferences</h2>
</div>
<% if can?(:manage, account_config) %>
<%= form_for account_config, url: account_configs_path, method: :post do |f| %>
<%= f.hidden_field :key %>
<div class="flex items-center justify-between py-2.5">
<span>
Apply multiple PDF digital signatures in the document per each signer
</span>
<%= f.check_box :value, { class: 'toggle', checked: account_config.value == 'multiple', onchange: 'this.form.requestSubmit()' }, 'multiple', 'single' %>
</div>
<% end %>
<% end %>
<% account_config = AccountConfig.where(account: current_account, key: AccountConfig::ESIGNING_PREFERENCE_KEY).first_or_initialize(value: 'multiple') %>
<% if can?(:manage, account_config) %>
<div class="px-1 mt-8 max-w-xl">
<div class="flex justify-between items-end mb-4 mt-8">
<h2 class="text-3xl font-bold">Preferences</h2>
</div>
<% end %>
<% if can?(:manage, account_config) %>
<%= form_for account_config, url: account_configs_path, method: :post do |f| %>
<%= f.hidden_field :key %>
<div class="flex items-center justify-between py-2.5">
<span>
Apply multiple PDF digital signatures in the document per each signer
</span>
<%= f.check_box :value, { class: 'toggle', checked: account_config.value == 'multiple', onchange: 'this.form.requestSubmit()' }, 'multiple', 'single' %>
</div>
<% end %>
<% end %>
</div>
<% end %>
</div>
</div>
+1 -1
View File
@@ -1 +1 @@
<%= render 'docuseal_logo' %>
<%= render 'start_form/docuseal_logo' %>
+2 -2
View File
@@ -19,7 +19,7 @@
<div dir="auto" class="text-center text-2xl font-semibold">
<%= t('form_has_been_submitted_already') %>
</div>
<% if Accounts.can_send_emails?(@submitter.submission.template.account) %>
<% if Accounts.can_send_emails?(@submitter.account) %>
<div>
<%= button_to button_title(title: t('send_copy_to_email'), disabled_with: t('sending'), icon: svg_icon('mail_forward', class: 'w-6 h-6')), send_submission_email_index_path, params: { submitter_slug: @submitter.slug }, form: { onsubmit: 'event.submitter.disabled = true' }, class: 'base-button w-full' %>
</div>
@@ -31,4 +31,4 @@
<% end %>
</div>
</div>
<%= render 'shared/attribution', link_path: '/start' %>
<%= render 'shared/attribution', link_path: '/start', account: @submitter.account %>
+1 -1
View File
@@ -37,4 +37,4 @@
<% end %>
</div>
</div>
<%= render 'shared/attribution', link_path: '/start' %>
<%= render 'shared/attribution', link_path: '/start', account: @template.account %>
+6 -6
View File
@@ -6,7 +6,7 @@
</a>
<div class="space-x-3 flex items-center">
<% if @submission.audit_trail.present? %>
<a href="<%= rails_blob_path(@submission.audit_trail) %>" class="white-button" target="_blank">
<a href="<%= ActiveStorage::Blob.proxy_url(@submission.audit_trail.blob) %>" class="white-button" target="_blank">
<%= svg_icon('external_link', class: 'w-6 h-6') %>
<span class="hidden md:inline">
Audit Log
@@ -34,7 +34,7 @@
<% (@submission.template_schema || @submission.template.schema).each do |item| %>
<% document = @submission.template_schema_documents.find { |a| item['attachment_uuid'] == a.uuid } %>
<a href="#<%= "page-#{document.uuid}-0" %>" onclick="[event.preventDefault(), window[event.target.closest('a').href.split('#')[1]].scrollIntoView({ behavior: 'smooth', block: 'start' })]" class="block cursor-pointer">
<img src="<%= document.preview_images.first.url %>" width="<%= document.preview_images.first.metadata['width'] %>" height="<%= document.preview_images.first.metadata['height'] %>" class="rounded border" loading="lazy">
<img src="<%= Docuseal::URL_CACHE.fetch([document.id, document.uuid, 0].join(':'), expires_in: 10.minutes) { document.preview_images.first.url } %>" width="<%= document.preview_images.first.metadata['width'] %>" height="<%= document.preview_images.first.metadata['height'] %>" class="rounded border" loading="lazy">
<div class="pb-2 pt-1.5 text-center" dir="auto">
<%= item['name'].presence || document.filename.base %>
</div>
@@ -53,9 +53,9 @@
<% preview_images_index = document.preview_images.loaded? ? document.preview_images.index_by { |e| e.filename.base.to_i } : {} %>
<% lazyload_metadata = document.preview_images.first.metadata %>
<% (document.metadata.dig('pdf', 'number_of_pages') || (document.preview_images.loaded? ? preview_images_index.size : document.preview_images.size)).times do |index| %>
<% page = preview_images_index[index] || page_blob_struct.new(metadata: lazyload_metadata, url: preview_document_page_path(document.uuid, "#{index}.jpg")) %>
<% page = preview_images_index[index] || page_blob_struct.new(metadata: lazyload_metadata, url: preview_document_page_path(document.signed_uuid, "#{index}.jpg")) %>
<div id="<%= "page-#{document.uuid}-#{index}" %>" class="relative">
<img loading="lazy" src="<%= page.url %>" width="<%= page.metadata['width'] %>" class="border rounded mb-4" height="<%= page.metadata['height'] %>">
<img loading="lazy" src="<%= Docuseal::URL_CACHE.fetch([document.id, document.uuid, index].join(':'), expires_in: 10.minutes) { page.url } %>" width="<%= page.metadata['width'] %>" class="border rounded mb-4" height="<%= page.metadata['height'] %>">
<div class="top-0 bottom-0 left-0 right-0 absolute">
<% document_annots_index[index]&.each do |annot| %>
<%= render 'submissions/annotation', annot: %>
@@ -144,10 +144,10 @@
<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" height="<%= attachments_index[value].metadata['height'] %>" width="<%= attachments_index[value].metadata['width'] %>" src="<%= attachments_index[value].url %>" loading="lazy">
<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">
</div>
<% elsif field['type'].in?(['image', 'stamp']) %>
<img class="object-contain mx-auto max-h-28" height="<%= attachments_index[value].metadata['height'] %>" width="<%= attachments_index[value].metadata['width'] %>" src="<%= attachments_index[value].url %>" loading="lazy">
<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">
<% elsif field['type'] == 'file' || field['type'] == 'payment' %>
<div class="flex flex-col justify-center">
<% Array.wrap(value).each do |val| %>
@@ -0,0 +1,30 @@
<div class="max-w-md mx-auto px-2 mt-12 mb-4">
<div class="space-y-6 mx-auto">
<div class="space-y-6">
<% if Docuseal.multitenant? %>
<div class="flex items-center justify-center">
<%= render 'start_form/docuseal_logo' %>
</div>
<% end %>
<div class="flex items-center bg-base-200 rounded-xl p-4 mb-4">
<div class="flex items-center">
<div class="mr-3">
<%= svg_icon('writing_sign', class: 'w-10 h-10') %>
</div>
<div>
<p dir="auto" class="text-lg font-bold mb-1"><%= @submission.template.name %></p>
<% if submitter = @submission.submitters.map(&:completed_at).max %>
<p dir="auto" class="text-sm"><%= t('signed_on_time', time: l(submitter.to_date, format: :long)) %></p>
<% end %>
</div>
</div>
</div>
</div>
<% if Docuseal.multitenant? %>
<div>
<%= link_to 'Create free account', registration_path, class: 'white-button w-full' %>
</div>
<% end %>
</div>
</div>
<%= render 'shared/attribution', link_path: '/start', account: @submission.account %>
+2 -2
View File
@@ -11,11 +11,11 @@
</div>
<div dir="auto">
<p class="text-lg font-bold mb-1"><%= @submitter.submission.template.name %></p>
<p class="text-sm"><%= t('form_has_been_deleted_by_html', name: @submitter.submission.template.account.name) %></p>
<p class="text-sm"><%= t('form_has_been_deleted_by_html', name: @submitter.account.name) %></p>
</div>
</div>
</div>
</div>
</div>
</div>
<%= render 'shared/attribution', link_path: '/start' %>
<%= render 'shared/attribution', link_path: '/start', account: @submitter.account %>
+15 -13
View File
@@ -17,7 +17,7 @@
</div>
</div>
<div>
<% if Accounts.can_send_emails?(@submitter.submission.template.account) && @submitter.email.present? %>
<% if Accounts.can_send_emails?(@submitter.account) && @submitter.email.present? %>
<%= button_to button_title(title: t('send_copy_to_email'), disabled_with: t('sending'), icon: svg_icon('mail_forward', class: 'w-6 h-6')), send_submission_email_index_path, params: { submitter_slug: @submitter.slug }, form: { onsubmit: 'event.submitter.disabled = true' }, class: 'white-button w-full' %>
<% if @submitter.submission.template.submitters.size != 1 %>
<div class="divider uppercase"><%= t('or') %></div>
@@ -25,18 +25,20 @@
<div class="py-2"></div>
<% end %>
<% end %>
<download-button data-src="<%= submitter_download_index_path(@submitter.slug) %>" class="base-button w-full">
<span class="flex items-center justify-center space-x-2" data-target="download-button.defaultButton">
<%= svg_icon('download', class: 'w-6 h-6') %>
<span><%= t('download_documents') %></span>
</span>
<span class="flex items-center justify-center space-x-2 hidden" data-target="download-button.loadingButton">
<%= svg_icon('loader', class: 'w-6 h-6 animate-spin') %>
<span><%= t('downloading') %></span>
</span>
</download-button>
<% if @submitter.completed_at > 15.minutes.ago || (current_user && current_user.account.submitters.exists?(id: @submitter.id)) %>
<download-button data-src="<%= submitter_download_index_path(@submitter.slug) %>" class="base-button w-full">
<span class="flex items-center justify-center space-x-2" data-target="download-button.defaultButton">
<%= svg_icon('download', class: 'w-6 h-6') %>
<span><%= t('download_documents') %></span>
</span>
<span class="flex items-center justify-center space-x-2 hidden" data-target="download-button.loadingButton">
<%= svg_icon('loader', class: 'w-6 h-6 animate-spin') %>
<span><%= t('downloading') %></span>
</span>
</download-button>
<% end %>
</div>
<% if @submitter.submission.template.submitters.size == 1 && %w[api embed].exclude?(@submitter.submission.source) && @submitter.account.account_configs.find_or_initialize_by(key: AccountConfig::ALLOW_TO_RESUBMIT).value != false %>
<% if @submitter.submission.template.submitters.size == 1 && %w[api embed].exclude?(@submitter.submission.source) && @submitter.account.account_configs.find_or_initialize_by(key: AccountConfig::ALLOW_TO_RESUBMIT).value != false && !@submitter.template.archived_at? %>
<div class="divider uppercase"><%= t('or') %></div>
<div>
<%= button_to button_title(title: t('resubmit'), disabled_with: t('resubmit'), icon: svg_icon('reload', class: 'w-6 h-6')), start_form_path(@submitter.submission.template.slug), params: { submitter: { email: @submitter.email, phone: @submitter.phone, name: @submitter.name }, resubmit: @submitter.slug }, method: :put, form: { onsubmit: 'event.submitter.disabled = true' }, class: 'white-button w-full' %>
@@ -44,4 +46,4 @@
<% end %>
</div>
</div>
<%= render 'shared/attribution', link_path: '/start' %>
<%= render 'shared/attribution', link_path: '/start', account: @submitter.account %>
+3 -3
View File
@@ -13,7 +13,7 @@
<% preview_images_index = document.preview_images.loaded? ? document.preview_images.index_by { |e| e.filename.base.to_i } : {} %>
<% lazyload_metadata = document.preview_images.last.metadata %>
<% (document.metadata.dig('pdf', 'number_of_pages') || (document.preview_images.loaded? ? preview_images_index.size : document.preview_images.size)).times do |index| %>
<% page = preview_images_index[index] || page_blob_struct.new(metadata: lazyload_metadata, url: preview_document_page_path(document.uuid, "#{index}.jpg")) %>
<% page = preview_images_index[index] || page_blob_struct.new(metadata: lazyload_metadata, url: preview_document_page_path(document.signed_uuid, "#{index}.jpg")) %>
<div class="relative my-4 shadow-md">
<img loading="lazy" src="<%= page.url %>" width="<%= page.metadata['width'] %>" height="<%= page.metadata['height'] %>">
<div id="page-<%= [document.uuid, index].join('-') %>" class="top-0 bottom-0 left-0 right-0 absolute">
@@ -24,13 +24,13 @@
<% value = values[field['uuid']].presence || (field['readonly'] ? Submitters::SubmitValues.template_default_value_for_submitter(field['default_value'], @submitter.submission.submitters.find { |e| e.uuid == field['submitter_uuid'] }, with_time: false) : nil) %>
<% next if value.blank? %>
<% next if !field['readonly'] && field['submitter_uuid'] == @submitter.uuid %>
<%= render 'submissions/value', area:, field:, attachments_index:, value:, locale: @submitter.submission.template.account.locale %>
<%= render 'submissions/value', area:, field:, attachments_index:, value:, locale: @submitter.account.locale %>
<% end %>
</div>
</div>
<% end %>
<% end %>
<%= render 'shared/attribution', link_path: '/start' %>
<%= render 'shared/attribution', link_path: '/start', account: @submitter.account %>
</div>
</div>
</div>
+6 -1
View File
@@ -12,11 +12,16 @@
</a>
<folder-autocomplete class="flex justify-between w-full">
<input id="folder_name" placeholder="Folder Name" type="text" class="w-full outline-none border-transparent focus:border-transparent focus:ring-0 bg-base-100 px-1 peer" name="folder_name" value="<%= params[:folder_name].presence || @base_template&.folder&.name || TemplateFolder::DEFAULT_NAME %>" onblur="window.folder_name.value = window.folder_name.value || 'Default'" autocomplete="off">
<a href="#" onclick="[event.preventDefault(), window.folder_name.value = '', window.folder_name.focus()]" class="shrink-0 link peer-focus:hidden mr-1.5">
<a href="#" onclick="[event.preventDefault(), window.folder_name.value = '', window.folder_name.focus()]" class="shrink-0 link peer-focus:hidden mr-1.5">
Change Folder
</a>
</folder-autocomplete>
</div>
<% if @base_template && (can?(:manage, :tenants) || true_user != current_user) && true_user.account.account_linked_accounts.exists?(account_type: :linked) %>
<div class="form-control mb-4">
<%= select_tag :account_id, options_for_select([true_user.account, *true_user.account.linked_accounts.where.not(id: true_user.account.testing_accounts)].map { |e| [e.name, e.id] }, current_account.id), required: true, class: 'base-select' %>
</div>
<% end %>
<div class="form-control">
<%= f.button button_title(title: @base_template ? 'Submit' : 'Create', disabled_with: 'Creating'), class: 'base-button' %>
</div>
+2
View File
@@ -22,6 +22,8 @@ module DocuSeal
config.active_storage.routes_prefix = ''
config.active_storage.draw_routes = ENV['MULTITENANT'] != 'true'
config.i18n.available_locales = %i[en en-US en-GB es-ES fr-FR pt-PT de-DE es it de fr pl uk cs pt he nl]
config.i18n.fallbacks = [:en]
+2 -1
View File
@@ -52,6 +52,7 @@ Rails.application.configure do
end
config.active_storage.resolve_model_to_route = :rails_storage_proxy if ENV['ACTIVE_STORAGE_PUBLIC'] != 'true'
config.active_storage.service_urls_expire_in = 15.minutes
# Mount Action Cable outside main process or domain.
# config.action_cable.mount_path = nil
@@ -107,7 +108,7 @@ Rails.application.configure do
logger.formatter = config.log_formatter
config.logger = ActiveSupport::TaggedLogging.new(logger)
encryption_secret = Digest::SHA256.hexdigest(ENV['SECRET_KEY_BASE'].to_s)
encryption_secret = ENV['ENCRYPTION_SECRET'].presence || Digest::SHA256.hexdigest(ENV['SECRET_KEY_BASE'].to_s)
config.active_record.encryption = {
primary_key: encryption_secret.first(32),
+36 -1
View File
@@ -5,8 +5,43 @@ ActiveSupport.on_load(:active_storage_attachment) do
has_many_attached :preview_images
def signed_uuid
@signed_uuid ||= ApplicationRecord.signed_id_verifier.generate(uuid, expires_in: 6.hours, purpose: :attachment)
end
def preview_image_url
preview_images.joins(:blob).find_by(blob: { filename: '0.jpg' })&.url
first_page = preview_images.joins(:blob).find_by(blob: { filename: '0.jpg' })
return unless first_page
ActiveStorage::Blob.proxy_url(first_page.blob)
end
end
ActiveSupport.on_load(:active_storage_blob) do
attribute :uuid, :string, default: -> { SecureRandom.uuid }
def self.proxy_url(blob, expires_in: nil)
Rails.application.routes.url_helpers.blobs_proxy_url(
signed_uuid: blob.signed_uuid(expires_in:), filename: blob.filename,
**Docuseal.default_url_options
)
end
def uuid
super || begin
new_uuid = SecureRandom.uuid
update_columns(uuid: new_uuid)
new_uuid
end
end
def signed_uuid(expires_in: nil)
ApplicationRecord.signed_id_verifier.generate([uuid, 'blob'], expires_in:)
end
def delete
service.delete(key)
end
end
+1 -1
View File
@@ -113,7 +113,7 @@ Devise.setup do |config|
# This can reduce the time taken to boot the app but if your application
# requires the Devise mappings to be loaded during boot time the application
# won't boot properly.
# config.reload_routes = true
config.reload_routes = false
# ==> Configuration for :database_authenticatable
# For bcrypt, this is the cost for hashing the password and defaults to 12. If
+15
View File
@@ -6,7 +6,22 @@ if defined?(Rollbar)
Rollbar.configure do |config|
config.access_token = ENV.fetch('ROLLBAR_ACCESS_TOKEN', nil)
config.transform << proc do |options|
data = options[:payload]['data']
if data[:request]
data[:request][:cookies] = {}
data[:request][:session] = {}
data[:request][:url] =
data[:request][:url].to_s.sub(%r{(/[sde]/)\w{8}}, '\1********').sub(/\A(.*?)--(.*)/, '\1--********')
end
end
config.enabled = true
config.collect_user_ip = false
config.anonymize_user_ip = true
config.scrub_headers += %w[X-Auth-Token Cookie X-Csrf-Token Referer]
config.scrub_fields += %i[slug uuid attachment_uuid]
config.exception_level_filters['ActionController::RoutingError'] = 'ignore'
+17 -2
View File
@@ -53,6 +53,7 @@ Rails.application.routes.draw do
resources :verify_pdf_signature, only: %i[create]
resource :mfa_setup, only: %i[show new edit create destroy], controller: 'mfa_setup'
resources :account_configs, only: %i[create]
resources :encrypted_user_configs, only: %i[destroy]
resources :timestamp_server, only: %i[create]
resources :dashboard, only: %i[index]
resources :setup, only: %i[index create]
@@ -80,7 +81,19 @@ Rails.application.routes.draw do
resource :code_modal, only: %i[show], controller: 'templates_code_modal'
resources :submissions_export, only: %i[index new]
end
resources :preview_document_page, only: %i[show], path: '/preview/:attachment_uuid'
resources :preview_document_page, only: %i[show], path: '/preview/:signed_uuid'
resource :blobs_proxy, only: %i[show], path: '/blobs_proxy/:signed_uuid/*filename',
controller: 'api/active_storage_blobs_proxy'
if Docuseal.multitenant?
resource :blobs_proxy_legacy, only: %i[show],
path: '/blobs/proxy/:signed_id/*filename',
controller: 'api/active_storage_blobs_proxy_legacy',
as: :rails_blob
get '/disk/:encoded_key/*filename' => 'active_storage/disk#show', as: :rails_disk_service
put '/disk/:encoded_token' => 'active_storage/disk#update', as: :update_rails_disk_service
post '/direct_uploads' => 'active_storage/direct_uploads#create', as: :rails_direct_uploads
end
resources :start_form, only: %i[show update], path: 'd', param: 'slug' do
get :completed
@@ -94,7 +107,9 @@ Rails.application.routes.draw do
get :completed
end
resources :submissions_preview, only: %i[show], path: 'e', param: 'slug'
resources :submissions_preview, only: %i[show], path: 'e', param: 'slug' do
get :completed
end
resources :send_submission_email, only: %i[create] do
get :success, on: :collection
@@ -0,0 +1,9 @@
# frozen_string_literal: true
class AddUuidToActiveStorageBlobs < ActiveRecord::Migration[7.1]
def change
add_column :active_storage_blobs, :uuid, :string
add_index :active_storage_blobs, :uuid, unique: true
end
end
+3 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2024_02_03_113455) do
ActiveRecord::Schema[7.1].define(version: 2024_02_18_082157) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@@ -74,7 +74,9 @@ ActiveRecord::Schema[7.1].define(version: 2024_02_03_113455) do
t.bigint "byte_size", null: false
t.string "checksum"
t.datetime "created_at", null: false
t.string "uuid"
t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true
t.index ["uuid"], name: "index_active_storage_blobs_on_uuid", unique: true
end
create_table "active_storage_variant_records", force: :cascade do |t|
+1
View File
@@ -1,6 +1,7 @@
# frozen_string_literal: true
module Docuseal
URL_CACHE = ActiveSupport::Cache::MemoryStore.new
PRODUCT_URL = 'https://www.docuseal.co'
NEWSLETTER_URL = "#{PRODUCT_URL}/newsletters".freeze
ENQUIRIES_URL = "#{PRODUCT_URL}/enquiries".freeze
+1 -1
View File
@@ -9,7 +9,7 @@ module Params
validator.call
rescue InvalidParameterError => e
Rollbar.error(e) if defined?(Rollbar)
Rollbar.warning(e) if defined?(Rollbar)
raise e unless validator.dry_run?
rescue StandardError => e
+12
View File
@@ -70,6 +70,18 @@ class PdfProcessor < HexaPDF::Content::Processor
page.contents = process_handler_instance.contents
end
page[:Annots].to_a.each do |annot|
next unless annot
text = annot[:Contents].to_s.squish
next unless text.starts_with?('{{') && text.ends_with?('}}')
result_handler.call({ text:, rect: annot[:Rect] }, page, acc)
page[:Annots].delete(annot)
end
process_handler_instance.result.each do |item|
result_handler.call(item, page, acc)
end
+1 -1
View File
@@ -28,7 +28,7 @@ module Submissions
end
def create_from_emails(template:, user:, emails:, source:, mark_as_sent: false, params: {})
preferences = Submitters.normalize_preferences(template.account, user, params)
preferences = Submitters.normalize_preferences(user.account, user, params)
parse_emails(emails).uniq.map do |email|
submission = template.submissions.new(created_by_user: user,
+2 -2
View File
@@ -5,10 +5,10 @@ module Submissions
module_function
def call(template:, user:, submissions_attrs:, source:, submitters_order:, mark_as_sent: false, params: {})
preferences = Submitters.normalize_preferences(template.account, user, params)
preferences = Submitters.normalize_preferences(user.account, user, params)
Array.wrap(submissions_attrs).map do |attrs|
submission_preferences = Submitters.normalize_preferences(template.account, user, attrs)
submission_preferences = Submitters.normalize_preferences(user.account, user, attrs)
submission_preferences = preferences.merge(submission_preferences)
set_submission_preferences = submission_preferences.slice('send_email', 'bcc_completed')
+27 -21
View File
@@ -4,8 +4,8 @@ module Submissions
module GenerateAuditTrail
FONT_SIZE = 9
TEXT_COLOR = '525252'
FONT_PATH = '/fonts/LiberationSans-Regular.ttf'
FONT_BOLD_PATH = '/fonts/LiberationSans-Bold.ttf'
FONT_PATH = '/fonts/GoNotoKurrent-Regular.ttf'
FONT_BOLD_PATH = '/fonts/GoNotoKurrent-Bold.ttf'
FONT_NAME = if File.exist?(FONT_PATH)
FONT_PATH
else
@@ -28,6 +28,7 @@ module Submissions
}.freeze
RTL_REGEXP = TextUtils::RTL_REGEXP
MAX_IMAGE_HEIGHT = 100
module_function
@@ -61,7 +62,7 @@ module Submissions
style.frame = style.create_frame(canvas.context, 50)
end
composer.style(:base, font: FONT_NAME, font_size: FONT_SIZE, fill_color: TEXT_COLOR, line_spacing: 1.2)
composer.style(:base, font: FONT_NAME, font_size: FONT_SIZE, fill_color: TEXT_COLOR, line_spacing: 1)
composer.style(:link, fill_color: 'hp-blue-light', underline: true)
composer.new_page
@@ -71,16 +72,16 @@ module Submissions
column.text(account.linked_account_account&.testing? ? 'Testing Log - Not for Production Use' : 'Audit Log',
font_size: 16,
padding: [15, 0, 0, 0],
position: :float, position_hint: :right)
padding: [10, 0, 0, 0],
position: :float, text_align: :right)
end
composer.column(columns: 1) do |column|
column.text("Envelope ID: #{submission.id}", font_size: 12, padding: [20, 0, 10, 0], position: :float)
column.text("Envelope ID: #{submission.id}", font_size: 12, padding: [15, 0, 8, 0], position: :float)
unless submission.source.in?(%w[embed api])
column.formatted_text([{ link: verify_url, text: 'Verify', style: :link }],
font_size: 9, padding: [22, 0, 10, 0], position: :float, align: :right)
font_size: 9, padding: [15, 0, 10, 0], position: :float, text_align: :right)
end
end
@@ -97,7 +98,7 @@ module Submissions
end
link =
Rails.application.routes.url_helpers.rails_blob_url(document, **Docuseal.default_url_options)
ActiveStorage::Blob.proxy_url(document.blob)
[
composer.document.layout.formatted_text_box(
@@ -114,13 +115,13 @@ module Submissions
{ text: 'Generated at: ', font: [FONT_BOLD_NAME, { variant: :bold }] },
"#{I18n.l(document.created_at.in_time_zone(account.timezone), format: :long, locale: account.locale)} " \
"#{TimeUtils.timezone_abbr(account.timezone, document.created_at)}"
], line_spacing: 1.8
], line_spacing: 1.3
)
]
end
if documents_data.present?
composer.table(documents_data, cell_style: { padding: [0, 0, 25, 0], border: { width: 0 } })
composer.table(documents_data, cell_style: { padding: [0, 0, 20, 0], border: { width: 0 } })
composer.draw_box(divider)
end
@@ -151,7 +152,7 @@ module Submissions
submitter.email && { text: "#{submitter.email}\n", font: [FONT_BOLD_NAME, { variant: :bold }] },
submitter.name && { text: "#{TextUtils.maybe_rtl_reverse(submitter.name)}\n" },
submitter.phone && { text: "#{submitter.phone}\n" }
].compact_blank, line_spacing: 1.8, padding: [0, 20, 0, 0]
].compact_blank, line_spacing: 1.3, padding: [0, 20, 0, 0]
)
],
[
@@ -167,7 +168,7 @@ module Submissions
completed_event.data['sid'] && { text: "Session ID: #{completed_event.data['sid']}\n" },
completed_event.data['ua'] && { text: "User agent: #{completed_event.data['ua']}\n" },
"\n"
].compact_blank, line_spacing: 1.8, padding: [10, 20, 20, 0]
].compact_blank, line_spacing: 1.3, padding: [10, 20, 20, 0]
)
]
]
@@ -192,8 +193,8 @@ module Submissions
font_size: 6
}
].compact_blank,
align: field['name'].to_s.match?(RTL_REGEXP) ? :right : :left,
line_spacing: 1.8, padding: [0, 0, 5, 0]
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])
attachment = submitter.attachments.find { |a| a.uuid == value }
@@ -207,11 +208,16 @@ module Submissions
width = field['type'] == 'initials' ? 100 : 200
height = resized_image.height * (width.to_f / resized_image.width)
composer.image(io, width:, height:, margin: [0, 0, 10, 0])
if height > MAX_IMAGE_HEIGHT
width = (MAX_IMAGE_HEIGHT / height) * width
height = MAX_IMAGE_HEIGHT
end
composer.image(io, width:, height:, margin: [5, 0, 10, 0])
composer.formatted_text_box([{ text: '' }])
elsif field['type'].in?(%w[file payment])
if field['type'] == 'payment'
unit = CURRENCY_SYMBOLS[field['preferences']['currency']]
unit = CURRENCY_SYMBOLS[field['preferences']['currency']] || field['preferences']['currency']
price = ApplicationController.helpers.number_to_currency(field['preferences']['price'], unit:)
@@ -222,7 +228,7 @@ module Submissions
Array.wrap(value).map do |uuid|
attachment = submitter.attachments.find { |a| a.uuid == uuid }
link =
Rails.application.routes.url_helpers.rails_blob_url(attachment, **Docuseal.default_url_options)
ActiveStorage::Blob.proxy_url(attachment.blob)
{ link:, text: "#{attachment.filename}\n", style: :link }
end,
@@ -239,7 +245,7 @@ module Submissions
value = Submissions::GenerateResultAttachments::REPLACE_EMOJI[value] || value
composer.formatted_text_box([{ text: TextUtils.maybe_rtl_reverse(value.to_s.presence || 'n/a') }],
align: value.to_s.match?(RTL_REGEXP) ? :right : :left,
text_align: value.to_s.match?(RTL_REGEXP) ? :right : :left,
padding: [0, 0, 10, 0])
end
]
@@ -270,7 +276,7 @@ module Submissions
]
end
composer.table(events_data, cell_style: { padding: [0, 0, 20, 0], border: { width: 0 } }) if events_data.present?
composer.table(events_data, cell_style: { padding: [0, 0, 12, 0], border: { width: 0 } }) if events_data.present?
io = StringIO.new
@@ -308,8 +314,8 @@ module Submissions
font_size: 20,
font: [FONT_BOLD_NAME, { variant: :bold }],
width: 100,
padding: [12, 0, 0, 8],
position: :float, position_hint: :left)
padding: [5, 0, 0, 8],
position: :float, text_align: :left)
end
# rubocop:enable Metrics
end
+7 -3
View File
@@ -80,7 +80,7 @@ module Submissions
submission_data += submitter.documents.map.with_index(1) do |attachment, index|
{
name: "Document #{index}",
value: attachment.url
value: ActiveStorage::Blob.proxy_url(attachment.blob)
}
end
end
@@ -131,9 +131,13 @@ module Submissions
value =
if template_field_type.in?(%w[image signature])
attachments_index[submitter_value]&.url
attachment = attachments_index[submitter_value]
ActiveStorage::Blob.proxy_url(attachment.blob) if attachment
elsif template_field_type == 'file'
Array.wrap(submitter_value).compact_blank.filter_map { |e| attachments_index[e]&.url }
Array.wrap(submitter_value).compact_blank.filter_map do |e|
attachment = attachments_index[e]
ActiveStorage::Blob.proxy_url(attachment.blob) if attachment
end
else
submitter_value
end
@@ -3,7 +3,7 @@
module Submissions
module GenerateResultAttachments
FONT_SIZE = 11
FONT_PATH = '/fonts/LiberationSans-Regular.ttf'
FONT_PATH = '/fonts/GoNotoKurrent-Regular.ttf'
FONT_NAME = if File.exist?(FONT_PATH)
FONT_PATH
else
@@ -22,14 +22,15 @@ module Submissions
SUPPORTED_IMAGE_TYPES = ['image/png', 'image/jpeg'].freeze
REPLACE_EMOJI = {
'✔️' => 'V'
'✔️' => 'V',
'✔' => 'V'
}.freeze
module_function
# rubocop:disable Metrics
def call(submitter)
cell_layouter = HexaPDF::Layout::TextLayouter.new(valign: :center, align: :center)
cell_layouter = HexaPDF::Layout::TextLayouter.new(text_valign: :center, text_align: :center)
template = submitter.submission.template
@@ -64,8 +65,8 @@ module Submissions
value = submitter.values[field['uuid']]
layouter = HexaPDF::Layout::TextLayouter.new(valign: :center,
align: value.to_s.match?(RTL_REGEXP) ? :right : :left,
layouter = HexaPDF::Layout::TextLayouter.new(text_valign: :center,
text_align: value.to_s.match?(RTL_REGEXP) ? :right : :left,
font: pdf.fonts.add(FONT_NAME), font_size:)
next if Array.wrap(value).compact_blank.blank?
@@ -130,7 +131,7 @@ module Submissions
height - (area['y'] * height) - lines[..next_index].sum(&:height) + height_diff
],
A: { Type: :Action, S: :URI,
URI: h.rails_blob_url(attachment, **Docuseal.default_url_options) }
URI: ActiveStorage::Blob.proxy_url(attachment.blob) }
}
)
@@ -340,8 +341,6 @@ module Submissions
def fetch_sign_reason(submitter)
reason_name = submitter.email || submitter.name || submitter.phone
return sign_reason(reason_name) if Docuseal.multitenant?
config =
if Docuseal.multitenant?
AccountConfig.where(account: submitter.account, key: AccountConfig::ESIGNING_PREFERENCE_KEY)
+31
View File
@@ -0,0 +1,31 @@
# frozen_string_literal: true
module Submitters
module GenerateFontImage
WIDTH = 3000
HEIGHT = 160
FONTS = {
'Dancing Script Regular' => '/fonts/DancingScript-Regular.otf',
'Go Noto Kurrent-Bold Bold' => '/fonts/GoNotoKurrent-Bold.ttf'
}.freeze
FONT_ALIASES = {
'initials' => 'Go Noto Kurrent-Bold Bold',
'signature' => 'Dancing Script Regular'
}.freeze
module_function
def call(text, font: nil)
font = FONT_ALIASES[font] || font
text_image = Vips::Image.text(text, font:, fontfile: FONTS[font],
width: WIDTH, height: HEIGHT, wrap: :none)
text_mask = Vips::Image.black(text_image.width, text_image.height)
text_mask.bandjoin(text_image).copy(interpretation: :b_w).write_to_buffer('.png')
end
end
end
+67 -17
View File
@@ -4,8 +4,11 @@ module Submitters
module NormalizeValues
CHECKSUM_CACHE_STORE = ActiveSupport::Cache::MemoryStore.new
UnknownFieldName = Class.new(StandardError)
UnknownSubmitterName = Class.new(StandardError)
BaseError = Class.new(StandardError)
UnknownFieldName = Class.new(BaseError)
InvalidDefaultValue = Class.new(BaseError)
UnknownSubmitterName = Class.new(BaseError)
module_function
@@ -31,7 +34,8 @@ module Submitters
field = fields_uuid_index[key]
if field['type'].in?(%w[initials signature image file]) && value.present?
new_value, new_attachments = normalize_attachment_value(value, template.account, for_submitter)
new_value, new_attachments =
normalize_attachment_value(value, field['type'], template.account, attachments, for_submitter)
attachments.push(*new_attachments)
@@ -57,7 +61,7 @@ module Submitters
value
end
rescue Date::Error => e
Rollbar.error(e) if defined?(Rollbar)
Rollbar.warning(e) if defined?(Rollbar)
value
end
@@ -66,7 +70,8 @@ module Submitters
if submitter_name
submitter =
template.submitters.find { |e| e['name'] == submitter_name } ||
raise(UnknownSubmitterName, "Unknown submitter role: #{submitter_name}")
raise(UnknownSubmitterName,
"Unknown submitter role: #{submitter_name}. Template defines #{template.submitters.pluck('name')}")
end
fields = for_submitter&.submission&.template_fields || template.fields
@@ -86,20 +91,37 @@ module Submitters
.merge(fields.index_by { |e| e['name'].to_s.downcase })
end
def normalize_attachment_value(value, account, for_submitter = nil)
def normalize_attachment_value(value, type, account, attachments, for_submitter = nil)
if value.is_a?(Array)
new_attachments = value.map { |v| find_or_build_attachment(v, account, for_submitter) }
new_attachments = value.map do |v|
new_attachment = find_or_build_attachment(v, type, account, for_submitter)
attachments.find { |a| a.blob_id == new_attachment.blob_id } || new_attachment
end
[new_attachments.map(&:uuid), new_attachments]
else
new_attachment = find_or_build_attachment(value, account, for_submitter)
new_attachment = find_or_build_attachment(value, type, account, for_submitter)
[new_attachment.uuid, new_attachment]
existing_attachment = attachments.find { |a| a.blob_id == new_attachment.blob_id }
attachment = existing_attachment || new_attachment
[attachment.uuid, attachment]
end
end
def find_or_build_attachment(value, account, for_submitter = nil)
blob = find_or_create_blobs(account, value)
def find_or_build_attachment(value, type, account, for_submitter = nil)
blob =
if value.match?(%r{\Ahttps?://})
find_or_create_blob_from_url(account, value)
elsif type.in?(%w[signature initials]) && value.length < 50
find_or_create_blob_from_text(account, value, type)
elsif (data = Base64.decode64(value)) && Marcel::MimeType.for(data).include?('image')
find_or_create_blob_from_base64(account, data, type)
else
raise InvalidDefaultValue, "Invalid value, url, base64 or text < 50 chars is expected: #{value.first(50)}..."
end
attachment = for_submitter.attachments.find_by(blob_id: blob.id) if for_submitter
@@ -111,7 +133,31 @@ module Submitters
attachment
end
def find_or_create_blobs(account, url)
def find_or_create_blob_from_base64(account, data, type)
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"
)
end
def find_or_create_blob_from_text(account, text, type)
data = Submitters::GenerateFontImage.call(text, font: type)
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"
)
end
def find_or_create_blob_from_url(account, url)
cache_key = [account.id, url].join(':')
checksum = CHECKSUM_CACHE_STORE.fetch(cache_key)
@@ -134,11 +180,15 @@ module Submitters
end
def find_blob_by_checksum(checksum, account)
ActiveStorage::Blob
.joins('JOIN active_storage_attachments ON active_storage_attachments.blob_id = active_storage_blobs.id')
.where(active_storage_attachments: { record_id: account.submitters.select(:id),
record_type: 'Submitter' })
.find_by(checksum:)
blob = ActiveStorage::Blob.find_by(checksum:)
return unless blob
return blob unless blob.attachments.exists?
return blob if account.submitters.exists?(id: blob.attachments.where(record_type: 'Submitter').select(:record_id))
nil
end
def conn
+1 -1
View File
@@ -19,7 +19,7 @@ module Submitters
serialize_params = {
include: {},
only: %i[id slug uuid name email phone completed_at external_id
metadata opened_at sent_at created_at updated_at],
submission_id metadata opened_at sent_at created_at updated_at],
methods: %i[status application_key]
}
+14 -5
View File
@@ -16,15 +16,24 @@ module Submitters
submitter_name = (submitter.submission.template_submitters ||
submitter.submission.template.submitters).find { |e| e['uuid'] == submitter.uuid }['name']
submitter.as_json(methods: %i[application_key],
include: [template: { only: %i[id name external_id created_at updated_at] }])
submitter.as_json(methods: %i[status application_key],
only: %i[id submission_id email phone name ua ip sent_at opened_at
completed_at created_at updated_at external_id metadata])
.except('uuid', 'values', 'slug')
.merge('values' => values,
.merge('role' => submitter_name,
'preferences' => submitter.preferences.except('default_values'),
'values' => values,
'documents' => documents,
'audit_log_url' => submitter.submission.audit_log_url,
'submission_url' => r.submissions_preview_url(submitter.submission.slug,
**Docuseal.default_url_options),
'role' => submitter_name)
'template' => submitter.template.as_json(only: %i[id name external_id created_at
updated_at]),
'submission' => {
**submitter.submission.slice(:id, :audit_log_url, :created_at),
status: submitter.submission.submitters.all?(&:completed_at?) ? 'completed' : 'pending',
url: r.submissions_preview_url(submitter.submission.slug, **Docuseal.default_url_options)
})
end
def build_values_array(submitter)
@@ -67,7 +76,7 @@ module Submitters
def rails_storage_proxy_url(attachment)
return if attachment.blank?
r.rails_storage_proxy_url(attachment, **Docuseal.default_url_options)
ActiveStorage::Blob.proxy_url(attachment.blob)
end
def r
-2
View File
@@ -98,8 +98,6 @@ module Templates
record: attachment
)
end
io
end
end
end
+1 -1
View File
@@ -42,7 +42,7 @@ module TimeUtils
I18n.l(date, format: i18n_format, locale:)
rescue Date::Error => e
Rollbar.error(e) if defined?(Rollbar)
Rollbar.warning(e) if defined?(Rollbar)
string
end
+2
View File
@@ -12,6 +12,8 @@ RSpec.describe 'Submission Preview' do
let(:submitters) { template.submitters.map { |s| create(:submitter, submission:, uuid: s['uuid']) } }
before do
sign_in(user)
visit submissions_preview_path(slug: submission.slug)
end