Merge from docusealco/wip

This commit is contained in:
Alex Turchyn authored and GitHub committed 2026-10-26 12:27:46 +02:00
commit 13a1835c00
44 files changed
+291 -180

No files matched your search

+4 -4
View File
@@ -12,7 +12,7 @@ jobs:
- name: Install Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: 4.0.5
ruby-version: 4.0.9
- 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.5
ruby-version: 4.0.9
- 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.5
ruby-version: 4.0.9
- name: Cache gems
uses: actions/cache@v4
with:
@@ -113,7 +113,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 15
container:
image: ruby:4.0.5-alpine
image: ruby:4.0.9-alpine
services:
postgres:
+5 -4
View File
@@ -1,4 +1,4 @@
FROM ruby:4.0.5-alpine AS download
FROM ruby:4.0.9-alpine AS download
WORKDIR /fonts
@@ -17,7 +17,7 @@ RUN apk --no-cache add wget unzip && \
mkdir -p /pdfium-linux && \
unzip -q pdfium-linux.zip -d /pdfium-linux
FROM ruby:4.0.5-alpine AS webpack
FROM ruby:4.0.9-alpine AS webpack
ENV RAILS_ENV=production
ENV NODE_ENV=production
@@ -44,7 +44,7 @@ COPY ./app/views ./app/views
RUN echo "gem 'shakapacker'" > Gemfile && ./bin/shakapacker
FROM ruby:4.0.5-alpine AS app
FROM ruby:4.0.9-alpine AS app
ENV RAILS_ENV=production
ENV BUNDLE_WITHOUT="development:test"
@@ -52,7 +52,8 @@ ENV OPENSSL_CONF=/etc/openssl_legacy.cnf
WORKDIR /app
RUN apk add --no-cache libpq vips redis onnxruntime leptonica jemalloc font-liberation font-carlito && \
RUN apk upgrade --no-cache ca-certificates-bundle && \
apk add --no-cache libpq vips redis onnxruntime leptonica jemalloc font-liberation font-carlito && \
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
+1 -1
View File
@@ -2,7 +2,7 @@
source 'https://rubygems.org'
ruby '4.0.5'
ruby '4.0.9'
gem 'addressable'
gem 'arabic-letter-connector', require: false
+6 -6
View File
@@ -572,15 +572,15 @@ GEM
base64
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
wordcraft (0.8.0-aarch64-linux-gnu)
wordcraft (0.11.0-aarch64-linux-gnu)
ffi (>= 1.15)
wordcraft (0.8.0-aarch64-linux-musl)
wordcraft (0.11.0-aarch64-linux-musl)
ffi (>= 1.15)
wordcraft (0.8.0-arm64-darwin)
wordcraft (0.11.0-arm64-darwin)
ffi (>= 1.15)
wordcraft (0.8.0-x86_64-linux-gnu)
wordcraft (0.11.0-x86_64-linux-gnu)
ffi (>= 1.15)
wordcraft (0.8.0-x86_64-linux-musl)
wordcraft (0.11.0-x86_64-linux-musl)
ffi (>= 1.15)
xlsxtream (3.1.0)
zip_kit (>= 6.2, < 7)
@@ -664,7 +664,7 @@ DEPENDENCIES
xlsxtream
RUBY VERSION
ruby 4.0.5
ruby 4.0.9
BUNDLED WITH
4.0.3
@@ -1,74 +0,0 @@
# frozen_string_literal: true
module Api
class AttachmentsController < ActionController::API
include ActionController::Cookies
include ActiveStorage::SetCurrent
COOKIE_STORE_LIMIT = 10
def create
@submitter = Submitter.find_by!(slug: params[:submitter_slug])
unless can_upload?(@submitter)
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 = ImageUtils.load_vips(file.read, content_type: file.content_type)
if ImageUtils.blank?(image)
Rollbar.error("Empty signature: #{@submitter.id}") if defined?(Rollbar)
return render json: { error: "#{params[:type]} is empty" }, status: :unprocessable_content
end
if ImageUtils.error?(image)
Rollbar.error("Error signature: #{@submitter.id}") if defined?(Rollbar)
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, file, metadata:)
if params[:remember_signature] == 'true' && @submitter.email.present?
cookies.permanent.encrypted[:signature_uuids] = build_new_cookie_signatures_json(@submitter, attachment)
end
render json: attachment.as_json(only: %i[uuid created_at], methods: %i[url filename content_type])
rescue Submitters::MaliciousFileExtension => e
Rollbar.error(e) if defined?(Rollbar)
render json: { error: e.message }, status: :unprocessable_content
end
def can_upload?(submitter)
!submitter.declined_at? &&
!submitter.completed_at? &&
!submitter.submission.archived_at? &&
!submitter.submission.expired? &&
!submitter.submission.template&.archived_at?
end
def build_new_cookie_signatures_json(submitter, attachment)
values =
begin
JSON.parse(cookies.encrypted[:signature_uuids].presence || '{}')
rescue JSON::ParserError
{}
end
values[submitter.email] = attachment.uuid
values = values.to_a.last(COOKIE_STORE_LIMIT).to_h if values.size > COOKIE_STORE_LIMIT
values.to_json
end
end
end
@@ -1,15 +0,0 @@
# frozen_string_literal: true
module Api
class SubmitterEmailClicksController < ActionController::API
def create
@submitter = Submitter.find_by!(slug: params[:submitter_slug])
if params[:t] == SubmissionEvents.build_tracking_param(@submitter, 'click_email')
SubmissionEvents.create_with_tracking_data(@submitter, 'click_email', request)
end
render json: {}
end
end
end
+1 -4
View File
@@ -179,10 +179,7 @@ module Api
submitters = submitters.where(external_id: params[:external_id]) if params[:external_id].present?
submitters = submitters.where(slug: params[:slug]) if params[:slug].present?
submitters = submitters.where(submission_id: params[:submission_id]) if params[:submission_id].present?
if params[:template_id].present?
submitters = submitters.joins(:submission).where(submissions: { template_id: params[:template_id] })
end
submitters = submitters.where(template_id: params[:template_id]) if params[:template_id].present?
maybe_filter_by_completed_at(submitters, params)
end
+1
View File
@@ -228,6 +228,7 @@ safeRegisterElement('template-builder', class extends HTMLElement {
template,
customFields: reactive(JSON.parse(this.dataset.customFields || '[]')),
dateFormats: JSON.parse(this.dataset.dateFormats || '[]'),
autosave: this.dataset.autosave !== 'false',
dynamicDocuments: reactive(JSON.parse(this.dataset.dynamicDocuments || '[]')),
backgroundColor: '#faf7f5',
locale: this.dataset.locale,
+14 -2
View File
@@ -12,7 +12,14 @@ export default class extends HTMLElement {
toggle = (event) => {
if (event?.type === 'input' && event.target.matches(this.fieldSelector('name'))) return
const isNameOnly = (field) => this.isFilled('name') && !this.isFilled(field)
if (event?.type === 'focusin' && !this.isKeepSendUsed && this.isFocusFromName(event)) {
this.isKeepSendUsed = true
this.keepSendInput = event.target
} else if (event?.type === 'focusout' && event.target === this.keepSendInput) {
this.keepSendInput = null
}
const isNameOnly = (field) => this.isFilled('name') && !this.isFilled(field) && !this.keepSendInput?.matches(this.fieldSelector(field))
const isSend = (this.isChecked('send_email') && !isNameOnly('email')) ||
(this.isChecked('send_sms') && !isNameOnly('phone'))
@@ -29,12 +36,17 @@ export default class extends HTMLElement {
return [...this.form.querySelectorAll(this.fieldSelector(field))].some((input) => input.value.trim())
}
isFocusFromName ({ target, relatedTarget }) {
return !!relatedTarget?.matches(this.fieldSelector('name')) && !!relatedTarget.value.trim() &&
['email', 'phone'].some((field) => target.matches(this.fieldSelector(field)))
}
fieldSelector (field) {
return `[name$="[][${field}]"]`
}
get events () {
return ['change', 'input', 'linked-input.update']
return ['change', 'input', 'linked-input.update', 'focusin', 'focusout']
}
get form () {
+49 -2
View File
@@ -415,9 +415,9 @@
<a
:href="`/templates/${template.id}/form`"
:data-native-actions="template.submitters.length > 1 ? 'users' : null"
data-turbo="false"
data-turbo-prefetch="false"
class="flex items-center justify-center space-x-2"
@click.exact="isPreviewLoading = true"
@click.exact="onSaveAndPreviewClick"
>
<IconInnerShadowTop
v-if="isPreviewLoading"
@@ -640,6 +640,7 @@
:draw-field-type="drawFieldType"
:draw-custom-field="drawCustomField"
:draw-option="drawOption"
:autosave="autosave"
@update="onDynamicDocumentUpdate"
@draw="clearDrawField"
@add-custom-field="addCustomField"
@@ -1379,6 +1380,7 @@ export default {
isPreviewLoading: false,
isLoadingBlankPage: false,
isSaving: false,
hasUnsavedChanges: false,
isDetectingPageFields: false,
detectFieldsQueue: [],
detectingAnalyzingProgress: null,
@@ -1662,6 +1664,11 @@ export default {
window.addEventListener('dragleave', this.onWindowDragLeave)
if (!this.autosave) {
window.addEventListener('beforeunload', this.onBeforeUnload)
document.addEventListener('turbo:before-visit', this.onBeforeVisit)
}
this.$nextTick(() => {
if (document.location.search?.includes('stripe_connect_success')) {
document.querySelector('form[action="/auth/stripe_connect"]')?.closest('.dropdown')?.querySelector('label')?.focus()
@@ -1685,6 +1692,11 @@ export default {
this.resizeObserver.disconnect()
window.removeEventListener('dragleave', this.onWindowDragLeave)
if (!this.autosave) {
window.removeEventListener('beforeunload', this.onBeforeUnload)
document.removeEventListener('turbo:before-visit', this.onBeforeVisit)
}
},
beforeUpdate () {
this.documentRefs = []
@@ -2241,6 +2253,37 @@ export default {
this.insertField(field)
}
},
onSaveAndPreviewClick (event) {
this.isPreviewLoading = true
if (!this.autosave) {
event.preventDefault()
const dynamicDocumentRefs = this.documentRefs.filter((ref) => ref.isDynamic)
dynamicDocumentRefs.forEach((ref) => ref.update())
this.rebuildVariablesSchema({ disable: false })
Promise.all([this.save({ force: true }), ...dynamicDocumentRefs.map((ref) => ref.saveBody())]).then(() => {
window.Turbo.visit(`/templates/${this.template.id}/form`)
})
}
},
onBeforeUnload (event) {
if (this.hasUnsavedChanges) {
event.preventDefault()
event.returnValue = ''
return ''
}
},
onBeforeVisit (event) {
if (this.hasUnsavedChanges && !window.confirm(this.t('close_without_saving_changes'))) {
event.preventDefault()
}
},
closeDropdown () {
document.activeElement.blur()
},
@@ -4036,6 +4079,8 @@ export default {
}
if (!this.autosave && !force) {
this.hasUnsavedChanges = true
return Promise.resolve({})
}
@@ -4058,6 +4103,8 @@ export default {
}),
headers: { 'Content-Type': 'application/json' }
}).then(() => {
this.hasUnsavedChanges = false
if (this.onSave) {
this.onSave(this.template)
}
@@ -84,6 +84,11 @@ export default {
required: false,
default: true
},
autosave: {
type: Boolean,
required: false,
default: true
},
selectedSubmitter: {
type: Object,
required: false,
@@ -279,7 +284,7 @@ export default {
this.updatedSectionEditors.set(section.id, editor)
this.saveTimer = setTimeout(() => this.updateSectionsAndSave(), 1000)
this.saveTimer = setTimeout(() => this.autosave ? this.updateSectionsAndSave() : this.updateSections(), 1000)
},
loadListsStyle () {
this.listsStyle = this.bodyDom.querySelector('body > style[data-lists]')?.textContent || null
@@ -299,7 +304,9 @@ export default {
updateVariablesSchema () {
this.document.variables_schema = buildVariablesSchema(this.bodyDom.body)
},
updateSectionsAndSave () {
updateSections () {
delete this.saveTimer
this.updatedSectionEditors.forEach((editor, sectionId) => {
const target = this.bodyDom.getElementById(sectionId)
@@ -315,6 +322,9 @@ export default {
this.updateVariablesSchema()
this.$emit('update', this.document)
},
updateSectionsAndSave () {
this.updateSections()
return this.saveBody()
},
+7
View File
@@ -70,6 +70,7 @@ const en = {
remove_condition: 'Remove condition',
add_condition: 'Add condition',
are_you_sure_: 'Are you sure?',
close_without_saving_changes: 'Close without saving changes?',
sign_yourself: 'Sign Yourself',
set_signing_date: 'Set signing date',
signing_date: 'Signing Date',
@@ -371,6 +372,7 @@ const es = {
down: 'Abajo',
set_signing_date: 'Establecer fecha de firma',
are_you_sure_: '¿Estás seguro?',
close_without_saving_changes: '¿Cerrar sin guardar los cambios?',
sign_yourself: 'Firma tú mismo',
checked: 'Marcado',
current_date: 'Fecha actual',
@@ -652,6 +654,7 @@ const it = {
remove_condition: 'Rimuovi condizione',
add_condition: 'Aggiungi condizione',
are_you_sure_: 'Sei sicuro?',
close_without_saving_changes: 'Chiudere senza salvare le modifiche?',
sign_yourself: 'Firma te stesso',
set_signing_date: 'Imposta data di firma',
signing_date: 'Data di firma',
@@ -953,6 +956,7 @@ const pt = {
down: 'Para baixo',
set_signing_date: 'Definir data de assinatura',
are_you_sure_: 'Tem certeza?',
close_without_saving_changes: 'Fechar sem salvar as alterações?',
sign_yourself: 'Assine você mesmo',
checked: 'Marcado',
current_date: 'Data atual',
@@ -1234,6 +1238,7 @@ const fr = {
remove_condition: 'Supprimer la condition',
add_condition: 'Ajouter une condition',
are_you_sure_: 'Êtes-vous sûr ?',
close_without_saving_changes: 'Fermer sans enregistrer les modifications ?',
sign_yourself: 'Signer vous-même',
set_signing_date: 'Définir la date de signature',
signing_date: 'Date de signature',
@@ -1525,6 +1530,7 @@ const de = {
remove_condition: 'Bedingung entfernen',
add_condition: 'Bedingung hinzufügen',
are_you_sure_: 'Sind Sie sicher?',
close_without_saving_changes: 'Ohne Speichern der Änderungen schließen?',
sign_yourself: 'Selbst unterschreiben',
set_signing_date: 'Unterzeichnungsdatum festlegen',
signing_date: 'Unterzeichnungsdatum',
@@ -1816,6 +1822,7 @@ const nl = {
remove_condition: 'Voorwaarde verwijderen',
add_condition: 'Voorwaarde toevoegen',
are_you_sure_: 'Weet u het zeker?',
close_without_saving_changes: 'Sluiten zonder wijzigingen op te slaan?',
sign_yourself: 'Zelf ondertekenen',
set_signing_date: 'Ondertekeningsdatum instellen',
signing_date: 'Ondertekeningsdatum',
+8 -6
View File
@@ -30,14 +30,16 @@ class SendTestWebhookRequestJob
raise LocalhostError, "Can't send to localhost." if uri.host.in?(SendWebhookRequest::LOCALHOSTS)
end
Faraday.post(webhook_url.url,
{
event_type: 'form.completed',
timestamp: Time.current.iso8601,
data: Submitters::SerializeForWebhook.call(submitter)
}.to_json,
body_json = {
event_type: 'form.completed',
timestamp: Time.current.iso8601,
data: Submitters::SerializeForWebhook.call(submitter)
}.to_json
Faraday.post(webhook_url.url, body_json,
'Content-Type' => 'application/json',
'User-Agent' => USER_AGENT,
'X-Docuseal-Signature' => WebhookUrls::Signatures.sign(webhook_url.hmac_secret, body: body_json),
**webhook_url.secret.to_h)
end
end
+1
View File
@@ -61,6 +61,7 @@ class AccountConfig < ApplicationRecord
DOCUMENT_FILENAME_FORMAT_KEY = 'document_filename_format'
TEMPLATE_CUSTOM_FIELDS_KEY = 'template_custom_fields'
TEMPLATE_DATE_FORMATS_KEY = 'template_date_formats'
TEMPLATE_BUILDER_AUTOSAVE_KEY = 'template_builder_autosave'
POLICY_LINKS_KEY = 'policy_links'
ENABLE_MCP_KEY = 'enable_mcp'
+9 -1
View File
@@ -25,15 +25,17 @@
# account_id :bigint not null
# external_id :string
# submission_id :bigint not null
# template_id :bigint
#
# Indexes
#
# index_submitters_on_account_id_and_completed_at (account_id,completed_at) WHERE (completed_at IS NOT NULL)
# index_submitters_on_account_id_and_id (account_id,id)
# index_submitters_on_email (email)
# index_submitters_on_email_and_template_id (email,template_id) WHERE (email IS NOT NULL)
# index_submitters_on_external_id (external_id)
# index_submitters_on_slug (slug) UNIQUE
# index_submitters_on_submission_id (submission_id)
# index_submitters_on_template_id_and_id (template_id,id)
#
# Foreign Keys
#
@@ -68,6 +70,8 @@ class Submitter < ApplicationRecord
scope :completed, -> { where.not(completed_at: nil) }
before_validation :set_template_id, on: :create
after_destroy :anonymize_email_events, if: -> { Docuseal.multitenant? }
def status
@@ -124,6 +128,10 @@ class Submitter < ApplicationRecord
private
def set_template_id
self.template_id = submission&.template_id
end
def anonymize_email_events
email_events.each do |event|
event.update!(email: Digest::MD5.base64digest(event.email))
+3 -3
View File
@@ -33,10 +33,10 @@
<% end %>
<turbo-frame id="drawer"></turbo-frame>
<%= render turbo_native_app? ? 'shared/native_navbar' : 'shared/navbar' %>
<% if flash.present? %><%= render 'shared/flash' %><% end %>
<div class="max-w-6xl mx-auto px-4 md:px-2 mb-8">
<main class="max-w-6xl mx-auto px-4 md:px-2 mb-8">
<%= yield %>
</div>
</main>
<% if flash.present? %><%= render 'shared/flash' %><% end %>
<%= render 'shared/body_scripts' %>
</body>
</html>
+3 -1
View File
@@ -13,6 +13,8 @@
<%= stylesheet_pack_tag 'form', media: 'all', data: { turbo_track: 'dynamic' } %>
</head>
<body>
<%= yield %>
<main>
<%= yield %>
</main>
</body>
</html>
+4 -2
View File
@@ -13,8 +13,10 @@
<%= stylesheet_pack_tag 'application', media: 'all', data: { turbo_track: 'dynamic' } %>
</head>
<body>
<% if flash.present? %><%= render 'shared/flash' %><% end %>
<%= yield %>
<main>
<%= yield %>
</main>
<turbo-frame id="modal"></turbo-frame>
<% if flash.present? %><%= render 'shared/flash' %><% end %>
</body>
</html>
+2 -2
View File
@@ -1,5 +1,5 @@
<%= render 'shared/navbar_warning' %>
<div class="max-w-6xl mb-4 mx-auto px-4 md:px-2 py-3 flex items-center justify-between">
<nav class="max-w-6xl mb-4 mx-auto px-4 md:px-2 py-3 flex items-center justify-between">
<div class="flex items-center space-x-4">
<a href="<%= root_path %>" class="text-2xl font-bold items-center flex space-x-2">
<%= render 'shared/title' %>
@@ -106,4 +106,4 @@
<% end %>
</div>
<% end %>
</div>
</nav>
+2 -2
View File
@@ -1,4 +1,4 @@
<main class="max-w-md mx-auto px-2 mt-12 mb-4">
<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">
<div class="flex items-center justify-center">
@@ -32,5 +32,5 @@
</toggle-submit>
<% end %>
</div>
</main>
</div>
<%= render 'shared/attribution', link_path: '/start', account: @submitter.account %>
@@ -2,7 +2,7 @@
<% I18n.with_locale(@template.account.locale) do %>
<% content_for(:html_description, t('account_name_has_invited_you_to_fill_and_sign_documents_online_effortlessly_with_a_secure_fast_and_user_friendly_digital_document_signing_solution', account_name: @template.account.name)) %>
<% end %>
<main class="max-w-md mx-auto px-2 mt-12 mb-4">
<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">
<div class="flex items-center justify-center">
@@ -55,4 +55,4 @@
<%= button_to t(:re_send_email), start_form_email_2fa_send_index_path, params: { slug: @template.slug, resend: true, submitter: { name: params[:name] || params.dig(:submitter, :name), email: params[:email] || params.dig(:submitter, :email), phone: params[:phone] || params.dig(:submitter, :phone) } }.compact, method: :post, id: 'resend_code', class: 'hidden' %>
<% end %>
</div>
</main>
</div>
+2 -2
View File
@@ -2,7 +2,7 @@
<% I18n.with_locale(@template.account.locale) do %>
<% content_for(:html_description, t('account_name_has_invited_you_to_fill_and_sign_documents_online_effortlessly_with_a_secure_fast_and_user_friendly_digital_document_signing_solution', account_name: @template.account.name)) %>
<% end %>
<main class="max-w-md mx-auto px-2 mt-12 mb-4">
<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">
<div class="flex items-center justify-center">
@@ -28,4 +28,4 @@
<%= link_to t('back'), @resubmit_submitter ? submit_form_path(@resubmit_submitter.slug) : start_form_path(@template.slug, @submitter.slice(:name, :email, :phone).compact_blank), class: 'base-button' %>
</div>
</div>
</main>
</div>
+2 -2
View File
@@ -2,7 +2,7 @@
<% I18n.with_locale(@template.account.locale) do %>
<% content_for(:html_description, t('share_link_is_currently_disabled')) %>
<% end %>
<main class="max-w-md space-y-6 mx-auto px-2 mt-12 mb-4">
<div class="max-w-md space-y-6 mx-auto px-2 mt-12 mb-4">
<div class="text-center w-full space-y-6">
<%= render 'start_form/banner' %>
<p class="text-xl font-semibold text-center">
@@ -27,5 +27,5 @@
<%= button_to button_title(title: t('enable_shared_link'), icon: svg_icon('lock_open', class: 'w-6 h-6')), template_share_link_path(@template), params: { template: { shared_link: true }, redir: start_form_path(slug: @template.slug) }, method: :post, class: 'white-button w-full' %>
</toggle-submit>
<% end %>
</main>
</div>
<%= render 'shared/attribution', link_path: '/start', account: @template.account %>
+2 -2
View File
@@ -2,7 +2,7 @@
<% I18n.with_locale(@template.account.locale) do %>
<% content_for(:html_description, t('account_name_has_invited_you_to_fill_and_sign_documents_online_effortlessly_with_a_secure_fast_and_user_friendly_digital_document_signing_solution', account_name: @template.account.name)) %>
<% end %>
<main class="max-w-md mx-auto px-2 mt-12 mb-4">
<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">
<div class="text-center w-full space-y-6">
@@ -66,4 +66,4 @@
<%= render 'shared/attribution', link_path: '/start', account: @template.account %>
<%= render 'start_form/policy', account: @template.account %>
</div>
</main>
</div>
+2 -2
View File
@@ -11,7 +11,7 @@
<% with_timestamp_seconds = configs.find { |e| e.key == AccountConfig::WITH_TIMESTAMP_SECONDS_KEY }&.value == true %>
<% with_signature_id_reason = configs.find { |e| e.key == AccountConfig::WITH_SIGNATURE_ID_REASON_KEY }&.value != false %>
<% with_signature_id_completed_at = configs.find { |e| e.key == AccountConfig::WITH_SIGNATURE_ID_COMPLETED_AT_KEY }&.value == true %>
<main style="max-width: 1600px" class="mx-auto pl-4">
<div style="max-width: 1600px" class="mx-auto pl-4">
<% unless native_tab == 'recipients' %>
<div class="flex justify-between items-center pr-4 sticky top-0 md:relative z-10 bg-base-100 <%= 'py-1.5' unless turbo_native_app? %>">
<% unless turbo_native_app? %>
@@ -394,7 +394,7 @@
</label>
</toggle-visible>
<% end %>
</main>
</div>
<% unless request.headers['HTTP_X_TURBO_REQUEST_ID'] %>
<%= render 'scripts/autosize_field' %>
<% end %>
+2 -2
View File
@@ -1,5 +1,5 @@
<% completed_at = @submitter.completed_at || @submitter.submission.completed_at %>
<main class="max-w-md mx-auto px-2 mt-12 mb-4">
<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">
<div class="flex items-center justify-center">
@@ -51,5 +51,5 @@
</toggle-submit>
<% end %>
</div>
</main>
</div>
<%= render 'shared/attribution', link_path: '/start', account: @submitter.account %>
+2 -2
View File
@@ -3,7 +3,7 @@
<% I18n.with_locale(@submitter.account.locale) do %>
<% content_for(:html_description, t('account_name_has_invited_you_to_fill_and_sign_documents_online_effortlessly_with_a_secure_fast_and_user_friendly_digital_document_signing_solution', account_name: @submitter.account.name)) %>
<% end %>
<main class="max-w-md mx-auto px-2 mt-12 mb-4">
<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">
<div class="flex items-center justify-center">
@@ -70,4 +70,4 @@
<div><%= t('please_contact_the_requester_to_specify_your_email_for_two_factor_authentication') %></div>
<% end %>
</div>
</main>
</div>
+2 -2
View File
@@ -14,7 +14,7 @@
<% delegate_modal_id = nil %>
<% banner_html = turbo_native_app? ? '' : local_assigns[:banner_html] %>
<div style="max-height: -webkit-fill-available;">
<main id="scrollbox">
<div id="scrollbox">
<div id="sr_only_content"></div>
<div class="mx-auto block pb-72" style="max-width: 1000px">
<%# flex block w-full sticky top-0 z-50 space-x-2 items-center bg-yellow-100 p-2 border-y border-yellow-200 transition-transform duration-300 %>
@@ -172,7 +172,7 @@
<% end %>
<% end %>
</div>
</main>
</div>
</div>
<div class="fixed bottom-0 w-full h-0 z-50">
<div class="mx-auto" style="max-width: 1000px">
+3 -2
View File
@@ -7,7 +7,8 @@
<%= 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 %>
<% account_configs = current_account.account_configs.where(key: [AccountConfig::TEMPLATE_CUSTOM_FIELDS_KEY, AccountConfig::TEMPLATE_DATE_FORMATS_KEY]).to_a %>
<% account_configs = current_account.account_configs.where(key: [AccountConfig::TEMPLATE_CUSTOM_FIELDS_KEY, AccountConfig::TEMPLATE_DATE_FORMATS_KEY, AccountConfig::TEMPLATE_BUILDER_AUTOSAVE_KEY]).to_a %>
<% custom_fields = account_configs.find { |e| e.key == AccountConfig::TEMPLATE_CUSTOM_FIELDS_KEY }&.value || [] %>
<% date_formats = account_configs.find { |e| e.key == AccountConfig::TEMPLATE_DATE_FORMATS_KEY }&.value || [] %>
<template-builder data-native-platform="<%= turbo_native_ios? ? 'ios' : (turbo_native_android? ? 'android' : '') %>" data-accept-file-types="image/*, application/pdf, application/zip, application/json, <%= Templates::CreateAttachments.document_extensions.join(', ') %>" class="grid" data-template="<%= @template_data.to_json %>" data-custom-fields="<%= custom_fields.to_json %>" data-date-formats="<%= date_formats.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>
<% autosave = account_configs.find { |e| e.key == AccountConfig::TEMPLATE_BUILDER_AUTOSAVE_KEY }&.value != false %>
<template-builder data-native-platform="<%= turbo_native_ios? ? 'ios' : (turbo_native_android? ? 'android' : '') %>" data-accept-file-types="image/*, application/pdf, application/zip, application/json, <%= Templates::CreateAttachments.document_extensions.join(', ') %>" class="grid" data-template="<%= @template_data.to_json %>" data-custom-fields="<%= custom_fields.to_json %>" data-date-formats="<%= date_formats.to_json %>" data-autosave="<%= autosave %>" 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>
@@ -28,7 +28,7 @@
</submit-form>
</form>
<% end %>
<a href="<%= edit_template_path(@template) %>" class="base-button" data-turbo="false" style="flex-shrink: 0; padding: 0px 24px;">
<a href="<%= edit_template_path(@template) %>" class="base-button" data-turbo-prefetch="false" style="flex-shrink: 0; padding: 0px 24px;">
<%= t('exit_preview') %>
</a>
</div>
+2 -2
View File
@@ -5,11 +5,11 @@
"note": "Permitted parameters are necessary for creating submitters via API"
},
{
"fingerprint": "645f2f352a813105b8b2eeadc68101d2e7ea04b8e839aa1336616be56f1108ca",
"fingerprint": "58619647d807d5bedb9499d6b1432f7f9cee385167aa97b0cb5e1b31a3db3f40",
"note": "Safe SQL"
},
{
"fingerprint": "5bcf4e6426d17b0efd98e47fde7c012f09500b9595af9cff226cf09341c023be",
"fingerprint": "8538c7cca701df19fdc3c1c6c5b7b0a1a3acacc19c840d63147553014d60ffa0",
"note": "Safe SQL"
},
{
+1 -9
View File
@@ -25,8 +25,6 @@ Rails.application.routes.draw do
namespace :api, defaults: { format: :json } do
resource :user, only: %i[show]
resources :attachments, only: %i[create]
resources :submitter_email_clicks, only: %i[create]
resources :submitter_form_views, only: %i[create]
resources :submitters, only: %i[index show update]
resources :submissions, only: %i[index show create update destroy] do
@@ -128,13 +126,7 @@ Rails.application.routes.draw do
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
get '/disk/:encoded_key/*filename' => 'active_storage/disk#show', as: :rails_disk_service if Rails.env.local?
ActiveSupport.run_load_hooks(:multitenant_routes, self)
end
@@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddTemplateIdToSubmitters < ActiveRecord::Migration[8.1]
def change
add_column :submitters, :template_id, :bigint, if_not_exists: true
end
end
@@ -0,0 +1,37 @@
# frozen_string_literal: true
class PopulateSubmitterTemplateId < ActiveRecord::Migration[8.1]
disable_ddl_transaction!
class MigrationSubmitter < ApplicationRecord
self.table_name = 'submitters'
end
class MigrationSubmission < ApplicationRecord
self.table_name = 'submissions'
end
def up
return if ENV['MULTITENANT'] == 'true'
max_id = MigrationSubmitter.maximum(:id)
return unless max_id
submission_template_id =
Arel::Nodes::Grouping.new(
MigrationSubmission.arel_table.project(MigrationSubmission.arel_table[:template_id])
.where(MigrationSubmission.arel_table[:id].eq(MigrationSubmitter.arel_table[:submission_id]))
.ast
)
(1..max_id).step(10_000) do |start_id|
MigrationSubmitter.where(template_id: nil, id: start_id...(start_id + 10_000))
.update_all(template_id: submission_template_id)
end
end
def down
nil
end
end
@@ -0,0 +1,15 @@
# frozen_string_literal: true
class AddSubmittersTemplateIdIndexes < ActiveRecord::Migration[8.1]
def up
add_index :submitters, %i[email template_id], where: 'email IS NOT NULL', if_not_exists: true
add_index :submitters, %i[template_id id], if_not_exists: true
remove_index :submitters, :email, if_exists: true
end
def down
add_index :submitters, :email, if_not_exists: true
remove_index :submitters, %i[template_id id], if_exists: true
remove_index :submitters, %i[email template_id], if_exists: true
end
end
+4 -2
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.1].define(version: 2026_09_30_061927) do
ActiveRecord::Schema[8.1].define(version: 2026_10_25_120002) do
# These are extensions that must be enabled in order to support this database
enable_extension "btree_gin"
enable_extension "pg_catalog.plpgsql"
@@ -424,6 +424,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_09_30_061927) do
t.datetime "sent_at"
t.string "slug", null: false
t.bigint "submission_id", null: false
t.bigint "template_id"
t.string "timezone"
t.string "ua"
t.datetime "updated_at", null: false
@@ -431,10 +432,11 @@ ActiveRecord::Schema[8.1].define(version: 2026_09_30_061927) do
t.text "values", null: false
t.index ["account_id", "completed_at"], name: "index_submitters_on_account_id_and_completed_at", where: "(completed_at IS NOT NULL)"
t.index ["account_id", "id"], name: "index_submitters_on_account_id_and_id"
t.index ["email"], name: "index_submitters_on_email"
t.index ["email", "template_id"], name: "index_submitters_on_email_and_template_id", where: "(email IS NOT NULL)"
t.index ["external_id"], name: "index_submitters_on_external_id"
t.index ["slug"], name: "index_submitters_on_slug", unique: true
t.index ["submission_id"], name: "index_submitters_on_submission_id"
t.index ["template_id", "id"], name: "index_submitters_on_template_id_and_id"
end
create_table "template_accesses", force: :cascade do |t|
+1 -4
View File
@@ -7,10 +7,7 @@ class ApiPathConsiderJsonMiddleware
def call(env)
if env['PATH_INFO'].starts_with?('/api') &&
(!env['PATH_INFO'].ends_with?('/documents') || env['REQUEST_METHOD'] != 'POST') &&
!env['PATH_INFO'].ends_with?('/attachments') &&
!env['PATH_INFO'].ends_with?('/submitter_sms_clicks') &&
!env['PATH_INFO'].ends_with?('/submitter_email_clicks')
!env['PATH_INFO'].ends_with?('/submitter_sms_clicks')
env['CONTENT_TYPE'] = 'application/json'
end
+13 -3
View File
@@ -26,6 +26,8 @@ module ReplaceEmailVariables
DOCUMENTS_LINKS = /\{+documents\.links\}+/i
DOCUMENTS_LINK = /\{+documents\.link\}+/i
ESCAPED_BRACES = "\u{E000}\u{E001}"
EMAIL_HOST = ENV.fetch('EMAIL_HOST', nil)
module_function
@@ -86,7 +88,11 @@ module ReplaceEmailVariables
build_submitters_n_field(submitter.submission, index, :values, match[:field_name].to_s.strip)
end
replace(text, SENDER_EMAIL, html_escape:) { submitter.submission.created_by_user&.email.to_s.sub(/\+\w+@/, '@') }
text = replace(text, SENDER_EMAIL, html_escape:) do
submitter.submission.created_by_user&.email.to_s.sub(/\+\w+@/, '@')
end
text.tr(ESCAPED_BRACES, '{}')
end
# rubocop:enable Metrics
@@ -124,6 +130,8 @@ module ReplaceEmailVariables
ActiveStorage::Blob.proxy_url(attachment.blob, expires_at:)
end
elsif (mask = field.dig('preferences', 'mask').presence)
TextUtils.mask_value(value[field['uuid']].to_s, mask)
else
value[field&.dig('uuid')]
end
@@ -134,10 +142,12 @@ module ReplaceEmailVariables
def replace(text, var, html_escape: false)
text.gsub(var) do
value = yield(Regexp.last_match).to_s.tr('{}', ESCAPED_BRACES)
if html_escape
ERB::Util.html_escape(yield(Regexp.last_match))
ERB::Util.html_escape(value)
else
yield(Regexp.last_match)
value
end
end
end
+20 -7
View File
@@ -3,6 +3,8 @@
module SearchEntries
MAX_VALUE_LENGTH = 100
MUTEX = Mutex.new
UUID_REGEXP = /\A[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\z/i
module_function
@@ -70,10 +72,10 @@ module SearchEntries
arel
else
"tsvector @@ (quote_literal(coalesce((ts_lexize('english_stem', :keyword))[1], :keyword)) || ':*')::tsquery"
"tsvector @@ (quote_literal(coalesce((ts_lexize(:dictionary, :keyword))[1], :keyword)) || ':*')::tsquery"
end
[sql, { keyword: }]
[sql, { keyword:, dictionary: default_dictionary }]
end
end
@@ -83,7 +85,7 @@ module SearchEntries
Arel.sql("ngram @@ (quote_literal(:term#{terms.size - 1}) || ':' || :weight)::tsquery")
else
Arel.sql(<<~SQL.squish)
(quote_literal(coalesce((ts_lexize('english_stem', :term#{terms.size - 1}))[1], :term#{terms.size - 1})) || ':*' || :weight)::tsquery
(quote_literal(coalesce((ts_lexize(:dictionary, :term#{terms.size - 1}))[1], :term#{terms.size - 1})) || ':*' || :weight)::tsquery
SQL
end
@@ -91,7 +93,7 @@ module SearchEntries
index = terms.index(term)
arel = Arel.sql(<<~SQL.squish)
(quote_literal(coalesce((ts_lexize('english_stem', :term#{index}))[1], :term#{index})) || ':' || :weight)::tsquery
(quote_literal(coalesce((ts_lexize(:dictionary, :term#{index}))[1], :term#{index})) || ':' || :weight)::tsquery
SQL
acc ? Arel::Nodes::InfixOperation.new('&&', arel, acc) : arel
@@ -109,7 +111,8 @@ module SearchEntries
)
end
[query.to_sql, terms.index_by.with_index { |_, index| :"term#{index}" }.merge(weight:)]
[query.to_sql,
terms.index_by.with_index { |_, index| :"term#{index}" }.merge(weight:, dictionary: default_dictionary)]
end
def build_weights_wildcard_tsquery(keyword, weight)
@@ -122,11 +125,11 @@ module SearchEntries
SQL
else
<<~SQL.squish
tsvector @@ (quote_literal(coalesce((ts_lexize('english_stem', :keyword))[1], :keyword)) || ':*' || :weight)::tsquery
tsvector @@ (quote_literal(coalesce((ts_lexize(:dictionary, :keyword))[1], :keyword)) || ':*' || :weight)::tsquery
SQL
end
[sql, { keyword:, weight: }]
[sql, { keyword:, weight:, dictionary: default_dictionary }]
end
def index_submitter(submitter)
@@ -256,4 +259,14 @@ module SearchEntries
ngrams.uniq { |e| e.sub(/':[\d,]/, "':1") }.join(' ')
end
def default_dictionary
@default_dictionary || MUTEX.synchronize do
@default_dictionary ||= SearchEntry.connection.select_value(<<~SQL.squish)
SELECT mapdict::regdictionary::text FROM pg_ts_config_map
WHERE mapcfg = get_current_ts_config() AND maptokentype = 1
ORDER BY mapseqno LIMIT 1
SQL
end
end
end
@@ -884,6 +884,8 @@ module Submissions
end
def maybe_flatten_pdf(pdf)
pdf.catalog.delete(:AcroForm) if pdf.catalog[:AcroForm].is_a?(Integer)
pdf.acro_form.create_appearances(force: true) if pdf.acro_form && pdf.acro_form[:NeedAppearances]
pdf.acro_form&.flatten
rescue HexaPDF::MissingGlyphError
+1
View File
@@ -65,6 +65,7 @@ module Submitters
submissions = submissions.where(source:) if source
Submitter
.where(template_id: template.id)
.where(submission: submissions)
.order(id: :desc)
.where(declined_at: nil)
+31 -1
View File
@@ -9,6 +9,8 @@ module Submitters
PHONE_REGEXP = /[+\d()\s-]+/
NONEDITABLE_FIELD_TYPES = %w[stamp heading strikethrough].freeze
REQUIRED_FIELD_TYPES = %w[payment kba verification].freeze
CHOICE_FIELD_TYPES = %w[radio multiple select].freeze
BOOLEAN_VALUES = [true, false].freeze
STRFTIME_MAP = {
'hour' => '%-k',
@@ -55,6 +57,7 @@ module Submitters
validate_values!(reason_field ? values.except(reason_field['uuid']) : values, submitter, params, request)
if params[:completed] == 'true'
validate_invite_submitters!(submitter)
maybe_invite_via_field(submitter, request)
SubmissionEvents.create_with_tracking_data(submitter, 'complete_form', request)
@@ -504,7 +507,7 @@ module Submitters
submitter
end
def validate_value!(_value, field, _params, submitter, _request)
def validate_value!(value, field, _params, submitter, _request)
raise ValidationError, 'Missing field' unless field
raise ValidationError, 'Invalid field' if field['submitter_uuid'] != submitter.uuid
@@ -514,7 +517,34 @@ module Submitters
raise ValidationError, 'Read-only field'
end
raise ValidationError, 'Value is invalid' if field['type'] == 'checkbox' && !value.in?(BOOLEAN_VALUES)
validate_choice_value!(value, field, submitter) if field['type'].in?(CHOICE_FIELD_TYPES)
true
end
def validate_choice_value!(value, field, submitter)
return if value.blank?
raise ValidationError, 'Value is invalid' unless value.is_a?(field['type'] == 'multiple' ? Array : String)
option_names = field['options'].to_a.map.with_index do |option, index|
option['value'].presence ||
"#{I18n.t('option', locale: submitter.metadata.fetch('lang', submitter.account.locale))} #{index + 1}"
end
raise ValidationError, 'Value is invalid' unless Array.wrap(value).all? { |item| option_names.include?(item) }
end
def validate_invite_submitters!(submitter)
submission = submitter.submission
is_pending_invite = (submission.template_submitters || submission.template.submitters).any? do |s|
s['invite_by_uuid'] == submitter.uuid && submission.submitters.none? { |e| e.uuid == s['uuid'] }
end
raise ValidationError, 'Invite required' if is_pending_invite
end
end
end
+1 -1
View File
@@ -6,7 +6,7 @@ module Templates
ZIP_CONTENT_TYPE = 'application/zip'
X_ZIP_CONTENT_TYPE = 'application/x-zip-compressed'
JSON_CONTENT_TYPE = 'application/json'
WORD_EXTENSIONS = %w[.docx .doc .rtf].freeze
WORD_EXTENSIONS = %w[.docx .doc .odt .rtf].freeze
DOCUMENT_EXTENSIONS = %w[.docx .doc .xlsx .xls .odt .rtf].freeze
DOCUMENT_CONTENT_TYPES = %w[
+4 -1
View File
@@ -180,8 +180,11 @@ module VerifyPdfSignature
return false if byte_range.size != 4 || byte_range.any?(&:negative?) || byte_range[0].positive?
return false if signature.signed_end > io.size
return false if byte_range[2] != byte_range[1] + (signature.contents.bytesize * 2) + 2
byte_range[2] == byte_range[1] + (signature.contents.bytesize * 2) + 2
io.seek(byte_range[1])
io.read(byte_range[2] - byte_range[1]).casecmp?("<#{signature.contents.unpack1('H*')}>")
end
def unsigned_changes?(document, io, signed_end)