- <%= link_to t('all'), url_for(params.to_unsafe_h.except(:status)), style: 'margin-bottom: -1px', class: "tab h-10 text-base #{params[:status].blank? ? 'tab-active tab-bordered' : 'pb-[3px]'}" %>
- <%= link_to t('succeeded'), url_for(params.to_unsafe_h.merge(status: 'success')), style: 'margin-bottom: -1px', class: "tab h-10 text-base #{params[:status] == 'success' ? 'tab-active tab-bordered' : 'pb-[3px]'}" %>
- <%= link_to t('failed'), url_for(params.to_unsafe_h.merge(status: 'error')), style: 'margin-bottom: -1px', class: "tab h-10 text-base #{params[:status] == 'error' ? 'tab-active tab-bordered' : 'pb-[3px]'}" %>
+ <%= link_to t('all'), url_for(params: request.query_parameters.except('status')), style: 'margin-bottom: -1px', class: "tab h-10 text-base #{params[:status].blank? ? 'tab-active tab-bordered' : 'pb-[3px]'}" %>
+ <%= link_to t('succeeded'), url_for(params: request.query_parameters.merge('status' => 'success')), style: 'margin-bottom: -1px', class: "tab h-10 text-base #{params[:status] == 'success' ? 'tab-active tab-bordered' : 'pb-[3px]'}" %>
+ <%= link_to t('failed'), url_for(params: request.query_parameters.merge('status' => 'error')), style: 'margin-bottom: -1px', class: "tab h-10 text-base #{params[:status] == 'error' ? 'tab-active tab-bordered' : 'pb-[3px]'}" %>
diff --git a/cloudbuild.yaml b/cloudbuild.yaml
new file mode 100644
index 000000000..a991c7c2c
--- /dev/null
+++ b/cloudbuild.yaml
@@ -0,0 +1,45 @@
+steps:
+ - name: 'gcr.io/cloud-builders/docker'
+ args: ['run', '--privileged', 'tonistiigi/binfmt', '--install', 'all']
+ id: qemu
+
+ - name: 'gcr.io/cloud-builders/docker'
+ args: ['buildx', 'create', '--name', 'multiarch', '--driver', 'docker-container', '--use']
+ id: buildx-create
+ waitFor: ['qemu']
+
+ - name: 'gcr.io/cloud-builders/docker'
+ args: ['buildx', 'inspect', '--bootstrap']
+ id: buildx-bootstrap
+ waitFor: ['buildx-create']
+
+ - name: 'bash'
+ args:
+ - -c
+ - |
+ echo "${_IMG_TAG}" > .version
+ id: version
+
+ - name: 'gcr.io/cloud-builders/docker'
+ args:
+ - 'buildx'
+ - 'build'
+ - '--builder=multiarch'
+ - '--platform=linux/amd64,linux/arm64'
+ - '--cache-from=type=registry,ref=us-central1-docker.pkg.dev/$PROJECT_ID/docuseal/docuseal:buildcache'
+ - '--cache-to=type=registry,ref=us-central1-docker.pkg.dev/$PROJECT_ID/docuseal/docuseal:buildcache,mode=max'
+ - '--tag=us-central1-docker.pkg.dev/$PROJECT_ID/docuseal/docuseal:${_IMG_TAG}'
+ - '--tag=us-central1-docker.pkg.dev/$PROJECT_ID/docuseal/docuseal:latest'
+ - '--push'
+ - '.'
+ waitFor: ['buildx-bootstrap', 'version']
+
+substitutions:
+ _IMG_TAG: 'latest'
+
+options:
+ machineType: E2_HIGHCPU_32
+ logging: CLOUD_LOGGING_ONLY
+
+images: []
+timeout: 7200s
diff --git a/config/dotenv.rb b/config/dotenv.rb
index b17d9aac3..cb0f8e7c2 100644
--- a/config/dotenv.rb
+++ b/config/dotenv.rb
@@ -60,7 +60,7 @@
ENV['DATABASE_URL'] = ENV['DATABASE_URL'].to_s.empty? ? database_url : ENV.fetch('DATABASE_URL', nil)
end
- unless Process.euid == 2000
+ if Process.uid.zero?
begin
test_file = "#{ENV.fetch('WORKDIR', '.')}/test"
diff --git a/config/initializers/active_storage.rb b/config/initializers/active_storage.rb
index 78637ddbc..2bcdc57bb 100644
--- a/config/initializers/active_storage.rb
+++ b/config/initializers/active_storage.rb
@@ -10,6 +10,7 @@ def signed_uuid
end
end
+# rubocop:disable Metrics/BlockLength
ActiveSupport.on_load(:active_storage_blob) do
attribute :uuid, :string, default: -> { SecureRandom.uuid }
attribute :io_data, :string, default: ''
@@ -22,6 +23,12 @@ def self.proxy_url(blob, expires_at: nil, filename: nil, host: nil)
)
end
+ def self.proxy_path(blob, expires_at: nil, filename: nil)
+ Rails.application.routes.url_helpers.blobs_proxy_path(
+ signed_uuid: blob.signed_uuid(expires_at:), filename: filename || blob.filename
+ )
+ end
+
def uuid
super || begin
new_uuid = SecureRandom.uuid
@@ -40,6 +47,7 @@ def delete
service.delete(key)
end
end
+# rubocop:enable Metrics/BlockLength
ActiveStorage::LogSubscriber.detach_from(:active_storage) if Rails.env.production?
diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb
index efa97448c..2f21a7ea8 100644
--- a/config/initializers/devise.rb
+++ b/config/initializers/devise.rb
@@ -4,7 +4,7 @@
class FailureApp < Devise::FailureApp
def respond
- Rollbar.warning('Invalid password') if defined?(Rollbar) && warden_message == :invalid
+ Rails.logger.warn('Invalid password') if warden_message == :invalid
super
end
@@ -334,6 +334,16 @@ def devise_mail(record, action, opts = {}, &)
# changed. Defaults to true, so a user is signed in automatically after changing a password.
# config.sign_in_after_change_password = true
+ if ENV['GOOGLE_CLIENT_ID'].present?
+ oauth_opts = {}
+ oauth_opts[:hd] = ENV['GOOGLE_ALLOWED_DOMAIN'] if ENV['GOOGLE_ALLOWED_DOMAIN'].present?
+
+ config.omniauth :google_oauth2,
+ ENV.fetch('GOOGLE_CLIENT_ID'),
+ ENV.fetch('GOOGLE_CLIENT_SECRET'),
+ oauth_opts
+ end
+
ActiveSupport.run_load_hooks(:devise_config, config)
end
# rubocop:enable Metrics/BlockLength
diff --git a/config/locales/i18n.yml b/config/locales/i18n.yml
index c95a793a0..5d151cbbc 100644
--- a/config/locales/i18n.yml
+++ b/config/locales/i18n.yml
@@ -44,7 +44,7 @@ en: &en
click_here_to_send_a_reset_password_email_html: '
Click here to send a reset password email.'
edit_order: Edit Order
expirable_file_download_links: Expirable file download links
- invite_form_fields: Invite form fields
+ sender_form_fields: Sender form fields
default_parties: Default parties
authenticate_embedded_form_preview_with_token: Authenticate embedded form preview with token
stripe_integration: Stripe Integration
@@ -173,6 +173,8 @@ en: &en
sign_in: Sign In
signing_in: Signing In
sign_in_with_microsoft: Sign in with Microsoft
+ user_not_found: User not found. Please contact your administrator.
+ authentication_failed: Authentication failed. Please try again.
sign_in_with_google: Sign in with Google
forgot_your_password_: Forgot your password?
create_free_account: Create free account
@@ -218,7 +220,7 @@ en: &en
copy: Copy
copied: Copied
rotate: Rotate
- remove_existing_api_token_and_generated_a_new_one_are_you_sure_: Remove existing API token and generated a new one. Are you sure?
+ remove_existing_api_token_and_generated_a_new_one_are_you_sure_: Remove existing API token and generate a new one. Are you sure?
request_signature_multiple_submitters_with_default_values: Request signature, multiple submitters with default values
request_signature_single_submitter: Request signature, single submitter
template_details: Template details
@@ -330,8 +332,8 @@ en: &en
initials: Initials
update_initials: Update Initials
unable_to_save_initials: Unable to save initials.
- initials_has_been_saved: Initials has been saved.
- initials_has_been_removed: Initials has been removed.
+ initials_has_been_saved: Initials have been saved.
+ initials_has_been_removed: Initials have been removed.
change_password: Change Password
two_factor_authentication: Two-Factor Authentication
2fa_is_not_configured: 2FA is not configured
@@ -351,6 +353,8 @@ en: &en
but_not_activated: but not activated
email_has_been_sent: Email has been sent.
email_has_been_sent_already: Email has been sent already.
+ sms_has_been_sent: SMS has been sent.
+ sms_has_been_sent_already: SMS has been sent already.
initial_setup: Initial Setup
demo_environment: Demo Environment
close: Close
@@ -388,7 +392,7 @@ en: &en
from: From
account_sid: Account SID
send_sms_via_webhook: Send SMS via webhook
- webhook_integration_allows_to_send_sms_using_any_provider: Webhook integration allows to send SMS using any provider
+ webhook_integration_allows_to_send_sms_using_any_provider: Webhook integration allows you to send SMS using any provider
test: Test
single_sign_on_with_saml_2_0: Single Sign On with SAML 2.0
force_sso_disable_login_with_email_and_password: Force SSO (disable login with email and password)
@@ -420,7 +424,7 @@ en: &en
send_signature_request_emails_without_limits_with_docuseal_pro: Send signature request emails without limits with DocuSeal Pro
count_emails_used: '%{count} emails used'
has_been_connected: has been connected
- sms_not_configured: SMS not Configure
+ sms_not_configured: SMS not Configured
configure_sms_settings_in_order_to_send_text_messages_: 'Configure SMS settings in order to send text messages:'
go_to_sms_settings: Go to SMS settings
back_to_active: Back to Active
@@ -500,6 +504,7 @@ en: &en
submission_requester: Submission requester
specified_email: Specified email
invite_by_name: 'Invite by %{name}'
+ invite_via_form_field: Invite via Form Field
same_as_name: 'Same as %{name}'
default_email: Default Email
processing: Processing
@@ -596,7 +601,7 @@ en: &en
upload_file: Upload file
upgrade_your_plan_to_invite_more_users_contact_email: 'Upgrade your plan to invite more users (contact %{email}).'
contact_your_admin_email_to_invite_more_users: 'Contact your admin %{email} to invite more users.'
- contact_your_administrator_to_add_new_users: Contact your administrator to add new user
+ contact_your_administrator_to_add_new_users: Contact your administrator to add new users
one_hour: 1 hour
two_hours: 2 hours
four_hours: 4 hours
@@ -625,7 +630,7 @@ en: &en
personalize_email_content: Personalize email content
automated_reminders: Automated reminders
bulk_send_from_spreadsheet: Bulk send from spreadsheet
- identify_verification_via_sms: Identify verification via SMS
+ identify_verification_via_sms: Identity verification via SMS
start_with_pro: Start with Pro
user_month: user / month
developer_sandbox: Developer Sandbox.
@@ -642,6 +647,8 @@ en: &en
send_email_copy_with_completed_documents_to_a_specified_bcc_address: Send email copy with completed documents to a specified BCC address.
re_send_email: Re-send Email
send_email: Send Email
+ re_send_sms: Re-send SMS
+ send_sms: Send SMS
copy_share_link: Copy Share Link
copied_to_clipboard: Copied to Clipboard
link: Link
@@ -703,6 +710,12 @@ en: &en
key: Key
value: Value
webhook_secret: Webhook Secret
+ signature_verification: Signature Verification
+ all_webhook_requests_are_signed_with_hmac_sha256: All webhook requests are signed with HMAC-SHA256. Use the signing key below to verify authenticity.
+ signing_key: Signing Key
+ regenerate: Regenerate
+ verification_example: Verification example
+ custom_header: Custom Header
author: Author
to: To
created_at: Created at
@@ -737,7 +750,7 @@ en: &en
find_suitable_zapier_templates_to_automate_your_workflow: Find suitable Zapier templates to automate your workflow.
get_started: Get started
click_here_to_learn_more_about_user_roles_and_permissions_html: '
Click here to learn more about user roles and permissions.'
- count_10_signature_request_emails_sent_this_month_upgrade_to_pro_to_send_unlimited_signature_request_email: '%{count} / 10 signature request emails sent this month. Upgrade to Pro to send unlimited signature request email.'
+ count_10_signature_request_emails_sent_this_month_upgrade_to_pro_to_send_unlimited_signature_request_email: '%{count} / 10 signature request emails sent this month. Upgrade to Pro to send unlimited signature request emails.'
test_mode_emails_limit_will_be_reset_within_24_hours: Test mode emails limit will be reset within 24 hours.
on_a_scale_of_1_to_10_how_satisfied_are_you_with_the_docuseal_product_: On a scale of 1 to 10, how satisfied are you with the DocuSeal product?
tell_us_more_about_your_experience: Tell us more about your experience
@@ -755,7 +768,7 @@ en: &en
manage_plan: Manage plan
this_submission_has_multiple_signers_which_prevents_the_use_of_a_sharing_link_html: This submission has multiple signers, which prevents the use of a sharing link as it's unclear which signer is responsible for specific fields. To resolve this, follow this
guide to define the default signer details.
welcome_to_docuseal: Welcome to DocuSeal
- start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document: Start a quick tour to learn how to create an send your first document
+ start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document: Start a quick tour to learn how to create and send your first document
start_tour: Start Tour
name_a_z: Name A-Z
recently_used: Recently used
@@ -815,7 +828,7 @@ en: &en
connect_gmail_or_outlook: Connect Gmail or Outlook
connect_your_email_to_bulk_send: Connect your email to bulk send
connect_your_email_or_outlook_account_or_add_smtp_settings_to_bulk_send: Connect your Gmail or Outlook account or add SMTP settings to bulk send.
- are_you_sure_you_want_to_add_recipients_without_sending_to_send_emails_it_requires_to_connect_gmail_or_outlook: Are you sure you want to add recipients without sending? To send emails it requires to connect Gmail or Outlook.
+ are_you_sure_you_want_to_add_recipients_without_sending_to_send_emails_it_requires_to_connect_gmail_or_outlook: Are you sure you want to add recipients without sending? To send emails you need to connect Gmail or Outlook.
template_name_has_been_completed_by_submitters_html: '"
{template.name} " has been completed by
{submission.submitters} '
please_check_the_copy_of_your_template_name_in_the_email_attachments_html: 'Please check the copy of your "
{template.name} " in the email attachments.'
you_have_been_invited_to_sign_the_template_name_html: 'You have been invited to sign the "
{template.name} ".'
@@ -886,6 +899,13 @@ en: &en
if_you_didnt_request_this_you_can_ignore_this_email: "If you didn't request this, please ignore this email."
your_password_wont_change_until_you_open_the_link_above_and_set_a_new_one: "Your password won't change until you open the link above and set a new one."
too_many_requests_try_again_later: Too many requests, try again later.
+ bold: Bold
+ italic: Italic
+ underline: Underline
+ undo: Undo
+ redo: Redo
+ add_variable: Add variable
+ enter_a_url_or_variable_name: Enter a URL or variable name
devise:
confirmations:
confirmed: Your email address has been successfully confirmed.
@@ -1002,6 +1022,16 @@ en: &en
events:
range_with_total: "%{from}-%{to} of %{count} events"
range_without_total: "%{from}-%{to} events"
+ variables:
+ account_name: Account name
+ submitter_link: Submitter link
+ template_name: Template name
+ submission_submitters: Submitters list
+ submission_link: Submission link
+ documents_link: Documents link
+ time:
+ formats:
+ detailed: "%B %d, %Y %H:%M:%S"
es: &es
knowledge_based_authentication: Autenticación basada en el conocimiento
@@ -1022,7 +1052,7 @@ es: &es
party: Parte
edit_order: Edita Pedido
select: Seleccionar
- invite_form_fields: Invitar campos del formulario
+ sender_form_fields: Campos del formulario del remitente
pro: Pro
default_parties: Partes predeterminadas
authenticate_embedded_form_preview_with_token: Autenticar vista previa del formulario incrustado con token
@@ -1487,6 +1517,7 @@ es: &es
submission_requester: Solicitante del envío
specified_email: Correo electrónico especificado
invite_by_name: 'Invitar por %{name}'
+ invite_via_form_field: Invitar a través de campo del formulario
same_as_name: 'Igual que %{name}'
default_email: Correo electrónico predeterminado
processing: Procesando
@@ -1870,6 +1901,13 @@ es: &es
if_you_didnt_request_this_you_can_ignore_this_email: "Si no solicitaste esto, puedes ignorar este correo electrónico."
your_password_wont_change_until_you_open_the_link_above_and_set_a_new_one: "Tu contraseña no cambiará hasta que abras el enlace anterior y establezcas una nueva."
too_many_requests_try_again_later: Demasiadas solicitudes. Intenta de nuevo más tarde.
+ bold: Negrita
+ italic: Cursiva
+ underline: Subrayado
+ undo: Deshacer
+ redo: Rehacer
+ add_variable: Agregar variable
+ enter_a_url_or_variable_name: Ingrese una URL o nombre de variable
devise:
confirmations:
confirmed: Tu dirección de correo electrónico ha sido confirmada correctamente.
@@ -1986,6 +2024,16 @@ es: &es
events:
range_with_total: "%{from}-%{to} de %{count} eventos"
range_without_total: "%{from}-%{to} eventos"
+ variables:
+ account_name: Nombre de la cuenta
+ submitter_link: Enlace del firmante
+ template_name: Nombre de la plantilla
+ submission_submitters: Lista de firmantes
+ submission_link: Enlace del envío
+ documents_link: Enlace de los documentos
+ time:
+ formats:
+ detailed: "%-d de %B de %Y %H:%M:%S"
it: &it
knowledge_based_authentication: Autenticazione basata sulla conoscenza
@@ -2006,7 +2054,7 @@ it: &it
party: Parte
edit_order: Modifica Ordine
select: Seleziona
- invite_form_fields: Invita campi modulo
+ sender_form_fields: Campi del modulo del mittente
pro: Pro
default_parties: Parti predefiniti
authenticate_embedded_form_preview_with_token: "Autentica l'anteprima del modulo incorporato con il token"
@@ -2471,6 +2519,7 @@ it: &it
submission_requester: "Richiedente dell'invio"
specified_email: Email specificata
invite_by_name: 'Invito da %{name}'
+ invite_via_form_field: Invito tramite campo del modulo
same_as_name: 'Uguale a %{name}'
default_email: Email predefinita
processing: Elaborazione in corso
@@ -2855,6 +2904,13 @@ it: &it
if_you_didnt_request_this_you_can_ignore_this_email: "Se non hai richiesto questo, puoi ignorare questa email."
your_password_wont_change_until_you_open_the_link_above_and_set_a_new_one: "La tua password non cambierà finché non apri il link sopra e ne imposti una nuova."
too_many_requests_try_again_later: Troppe richieste. Riprova più tardi.
+ bold: Grassetto
+ italic: Corsivo
+ underline: Sottolineato
+ undo: Annulla
+ redo: Ripeti
+ add_variable: Aggiungi variabile
+ enter_a_url_or_variable_name: Inserisci un URL o nome variabile
devise:
confirmations:
confirmed: Il tuo indirizzo email è stato confermato con successo.
@@ -2971,6 +3027,16 @@ it: &it
events:
range_with_total: "%{from}-%{to} di %{count} eventi"
range_without_total: "%{from}-%{to} eventi"
+ variables:
+ account_name: Nome dell'account
+ submitter_link: Link del firmatario
+ template_name: Nome del modello
+ submission_submitters: Lista dei firmatari
+ submission_link: Link dell'invio
+ documents_link: Link dei documenti
+ time:
+ formats:
+ detailed: "%d %B %Y %H:%M:%S"
fr: &fr
knowledge_based_authentication: Authentification basée sur la connaissance
@@ -2991,7 +3057,7 @@ fr: &fr
party: Partie
edit_order: Modifier l’ordre
select: Sélectionner
- invite_form_fields: Champs du formulaire d’invitation
+ sender_form_fields: Champs du formulaire de l’expéditeur
pro: Pro
default_parties: Parties par défaut
authenticate_embedded_form_preview_with_token: Authentifier l’aperçu du formulaire intégré avec un jeton
@@ -3456,6 +3522,7 @@ fr: &fr
submission_requester: Demandeur de soumission
specified_email: E‑mail spécifié
invite_by_name: Inviter par %{name}
+ invite_via_form_field: Inviter via champ du formulaire
same_as_name: Identique à %{name}
default_email: E‑mail par défaut
processing: Traitement en cours
@@ -3836,6 +3903,13 @@ fr: &fr
if_you_didnt_request_this_you_can_ignore_this_email: "Si vous n'avez pas fait cette demande, veuillez ignorer cet e-mail."
your_password_wont_change_until_you_open_the_link_above_and_set_a_new_one: "Votre mot de passe ne changera pas tant que vous n’aurez pas ouvert le lien ci-dessus et défini un nouveau mot de passe."
too_many_requests_try_again_later: Trop de demandes. Réessayez plus tard.
+ bold: Gras
+ italic: Italique
+ underline: Souligné
+ undo: Annuler
+ redo: Rétablir
+ add_variable: Ajouter une variable
+ enter_a_url_or_variable_name: Entrez une URL ou un nom de variable
devise:
confirmations:
confirmed: Votre adresse e-mail a été confirmée avec succès.
@@ -3952,6 +4026,16 @@ fr: &fr
events:
range_with_total: "%{from}-%{to} sur %{count} événements"
range_without_total: "%{from}-%{to} événements"
+ variables:
+ account_name: Nom du compte
+ submitter_link: Lien du signataire
+ template_name: Nom du modèle
+ submission_submitters: Liste des signataires
+ submission_link: Lien de la soumission
+ documents_link: Lien des documents
+ time:
+ formats:
+ detailed: "%A %d %B %Y %Hh%Mm%Ss"
pt: &pt
knowledge_based_authentication: Autenticação baseada em conhecimento
@@ -3972,7 +4056,7 @@ pt: &pt
party: Parte
edit_order: Edita Pedido
select: Selecionar
- invite_form_fields: Convidar campos do formulário
+ sender_form_fields: Campos do formulário do remetente
pro: Pro
default_parties: Partes padrão
authenticate_embedded_form_preview_with_token: Autenticar visualização incorporada do formulário com token
@@ -4437,6 +4521,7 @@ pt: &pt
submission_requester: Solicitante de submissão
specified_email: E-mail especificado
invite_by_name: 'Convidado por %{name}'
+ invite_via_form_field: Convidar via campo do formulário
same_as_name: 'Igual a %{name}'
default_email: E-mail padrão
processing: Processando
@@ -4820,6 +4905,13 @@ pt: &pt
if_you_didnt_request_this_you_can_ignore_this_email: "Se você não solicitou isso, pode ignorar este e-mail."
your_password_wont_change_until_you_open_the_link_above_and_set_a_new_one: "Sua senha não será alterada até que você abra o link acima e defina uma nova."
too_many_requests_try_again_later: Muitas solicitações. Tente novamente mais tarde.
+ bold: Negrito
+ italic: Itálico
+ underline: Sublinhado
+ undo: Desfazer
+ redo: Refazer
+ add_variable: Adicionar variável
+ enter_a_url_or_variable_name: Digite uma URL ou nome de variável
devise:
confirmations:
confirmed: Seu endereço de e-mail foi confirmado com sucesso.
@@ -4936,6 +5028,16 @@ pt: &pt
events:
range_with_total: "%{from}-%{to} de %{count} eventos"
range_without_total: "%{from}-%{to} eventos"
+ variables:
+ account_name: Nome da conta
+ submitter_link: Link do signatário
+ template_name: Nome do modelo
+ submission_submitters: Lista de signatários
+ submission_link: Link da submissão
+ documents_link: Link dos documentos
+ time:
+ formats:
+ detailed: "%A, %d de %B de %Y, %H:%M:%Sh"
de: &de
knowledge_based_authentication: Wissensbasierte Authentifizierung
@@ -4965,7 +5067,7 @@ de: &de
click_here_to_send_a_reset_password_email_html: '
Klicken Sie hier , um eine E-Mail zum Zurücksetzen des Passworts zu senden.'
edit_order: Bestellung bearbeiten
expirable_file_download_links: Ablaufbare Datei-Download-Links
- invite_form_fields: Einladungsformular-Felder
+ sender_form_fields: Absenderformular-Felder
default_parties: Standardparteien
authenticate_embedded_form_preview_with_token: Eingebettete Formularvorschau mit Token authentifizieren
stripe_integration: Stripe-Integration
@@ -5421,6 +5523,7 @@ de: &de
submission_requester: Anfragende Person
specified_email: Angegebene E-Mail
invite_by_name: 'Einladung von %{name}'
+ invite_via_form_field: Einladung über Formularfeld
same_as_name: 'Gleich wie %{name}'
default_email: Standard-E-Mail
processing: Verarbeitung
@@ -5804,6 +5907,13 @@ de: &de
if_you_didnt_request_this_you_can_ignore_this_email: "Wenn Sie dies nicht angefordert haben, können Sie diese E-Mail ignorieren."
your_password_wont_change_until_you_open_the_link_above_and_set_a_new_one: "Ihr Passwort wird erst geändert, wenn Sie den obigen Link öffnen und ein neues festlegen."
too_many_requests_try_again_later: Zu viele Anfragen. Versuchen Sie es später erneut.
+ bold: Fett
+ italic: Kursiv
+ underline: Unterstrichen
+ undo: Rückgängig
+ redo: Wiederholen
+ add_variable: Variable hinzufügen
+ enter_a_url_or_variable_name: Geben Sie eine URL oder einen Variablennamen ein
devise:
confirmations:
confirmed: Ihre E-Mail-Adresse wurde erfolgreich bestätigt.
@@ -5920,6 +6030,16 @@ de: &de
events:
range_with_total: "%{from}-%{to} von %{count} Ereignissen"
range_without_total: "%{from}-%{to} Ereignisse"
+ variables:
+ account_name: Kontoname
+ submitter_link: Link des Unterzeichners
+ template_name: Vorlagenname
+ submission_submitters: Liste der Unterzeichner
+ submission_link: Link der Einreichung
+ documents_link: Link der Dokumente
+ time:
+ formats:
+ detailed: "%A, %d. %B %Y, %H:%M:%S Uhr"
pl:
require_phone_2fa_to_open: Wymagaj uwierzytelniania telefonicznego 2FA do otwarcia
@@ -6337,7 +6457,7 @@ nl: &nl
click_here_to_send_a_reset_password_email_html:
Klik hier om een e-mail voor wachtwoordherstel te verzenden.
edit_order: Volgorde bewerken
expirable_file_download_links: Verlopende downloadlinks voor bestanden
- invite_form_fields: Velden van uitnodigingsformulier
+ sender_form_fields: Velden van afzenderformulier
default_parties: Standaard partijen
authenticate_embedded_form_preview_with_token: Preview van ingesloten formulier authenticeren met token
stripe_integration: Stripe-integratie
@@ -6794,6 +6914,7 @@ nl: &nl
submission_requester: Aanvrager van inzending
specified_email: Opgegeven e-mail
invite_by_name: Uitnodigen door %{name}
+ invite_via_form_field: Uitnodigen via formulierveld
same_as_name: Zelfde als %{name}
default_email: Standaard e-mail
processing: Verwerken
@@ -7173,6 +7294,13 @@ nl: &nl
if_you_didnt_request_this_you_can_ignore_this_email: "Als je dit niet hebt aangevraagd, kun je deze e-mail negeren."
your_password_wont_change_until_you_open_the_link_above_and_set_a_new_one: "Je wachtwoord wordt niet gewijzigd totdat je de bovenstaande link opent en een nieuw wachtwoord instelt."
too_many_requests_try_again_later: Te veel verzoeken. Probeer het later opnieuw.
+ bold: Vet
+ italic: Cursief
+ underline: Onderstreept
+ undo: Ongedaan maken
+ redo: Opnieuw
+ add_variable: Variabele toevoegen
+ enter_a_url_or_variable_name: Voer een URL of variabelenaam in
devise:
confirmations:
confirmed: Je e-mailadres is succesvol bevestigd.
@@ -7289,6 +7417,16 @@ nl: &nl
events:
range_with_total: "%{from}-%{to} van %{count} gebeurtenissen"
range_without_total: "%{from}-%{to} gebeurtenissen"
+ variables:
+ account_name: Accountnaam
+ submitter_link: Link van de ondertekenaar
+ template_name: Sjabloonnaam
+ submission_submitters: Lijst van ondertekenaars
+ submission_link: Link van de inzending
+ documents_link: Link van de documenten
+ time:
+ formats:
+ detailed: "%d %B %Y %H:%M:%S"
ar:
require_phone_2fa_to_open: "تطلب فتح عبر تحقق الهاتف ذو العاملين"
@@ -7586,12 +7724,18 @@ en-US:
date:
formats:
default: "%m/%d/%Y"
+ time:
+ formats:
+ detailed: "%B %d, %Y %I:%M:%S %p"
en-GB:
<<: *en
date:
formats:
default: "%d/%m/%Y"
+ time:
+ formats:
+ detailed: "%d %B, %Y %H:%M:%S"
es-ES:
<<: *es
diff --git a/config/routes.rb b/config/routes.rb
index 4447a2399..983cdf6ed 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -14,8 +14,13 @@
get 'up' => 'rails/health#show'
get 'manifest' => 'pwa#manifest'
- devise_for :users, path: '/', only: %i[sessions passwords],
- controllers: { sessions: 'sessions', passwords: 'passwords' }
+ devise_modules = %i[sessions passwords]
+ devise_controllers = { sessions: 'sessions', passwords: 'passwords' }
+ if ENV['GOOGLE_CLIENT_ID'].present?
+ devise_modules << :omniauth_callbacks
+ devise_controllers[:omniauth_callbacks] = 'omniauth_callbacks'
+ end
+ devise_for :users, path: '/', only: devise_modules, controllers: devise_controllers
devise_scope :user do
resource :invitation, only: %i[update] do
@@ -56,7 +61,7 @@
resources :account_custom_fields, only: %i[create]
resources :user_configs, only: %i[create]
resources :encrypted_user_configs, only: %i[destroy]
- resources :timestamp_server, only: %i[create]
+ resources :timestamp_server, only: %i[create] unless Docuseal.multitenant?
resources :dashboard, only: %i[index]
resources :setup, only: %i[index create]
resource :newsletter, only: %i[show update]
@@ -160,6 +165,7 @@
resources :submitters, only: %i[], param: 'slug' do
resources :download, only: %i[index], controller: 'submissions_download'
resources :send_email, only: %i[create], controller: 'submitters_send_email'
+ resources :send_sms, only: %i[create], controller: 'submitters_send_sms'
resources :debug, only: %i[index], controller: 'submissions_debug' if Rails.env.development?
end
@@ -167,7 +173,7 @@
unless Docuseal.multitenant?
resources :storage, only: %i[index create], controller: 'storage_settings'
resources :search_entries_reindex, only: %i[create]
- resources :sms, only: %i[index], controller: 'sms_settings'
+ resources :sms, only: %i[index create], controller: 'sms_settings'
end
if Docuseal.demo? || !Docuseal.multitenant?
resources :api, only: %i[index create], controller: 'api_settings'
diff --git a/config/sidekiq.yml b/config/sidekiq.yml
index 01ba85a5a..784ec9a40 100644
--- a/config/sidekiq.yml
+++ b/config/sidekiq.yml
@@ -5,7 +5,7 @@ queues:
- [images, 1]
- [mailers, 1]
- [recurrent, 1]
- - [rollbar, 1]
+
production:
:concurrency: 15
diff --git a/config/webpack/webpack.config.js b/config/webpack/webpack.config.js
index be17c2cc9..ac635e539 100644
--- a/config/webpack/webpack.config.js
+++ b/config/webpack/webpack.config.js
@@ -13,9 +13,7 @@ const configs = generateWebpackConfig({
runtimeChunk: false,
concatenateModules: !process.env.BUNDLE_ANALYZE,
splitChunks: {
- chunks (chunk) {
- return chunk.name !== 'rollbar'
- },
+ chunks: 'all',
cacheGroups: {
default: false,
applicationVendors: {
diff --git a/db/migrate/20260215210000_add_signing_key_to_webhook_urls.rb b/db/migrate/20260215210000_add_signing_key_to_webhook_urls.rb
new file mode 100644
index 000000000..12d8cfc27
--- /dev/null
+++ b/db/migrate/20260215210000_add_signing_key_to_webhook_urls.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class AddSigningKeyToWebhookUrls < ActiveRecord::Migration[7.1]
+ def change
+ add_column :webhook_urls, :signing_key, :text
+ end
+end
diff --git a/lib/accounts.rb b/lib/accounts.rb
index fbbf688c7..7e55877bf 100644
--- a/lib/accounts.rb
+++ b/lib/accounts.rb
@@ -173,6 +173,10 @@ def load_trusted_certs(account)
*Docuseal.trusted_certs]
end
+ def can_send_sms?(account)
+ EncryptedConfig.exists?(account:, key: EncryptedConfig::SMS_KEY)
+ end
+
def can_send_emails?(_account, **_params)
return true if Docuseal.multitenant?
return true if ENV['SMTP_ADDRESS'].present?
diff --git a/lib/action_mailer_events_observer.rb b/lib/action_mailer_events_observer.rb
index 273564ac2..08da5578c 100644
--- a/lib/action_mailer_events_observer.rb
+++ b/lib/action_mailer_events_observer.rb
@@ -27,7 +27,7 @@ def delivered_email(mail)
)
end
rescue StandardError => e
- Rollbar.error(e) if defined?(Rollbar)
+ Rails.logger.error(e)
raise if Rails.env.local?
end
diff --git a/lib/docuseal.rb b/lib/docuseal.rb
index 7bba653cb..7ef769422 100644
--- a/lib/docuseal.rb
+++ b/lib/docuseal.rb
@@ -4,9 +4,7 @@ module Docuseal
URL_CACHE = ActiveSupport::Cache::MemoryStore.new
PRODUCT_URL = 'https://www.docuseal.com'
PRODUCT_EMAIL_URL = ENV.fetch('PRODUCT_EMAIL_URL', PRODUCT_URL)
- NEWSLETTER_URL = "#{PRODUCT_URL}/newsletters".freeze
- ENQUIRIES_URL = "#{PRODUCT_URL}/enquiries".freeze
- PRODUCT_NAME = 'DocuSeal'
+ PRODUCT_NAME = ENV.fetch('PRODUCT_NAME', 'Kencove eSign')
DEFAULT_APP_URL = ENV.fetch('APP_URL', 'http://localhost:3000')
GITHUB_URL = 'https://github.com/docusealco/docuseal'
DISCORD_URL = 'https://discord.gg/qygYCDGck9'
@@ -46,8 +44,14 @@ module Docuseal
protocol: ENV['FORCE_SSL'].present? ? 'https' : 'http'
}.freeze
+ REMOVE_BRANDING = ENV['REMOVE_BRANDING'] == 'true'
+
module_function
+ def remove_branding?
+ REMOVE_BRANDING
+ end
+
def version
@version ||=
if VERSION_FILE_PATH.exist?
diff --git a/lib/download_utils.rb b/lib/download_utils.rb
index dce5427a0..8b352502b 100644
--- a/lib/download_utils.rb
+++ b/lib/download_utils.rb
@@ -35,16 +35,16 @@ module DownloadUtils
module_function
- def call(url)
+ def call(url, validate: Docuseal.multitenant?)
uri = begin
URI(url)
rescue URI::Error
Addressable::URI.parse(url).normalize
end
- validate_uri!(uri) if Docuseal.multitenant?
+ validate_uri!(uri) if validate
- resp = conn.get(uri)
+ resp = conn(validate:).get(uri)
raise UnableToDownload, "Error loading: #{uri}" if resp.status >= 400
@@ -52,14 +52,15 @@ def call(url)
end
def validate_uri!(uri)
- raise UnableToDownload, "Error loading: #{uri}. Only HTTPS is allowed." if uri.scheme != 'https'
+ raise UnableToDownload, "Error loading: #{uri}. Only HTTPS is allowed." if uri.scheme != 'https' ||
+ [443, nil].exclude?(uri.port)
raise UnableToDownload, "Error loading: #{uri}. Can't download from localhost." if uri.host.in?(LOCALHOSTS)
end
- def conn
+ def conn(validate: Docuseal.multitenant?)
Faraday.new do |faraday|
faraday.response :follow_redirects, callback: lambda { |_, new_env|
- validate_uri!(new_env[:url]) if Docuseal.multitenant?
+ validate_uri!(new_env[:url]) if validate
}
end
end
diff --git a/lib/load_active_storage_configs.rb b/lib/load_active_storage_configs.rb
index d82c4f726..d3f308fe6 100644
--- a/lib/load_active_storage_configs.rb
+++ b/lib/load_active_storage_configs.rb
@@ -29,7 +29,12 @@ def reload
service_configurations = ActiveSupport::ConfigurationFile.parse(STORAGE_YML_PATH)
service_configurations[service].merge!(configs) if configs.present?
- service_configurations[service][:force_path_style] = true if configs&.dig('endpoint').present?
+ if configs&.dig('endpoint').present?
+ service_configurations[service][:force_path_style] = true
+ if configs['endpoint'].include?('cloudflarestorage.com')
+ service_configurations[service][:request_checksum_calculation] = 'when_required'
+ end
+ end
if service == 'google'
service_configurations[service][:credentials] = JSON.parse(configs.fetch('credentials', '{}'))
diff --git a/lib/markdown_to_html.rb b/lib/markdown_to_html.rb
index e9d04f75f..3d29566a5 100644
--- a/lib/markdown_to_html.rb
+++ b/lib/markdown_to_html.rb
@@ -1,13 +1,132 @@
# frozen_string_literal: true
module MarkdownToHtml
- LINK_REGEXP = %r{\[([^\]]+)\]\((https?://[^)]+)\)}
+ TAGS = {
+ '' => %w[
],
+ '*' => %w[
],
+ '~' => %w[
]
+ }.freeze
+
+ INLINE_TOKENIZER = /(\[)|(\]\(([^)]+?)\))|(?:`([^`].*?)`)|(\*\*\*|\*\*|\*|~~)/
+
+ ALLOWED_TAGS = %w[p br strong b em i u a].freeze
+ ALLOWED_ATTRIBUTES = %w[href].freeze
module_function
- def call(text)
- text.gsub(LINK_REGEXP) do
- ApplicationController.helpers.link_to(Regexp.last_match(1), Regexp.last_match(2))
+ def call(markdown)
+ return '' if markdown.blank?
+
+ text = auto_link_urls(markdown)
+ html = render_markdown(text)
+
+ ActionController::Base.helpers.sanitize(html, tags: ALLOWED_TAGS, attributes: ALLOWED_ATTRIBUTES)
+ end
+
+ BRACKETS = { ')' => '(', ']' => '[', '}' => '{' }.freeze
+
+ def auto_link_urls(text)
+ link_parts = text.split(%r{((?:https?://|www\.)[^\s<>\u00A0"]+)})
+
+ link_parts.map.with_index do |part, index|
+ if part.match?(%r{\A(?:https?://|www\.)}) && !(index > 0 && link_parts[index - 1]&.match?(/\]\(\s*\z/))
+ url_part = part.dup
+ punctuation = []
+
+ while url_part.sub!(%r{[^\p{Word}/\-=;]\z}, '')
+ punctuation.push(::Regexp.last_match(0))
+
+ opening = BRACKETS[punctuation.last]
+
+ next unless opening && url_part.count(opening) > url_part.count(punctuation.last)
+
+ url_part << punctuation.pop
+
+ break
+ end
+
+ trail = punctuation.reverse.join
+ url = url_part.start_with?('www.') ? "https://#{url_part}" : url_part
+
+ "[#{url_part}](#{url})#{trail}"
+ else
+ part
+ end
+ end.join
+ end
+
+ def render_markdown(text)
+ text = text.gsub(/\+\+([^+]+)\+\+/, '
\1 ')
+
+ paragraphs = text.split(/\n{2,}/)
+
+ html = paragraphs.filter_map do |para|
+ content = para.strip
+
+ next if content.empty?
+
+ next '
' if [' ', ' '].include?(content)
+
+ content = content.gsub(/ *\n/, '
')
+
+ "
#{parse_inline(content)}
"
+ end.join
+
+ html.presence || '
'
+ end
+
+ # rubocop:disable Metrics
+ def parse_inline(text)
+ context = []
+ out = ''
+ last = 0
+
+ tag = lambda do |t|
+ desc = TAGS[t[1] || '']
+
+ return t unless desc
+
+ is_end = context.last == t
+ is_end ? context.pop : context.push(t)
+ desc[is_end ? 1 : 0]
end
+
+ flush = lambda do
+ str = ''
+ str += tag.call(context.last) while context.any?
+ str
+ end
+
+ while last <= text.length && (m = INLINE_TOKENIZER.match(text, last))
+ prev = text[last...m.begin(0)]
+ last = m.end(0)
+ chunk = m[0]
+
+ if m[4]
+ chunk = "
#{ERB::Util.html_escape(m[4])}"
+ elsif m[2]
+ out = out.sub(/\A(.*)
/m, "\\1 ")
+ out = out.gsub(' ', '[')
+ chunk = "#{flush.call} "
+ elsif m[1]
+ chunk = '
'
+ elsif m[5]
+ chunk =
+ if m[5] == '***'
+ if context.include?('*') && context.include?('**')
+ tag.call('*') + tag.call('**')
+ else
+ tag.call('**') + tag.call('*')
+ end
+ else
+ tag.call(m[5])
+ end
+ end
+
+ out += prev.to_s + chunk
+ end
+
+ (out + text[last..].to_s + flush.call).gsub(' ', '[')
end
+ # rubocop:enable Metrics
end
diff --git a/lib/params/base_validator.rb b/lib/params/base_validator.rb
index cd0005f64..91f71471a 100644
--- a/lib/params/base_validator.rb
+++ b/lib/params/base_validator.rb
@@ -11,11 +11,11 @@ def self.call(...)
validator.call
rescue InvalidParameterError => e
- Rollbar.warning(e) if defined?(Rollbar)
+ Rails.logger.warn(e)
raise e unless validator.dry_run?
rescue StandardError => e
- Rollbar.error(e) if defined?(Rollbar)
+ Rails.logger.error(e)
raise e unless Rails.env.production?
end
diff --git a/lib/rate_limit.rb b/lib/rate_limit.rb
index 3f01705ca..ac5798024 100644
--- a/lib/rate_limit.rb
+++ b/lib/rate_limit.rb
@@ -3,7 +3,16 @@
module RateLimit
LimitApproached = Class.new(StandardError)
- STORE = ActiveSupport::Cache::MemoryStore.new
+ STORE = begin
+ redis_url = ENV.fetch('REDIS_URL', nil)
+ if redis_url.present?
+ ActiveSupport::Cache::RedisCacheStore.new(url: redis_url, namespace: 'rate_limit')
+ else
+ ActiveSupport::Cache::MemoryStore.new
+ end
+ rescue StandardError
+ ActiveSupport::Cache::MemoryStore.new
+ end
module_function
diff --git a/lib/replace_email_variables.rb b/lib/replace_email_variables.rb
index 4222e676e..392c585d9 100644
--- a/lib/replace_email_variables.rb
+++ b/lib/replace_email_variables.rb
@@ -89,8 +89,10 @@ def call(text, submitter:, tracking_event_type: 'click_email', html_escape: fals
# rubocop:enable Metrics
def build_documents_links_text(submitter, sig = nil)
+ url_options = build_url_options_for(submitter)
+
Rails.application.routes.url_helpers.submissions_preview_url(
- submitter.submission.slug, { sig:, **Docuseal.default_url_options }.compact
+ submitter.submission.slug, { sig:, **url_options }.compact
)
end
@@ -139,14 +141,9 @@ def replace(text, var, html_escape: false)
end
def build_submitter_link(submitter, tracking_event_type)
- if tracking_event_type == 'click_email'
- url_options =
- if EMAIL_HOST.present?
- { host: EMAIL_HOST, protocol: ENV['FORCE_SSL'].present? ? 'https' : 'http' }
- else
- Docuseal.default_url_options
- end
+ url_options = build_url_options_for(submitter, is_email: tracking_event_type == 'click_email')
+ if tracking_event_type == 'click_email'
Rails.application.routes.url_helpers.submit_form_url(
slug: submitter.slug,
t: SubmissionEvents.build_tracking_param(submitter, 'click_email'),
@@ -156,11 +153,22 @@ def build_submitter_link(submitter, tracking_event_type)
Rails.application.routes.url_helpers.submit_form_url(
slug: submitter.slug,
c: SubmissionEvents.build_tracking_param(submitter, 'click_sms'),
- **Docuseal.default_url_options
+ **url_options
)
end
end
+ def build_url_options_for(submitter, is_email: true)
+ if Docuseal.multitenant? &&
+ (config = AccountConfig.find_by(account_id: submitter.account_id, key: :custom_domain))
+ { host: config.value, protocol: 'https' }
+ elsif is_email && EMAIL_HOST.present?
+ { host: EMAIL_HOST, protocol: ENV['FORCE_SSL'].present? ? 'https' : 'http' }
+ else
+ Docuseal.default_url_options
+ end
+ end
+
def build_submission_link(submission)
Rails.application.routes.url_helpers.submission_url(submission, **Docuseal.default_url_options)
end
diff --git a/lib/send_sms.rb b/lib/send_sms.rb
new file mode 100644
index 000000000..067f272ea
--- /dev/null
+++ b/lib/send_sms.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+module SendSms
+ SmsError = Class.new(StandardError)
+
+ TWILIO_API_URL = 'https://api.twilio.com/2010-04-01/Accounts/%s/Messages.json'
+
+ module_function
+
+ def call(to:, body:, account_sid:, auth_token:, from_number:)
+ url = format(TWILIO_API_URL, account_sid:)
+
+ conn = Faraday.new do |f|
+ f.request :url_encoded
+ f.adapter Faraday.default_adapter
+ end
+
+ conn.basic_auth(account_sid, auth_token)
+
+ response = conn.post(url, { 'To' => to, 'From' => from_number, 'Body' => body }) do |req|
+ req.options.open_timeout = 8
+ req.options.read_timeout = 15
+ end
+
+ return JSON.parse(response.body) if response.success?
+
+ parsed = JSON.parse(response.body)
+ message = parsed['message'] || response.body
+
+ raise SmsError, message
+ rescue JSON::ParserError
+ return {} if response&.success?
+
+ raise SmsError, response&.body
+ end
+end
diff --git a/lib/send_webhook_request.rb b/lib/send_webhook_request.rb
index a3474eaf9..0f5404683 100644
--- a/lib/send_webhook_request.rb
+++ b/lib/send_webhook_request.rb
@@ -13,7 +13,7 @@ module SendWebhookRequest
module_function
- # rubocop:disable Metrics/AbcSize
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
def call(webhook_url, event_uuid:, event_type:, record:, data:, attempt: 0)
uri = begin
URI(webhook_url.url)
@@ -22,7 +22,7 @@ def call(webhook_url, event_uuid:, event_type:, record:, data:, attempt: 0)
end
if Docuseal.multitenant?
- raise HttpsError, 'Only HTTPS is allowed.' if uri.scheme != 'https' &&
+ raise HttpsError, 'Only HTTPS is allowed.' if (uri.scheme != 'https' || [443, nil].exclude?(uri.port)) &&
!AccountConfig.exists?(key: :allow_http,
account_id: webhook_url.account_id)
raise LocalhostError, "Can't send to localhost." if uri.host.in?(LOCALHOSTS)
@@ -43,6 +43,8 @@ def call(webhook_url, event_uuid:, event_type:, record:, data:, attempt: 0)
data: data
}.to_json
+ sign_request(req, webhook_url)
+
req.options.read_timeout = 15
req.options.open_timeout = 8
end
@@ -53,7 +55,17 @@ def call(webhook_url, event_uuid:, event_type:, record:, data:, attempt: 0)
rescue Faraday::Error => e
handle_error(webhook_event, attempt:, error_message: e.message&.truncate(100))
end
- # rubocop:enable Metrics/AbcSize
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
+
+ def sign_request(req, webhook_url)
+ key = webhook_url.ensure_signing_key!
+ timestamp = Time.current.to_i.to_s
+ signature = OpenSSL::HMAC.hexdigest('SHA256', key, "#{timestamp}.#{req.body}")
+
+ req.headers['X-Webhook-Signature'] = "sha256=#{signature}"
+ req.headers['X-Webhook-Timestamp'] = timestamp
+ req.headers['X-Webhook-Request-Id'] = SecureRandom.uuid
+ end
def create_webhook_event(webhook_url, event_uuid:, event_type:, record:)
return if event_uuid.blank?
diff --git a/lib/submissions.rb b/lib/submissions.rb
index aaf18ea4a..8fd26bf0a 100644
--- a/lib/submissions.rb
+++ b/lib/submissions.rb
@@ -18,7 +18,8 @@ def search(current_user, submissions, keyword, search_values: false, search_temp
def plain_search(submissions, keyword, search_values: false, search_template: false)
return submissions if keyword.blank?
- term = "%#{keyword.downcase}%"
+ sanitized = ActiveRecord::Base.sanitize_sql_like(keyword.downcase)
+ term = "%#{sanitized}%"
arel_table = Submitter.arel_table
@@ -31,7 +32,7 @@ def plain_search(submissions, keyword, search_values: false, search_template: fa
if search_template
submissions = submissions.left_joins(:template)
- arel = arel.or(Template.arel_table[:name].lower.matches("%#{keyword.downcase}%"))
+ arel = arel.or(Template.arel_table[:name].lower.matches("%#{sanitized}%"))
end
submissions.joins(:submitters).where(arel).group(:id)
diff --git a/lib/submissions/create_from_submitters.rb b/lib/submissions/create_from_submitters.rb
index b1e318cf5..b367403c0 100644
--- a/lib/submissions/create_from_submitters.rb
+++ b/lib/submissions/create_from_submitters.rb
@@ -56,7 +56,9 @@ def call(template:, user:, submissions_attrs:, source:, submitters_order:, param
template_submitter = template_submitters.find { |e| e['uuid'] == uuid }
end
- template_submitter = template_submitter.except('optional_invite_by_uuid', 'invite_by_uuid')
+ template_submitter = template_submitter.except('optional_invite_by_uuid', 'invite_by_uuid',
+ 'invite_via_field_uuid')
+
template_submitter['order'] = submitter_attrs['order'] if submitter_attrs['order'].present?
submission.template_submitters << template_submitter
@@ -113,7 +115,10 @@ def maybe_add_invite_submitters(submission, template, submitter_attrs)
item = item.merge('invite_by_uuid' => invite_by_uuid) if invite_by_uuid
end
- next if item['invite_by_uuid'].blank? && item['optional_invite_by_uuid'].blank?
+ next if item['invite_by_uuid'].blank? &&
+ item['optional_invite_by_uuid'].blank? &&
+ item['invite_via_field_uuid'].blank?
+
next if submission.template_submitters.any? { |e| e['uuid'] == item['uuid'] }
item = item.merge('order' => submitter_attr['order']) if submitter_attr && submitter_attr['order'].present?
diff --git a/lib/submissions/ensure_audit_generated.rb b/lib/submissions/ensure_audit_generated.rb
index eacb664b7..73274cd86 100644
--- a/lib/submissions/ensure_audit_generated.rb
+++ b/lib/submissions/ensure_audit_generated.rb
@@ -44,7 +44,6 @@ def call(submission)
total_wait_time > CHECK_COMPLETE_TIMEOUT ? raise : retry
rescue StandardError => e
- Rollbar.error(e) if defined?(Rollbar)
Rails.logger.error(e)
LockEvent.create!(key:, event_name: :fail)
diff --git a/lib/submissions/ensure_combined_generated.rb b/lib/submissions/ensure_combined_generated.rb
index 4b55a4b72..880c7dea3 100644
--- a/lib/submissions/ensure_combined_generated.rb
+++ b/lib/submissions/ensure_combined_generated.rb
@@ -43,7 +43,6 @@ def call(submitter)
total_wait_time > CHECK_COMPLETE_TIMEOUT ? raise : retry
rescue StandardError => e
- Rollbar.error(e) if defined?(Rollbar)
Rails.logger.error(e)
LockEvent.create!(key:, event_name: :fail)
diff --git a/lib/submissions/ensure_result_generated.rb b/lib/submissions/ensure_result_generated.rb
index 468b54cf0..367eacd41 100644
--- a/lib/submissions/ensure_result_generated.rb
+++ b/lib/submissions/ensure_result_generated.rb
@@ -42,7 +42,6 @@ def call(submitter)
total_wait_time > CHECK_COMPLETE_TIMEOUT ? raise : retry
rescue StandardError => e
- Rollbar.error(e) if defined?(Rollbar)
Rails.logger.error(e)
LockEvent.create!(key:, event_name: :fail)
diff --git a/lib/submissions/generate_audit_trail.rb b/lib/submissions/generate_audit_trail.rb
index 92fa783c7..52c88c4cc 100644
--- a/lib/submissions/generate_audit_trail.rb
+++ b/lib/submissions/generate_audit_trail.rb
@@ -116,6 +116,7 @@ def build_audit_trail(submission)
configs = submission.account.account_configs.where(key: [AccountConfig::WITH_AUDIT_VALUES_KEY,
AccountConfig::WITH_SIGNATURE_ID,
AccountConfig::WITH_FILE_LINKS_KEY,
+ AccountConfig::WITH_TIMESTAMP_SECONDS_KEY,
AccountConfig::WITH_AUDIT_SENDER_KEY,
AccountConfig::WITH_SUBMITTER_TIMEZONE_KEY])
@@ -126,6 +127,7 @@ def build_audit_trail(submission)
with_audit_values = configs.find { |c| c.key == AccountConfig::WITH_AUDIT_VALUES_KEY }&.value != false
with_audit_sender = configs.find { |c| c.key == AccountConfig::WITH_AUDIT_SENDER_KEY }&.value == true
with_submitter_timezone = configs.find { |c| c.key == AccountConfig::WITH_SUBMITTER_TIMEZONE_KEY }&.value == true
+ with_timestamp_seconds = configs.find { |c| c.key == AccountConfig::WITH_TIMESTAMP_SECONDS_KEY }&.value == true
timezone = account.timezone
timezone = last_submitter.timezone || account.timezone if with_submitter_timezone
@@ -489,8 +491,10 @@ def build_audit_trail(submission)
end
end
+ time_format = with_timestamp_seconds ? :detailed : :long
+
[
- "#{I18n.l(event.event_timestamp.in_time_zone(timezone), format: :long, locale: account.locale)} " \
+ "#{I18n.l(event.event_timestamp.in_time_zone(timezone), format: time_format, locale: account.locale)} " \
"#{TimeUtils.timezone_abbr(timezone, event.event_timestamp)}",
composer.document.layout.formatted_text_box(text_box)
]
@@ -502,7 +506,7 @@ def build_audit_trail(submission)
end
def sign_reason
- 'Signed with DocuSeal.com'
+ "Signed with #{Docuseal.product_name}"
end
def select_attachments(submitter)
@@ -524,7 +528,7 @@ def show_verify?(submission)
def add_logo(column, _submission = nil)
column.image(PdfIcons.logo_io, width: 40, height: 40, position: :float)
- column.formatted_text([{ text: 'DocuSeal',
+ column.formatted_text([{ text: Docuseal.product_name,
link: Docuseal::PRODUCT_EMAIL_URL }],
font_size: 20,
font: [FONT_NAME, { variant: :bold }],
diff --git a/lib/submissions/generate_combined_attachment.rb b/lib/submissions/generate_combined_attachment.rb
index bafd20a7d..bfc41eb03 100644
--- a/lib/submissions/generate_combined_attachment.rb
+++ b/lib/submissions/generate_combined_attachment.rb
@@ -47,11 +47,11 @@ def call(submitter, with_audit: true)
def sign_pdf(io, pdf, sign_params)
pdf.sign(io, **sign_params)
rescue HexaPDF::MalformedPDFError, NoMethodError => e
- Rollbar.error(e) if defined?(Rollbar)
+ Rails.logger.error(e)
pdf.sign(io, write_options: { incremental: false }, **sign_params)
rescue HexaPDF::Error => e
- Rollbar.error(e) if defined?(Rollbar)
+ Rails.logger.error(e)
pdf.validate(auto_correct: true)
diff --git a/lib/submissions/generate_preview_attachments.rb b/lib/submissions/generate_preview_attachments.rb
index ff5a85a8e..ff72a5b54 100644
--- a/lib/submissions/generate_preview_attachments.rb
+++ b/lib/submissions/generate_preview_attachments.rb
@@ -15,6 +15,7 @@ def call(submission, values_hash: nil, submitter: nil, merge: false)
configs = submission.account.account_configs.where(key: [AccountConfig::FLATTEN_RESULT_PDF_KEY,
AccountConfig::WITH_SIGNATURE_ID,
AccountConfig::WITH_SUBMITTER_TIMEZONE_KEY,
+ AccountConfig::WITH_TIMESTAMP_SECONDS_KEY,
AccountConfig::WITH_FILE_LINKS_KEY,
AccountConfig::WITH_SIGNATURE_ID_REASON_KEY])
@@ -22,6 +23,7 @@ def call(submission, values_hash: nil, submitter: nil, merge: false)
with_file_links = configs.find { |c| c.key == AccountConfig::WITH_FILE_LINKS_KEY }&.value == true
is_flatten = configs.find { |c| c.key == AccountConfig::FLATTEN_RESULT_PDF_KEY }&.value != false
with_submitter_timezone = configs.find { |c| c.key == AccountConfig::WITH_SUBMITTER_TIMEZONE_KEY }&.value == true
+ with_timestamp_seconds = configs.find { |c| c.key == AccountConfig::WITH_TIMESTAMP_SECONDS_KEY }&.value == true
with_signature_id_reason =
configs.find { |c| c.key == AccountConfig::WITH_SIGNATURE_ID_REASON_KEY }&.value != false
@@ -37,7 +39,7 @@ def call(submission, values_hash: nil, submitter: nil, merge: false)
GenerateResultAttachments.fill_submitter_fields(s, submission.account, pdfs_index,
with_signature_id:, is_flatten:, with_headings: index.zero?,
with_submitter_timezone:, with_file_links:,
- with_signature_id_reason:)
+ with_signature_id_reason:, with_timestamp_seconds:)
end
template = submission.template
@@ -135,7 +137,7 @@ def build_pdf_attachment(pdf:, submission:, filename:, values_hash:, submitter:
begin
pdf.write(io, incremental: true, validate: false)
rescue HexaPDF::MalformedPDFError => e
- Rollbar.error(e) if defined?(Rollbar)
+ Rails.logger.error(e)
pdf.write(io, incremental: false, validate: false)
end
diff --git a/lib/submissions/generate_result_attachments.rb b/lib/submissions/generate_result_attachments.rb
index 72eeb9f10..50b43020e 100644
--- a/lib/submissions/generate_result_attachments.rb
+++ b/lib/submissions/generate_result_attachments.rb
@@ -37,7 +37,7 @@ module GenerateResultAttachments
bold_italic: FONT_BOLD_NAME
}.freeze
- SIGN_REASON = 'Signed by %s with DocuSeal.com'
+ SIGN_REASON = "Signed by %s with #{Docuseal.product_name}"
RTL_REGEXP = TextUtils::RTL_REGEXP
@@ -140,11 +140,13 @@ def generate_pdfs(submitter)
configs = submitter.account.account_configs.where(key: [AccountConfig::FLATTEN_RESULT_PDF_KEY,
AccountConfig::WITH_SIGNATURE_ID,
AccountConfig::WITH_FILE_LINKS_KEY,
+ AccountConfig::WITH_TIMESTAMP_SECONDS_KEY,
AccountConfig::WITH_SUBMITTER_TIMEZONE_KEY,
AccountConfig::WITH_SIGNATURE_ID_REASON_KEY])
with_signature_id = configs.find { |c| c.key == AccountConfig::WITH_SIGNATURE_ID }&.value == true
is_flatten = configs.find { |c| c.key == AccountConfig::FLATTEN_RESULT_PDF_KEY }&.value != false
+ with_timestamp_seconds = configs.find { |c| c.key == AccountConfig::WITH_TIMESTAMP_SECONDS_KEY }&.value == true
with_submitter_timezone = configs.find { |c| c.key == AccountConfig::WITH_SUBMITTER_TIMEZONE_KEY }&.value == true
with_file_links = configs.find { |c| c.key == AccountConfig::WITH_FILE_LINKS_KEY }&.value == true
with_signature_id_reason =
@@ -162,7 +164,7 @@ def generate_pdfs(submitter)
pdf.trailer.info[:DocumentID] = document_id
pdf.pages.each do |page|
- font_size = (([page.box.width, page.box.height].min / A4_SIZE[0].to_f) * 9).to_i
+ font_size = [(([page.box.width, page.box.height].min / A4_SIZE[0].to_f) * 9).to_i, 4].max
cnv = page.canvas(type: :overlay)
text =
@@ -195,11 +197,13 @@ def generate_pdfs(submitter)
fill_submitter_fields(submitter, submitter.account, pdfs_index, with_signature_id:, is_flatten:,
with_submitter_timezone:,
with_file_links:,
+ with_timestamp_seconds:,
with_signature_id_reason:)
end
def fill_submitter_fields(submitter, account, pdfs_index, with_signature_id:, is_flatten:, with_headings: nil,
- with_submitter_timezone: false, with_signature_id_reason: true, with_file_links: nil)
+ with_submitter_timezone: false, with_signature_id_reason: true,
+ with_timestamp_seconds: false, with_file_links: nil)
cell_layouters = Hash.new do |hash, valign|
hash[valign] = HexaPDF::Layout::TextLayouter.new(text_valign: valign.to_sym, text_align: :center)
end
@@ -277,7 +281,7 @@ def fill_submitter_fields(submitter, account, pdfs_index, with_signature_id:, is
begin
page.flatten_annotations
rescue StandardError => e
- Rollbar.error(e) if defined?(Rollbar)
+ Rails.logger.error(e)
end
end
@@ -320,13 +324,15 @@ def fill_submitter_fields(submitter, account, pdfs_index, with_signature_id:, is
timezone = submitter.account.timezone
timezone = submitter.timezone || submitter.account.timezone if with_submitter_timezone
+ time_format = with_timestamp_seconds ? :detailed : :long
+
if with_signature_id_reason || field.dig('preferences', 'reasons').present?
"#{"#{I18n.t('reason')}: " if reason_value}#{reason_value || I18n.t('digitally_signed_by')} " \
"#{submitter.name}#{" <#{submitter.email}>" if submitter.email.present?}\n" \
- "#{I18n.l(attachment.created_at.in_time_zone(timezone), format: :long)} " \
+ "#{I18n.l(attachment.created_at.in_time_zone(timezone), format: time_format)} " \
"#{TimeUtils.timezone_abbr(timezone, attachment.created_at)}"
else
- "#{I18n.l(attachment.created_at.in_time_zone(timezone), format: :long)} " \
+ "#{I18n.l(attachment.created_at.in_time_zone(timezone), format: time_format)} " \
"#{TimeUtils.timezone_abbr(timezone, attachment.created_at)}"
end
end
@@ -531,7 +537,7 @@ def fill_submitter_fields(submitter, account, pdfs_index, with_signature_id:, is
Array.wrap(value).include?(option_name)
else
- Rollbar.error("Invalid option: #{field['uuid']}") if defined?(Rollbar)
+ Rails.logger.error("Invalid option: #{field['uuid']}")
false
end
@@ -569,7 +575,11 @@ def fill_submitter_fields(submitter, account, pdfs_index, with_signature_id:, is
fill_color:,
font_size:)
- line_height = layouter.fit([text], cell_width, height).lines.first.height
+ line = layouter.fit([text], width, height).lines.first
+
+ line_height = line.height
+
+ cell_width = [line.width, cell_width].max
if preferences_font_size.blank? && line_height > (area['h'] * height)
text = HexaPDF::Layout::TextFragment.create(char,
@@ -731,7 +741,7 @@ def build_pdf_attachment(pdf:, submitter:, pkcs:, tsa_url:, uuid:, name:)
begin
pdf.sign(io, write_options: { validate: false }, **sign_params)
rescue HexaPDF::Error, NoMethodError => e
- Rollbar.error(e) if defined?(Rollbar)
+ Rails.logger.error(e)
begin
pdf.sign(io, write_options: { validate: false, incremental: false }, **sign_params)
@@ -746,7 +756,7 @@ def build_pdf_attachment(pdf:, submitter:, pkcs:, tsa_url:, uuid:, name:)
begin
pdf.write(io, incremental: true, validate: false)
rescue HexaPDF::Error, NoMethodError => e
- Rollbar.error(e) if defined?(Rollbar)
+ Rails.logger.error(e)
begin
pdf.write(io, incremental: false, validate: false)
@@ -831,7 +841,7 @@ def maybe_flatten_pdf(pdf)
rescue HexaPDF::MissingGlyphError
nil
rescue StandardError => e
- Rollbar.error(e) if defined?(Rollbar)
+ Rails.logger.error(e)
end
def maybe_rotate_pdf(pdf)
@@ -853,13 +863,13 @@ def maybe_rotate_pdf(pdf)
HexaPDF::Document.new(io:)
rescue StandardError => e
- Rollbar.error(e) if defined?(Rollbar)
+ Rails.logger.error(e)
pdf
end
def on_missing_glyph(character, font_wrapper)
- Rails.logger.info("Missing glyph: #{character}") if character.present? && defined?(Rollbar)
+ Rails.logger.info("Missing glyph: #{character}") if character.present?
replace_with =
if font_wrapper.font_type == :Type1
diff --git a/lib/submissions/timestamp_handler.rb b/lib/submissions/timestamp_handler.rb
index 5d914a5ea..3f01e0a59 100644
--- a/lib/submissions/timestamp_handler.rb
+++ b/lib/submissions/timestamp_handler.rb
@@ -44,7 +44,7 @@ def sign(io, byte_range)
if response.status != 200 || response.body.blank?
raise TimestampError if tsa_fallback_url.blank?
- Rollbar.error('TimestampError: use fallback URL') if defined?(Rollbar)
+ Rails.logger.error('TimestampError: use fallback URL')
response = Faraday.post(tsa_fallback_url, build_payload(digest.digest),
'content-type' => 'application/timestamp-query')
@@ -54,7 +54,6 @@ def sign(io, byte_range)
OpenSSL::Timestamp::Response.new(response.body).token.to_der
rescue StandardError => e
- Rollbar.error(e) if defined?(Rollbar)
Rails.logger.error(e)
OpenSSL::ASN1::GeneralizedTime.new(Time.now.utc).to_der
diff --git a/lib/submitters.rb b/lib/submitters.rb
index c557dd3e5..c60a7f846 100644
--- a/lib/submitters.rb
+++ b/lib/submitters.rb
@@ -14,6 +14,7 @@ module Submitters
UnableToSendCode = Class.new(StandardError)
InvalidOtp = Class.new(StandardError)
MaliciousFileExtension = Class.new(StandardError)
+ ArgumentError = Class.new(StandardError)
DANGEROUS_EXTENSIONS = Set.new(%w[
exe com bat cmd scr pif vbs vbe js jse wsf wsh msi msp
@@ -133,7 +134,7 @@ def create_attachment!(submitter, params)
filename: file.original_filename,
content_type: file.content_type)
else
- ActiveStorage::Blob.find_signed(params[:blob_signed_id])
+ raise ArgumentError, 'file param is missing'
end
ActiveStorage::Attachment.create!(
@@ -169,14 +170,22 @@ def normalize_preferences(account, user, params)
def send_signature_requests(submitters, delay_seconds: nil)
submitters.each_with_index do |submitter, index|
- next if submitter.email.blank?
+ if submitter.email.present? && !submitter.declined_at? && submitter.preferences['send_email'] != false
+ if delay_seconds
+ SendSubmitterInvitationEmailJob.perform_in((delay_seconds + index).seconds, 'submitter_id' => submitter.id)
+ else
+ SendSubmitterInvitationEmailJob.perform_async('submitter_id' => submitter.id)
+ end
+ end
+
+ next if submitter.phone.blank?
next if submitter.declined_at?
- next if submitter.preferences['send_email'] == false
+ next if submitter.preferences['send_sms'] == false
if delay_seconds
- SendSubmitterInvitationEmailJob.perform_in((delay_seconds + index).seconds, 'submitter_id' => submitter.id)
+ SendSubmitterInvitationSmsJob.perform_in((delay_seconds + index).seconds, 'submitter_id' => submitter.id)
else
- SendSubmitterInvitationEmailJob.perform_async('submitter_id' => submitter.id)
+ SendSubmitterInvitationSmsJob.perform_async('submitter_id' => submitter.id)
end
end
end
@@ -236,7 +245,7 @@ def send_shared_link_email_verification_code(submitter, request:)
TemplateMailer.otp_verification_email(submitter.submission.template, email: submitter.email).deliver_later!
rescue RateLimit::LimitApproached
- Rollbar.warning("Limit verification code for template: #{submitter.submission.template.id}") if defined?(Rollbar)
+ Rails.logger.warn("Limit verification code for template: #{submitter.submission.template.id}")
raise UnableToSendCode, I18n.t('too_many_attempts')
end
diff --git a/lib/submitters/authorized_for_form.rb b/lib/submitters/authorized_for_form.rb
new file mode 100644
index 000000000..81048a162
--- /dev/null
+++ b/lib/submitters/authorized_for_form.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+module Submitters
+ module AuthorizedForForm
+ Unauthorized = Class.new(StandardError)
+
+ module_function
+
+ def call(submitter, current_user, request)
+ pass_email_2fa?(submitter, request) && pass_link_2fa?(submitter, current_user, request)
+ end
+
+ def pass_email_2fa?(submitter, request)
+ return false unless submitter
+
+ return true if submitter.submission.template&.preferences&.dig('require_email_2fa') != true &&
+ submitter.preferences['require_email_2fa'] != true
+ return true if request.cookie_jar.encrypted[:email_2fa_slug] == submitter.slug
+
+ token = request.params[:two_factor_token].presence || request.headers['x-two-factor-token'].presence
+
+ return true if token.present? &&
+ Submitter.signed_id_verifier.verified(token, purpose: :email_two_factor) == submitter.slug
+
+ false
+ end
+
+ def pass_link_2fa?(submitter, current_user, request)
+ return false unless submitter
+
+ return true if submitter.submission.source != 'link'
+ return true unless submitter.submission.template&.preferences&.dig('shared_link_2fa') == true
+ return true if request.cookie_jar.encrypted[:email_2fa_slug] == submitter.slug
+ return true if submitter.email == current_user&.email && current_user&.account_id == submitter.account_id
+
+ if (token = request.params[:two_factor_token].presence || request.headers['x-two-factor-token'].presence)
+ link_2fa_key = [submitter.email.downcase.squish, submitter.submission.template.slug].join(':')
+
+ return true if Submitter.signed_id_verifier.verified(token, purpose: :email_two_factor) == link_2fa_key
+ end
+
+ false
+ end
+ end
+end
diff --git a/lib/submitters/form_configs.rb b/lib/submitters/form_configs.rb
index 38d18993a..4db19b32d 100644
--- a/lib/submitters/form_configs.rb
+++ b/lib/submitters/form_configs.rb
@@ -15,6 +15,7 @@ module FormConfigs
AccountConfig::ALLOW_TO_PARTIAL_DOWNLOAD_KEY,
AccountConfig::ALLOW_TYPED_SIGNATURE,
AccountConfig::WITH_SUBMITTER_TIMEZONE_KEY,
+ AccountConfig::WITH_TIMESTAMP_SECONDS_KEY,
AccountConfig::WITH_SIGNATURE_ID_REASON_KEY,
*(Docuseal.multitenant? ? [] : [AccountConfig::POLICY_LINKS_KEY])].freeze
@@ -35,6 +36,7 @@ def call(submitter, keys = [])
require_signing_reason = find_safe_value(configs, AccountConfig::REQUIRE_SIGNING_REASON_KEY) == true
enforce_signing_order = find_safe_value(configs, AccountConfig::ENFORCE_SIGNING_ORDER_KEY) == true
with_submitter_timezone = find_safe_value(configs, AccountConfig::WITH_SUBMITTER_TIMEZONE_KEY) == true
+ with_timestamp_seconds = find_safe_value(configs, AccountConfig::WITH_TIMESTAMP_SECONDS_KEY) == true
with_signature_id_reason = find_safe_value(configs, AccountConfig::WITH_SIGNATURE_ID_REASON_KEY) != false
with_field_labels = find_safe_value(configs, AccountConfig::WITH_FIELD_LABELS_KEY) != false
policy_links = find_safe_value(configs, AccountConfig::POLICY_LINKS_KEY)
@@ -43,7 +45,7 @@ def call(submitter, keys = [])
reuse_signature:, with_decline:, with_partial_download:,
policy_links:, enforce_signing_order:, completed_message:,
require_signing_reason:, prefill_signature:, with_submitter_timezone:,
- with_signature_id_reason:, with_signature_id:, with_field_labels: }
+ with_signature_id_reason:, with_signature_id:, with_field_labels:, with_timestamp_seconds: }
keys.each do |key|
attrs[key.to_sym] = configs.find { |e| e.key == key.to_s }&.value
diff --git a/lib/submitters/normalize_values.rb b/lib/submitters/normalize_values.rb
index 16cb268c6..1eee3dfdd 100644
--- a/lib/submitters/normalize_values.rb
+++ b/lib/submitters/normalize_values.rb
@@ -236,7 +236,7 @@ def find_or_create_blob_from_url(account, url)
return blob if blob
- data = DownloadUtils.call(url).body
+ data = DownloadUtils.call(url, validate: true).body
checksum = Digest::MD5.base64digest(data)
diff --git a/lib/submitters/submit_values.rb b/lib/submitters/submit_values.rb
index dc370791a..0f581c319 100644
--- a/lib/submitters/submit_values.rb
+++ b/lib/submitters/submit_values.rb
@@ -6,6 +6,7 @@ module SubmitValues
RequiredFieldError = Class.new(StandardError)
VARIABLE_REGEXP = /\{\{?(\w+)\}\}?/
+ PHONE_REGEXP = /[+\d()\s-]+/
NONEDITABLE_FIELD_TYPES = %w[stamp heading strikethrough].freeze
STRFTIME_MAP = {
@@ -45,14 +46,18 @@ def update_submitter!(submitter, params, request, validate_required: true)
assign_completed_attributes(submitter, request, validate_required:) if params[:completed] == 'true'
ApplicationRecord.transaction do
- maybe_set_signature_reason!(values, submitter, params)
- validate_values!(values, submitter, params, request)
+ reason_field = maybe_set_signature_reason!(values, submitter, params)
+ validate_values!(reason_field ? values.except(reason_field['uuid']) : values, submitter, params, request)
if (touch_attachment_uuid = params[:touch_attachment_uuid].presence)
ActiveStorage::Attachment.where(uuid: touch_attachment_uuid, record: submitter).touch_all(:created_at)
end
- SubmissionEvents.create_with_tracking_data(submitter, 'complete_form', request) if params[:completed] == 'true'
+ if params[:completed] == 'true'
+ maybe_invite_via_field(submitter, request)
+
+ SubmissionEvents.create_with_tracking_data(submitter, 'complete_form', request)
+ end
submitter.save!
end
@@ -90,7 +95,7 @@ def assign_completed_attributes(submitter, request, validate_required: true)
raise RequiredFieldError, uuid if validate_required
- Rollbar.warning("Required field #{submitter.id}: #{uuid}") if defined?(Rollbar)
+ Rails.logger.warn("Required field #{submitter.id}: #{uuid}")
end
submitter
@@ -107,7 +112,9 @@ def maybe_set_signature_reason!(values, submitter, params)
signature_field['preferences'] ||= {}
signature_field['preferences']['reason_field_uuid'] = reason_field_uuid
- unless submitter.submission.template_fields.find { |e| e['uuid'] == reason_field_uuid }
+ reason_field = submitter.submission.template_fields.find { |e| e['uuid'] == reason_field_uuid }
+
+ unless reason_field
reason_field = { 'type' => 'text',
'uuid' => reason_field_uuid,
'name' => I18n.t(:reason),
@@ -119,6 +126,8 @@ def maybe_set_signature_reason!(values, submitter, params)
end
submitter.submission.save!
+
+ reason_field
end
def normalized_values(params)
@@ -403,7 +412,54 @@ def replace_default_variables(value, attrs, submission, with_time: false)
end
end
- def validate_value!(_value, _field, _params, _submitter, _request)
+ def maybe_invite_via_field(submitter, request)
+ submission = submitter.submission
+
+ is_invited = false
+
+ submission.template_submitters.each do |s|
+ field_uuid = s['invite_via_field_uuid']
+
+ next if field_uuid.blank?
+
+ field = submission.template_fields.find { |e| e['uuid'] == field_uuid }
+
+ next unless field
+ next unless field['submitter_uuid'] == submitter.uuid
+
+ next if submission.submitters.exists?(uuid: s['uuid'])
+
+ value = submitter.values[field_uuid]
+
+ next if value.blank?
+
+ if value.include?('@')
+ email = Submissions.normalize_email(value)
+ elsif value.match?(PHONE_REGEXP)
+ phone = value.gsub(/[^+\d]/, '')
+ end
+
+ next if email.blank? && phone.blank?
+
+ submission.submitters.create!(uuid: s['uuid'], email:, phone:, account_id: submitter.account_id)
+
+ SubmissionEvents.create_with_tracking_data(submitter, 'invite_party', request, { uuid: submitter.uuid })
+
+ is_invited = true
+ end
+
+ submission.update!(submitters_order: :preserved) if is_invited
+
+ submitter
+ end
+
+ def validate_value!(_value, field, _params, submitter, _request)
+ if field['readonly'] == true
+ Rollbar.warning("Readonly field #{submitter.id}: #{field['uuid']}") if defined?(Rollbar)
+
+ raise ValidationError, 'Read-only field'
+ end
+
true
end
end
diff --git a/lib/template_folders.rb b/lib/template_folders.rb
index 00a6fc02a..d4a3af9e3 100644
--- a/lib/template_folders.rb
+++ b/lib/template_folders.rb
@@ -20,7 +20,9 @@ def filter_by_full_name(template_folders, name)
def search(folders, keyword)
return folders if keyword.blank?
- folders.where(TemplateFolder.arel_table[:name].lower.matches("%#{keyword.downcase}%"))
+ sanitized = ActiveRecord::Base.sanitize_sql_like(keyword.downcase)
+
+ folders.where(TemplateFolder.arel_table[:name].lower.matches("%#{sanitized}%"))
end
def filter_active_folders(template_folders, templates)
diff --git a/lib/templates.rb b/lib/templates.rb
index 73aaef807..ad63302bf 100644
--- a/lib/templates.rb
+++ b/lib/templates.rb
@@ -52,7 +52,9 @@ def search(current_user, templates, keyword)
def plain_search(templates, keyword)
return templates if keyword.blank?
- templates.where(Template.arel_table[:name].lower.matches("%#{keyword.downcase}%"))
+ sanitized = ActiveRecord::Base.sanitize_sql_like(keyword.downcase)
+
+ templates.where(Template.arel_table[:name].lower.matches("%#{sanitized}%"))
end
def fulltext_search(current_user, templates, keyword)
@@ -70,6 +72,7 @@ def fulltext_search(current_user, templates, keyword)
def filter_undefined_submitters(template_submitters)
template_submitters.to_a.select do |item|
item['invite_by_uuid'].blank? && item['optional_invite_by_uuid'].blank? &&
+ item['invite_via_field_uuid'].blank? &&
item['linked_to_uuid'].blank? && item['is_requester'].blank? && item['email'].blank?
end
end
diff --git a/lib/templates/build_annotations.rb b/lib/templates/build_annotations.rb
index 9a933f99c..c3b28ae5f 100644
--- a/lib/templates/build_annotations.rb
+++ b/lib/templates/build_annotations.rb
@@ -19,7 +19,7 @@ def call(data)
end
end
rescue StandardError => e
- Rollbar.error(e) if defined?(Rollbar)
+ Rails.logger.error(e)
[]
end
diff --git a/lib/templates/clone.rb b/lib/templates/clone.rb
index e4b5fc60a..1d213152a 100644
--- a/lib/templates/clone.rb
+++ b/lib/templates/clone.rb
@@ -4,7 +4,7 @@ module Templates
module Clone
module_function
- # rubocop:disable Metrics, Style/CombinableLoops
+ # rubocop:disable Metrics
def call(original_template, author:, external_id: nil, name: nil, folder_name: nil)
template = original_template.account.templates.new
@@ -49,20 +49,6 @@ def update_submitters_and_fields_and_schema(cloned_submitters, cloned_fields, cl
submitter['uuid'] = new_submitter_uuid
end
- cloned_submitters.each do |submitter|
- if submitter['optional_invite_by_uuid'].present?
- submitter['optional_invite_by_uuid'] = submitter_uuids_replacements[submitter['optional_invite_by_uuid']]
- end
-
- if submitter['invite_by_uuid'].present?
- submitter['invite_by_uuid'] = submitter_uuids_replacements[submitter['invite_by_uuid']]
- end
-
- if submitter['linked_to_uuid'].present?
- submitter['linked_to_uuid'] = submitter_uuids_replacements[submitter['linked_to_uuid']]
- end
- end
-
cloned_preferences['submitters'].to_a.each do |submitter|
submitter['uuid'] = submitter_uuids_replacements[submitter['uuid']]
end
@@ -97,8 +83,26 @@ def update_submitters_and_fields_and_schema(cloned_submitters, cloned_fields, cl
end
end
+ cloned_submitters.each do |submitter|
+ if submitter['optional_invite_by_uuid'].present?
+ submitter['optional_invite_by_uuid'] = submitter_uuids_replacements[submitter['optional_invite_by_uuid']]
+ end
+
+ if submitter['invite_by_uuid'].present?
+ submitter['invite_by_uuid'] = submitter_uuids_replacements[submitter['invite_by_uuid']]
+ end
+
+ if submitter['linked_to_uuid'].present?
+ submitter['linked_to_uuid'] = submitter_uuids_replacements[submitter['linked_to_uuid']]
+ end
+
+ if submitter['invite_via_field_uuid'].present?
+ submitter['invite_via_field_uuid'] = field_uuids_replacements[submitter['invite_via_field_uuid']]
+ end
+ end
+
[cloned_submitters, cloned_fields, cloned_schema, cloned_preferences]
end
- # rubocop:enable Metrics, Style/CombinableLoops
+ # rubocop:enable Metrics
end
end
diff --git a/lib/templates/create_attachments.rb b/lib/templates/create_attachments.rb
index 10b08ac78..b6297a768 100644
--- a/lib/templates/create_attachments.rb
+++ b/lib/templates/create_attachments.rb
@@ -18,6 +18,7 @@ module CreateAttachments
].freeze
ANNOTATIONS_SIZE_LIMIT = 6.megabytes
+ MAX_ZIP_SIZE = 100.megabytes
InvalidFileType = Class.new(StandardError)
PdfEncrypted = Class.new(StandardError)
@@ -72,9 +73,15 @@ def extract_zip_files(files)
Array.wrap(files).each do |file|
if file.content_type == ZIP_CONTENT_TYPE || file.content_type == X_ZIP_CONTENT_TYPE
+ total_size = 0
+
Zip::File.open(file.tempfile).each do |entry|
next if entry.directory?
+ total_size += entry.size
+
+ raise InvalidFileType, 'zip_too_large' if total_size > MAX_ZIP_SIZE
+
tempfile = Tempfile.new(entry.name)
tempfile.binmode
entry.get_input_stream { |in_stream| IO.copy_stream(in_stream, tempfile) }
diff --git a/lib/templates/find_acro_fields.rb b/lib/templates/find_acro_fields.rb
index 7940145a2..1552472b0 100644
--- a/lib/templates/find_acro_fields.rb
+++ b/lib/templates/find_acro_fields.rb
@@ -112,7 +112,7 @@ def call(pdf, attachment, data)
rescue StandardError => e
raise if Rails.env.local?
- Rollbar.error(e) if defined?(Rollbar)
+ Rails.logger.error(e)
[]
end
diff --git a/lib/templates/process_document.rb b/lib/templates/process_document.rb
index 6b40a5025..4b1c9e0d5 100644
--- a/lib/templates/process_document.rb
+++ b/lib/templates/process_document.rb
@@ -156,7 +156,7 @@ def build_and_upload_blob(doc, page_number, format = FORMAT)
blob
rescue Vips::Error, Pdfium::PdfiumError => e
- Rollbar.warning(e) if defined?(Rollbar)
+ Rails.logger.warn(e)
nil
ensure
diff --git a/lib/templates/serialize_for_api.rb b/lib/templates/serialize_for_api.rb
index ec9cb628f..1c0ae50cb 100644
--- a/lib/templates/serialize_for_api.rb
+++ b/lib/templates/serialize_for_api.rb
@@ -30,7 +30,7 @@ def call(template, schema_documents: template.schema_documents.preload(:blob), p
attachment = schema_documents.find { |e| e.uuid == item['attachment_uuid'] }
unless attachment
- Rollbar.error("Documents missing: #{template.id}") if defined?(Rollbar)
+ Rails.logger.error("Documents missing: #{template.id}")
next
end
diff --git a/lib/users.rb b/lib/users.rb
new file mode 100644
index 000000000..220812fcf
--- /dev/null
+++ b/lib/users.rb
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+
+module Users
+ module_function
+
+ def from_omniauth(oauth)
+ email = oauth.info.email.to_s.downcase
+ user = User.find_by(email:)
+
+ return user if user
+ return nil unless auto_create_enabled?
+ return nil unless domain_allowed?(email)
+
+ create_from_oauth(oauth, email)
+ end
+
+ def auto_create_enabled?
+ ENV['GOOGLE_AUTO_CREATE'].to_s.downcase.in?(%w[true 1 yes])
+ end
+
+ def domain_allowed?(email)
+ domain = ENV['GOOGLE_ALLOWED_DOMAIN'].to_s.strip
+ return true if domain.blank?
+
+ email.end_with?("@#{domain.downcase}")
+ end
+
+ def create_from_oauth(oauth, email)
+ account = Account.active.first
+ return nil unless account
+
+ role = ENV.fetch('GOOGLE_AUTO_CREATE_ROLE', User::ADMIN_ROLE)
+
+ account.users.create!(
+ email:,
+ first_name: oauth.info.first_name.to_s,
+ last_name: oauth.info.last_name.to_s,
+ password: SecureRandom.hex(32),
+ role:,
+ confirmed_at: Time.current
+ )
+ rescue ActiveRecord::RecordInvalid => e
+ Rails.logger.error("OAuth auto-create failed for #{email}: #{e.message}")
+ nil
+ end
+end
diff --git a/package.json b/package.json
index 44e3284fa..cbec723e2 100644
--- a/package.json
+++ b/package.json
@@ -14,6 +14,18 @@
"@hotwired/turbo-rails": "^7.3.0",
"@specious/htmlflow": "^1.1.0",
"@tabler/icons-vue": "^2.47.0",
+ "@tiptap/core": "^3.19.0",
+ "@tiptap/extension-bold": "^3.19.0",
+ "@tiptap/extension-document": "^3.19.0",
+ "@tiptap/extension-hard-break": "^3.19.0",
+ "@tiptap/extension-italic": "^3.19.0",
+ "@tiptap/extension-link": "^3.19.0",
+ "@tiptap/extension-paragraph": "^3.19.0",
+ "@tiptap/extension-text": "^3.19.0",
+ "@tiptap/extension-underline": "^3.19.0",
+ "@tiptap/extensions": "^3.19.0",
+ "@tiptap/markdown": "^3.19.0",
+ "@tiptap/pm": "^3.19.0",
"autocompleter": "^9.1.0",
"autoprefixer": "^10.4.14",
"babel-loader": "9.1.2",
@@ -33,7 +45,7 @@
"postcss-import": "^15.1.0",
"postcss-loader": "^7.3.0",
"qr-creator": "^1.0.0",
- "rollbar": "^2.26.4",
+
"sass": "^1.62.1",
"sass-loader": "^16.0.6",
"shakapacker": "9.5.0",
diff --git a/public/apple-icon-180x180.png b/public/apple-icon-180x180.png
index 54ae5fcff..08c0c3b8d 100644
Binary files a/public/apple-icon-180x180.png and b/public/apple-icon-180x180.png differ
diff --git a/public/apple-touch-icon-precomposed.png b/public/apple-touch-icon-precomposed.png
index d6a6d23c3..67113d910 100644
Binary files a/public/apple-touch-icon-precomposed.png and b/public/apple-touch-icon-precomposed.png differ
diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png
index d6a6d23c3..67113d910 100644
Binary files a/public/apple-touch-icon.png and b/public/apple-touch-icon.png differ
diff --git a/public/favicon-16x16.png b/public/favicon-16x16.png
index 45a2768fd..e38921b8f 100644
Binary files a/public/favicon-16x16.png and b/public/favicon-16x16.png differ
diff --git a/public/favicon-32x32.png b/public/favicon-32x32.png
index 01e7fada5..fd8ea8ddd 100644
Binary files a/public/favicon-32x32.png and b/public/favicon-32x32.png differ
diff --git a/public/favicon-96x96.png b/public/favicon-96x96.png
index a1eee7461..600a59f4d 100644
Binary files a/public/favicon-96x96.png and b/public/favicon-96x96.png differ
diff --git a/public/favicon.ico b/public/favicon.ico
index cb036abd8..c0c2bf89f 100644
Binary files a/public/favicon.ico and b/public/favicon.ico differ
diff --git a/public/favicon.svg b/public/favicon.svg
new file mode 100644
index 000000000..ebfb3040f
--- /dev/null
+++ b/public/favicon.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/public/logo.svg b/public/logo.svg
index e45f6c09e..3b56afd6d 100644
--- a/public/logo.svg
+++ b/public/logo.svg
@@ -1,4 +1,4 @@
-
-
-
+
+
+
diff --git a/spec/jobs/send_form_completed_webhook_request_job_spec.rb b/spec/jobs/send_form_completed_webhook_request_job_spec.rb
index b60a3d9b9..6eb7cdf8d 100644
--- a/spec/jobs/send_form_completed_webhook_request_job_spec.rb
+++ b/spec/jobs/send_form_completed_webhook_request_job_spec.rb
@@ -16,6 +16,10 @@
end
describe '#perform' do
+ around do |example|
+ freeze_time { example.run }
+ end
+
before do
stub_request(:post, webhook_url.url).to_return(status: 200)
end
diff --git a/spec/jobs/send_form_declined_webhook_request_job_spec.rb b/spec/jobs/send_form_declined_webhook_request_job_spec.rb
index 8e9d2d0d5..99f26eef0 100644
--- a/spec/jobs/send_form_declined_webhook_request_job_spec.rb
+++ b/spec/jobs/send_form_declined_webhook_request_job_spec.rb
@@ -16,6 +16,10 @@
end
describe '#perform' do
+ around do |example|
+ freeze_time { example.run }
+ end
+
before do
stub_request(:post, webhook_url.url).to_return(status: 200)
end
diff --git a/spec/jobs/send_form_started_webhook_request_job_spec.rb b/spec/jobs/send_form_started_webhook_request_job_spec.rb
index e09f42052..54a5c521f 100644
--- a/spec/jobs/send_form_started_webhook_request_job_spec.rb
+++ b/spec/jobs/send_form_started_webhook_request_job_spec.rb
@@ -16,6 +16,10 @@
end
describe '#perform' do
+ around do |example|
+ freeze_time { example.run }
+ end
+
before do
stub_request(:post, webhook_url.url).to_return(status: 200)
end
diff --git a/spec/jobs/send_form_viewed_webhook_request_job_spec.rb b/spec/jobs/send_form_viewed_webhook_request_job_spec.rb
index 310263410..5cbed3c30 100644
--- a/spec/jobs/send_form_viewed_webhook_request_job_spec.rb
+++ b/spec/jobs/send_form_viewed_webhook_request_job_spec.rb
@@ -16,6 +16,10 @@
end
describe '#perform' do
+ around do |example|
+ freeze_time { example.run }
+ end
+
before do
stub_request(:post, webhook_url.url).to_return(status: 200)
end
diff --git a/spec/jobs/send_submission_completed_webhook_request_job_spec.rb b/spec/jobs/send_submission_completed_webhook_request_job_spec.rb
index 97ec0b69d..72764b04f 100644
--- a/spec/jobs/send_submission_completed_webhook_request_job_spec.rb
+++ b/spec/jobs/send_submission_completed_webhook_request_job_spec.rb
@@ -13,6 +13,10 @@
end
describe '#perform' do
+ around do |example|
+ freeze_time { example.run }
+ end
+
before do
stub_request(:post, webhook_url.url).to_return(status: 200)
end
diff --git a/spec/jobs/send_submission_created_webhook_request_job_spec.rb b/spec/jobs/send_submission_created_webhook_request_job_spec.rb
index e80b97a6f..62a1d4328 100644
--- a/spec/jobs/send_submission_created_webhook_request_job_spec.rb
+++ b/spec/jobs/send_submission_created_webhook_request_job_spec.rb
@@ -13,6 +13,10 @@
end
describe '#perform' do
+ around do |example|
+ freeze_time { example.run }
+ end
+
before do
stub_request(:post, webhook_url.url).to_return(status: 200)
end
diff --git a/spec/jobs/send_submission_expired_webhook_request_job_spec.rb b/spec/jobs/send_submission_expired_webhook_request_job_spec.rb
index dbce55ba7..541eb73ba 100644
--- a/spec/jobs/send_submission_expired_webhook_request_job_spec.rb
+++ b/spec/jobs/send_submission_expired_webhook_request_job_spec.rb
@@ -13,6 +13,10 @@
end
describe '#perform' do
+ around do |example|
+ freeze_time { example.run }
+ end
+
before do
stub_request(:post, webhook_url.url).to_return(status: 200)
end
diff --git a/spec/jobs/send_submitter_invitation_sms_job_spec.rb b/spec/jobs/send_submitter_invitation_sms_job_spec.rb
new file mode 100644
index 000000000..60f7d754c
--- /dev/null
+++ b/spec/jobs/send_submitter_invitation_sms_job_spec.rb
@@ -0,0 +1,138 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe SendSubmitterInvitationSmsJob do
+ let(:account) { create(:account) }
+ let(:user) { create(:user, account:) }
+ let(:template) { create(:template, account:, author: user) }
+ let(:submission) { create(:submission, template:, created_by_user: user) }
+ let(:submitter) do
+ create(:submitter, submission:, uuid: template.submitters.first['uuid'], phone: '+15559876543')
+ end
+
+ let(:sms_config) do
+ { 'account_sid' => 'AC_test_sid', 'auth_token' => 'test_token', 'from_number' => '+15551234567' }
+ end
+
+ let(:twilio_url) { "https://api.twilio.com/2010-04-01/Accounts/#{sms_config['account_sid']}/Messages.json" }
+
+ before do
+ create(:encrypted_config, account:, key: EncryptedConfig::SMS_KEY, value: sms_config)
+ create(:encrypted_config, key: EncryptedConfig::ESIGN_CERTS_KEY,
+ value: GenerateCertificate.call.transform_values(&:to_pem))
+ end
+
+ describe '#perform' do
+ before do
+ stub_request(:post, twilio_url)
+ .to_return(status: 200, body: { 'sid' => 'SM_test_123', 'status' => 'queued' }.to_json)
+ end
+
+ it 'sends an SMS and creates a submission event with Twilio SID' do
+ described_class.new.perform('submitter_id' => submitter.id)
+
+ expect(WebMock).to have_requested(:post, twilio_url).once
+ event = SubmissionEvent.last
+ expect(event.event_type).to eq('send_sms')
+ expect(event.data['twilio_sid']).to eq('SM_test_123')
+ end
+
+ it 'updates submitter sent_at' do
+ described_class.new.perform('submitter_id' => submitter.id)
+
+ expect(submitter.reload.sent_at).to be_present
+ end
+
+ it 'skips when submitter is completed' do
+ submitter.update!(completed_at: Time.current)
+
+ described_class.new.perform('submitter_id' => submitter.id)
+
+ expect(WebMock).not_to have_requested(:post, twilio_url)
+ end
+
+ it 'skips when submission is archived' do
+ submitter.submission.update!(archived_at: Time.current)
+
+ described_class.new.perform('submitter_id' => submitter.id)
+
+ expect(WebMock).not_to have_requested(:post, twilio_url)
+ end
+
+ it 'skips when submitter has no phone' do
+ submitter.update!(phone: nil)
+
+ described_class.new.perform('submitter_id' => submitter.id)
+
+ expect(WebMock).not_to have_requested(:post, twilio_url)
+ end
+
+ it 'skips when no SMS config exists' do
+ EncryptedConfig.find_by(account:, key: EncryptedConfig::SMS_KEY).destroy
+
+ described_class.new.perform('submitter_id' => submitter.id)
+
+ expect(WebMock).not_to have_requested(:post, twilio_url)
+ end
+
+ context 'when SMS fails with SmsError' do
+ before do
+ stub_request(:post, twilio_url)
+ .to_return(status: 400, body: { 'message' => 'Invalid phone number' }.to_json)
+ end
+
+ it 'schedules a retry with incremented attempt' do
+ expect do
+ described_class.new.perform('submitter_id' => submitter.id)
+ end.to change(described_class.jobs, :size).by(1)
+
+ job = described_class.jobs.last
+ expect(job['args'].first['attempt']).to eq(1)
+ end
+
+ it 'does not create a submission event' do
+ described_class.new.perform('submitter_id' => submitter.id)
+
+ expect(SubmissionEvent.where(submitter:, event_type: 'send_sms')).to be_empty
+ end
+ end
+
+ context 'when max attempts reached' do
+ before do
+ stub_request(:post, twilio_url)
+ .to_return(status: 400, body: { 'message' => 'Invalid phone number' }.to_json)
+ end
+
+ it 'does not schedule another retry' do
+ expect do
+ described_class.new.perform('submitter_id' => submitter.id, 'attempt' => 4)
+ end.not_to change(described_class.jobs, :size)
+ end
+ end
+
+ context 'when network timeout occurs' do
+ before do
+ stub_request(:post, twilio_url).to_timeout
+ end
+
+ it 'schedules a retry' do
+ expect do
+ described_class.new.perform('submitter_id' => submitter.id)
+ end.to change(described_class.jobs, :size).by(1)
+ end
+ end
+
+ context 'when connection fails' do
+ before do
+ stub_request(:post, twilio_url).to_raise(Faraday::ConnectionFailed.new('Connection refused'))
+ end
+
+ it 'schedules a retry' do
+ expect do
+ described_class.new.perform('submitter_id' => submitter.id)
+ end.to change(described_class.jobs, :size).by(1)
+ end
+ end
+ end
+end
diff --git a/spec/jobs/send_template_created_webhook_request_job_spec.rb b/spec/jobs/send_template_created_webhook_request_job_spec.rb
index e5ce6f103..696d47d43 100644
--- a/spec/jobs/send_template_created_webhook_request_job_spec.rb
+++ b/spec/jobs/send_template_created_webhook_request_job_spec.rb
@@ -12,6 +12,10 @@
end
describe '#perform' do
+ around do |example|
+ freeze_time { example.run }
+ end
+
before do
stub_request(:post, webhook_url.url).to_return(status: 200)
end
diff --git a/spec/jobs/send_template_updated_webhook_request_job_spec.rb b/spec/jobs/send_template_updated_webhook_request_job_spec.rb
index f13675a10..c0ecec8bb 100644
--- a/spec/jobs/send_template_updated_webhook_request_job_spec.rb
+++ b/spec/jobs/send_template_updated_webhook_request_job_spec.rb
@@ -12,6 +12,10 @@
end
describe '#perform' do
+ around do |example|
+ freeze_time { example.run }
+ end
+
before do
stub_request(:post, webhook_url.url).to_return(status: 200)
end
diff --git a/spec/lib/rate_limit_spec.rb b/spec/lib/rate_limit_spec.rb
new file mode 100644
index 000000000..a371db5b1
--- /dev/null
+++ b/spec/lib/rate_limit_spec.rb
@@ -0,0 +1,68 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe RateLimit do
+ before do
+ described_class::STORE.clear
+ end
+
+ describe '.call' do
+ it 'allows requests under the limit' do
+ expect(described_class.call('test_key', limit: 5, ttl: 1.minute, enabled: true)).to be true
+ end
+
+ it 'raises LimitApproached when limit is exceeded' do
+ 3.times { described_class.call('test_key', limit: 3, ttl: 1.minute, enabled: true) }
+
+ expect do
+ described_class.call('test_key', limit: 3, ttl: 1.minute, enabled: true)
+ end.to raise_error(RateLimit::LimitApproached)
+ end
+
+ it 'resets after TTL expires' do
+ 3.times { described_class.call('test_key', limit: 3, ttl: 1.second, enabled: true) }
+
+ sleep 1.1
+
+ expect(described_class.call('test_key', limit: 3, ttl: 1.second, enabled: true)).to be true
+ end
+
+ it 'returns true when disabled' do
+ 100.times { described_class.call('test_key', limit: 1, ttl: 1.minute, enabled: false) }
+
+ expect(described_class.call('test_key', limit: 1, ttl: 1.minute, enabled: false)).to be true
+ end
+
+ it 'isolates keys from each other' do
+ 3.times { described_class.call('key_a', limit: 3, ttl: 1.minute, enabled: true) }
+
+ expect(described_class.call('key_b', limit: 3, ttl: 1.minute, enabled: true)).to be true
+ end
+ end
+
+ describe 'STORE' do
+ it 'uses MemoryStore when REDIS_URL is not set' do
+ store = described_class::STORE
+
+ expect(store).to be_a(ActiveSupport::Cache::MemoryStore) if ENV['REDIS_URL'].blank?
+ end
+
+ it 'falls back to MemoryStore on Redis connection error' do
+ store = begin
+ redis_url = 'redis://invalid-host:6379/0'
+ ActiveSupport::Cache::RedisCacheStore.new(url: redis_url, namespace: 'rate_limit')
+ rescue StandardError
+ ActiveSupport::Cache::MemoryStore.new
+ end
+
+ # The RedisCacheStore may be created without raising (it connects lazily),
+ # but our module's rescue block ensures a MemoryStore fallback on any error
+ expect(store).to be_a(ActiveSupport::Cache::Store)
+ end
+
+ it 'responds to increment for rate limiting' do
+ expect(described_class::STORE).to respond_to(:increment)
+ end
+ end
+end
diff --git a/spec/lib/send_sms_spec.rb b/spec/lib/send_sms_spec.rb
new file mode 100644
index 000000000..b67ef3676
--- /dev/null
+++ b/spec/lib/send_sms_spec.rb
@@ -0,0 +1,76 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe SendSms do
+ let(:account_sid) { 'AC_test_sid' }
+ let(:auth_token) { 'test_token' }
+ let(:from_number) { '+15551234567' }
+ let(:to_number) { '+15559876543' }
+ let(:body) { 'Test message' }
+ let(:twilio_url) { "https://api.twilio.com/2010-04-01/Accounts/#{account_sid}/Messages.json" }
+
+ let(:call_params) do
+ { to: to_number, body:, account_sid:, auth_token:, from_number: }
+ end
+
+ describe '.call' do
+ it 'returns parsed JSON on success' do
+ stub_request(:post, twilio_url)
+ .to_return(status: 200, body: { 'sid' => 'SM123', 'status' => 'queued' }.to_json)
+
+ result = described_class.call(**call_params)
+
+ expect(result).to eq({ 'sid' => 'SM123', 'status' => 'queued' })
+ end
+
+ it 'returns empty hash when success body is not parseable JSON' do
+ stub_request(:post, twilio_url)
+ .to_return(status: 200, body: 'not json')
+
+ result = described_class.call(**call_params)
+
+ expect(result).to eq({})
+ end
+
+ it 'raises SmsError with message on 4xx error' do
+ stub_request(:post, twilio_url)
+ .to_return(status: 400, body: { 'message' => 'Invalid phone number' }.to_json)
+
+ expect { described_class.call(**call_params) }
+ .to raise_error(SendSms::SmsError, 'Invalid phone number')
+ end
+
+ it 'raises SmsError with message on 5xx error' do
+ stub_request(:post, twilio_url)
+ .to_return(status: 500, body: { 'message' => 'Internal error' }.to_json)
+
+ expect { described_class.call(**call_params) }
+ .to raise_error(SendSms::SmsError, 'Internal error')
+ end
+
+ it 'raises SmsError with raw body when error response is not parseable JSON' do
+ stub_request(:post, twilio_url)
+ .to_return(status: 400, body: 'bad gateway')
+
+ expect { described_class.call(**call_params) }
+ .to raise_error(SendSms::SmsError, 'bad gateway')
+ end
+
+ it 'propagates Faraday::TimeoutError on network timeout' do
+ stub_request(:post, twilio_url).to_timeout
+
+ expect { described_class.call(**call_params) }
+ .to raise_error(Faraday::ConnectionFailed)
+ end
+
+ it 'sets open_timeout and read_timeout on the request' do
+ stub_request(:post, twilio_url)
+ .to_return(status: 200, body: { 'sid' => 'SM123' }.to_json)
+
+ described_class.call(**call_params)
+
+ expect(WebMock).to have_requested(:post, twilio_url).once
+ end
+ end
+end
diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb
index db91da56e..ecca6019a 100644
--- a/spec/rails_helper.rb
+++ b/spec/rails_helper.rb
@@ -54,6 +54,7 @@
config.include FactoryBot::Syntax::Methods
config.include Devise::Test::IntegrationHelpers
config.include SigningFormHelper
+ config.include ActiveSupport::Testing::TimeHelpers
config.before(:each, type: :system) do
if ENV['HEADLESS'] == 'false'
diff --git a/spec/system/email_settings_spec.rb b/spec/system/email_settings_spec.rb
index f7d683b88..fe842a1d5 100644
--- a/spec/system/email_settings_spec.rb
+++ b/spec/system/email_settings_spec.rb
@@ -61,7 +61,6 @@
expect(page).to have_field('Host', with: encrypted_config.value['host'])
expect(page).to have_field('Port', with: encrypted_config.value['port'])
expect(page).to have_field('Username', with: encrypted_config.value['username'])
- expect(page).to have_field('Password', with: encrypted_config.value['password'])
expect(page).to have_field('Domain', with: encrypted_config.value['domain'])
expect(page).to have_select('Authentication', selected: 'Plain')
expect(page).to have_field('Send from Email', with: encrypted_config.value['from_email'])
diff --git a/spec/system/newsletters_spec.rb b/spec/system/newsletters_spec.rb
index 274ba7e3b..b6c95788a 100644
--- a/spec/system/newsletters_spec.rb
+++ b/spec/system/newsletters_spec.rb
@@ -5,7 +5,6 @@
before do
sign_in(user)
- stub_request(:post, Docuseal::NEWSLETTER_URL).to_return(status: 200)
visit newsletter_path
end
@@ -19,7 +18,7 @@
it 'submits the newsletter form' do
click_button 'Submit'
- expect(a_request(:post, Docuseal::NEWSLETTER_URL)).to have_been_made.once
+ expect(page).to have_current_path(root_path, ignore_query: true)
end
it 'skips the newsletter form' do
diff --git a/spec/system/signing_form_spec.rb b/spec/system/signing_form_spec.rb
index 073ef79fc..7af76c2f5 100644
--- a/spec/system/signing_form_spec.rb
+++ b/spec/system/signing_form_spec.rb
@@ -626,6 +626,34 @@
end
end
+ context 'when the signature step with signing reason' do
+ let(:template) { create(:template, account:, author:, only_field_types: %w[signature]) }
+ let(:submission) { create(:submission, template:) }
+ let(:submitter) do
+ create(:submitter, submission:, uuid: template.submitters.first['uuid'], account:)
+ end
+
+ before do
+ create(:account_config, account:, key: AccountConfig::REQUIRE_SIGNING_REASON_KEY, value: true)
+ end
+
+ it 'completes the form with signing reason selected' do
+ visit submit_form_path(slug: submitter.slug)
+
+ find('#expand_form_button').click
+ draw_canvas
+ select 'Approved'
+ click_button 'Sign and Complete'
+
+ expect(page).to have_content('Document has been signed!')
+
+ submitter.reload
+
+ expect(submitter.completed_at).to be_present
+ expect(field_value(submitter, 'Signature')).to be_present
+ end
+ end
+
context 'when the number step' do
let(:template) { create(:template, account:, author:, only_field_types: %w[number]) }
let(:submission) { create(:submission, template:) }
diff --git a/yarn.lock b/yarn.lock
index 7973096e7..4a8f50218 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1743,6 +1743,11 @@
resolved "https://registry.yarnpkg.com/@rails/actioncable/-/actioncable-7.0.4.tgz#70a3ca56809f7aaabb80af2f9c01ae51e1a8ed41"
integrity sha512-tz4oM+Zn9CYsvtyicsa/AwzKZKL+ITHWkhiu7x+xF77clh2b4Rm+s6xnOgY/sGDWoFWZmtKsE95hxBPkgQQNnQ==
+"@remirror/core-constants@3.0.0":
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/@remirror/core-constants/-/core-constants-3.0.0.tgz#96fdb89d25c62e7b6a5d08caf0ce5114370e3b8f"
+ integrity sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==
+
"@sinclair/typebox@^0.25.16":
version "0.25.24"
resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.25.24.tgz#8c7688559979f7079aacaf31aa881c3aa410b718"
@@ -1767,6 +1772,89 @@
resolved "https://registry.yarnpkg.com/@tabler/icons/-/icons-2.47.0.tgz#c41c680d1947e3ab2d60af3febc4132287c60596"
integrity sha512-4w5evLh+7FUUiA1GucvGj2ReX2TvOjEr4ejXdwL/bsjoSkof6r1gQmzqI+VHrE2CpJpB3al7bCTulOkFa/RcyA==
+"@tiptap/core@^3.19.0":
+ version "3.19.0"
+ resolved "https://registry.yarnpkg.com/@tiptap/core/-/core-3.19.0.tgz#dca483b50e1b8a596f695aecde387a79fe7da717"
+ integrity sha512-bpqELwPW+DG8gWiD8iiFtSl4vIBooG5uVJod92Qxn3rA9nFatyXRr4kNbMJmOZ66ezUvmCjXVe/5/G4i5cyzKA==
+
+"@tiptap/extension-bold@^3.19.0":
+ version "3.19.0"
+ resolved "https://registry.yarnpkg.com/@tiptap/extension-bold/-/extension-bold-3.19.0.tgz#ef0ddfd9b242ef9c25e3348aef9bf2dc681cdc19"
+ integrity sha512-UZgb1d0XK4J/JRIZ7jW+s4S6KjuEDT2z1PPM6ugcgofgJkWQvRZelCPbmtSFd3kwsD+zr9UPVgTh9YIuGQ8t+Q==
+
+"@tiptap/extension-document@^3.19.0":
+ version "3.19.0"
+ resolved "https://registry.yarnpkg.com/@tiptap/extension-document/-/extension-document-3.19.0.tgz#dfa6889cff748d489e0bc1028918bf4571372ba5"
+ integrity sha512-AOf0kHKSFO0ymjVgYSYDncRXTITdTcrj1tqxVazrmO60KNl1Rc2dAggDvIVTEBy5NvceF0scc7q3sE/5ZtVV7A==
+
+"@tiptap/extension-hard-break@^3.19.0":
+ version "3.19.0"
+ resolved "https://registry.yarnpkg.com/@tiptap/extension-hard-break/-/extension-hard-break-3.19.0.tgz#7120524cec9ed4b957963693cb4c57cbecbaecf8"
+ integrity sha512-lAmQraYhPS5hafvCl74xDB5+bLuNwBKIEsVoim35I0sDJj5nTrfhaZgMJ91VamMvT+6FF5f1dvBlxBxAWa8jew==
+
+"@tiptap/extension-italic@^3.19.0":
+ version "3.19.0"
+ resolved "https://registry.yarnpkg.com/@tiptap/extension-italic/-/extension-italic-3.19.0.tgz#af2a9c095ec846e379041f3e17e1dd101a5a4bf8"
+ integrity sha512-6GffxOnS/tWyCbDkirWNZITiXRta9wrCmrfa4rh+v32wfaOL1RRQNyqo9qN6Wjyl1R42Js+yXTzTTzZsOaLMYA==
+
+"@tiptap/extension-link@^3.19.0":
+ version "3.19.0"
+ resolved "https://registry.yarnpkg.com/@tiptap/extension-link/-/extension-link-3.19.0.tgz#e8e656735bda6ca1d4b6577821e06274ab0ff6c8"
+ integrity sha512-HEGDJnnCPfr7KWu7Dsq+eRRe/mBCsv6DuI+7fhOCLDJjjKzNgrX2abbo/zG3D/4lCVFaVb+qawgJubgqXR/Smw==
+ dependencies:
+ linkifyjs "^4.3.2"
+
+"@tiptap/extension-paragraph@^3.19.0":
+ version "3.19.0"
+ resolved "https://registry.yarnpkg.com/@tiptap/extension-paragraph/-/extension-paragraph-3.19.0.tgz#91adde189aabf13a2bfbb2d961833d3bc2bc055f"
+ integrity sha512-xWa6gj82l5+AzdYyrSk9P4ynySaDzg/SlR1FarXE5yPXibYzpS95IWaVR0m2Qaz7Rrk+IiYOTGxGRxcHLOelNg==
+
+"@tiptap/extension-text@^3.19.0":
+ version "3.19.0"
+ resolved "https://registry.yarnpkg.com/@tiptap/extension-text/-/extension-text-3.19.0.tgz#353278c97bd8f5bdc29f06942fbd1e856bdb5b18"
+ integrity sha512-K95+SnbZy0h6hNFtfy23n8t/nOcTFEf69In9TSFVVmwn/Nwlke+IfiESAkqbt1/7sKJeegRXYO7WzFEmFl9Q/g==
+
+"@tiptap/extension-underline@^3.19.0":
+ version "3.19.0"
+ resolved "https://registry.yarnpkg.com/@tiptap/extension-underline/-/extension-underline-3.19.0.tgz#bbc81d085725981d256127ab416f91d0802ec2a4"
+ integrity sha512-800MGEWfG49j10wQzAFiW/ele1HT04MamcL8iyuPNu7ZbjbGN2yknvdrJlRy7hZlzIrVkZMr/1tz62KN33VHIw==
+
+"@tiptap/extensions@^3.19.0":
+ version "3.19.0"
+ resolved "https://registry.yarnpkg.com/@tiptap/extensions/-/extensions-3.19.0.tgz#5747c0ebf460b9669e8b4362561872448f66abfe"
+ integrity sha512-ZmGUhLbMWaGqnJh2Bry+6V4M6gMpUDYo4D1xNux5Gng/E/eYtc+PMxMZ/6F7tNTAuujLBOQKj6D+4SsSm457jw==
+
+"@tiptap/markdown@^3.19.0":
+ version "3.19.0"
+ resolved "https://registry.yarnpkg.com/@tiptap/markdown/-/markdown-3.19.0.tgz#dd05451b40f2a553cab0fdbb4a8714a2b2430b5c"
+ integrity sha512-Pnfacq2FHky1rqwmGwEmUJxuZu8VZ8XjaJIqsQC34S3CQWiOU+PukC9In2odzcooiVncLWT9s97jKuYpbmF1tQ==
+ dependencies:
+ marked "^17.0.1"
+
+"@tiptap/pm@^3.19.0":
+ version "3.19.0"
+ resolved "https://registry.yarnpkg.com/@tiptap/pm/-/pm-3.19.0.tgz#5cb499c7b2603ec6550d0c7a70b924f27fdb7692"
+ integrity sha512-789zcnM4a8OWzvbD2DL31d0wbSm9BVeO/R7PLQwLIGysDI3qzrcclyZ8yhqOEVuvPitRRwYLq+mY14jz7kY4cw==
+ dependencies:
+ prosemirror-changeset "^2.3.0"
+ prosemirror-collab "^1.3.1"
+ prosemirror-commands "^1.6.2"
+ prosemirror-dropcursor "^1.8.1"
+ prosemirror-gapcursor "^1.3.2"
+ prosemirror-history "^1.4.1"
+ prosemirror-inputrules "^1.4.0"
+ prosemirror-keymap "^1.2.2"
+ prosemirror-markdown "^1.13.1"
+ prosemirror-menu "^1.2.4"
+ prosemirror-model "^1.24.1"
+ prosemirror-schema-basic "^1.2.3"
+ prosemirror-schema-list "^1.5.0"
+ prosemirror-state "^1.4.3"
+ prosemirror-tables "^1.6.4"
+ prosemirror-trailing-node "^3.0.0"
+ prosemirror-transform "^1.10.2"
+ prosemirror-view "^1.38.1"
+
"@trysound/sax@0.2.0":
version "0.2.0"
resolved "https://registry.yarnpkg.com/@trysound/sax/-/sax-0.2.0.tgz#cccaab758af56761eb7bf37af6f03f326dd798ad"
@@ -1909,6 +1997,24 @@
resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee"
integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==
+"@types/linkify-it@^5":
+ version "5.0.0"
+ resolved "https://registry.yarnpkg.com/@types/linkify-it/-/linkify-it-5.0.0.tgz#21413001973106cda1c3a9b91eedd4ccd5469d76"
+ integrity sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==
+
+"@types/markdown-it@^14.0.0":
+ version "14.1.2"
+ resolved "https://registry.yarnpkg.com/@types/markdown-it/-/markdown-it-14.1.2.tgz#57f2532a0800067d9b934f3521429a2e8bfb4c61"
+ integrity sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==
+ dependencies:
+ "@types/linkify-it" "^5"
+ "@types/mdurl" "^2"
+
+"@types/mdurl@^2":
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/@types/mdurl/-/mdurl-2.0.0.tgz#d43878b5b20222682163ae6f897b20447233bdfd"
+ integrity sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==
+
"@types/mime@*":
version "3.0.1"
resolved "https://registry.yarnpkg.com/@types/mime/-/mime-3.0.1.tgz#5f8f2bca0a5863cb69bc0b0acd88c96cb1d4ae10"
@@ -2986,7 +3092,7 @@ cosmiconfig@^8.1.3:
parse-json "^5.0.0"
path-type "^4.0.0"
-crelt@^1.0.5, crelt@^1.0.6:
+crelt@^1.0.0, crelt@^1.0.5, crelt@^1.0.6:
version "1.0.6"
resolved "https://registry.yarnpkg.com/crelt/-/crelt-1.0.6.tgz#7cc898ea74e190fb6ef9dae57f8f81cf7302df72"
integrity sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==
@@ -4780,6 +4886,18 @@ lines-and-columns@^1.1.6:
resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632"
integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==
+linkify-it@^5.0.0:
+ version "5.0.0"
+ resolved "https://registry.yarnpkg.com/linkify-it/-/linkify-it-5.0.0.tgz#9ef238bfa6dc70bd8e7f9572b52d369af569b421"
+ integrity sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==
+ dependencies:
+ uc.micro "^2.0.0"
+
+linkifyjs@^4.3.2:
+ version "4.3.2"
+ resolved "https://registry.yarnpkg.com/linkifyjs/-/linkifyjs-4.3.2.tgz#d97eb45419aabf97ceb4b05a7adeb7b8c8ade2b1"
+ integrity sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==
+
loader-runner@^4.3.1:
version "4.3.1"
resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-4.3.1.tgz#6c76ed29b0ccce9af379208299f07f876de737e3"
@@ -4867,6 +4985,23 @@ make-dir@^3.0.2:
dependencies:
semver "^6.0.0"
+markdown-it@^14.0.0:
+ version "14.1.0"
+ resolved "https://registry.yarnpkg.com/markdown-it/-/markdown-it-14.1.0.tgz#3c3c5992883c633db4714ccb4d7b5935d98b7d45"
+ integrity sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==
+ dependencies:
+ argparse "^2.0.1"
+ entities "^4.4.0"
+ linkify-it "^5.0.0"
+ mdurl "^2.0.0"
+ punycode.js "^2.3.1"
+ uc.micro "^2.1.0"
+
+marked@^17.0.1:
+ version "17.0.1"
+ resolved "https://registry.yarnpkg.com/marked/-/marked-17.0.1.tgz#9db34197ac145e5929572ee49ef701e37ee9b2e6"
+ integrity sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==
+
math-intrinsics@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9"
@@ -4897,6 +5032,11 @@ mdn-data@2.0.30:
resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.30.tgz#ce4df6f80af6cfbe218ecd5c552ba13c4dfa08cc"
integrity sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==
+mdurl@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/mdurl/-/mdurl-2.0.0.tgz#80676ec0433025dd3e17ee983d0fe8de5a2237e0"
+ integrity sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==
+
media-typer@0.3.0:
version "0.3.0"
resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748"
@@ -5218,6 +5358,11 @@ optionator@^0.9.1:
type-check "^0.4.0"
word-wrap "^1.2.3"
+orderedmap@^2.0.0:
+ version "2.1.1"
+ resolved "https://registry.yarnpkg.com/orderedmap/-/orderedmap-2.1.1.tgz#61481269c44031c449915497bf5a4ad273c512d2"
+ integrity sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==
+
p-limit@^2.2.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1"
@@ -5739,6 +5884,160 @@ proper-lockfile@^4.1.2:
retry "^0.12.0"
signal-exit "^3.0.2"
+prosemirror-changeset@^2.3.0:
+ version "2.3.1"
+ resolved "https://registry.yarnpkg.com/prosemirror-changeset/-/prosemirror-changeset-2.3.1.tgz#eee3299cfabc7a027694e9abdc4e85505e9dd5e7"
+ integrity sha512-j0kORIBm8ayJNl3zQvD1TTPHJX3g042et6y/KQhZhnPrruO8exkTgG8X+NRpj7kIyMMEx74Xb3DyMIBtO0IKkQ==
+ dependencies:
+ prosemirror-transform "^1.0.0"
+
+prosemirror-collab@^1.3.1:
+ version "1.3.1"
+ resolved "https://registry.yarnpkg.com/prosemirror-collab/-/prosemirror-collab-1.3.1.tgz#0e8c91e76e009b53457eb3b3051fb68dad029a33"
+ integrity sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==
+ dependencies:
+ prosemirror-state "^1.0.0"
+
+prosemirror-commands@^1.0.0, prosemirror-commands@^1.6.2:
+ version "1.7.1"
+ resolved "https://registry.yarnpkg.com/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz#d101fef85618b1be53d5b99ea17bee5600781b38"
+ integrity sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==
+ dependencies:
+ prosemirror-model "^1.0.0"
+ prosemirror-state "^1.0.0"
+ prosemirror-transform "^1.10.2"
+
+prosemirror-dropcursor@^1.8.1:
+ version "1.8.2"
+ resolved "https://registry.yarnpkg.com/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz#2ed30c4796109ddeb1cf7282372b3850528b7228"
+ integrity sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==
+ dependencies:
+ prosemirror-state "^1.0.0"
+ prosemirror-transform "^1.1.0"
+ prosemirror-view "^1.1.0"
+
+prosemirror-gapcursor@^1.3.2:
+ version "1.4.0"
+ resolved "https://registry.yarnpkg.com/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.0.tgz#e1144a83b79db7ed0ec32cd0e915a0364220af43"
+ integrity sha512-z00qvurSdCEWUIulij/isHaqu4uLS8r/Fi61IbjdIPJEonQgggbJsLnstW7Lgdk4zQ68/yr6B6bf7sJXowIgdQ==
+ dependencies:
+ prosemirror-keymap "^1.0.0"
+ prosemirror-model "^1.0.0"
+ prosemirror-state "^1.0.0"
+ prosemirror-view "^1.0.0"
+
+prosemirror-history@^1.0.0, prosemirror-history@^1.4.1:
+ version "1.5.0"
+ resolved "https://registry.yarnpkg.com/prosemirror-history/-/prosemirror-history-1.5.0.tgz#ee21fc5de85a1473e3e3752015ffd6d649a06859"
+ integrity sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==
+ dependencies:
+ prosemirror-state "^1.2.2"
+ prosemirror-transform "^1.0.0"
+ prosemirror-view "^1.31.0"
+ rope-sequence "^1.3.0"
+
+prosemirror-inputrules@^1.4.0:
+ version "1.5.1"
+ resolved "https://registry.yarnpkg.com/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz#d2e935f6086e3801486b09222638f61dae89a570"
+ integrity sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==
+ dependencies:
+ prosemirror-state "^1.0.0"
+ prosemirror-transform "^1.0.0"
+
+prosemirror-keymap@^1.0.0, prosemirror-keymap@^1.2.2, prosemirror-keymap@^1.2.3:
+ version "1.2.3"
+ resolved "https://registry.yarnpkg.com/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz#c0f6ab95f75c0b82c97e44eb6aaf29cbfc150472"
+ integrity sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==
+ dependencies:
+ prosemirror-state "^1.0.0"
+ w3c-keyname "^2.2.0"
+
+prosemirror-markdown@^1.13.1:
+ version "1.13.4"
+ resolved "https://registry.yarnpkg.com/prosemirror-markdown/-/prosemirror-markdown-1.13.4.tgz#4620e6a0580cd52b5fc8e352c7e04830cd4b3048"
+ integrity sha512-D98dm4cQ3Hs6EmjK500TdAOew4Z03EV71ajEFiWra3Upr7diytJsjF4mPV2dW+eK5uNectiRj0xFxYI9NLXDbw==
+ dependencies:
+ "@types/markdown-it" "^14.0.0"
+ markdown-it "^14.0.0"
+ prosemirror-model "^1.25.0"
+
+prosemirror-menu@^1.2.4:
+ version "1.2.5"
+ resolved "https://registry.yarnpkg.com/prosemirror-menu/-/prosemirror-menu-1.2.5.tgz#dea00e7b623cea89f4d76963bee22d2ac2343250"
+ integrity sha512-qwXzynnpBIeg1D7BAtjOusR+81xCp53j7iWu/IargiRZqRjGIlQuu1f3jFi+ehrHhWMLoyOQTSRx/IWZJqOYtQ==
+ dependencies:
+ crelt "^1.0.0"
+ prosemirror-commands "^1.0.0"
+ prosemirror-history "^1.0.0"
+ prosemirror-state "^1.0.0"
+
+prosemirror-model@^1.0.0, prosemirror-model@^1.20.0, prosemirror-model@^1.21.0, prosemirror-model@^1.24.1, prosemirror-model@^1.25.0, prosemirror-model@^1.25.4:
+ version "1.25.4"
+ resolved "https://registry.yarnpkg.com/prosemirror-model/-/prosemirror-model-1.25.4.tgz#8ebfbe29ecbee9e5e2e4048c4fe8e363fcd56e7c"
+ integrity sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==
+ dependencies:
+ orderedmap "^2.0.0"
+
+prosemirror-schema-basic@^1.2.3:
+ version "1.2.4"
+ resolved "https://registry.yarnpkg.com/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.4.tgz#389ce1ec09b8a30ea9bbb92c58569cb690c2d695"
+ integrity sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==
+ dependencies:
+ prosemirror-model "^1.25.0"
+
+prosemirror-schema-list@^1.5.0:
+ version "1.5.1"
+ resolved "https://registry.yarnpkg.com/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz#5869c8f749e8745c394548bb11820b0feb1e32f5"
+ integrity sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==
+ dependencies:
+ prosemirror-model "^1.0.0"
+ prosemirror-state "^1.0.0"
+ prosemirror-transform "^1.7.3"
+
+prosemirror-state@^1.0.0, prosemirror-state@^1.2.2, prosemirror-state@^1.4.3, prosemirror-state@^1.4.4:
+ version "1.4.4"
+ resolved "https://registry.yarnpkg.com/prosemirror-state/-/prosemirror-state-1.4.4.tgz#72b5e926f9e92dcee12b62a05fcc8a2de3bf5b39"
+ integrity sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==
+ dependencies:
+ prosemirror-model "^1.0.0"
+ prosemirror-transform "^1.0.0"
+ prosemirror-view "^1.27.0"
+
+prosemirror-tables@^1.6.4:
+ version "1.8.5"
+ resolved "https://registry.yarnpkg.com/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz#104427012e5a5da1d2a38c122efee8d66bdd5104"
+ integrity sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==
+ dependencies:
+ prosemirror-keymap "^1.2.3"
+ prosemirror-model "^1.25.4"
+ prosemirror-state "^1.4.4"
+ prosemirror-transform "^1.10.5"
+ prosemirror-view "^1.41.4"
+
+prosemirror-trailing-node@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/prosemirror-trailing-node/-/prosemirror-trailing-node-3.0.0.tgz#5bc223d4fc1e8d9145e4079ec77a932b54e19e04"
+ integrity sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==
+ dependencies:
+ "@remirror/core-constants" "3.0.0"
+ escape-string-regexp "^4.0.0"
+
+prosemirror-transform@^1.0.0, prosemirror-transform@^1.1.0, prosemirror-transform@^1.10.2, prosemirror-transform@^1.10.5, prosemirror-transform@^1.7.3:
+ version "1.11.0"
+ resolved "https://registry.yarnpkg.com/prosemirror-transform/-/prosemirror-transform-1.11.0.tgz#f5c5050354423dc83c6b083f6f1959ec86a3f9ba"
+ integrity sha512-4I7Ce4KpygXb9bkiPS3hTEk4dSHorfRw8uI0pE8IhxlK2GXsqv5tIA7JUSxtSu7u8APVOTtbUBxTmnHIxVkIJw==
+ dependencies:
+ prosemirror-model "^1.21.0"
+
+prosemirror-view@^1.0.0, prosemirror-view@^1.1.0, prosemirror-view@^1.27.0, prosemirror-view@^1.31.0, prosemirror-view@^1.38.1, prosemirror-view@^1.41.4:
+ version "1.41.5"
+ resolved "https://registry.yarnpkg.com/prosemirror-view/-/prosemirror-view-1.41.5.tgz#3e152d14af633f2f5a73aba24e6130c63f643b2b"
+ integrity sha512-UDQbIPnDrjE8tqUBbPmCOZgtd75htE6W3r0JCmY9bL6W1iemDM37MZEKC49d+tdQ0v/CKx4gjxLoLsfkD2NiZA==
+ dependencies:
+ prosemirror-model "^1.20.0"
+ prosemirror-state "^1.0.0"
+ prosemirror-transform "^1.1.0"
+
proxy-addr@~2.0.7:
version "2.0.7"
resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025"
@@ -5752,6 +6051,11 @@ proxy-from-env@^1.1.0:
resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2"
integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==
+punycode.js@^2.3.1:
+ version "2.3.1"
+ resolved "https://registry.yarnpkg.com/punycode.js/-/punycode.js-2.3.1.tgz#6b53e56ad75588234e79f4affa90972c7dd8cdb7"
+ integrity sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==
+
punycode@^2.1.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.0.tgz#f67fa67c94da8f4d0cfff981aee4118064199b8f"
@@ -6010,6 +6314,11 @@ rollbar@^2.26.4:
optionalDependencies:
decache "^3.0.5"
+rope-sequence@^1.3.0:
+ version "1.3.4"
+ resolved "https://registry.yarnpkg.com/rope-sequence/-/rope-sequence-1.3.4.tgz#df85711aaecd32f1e756f76e43a415171235d425"
+ integrity sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==
+
run-applescript@^7.0.0:
version "7.1.0"
resolved "https://registry.yarnpkg.com/run-applescript/-/run-applescript-7.1.0.tgz#2e9e54c4664ec3106c5b5630e249d3d6595c4911"
@@ -6792,6 +7101,11 @@ typed-function@^4.1.1:
resolved "https://registry.yarnpkg.com/typed-function/-/typed-function-4.1.1.tgz#38ce3cae31f4f513bcb263563fdad27b2afa73e8"
integrity sha512-Pq1DVubcvibmm8bYcMowjVnnMwPVMeh0DIdA8ad8NZY2sJgapANJmiigSUwlt+EgXxpfIv8MWrQXTIzkfYZLYQ==
+uc.micro@^2.0.0, uc.micro@^2.1.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-2.1.0.tgz#f8d3f7d0ec4c3dea35a7e3c8efa4cb8b45c9e7ee"
+ integrity sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==
+
unbox-primitive@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.2.tgz#29032021057d5e6cdbd08c5129c226dff8ed6f9e"
@@ -6934,7 +7248,7 @@ vue@^3.3.2:
"@vue/server-renderer" "3.3.4"
"@vue/shared" "3.3.4"
-w3c-keyname@^2.2.4:
+w3c-keyname@^2.2.0, w3c-keyname@^2.2.4:
version "2.2.8"
resolved "https://registry.yarnpkg.com/w3c-keyname/-/w3c-keyname-2.2.8.tgz#7b17c8c6883d4e8b86ac8aba79d39e880f8869c5"
integrity sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==