Compare commits

...
23 Commits
Author SHA1 Message Date
Pete Matsyburka 3db7d47746 fix option remove 2024-03-17 19:40:30 +02:00
Pete Matsyburka 95899336b8 align number field 2024-03-17 19:06:00 +02:00
Pete Matsyburka f6691c110e adjust field settings 2024-03-17 17:04:39 +02:00
Pete Matsyburka 749722a9df add rate limit 2024-03-16 23:22:11 +02:00
Pete Matsyburka 265b668cb4 show bulk send message 2024-03-16 22:21:23 +02:00
Pete Matsyburka 0527e6a5c1 fix form font size 2024-03-16 01:59:00 +02:00
Pete Matsyburka d989b61d19 fix font size 2024-03-15 23:06:16 +02:00
Pete Matsyburka c1f3e6ff13 reduce image size 2024-03-15 21:16:30 +02:00
Pete Matsyburka 3c0fff2d82 skip on missing font character error 2024-03-15 20:24:12 +02:00
Pete Matsyburka bd599c264f rescue Encoding::CompatibilityError 2024-03-15 13:44:16 +02:00
Pete Matsyburka 0274093c89 save multiple document attachments 2024-03-15 13:03:10 +02:00
Pete Matsyburka 5845b5bdfd process malformed PDF 2024-03-15 12:17:52 +02:00
Pete Matsyburka 3dd0ba6b0b fix pdf font width 2024-03-14 23:10:21 +02:00
Pete Matsyburka e79306f8a3 optimize build annotations 2024-03-14 21:30:57 +02:00
Pete Matsyburka 0171b0c2dd allow to disable stamp logo 2024-03-14 14:09:43 +02:00
Pete Matsyburka a963ef53d3 adjust submitter role assign 2024-03-14 01:21:42 +02:00
Pete Matsyburka 344f302c76 refactor field validation 2024-03-13 20:25:41 +02:00
Pete Matsyburka 17ded282b2 add symbols font 2024-03-13 14:58:32 +02:00
Pete Matsyburka 2ce57df485 sanitize href 2024-03-13 11:54:56 +02:00
Pete Matsyburka 849289287e add html escape replace variable option 2024-03-12 23:47:58 +02:00
Pete Matsyburka 704fbc2393 try get log params 2024-03-11 21:34:59 +02:00
Pete Matsyburka 168fc8bd03 fix testing acocunt 2024-03-11 14:55:50 +02:00
Pete Matsyburka c5598d9efe fix replace email vars 2024-03-11 10:05:43 +02:00
38 changed files with 403 additions and 108 deletions

No files matched your search

+6 -3
View File
@@ -2,7 +2,9 @@ FROM ruby:3.2.2-alpine3.18 as fonts
WORKDIR /fonts
RUN apk --no-cache add wget && wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Regular.ttf && wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Bold.ttf && wget https://github.com/impallari/DancingScript/raw/master/fonts/DancingScript-Regular.otf && wget https://github.com/impallari/DancingScript/raw/master/OFL.txt
RUN apk --no-cache add fontforge wget && wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Regular.ttf && wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Bold.ttf && wget https://github.com/impallari/DancingScript/raw/master/fonts/DancingScript-Regular.otf && wget https://cdn.jsdelivr.net/gh/notofonts/notofonts.github.io/fonts/NotoSansSymbols2/hinted/ttf/NotoSansSymbols2-Regular.ttf && wget https://github.com/Maxattax97/gnu-freefont/raw/master/ttf/FreeSans.ttf && wget https://github.com/impallari/DancingScript/raw/master/OFL.txt
RUN fontforge -lang=py -c 'font1 = fontforge.open("FreeSans.ttf"); font2 = fontforge.open("NotoSansSymbols2-Regular.ttf"); font1.mergeFonts(font2); font1.generate("FreeSans.ttf")'
FROM ruby:3.2.2-alpine3.18 as webpack
@@ -38,11 +40,11 @@ ENV PIDFILE=/dev/null
WORKDIR /app
RUN apk add --no-cache build-base sqlite-dev libpq-dev mariadb-dev vips-dev vips-poppler poppler-utils vips-heif libc6-compat ttf-freefont && mkdir /fonts
RUN apk add --no-cache sqlite-dev libpq-dev mariadb-dev vips-dev vips-poppler poppler-utils vips-heif libc6-compat ttf-freefont && mkdir /fonts && rm /usr/share/fonts/freefont/FreeSans.otf
COPY ./Gemfile ./Gemfile.lock ./
RUN bundle update --bundler && bundle install && rm -rf ~/.bundle /usr/local/bundle/cache && ruby -e "puts Dir['/usr/local/bundle/**/{spec,rdoc,resources/shared,resources/collation,resources/locales}']" | xargs rm -rf
RUN apk add --no-cache build-base && bundle update --bundler && bundle install && apk del build-base && rm -rf ~/.bundle /usr/local/bundle/cache && ruby -e "puts Dir['/usr/local/bundle/**/{spec,rdoc,resources/shared,resources/collation,resources/locales}']" | xargs rm -rf
COPY ./bin ./bin
COPY ./app ./app
@@ -55,6 +57,7 @@ COPY ./tmp ./tmp
COPY LICENSE README.md Rakefile config.ru .version ./
COPY --from=fonts /fonts/GoNotoKurrent-Regular.ttf /fonts/GoNotoKurrent-Bold.ttf /fonts/DancingScript-Regular.otf /fonts/OFL.txt /fonts
COPY --from=fonts /fonts/FreeSans.ttf /usr/share/fonts/freefont
COPY --from=webpack /app/public/packs ./public/packs
RUN ln -s /fonts /app/public/fonts
@@ -19,6 +19,12 @@ module Api
render json: { error: e.message }, status: :unprocessable_entity
end
rescue_from RateLimit::LimitApproached do |e|
Rollbar.error(e) if defined?(Rollbar)
render json: { error: 'Too many requests' }, status: :too_many_requests
end
if Rails.env.production?
rescue_from CanCan::AccessDenied do |e|
Rollbar.warning(e) if defined?(Rollbar)
@@ -93,6 +93,7 @@ module Api
{ preferences: {},
conditions: [%i[field_uuid value action]],
options: [%i[value uuid]],
validation: %i[message pattern],
areas: [%i[x y w h cell_w attachment_uuid option_uuid page]] }]] }
]
@@ -22,6 +22,12 @@ class ApplicationController < ActionController::Base
redirect_to request.path
end
rescue_from RateLimit::LimitApproached do |e|
Rollbar.error(e) if defined?(Rollbar)
redirect_to request.referer, alert: 'Too many requests', status: :too_many_requests
end
if Rails.env.production?
rescue_from CanCan::AccessDenied do |e|
Rollbar.warning(e) if defined?(Rollbar)
@@ -5,6 +5,10 @@ class ConsoleRedirectController < ApplicationController
skip_authorization_check
def index
if request.path == '/upgrade'
params[:redir] = Docuseal.multitenant? ? "#{Docuseal::CONSOLE_URL}/plans" : "#{Docuseal::CONSOLE_URL}/on_premise"
end
return redirect_to(new_user_session_path({ redir: params[:redir] }.compact)) if true_user.blank?
auth = JsonWebToken.encode(uuid: true_user.uuid,
@@ -21,6 +21,8 @@ class SendSubmissionEmailController < ApplicationController
Submitter.find_by!(slug: params[:submitter_slug])
end
RateLimit.call("send-email-#{@submitter.id}", limit: 2, ttl: 5.minutes)
SubmitterMailer.documents_copy_email(@submitter, sig: true).deliver_later!
respond_to do |f|
+2
View File
@@ -20,6 +20,7 @@ import FolderAutocomplete from './elements/folder_autocomplete'
import SignatureForm from './elements/signature_form'
import SubmitForm from './elements/submit_form'
import PromptPassword from './elements/prompt_password'
import EmailsTextarea from './elements/emails_textarea'
import * as TurboInstantClick from './lib/turbo_instant_click'
@@ -53,6 +54,7 @@ window.customElements.define('folder-autocomplete', FolderAutocomplete)
window.customElements.define('signature-form', SignatureForm)
window.customElements.define('submit-form', SubmitForm)
window.customElements.define('prompt-password', PromptPassword)
window.customElements.define('emails-textarea', EmailsTextarea)
document.addEventListener('turbo:before-fetch-request', encodeMethodIntoRequestBody)
document.addEventListener('turbo:submit-end', async (event) => {
@@ -0,0 +1,56 @@
const emailRegexp = /([^@;,<>\s]+@[^@;,<>\s]+)/g
export default class extends HTMLElement {
connectedCallback () {
if (this.dataset.limit) {
this.textarea.addEventListener('input', () => {
const emails = this.textarea.value.match(emailRegexp) || []
this.updateCounter(emails.length)
})
}
}
updateCounter (count) {
let counter = document.getElementById('emails_counter')
let bulkMessage = document.getElementById('bulk_message')
if (count < 2) {
counter?.remove()
return
}
if ((count + 10) > this.dataset.limit) {
if (!counter) {
counter = document.createElement('span')
counter.id = 'emails_counter'
counter.classList.add('text-xs', 'right-0', 'absolute')
counter.style.bottom = '-15px'
this.textarea.parentNode.append(counter)
}
counter.innerText = `${count} / ${this.dataset.limit}`
}
if (this.dataset.bulkEnabled !== 'true') {
if (!bulkMessage) {
bulkMessage = document.createElement('span')
bulkMessage.id = 'bulk_message'
bulkMessage.classList.add('text-xs', 'left-0', 'absolute')
bulkMessage.style.bottom = '-15px'
this.textarea.parentNode.append(bulkMessage)
}
bulkMessage.innerHTML = '<a class="link" data-turbo="false" href="/upgrade">Upgrade</a> to bulk send multiple recipients'
}
}
get textarea () {
return this.querySelector('textarea')
}
}
@@ -28,6 +28,8 @@ export default class extends HTMLElement {
if (textarea && item[field]) {
textarea.value = textarea.value.replace(/[^;,\s]+$/, item[field] + ' ')
textarea.dispatchEvent(new Event('input', { bubbles: true }))
}
})
}
+19 -8
View File
@@ -157,6 +157,7 @@
ref="textContainer"
dir="auto"
class="flex items-center px-0.5 w-full"
:class="alignClasses[field.preferences?.align]"
>
<span v-if="Array.isArray(modelValue)">
{{ modelValue.join(', ') }}
@@ -167,6 +168,7 @@
<span
v-else
class="whitespace-pre-wrap"
:class="{ 'w-full': field.preferences?.align }"
>{{ modelValue }}</span>
</div>
</div>
@@ -253,6 +255,13 @@ export default {
phone: this.t('phone')
}
},
alignClasses () {
return {
center: 'text-center',
left: 'text-left',
right: 'text-right'
}
},
option () {
return this.field.options.find((o) => o.uuid === this.area.option_uuid)
},
@@ -338,17 +347,19 @@ export default {
},
watch: {
modelValue () {
if (this.field.type === 'text' && this.$refs.textContainer && (this.textOverflowChars === 0 || (this.textOverflowChars - 4) > this.modelValue.length)) {
this.textOverflowChars = this.$refs.textContainer.scrollHeight > this.$refs.textContainer.clientHeight ? this.modelValue.length : 0
}
this.$nextTick(() => {
if (['date', 'text', 'number'].includes(this.field.type) && this.$refs.textContainer && (this.textOverflowChars === 0 || (this.textOverflowChars - 4) > `${this.modelValue}`.length)) {
this.textOverflowChars = this.$refs.textContainer.scrollHeight > this.$refs.textContainer.clientHeight ? `${this.modelValue}`.length : 0
}
})
}
},
mounted () {
if (this.field.type === 'text' && this.$refs.textContainer) {
this.$nextTick(() => {
this.textOverflowChars = this.$refs.textContainer.scrollHeight > this.$refs.textContainer.clientHeight ? this.modelValue.length : 0
})
}
this.$nextTick(() => {
if (['date', 'text', 'number'].includes(this.field.type) && this.$refs.textContainer) {
this.textOverflowChars = this.$refs.textContainer.scrollHeight > this.$refs.textContainer.clientHeight ? `${this.modelValue}`.length : 0
}
})
},
methods: {
optionValue (option) {
+7 -1
View File
@@ -16,7 +16,8 @@
<div class="space-y-3 mt-5">
<a
v-if="completedButton.url"
:href="completedButton.url"
:href="sanitizeHref(completedButton.url)"
rel="noopener noreferrer nofollow"
class="white-button flex items-center w-full"
>
<span>
@@ -215,6 +216,11 @@ export default {
this.isDownloading = false
},
sanitizeHref (href) {
if (href && href.trim().match(/^((?:https?:\/\/)|\/)/)) {
return href.replace(/javascript:/g, '')
}
},
downloadSafariIos (urls) {
const fileRequests = urls.map((url) => {
return fetch(url).then(async (resp) => {
@@ -6,7 +6,7 @@
>
<a
v-if="item.startsWith('<a') && item.endsWith('</a>')"
:href="extractAttr(item, 'href')"
:href="sanitizeHref(extractAttr(item, 'href'))"
rel="noopener noreferrer nofollow"
:class="extractAttr(item, 'class') || 'link'"
target="_blank"
@@ -55,6 +55,11 @@ export default {
}
},
methods: {
sanitizeHref (href) {
if (href && href.trim().match(/^((?:https?:\/\/)|\/)/)) {
return href.replace(/javascript:/g, '')
}
},
extractAttr (text, attr) {
if (text.includes(attr)) {
return text.split(attr).pop().split('"')[1]
@@ -68,14 +68,13 @@
class="base-input !text-2xl w-full"
autocomplete="tel"
pattern="^\+[0-9\s\-]+$"
:oninvalid="`this.value ? this.setCustomValidity('${t('use_international_format')}...') : ''`"
oninput="this.setCustomValidity('')"
type="tel"
inputmode="tel"
:required="field.required"
placeholder="+1 234 567-8900"
:name="`values[${field.uuid}]`"
@input="$emit('update:model-value', $event.target.value)"
@invalid="$event.target.value ? $event.target.setCustomValidity(`${t('use_international_format')}...`) : ''"
@input="[$event.target.setCustomValidity(''), $emit('update:model-value', $event.target.value)]"
@focus="$emit('focus')"
>
</div>
+2 -2
View File
@@ -35,11 +35,11 @@
:class="{ '!pr-11 -mr-10': !field.validation?.pattern }"
:required="field.required"
:pattern="field.validation?.pattern"
:oninvalid="field.validation?.message ? `this.setCustomValidity(${JSON.stringify(field.validation.message)})` : ''"
:oninput="field.validation?.message ? `this.setCustomValidity('')` : ''"
:placeholder="`${t('type_here_')}${field.required ? '' : ` (${t('optional')})`}`"
type="text"
:name="`values[${field.uuid}]`"
@invalid="field.validation?.message ? $event.target.setCustomValidity(field.validation.message) : ''"
@input="field.validation?.message ? $event.target.setCustomValidity('') : ''"
@focus="$emit('focus')"
>
<textarea
+18 -9
View File
@@ -99,7 +99,7 @@
<div
class="flex items-center h-full w-full"
dir="auto"
:class="[bgColors[submitterIndex], field?.default_value ? '' : 'justify-center']"
:class="[bgColors[submitterIndex], field?.default_value ? (alignClasses[field.preferences?.align] || '') : 'justify-center']"
>
<span
v-if="field"
@@ -211,6 +211,13 @@ export default {
defaultName () {
return this.buildDefaultName(this.field, this.template.fields)
},
alignClasses () {
return {
center: 'justify-center',
left: 'justify-start',
right: 'justify-end'
}
},
optionIndexText () {
if (this.area.option_uuid && this.field.options) {
return `${this.field.options.findIndex((o) => o.uuid === this.area.option_uuid) + 1}.`
@@ -301,17 +308,19 @@ export default {
},
watch: {
'field.default_value' () {
if (this.field.type === 'text' && this.field.default_value && this.$refs.textContainer && (this.textOverflowChars === 0 || (this.textOverflowChars - 4) > this.field.default_value.length)) {
this.textOverflowChars = this.$el.clientHeight < this.$refs.textContainer.clientHeight ? this.field.default_value.length : 0
}
this.$nextTick(() => {
if (['date', 'text', 'number'].includes(this.field.type) && this.field.default_value && this.$refs.textContainer && (this.textOverflowChars === 0 || (this.textOverflowChars - 4) > `${this.field.default_value}`.length)) {
this.textOverflowChars = this.$el.clientHeight < this.$refs.textContainer.clientHeight ? `${this.field.default_value}`.length : 0
}
})
}
},
mounted () {
if (this.field.type === 'text' && this.field.default_value && this.$refs.textContainer && (this.textOverflowChars === 0 || (this.textOverflowChars - 4) > this.field.default_value)) {
this.$nextTick(() => {
this.textOverflowChars = this.$el.clientHeight < this.$refs.textContainer.clientHeight ? this.field.default_value.length : 0
})
}
this.$nextTick(() => {
if (['date', 'text', 'number'].includes(this.field.type) && this.field.default_value && this.$refs.textContainer && (this.textOverflowChars === 0 || (this.textOverflowChars - 4) > `${this.field.default_value}`.length)) {
this.textOverflowChars = this.$el.clientHeight < this.$refs.textContainer.clientHeight ? `${this.field.default_value}`.length : 0
}
})
},
methods: {
buildDefaultName: Field.methods.buildDefaultName,
+60 -8
View File
@@ -116,15 +116,41 @@
@click="closeDropdown"
>
<div
v-if="field.type === 'text' && !defaultField"
v-if="['number'].includes(field.type)"
class="py-1.5 px-1 relative"
@click.stop
>
<select
class="select select-bordered select-xs w-full max-w-xs h-7 !outline-0 font-normal"
@change="[field.preferences ||= {}, field.preferences.align = $event.target.value, save()]"
>
<option
v-for="value in ['left', 'right', 'center']"
:key="value"
:selected="field.preferences?.align ? value === field.preferences.align : value === 'left'"
:value="value"
>
{{ t(value) }}
</option>
</select>
<label
:style="{ backgroundColor: backgroundColor }"
class="absolute -top-1 left-2.5 px-1 h-4"
style="font-size: 8px"
>
{{ t('align') }}
</label>
</div>
<div
v-if="['text', 'number'].includes(field.type) && !defaultField"
class="py-1.5 px-1 relative"
@click.stop
>
<input
v-model="field.default_value"
type="text"
:placeholder="t('default_value')"
dir="auto"
:type="field.type"
class="input input-bordered input-xs w-full max-w-xs h-7 !outline-0"
@blur="save"
>
@@ -202,7 +228,7 @@
</label>
</div>
<li
v-if="field.type != 'phone'"
v-if="field.type != 'phone' && field.type != 'stamp'"
@click.stop
>
<label class="cursor-pointer py-1.5">
@@ -215,6 +241,20 @@
<span class="label-text">{{ t('required') }}</span>
</label>
</li>
<li
v-if="field.type == 'stamp'"
@click.stop
>
<label class="cursor-pointer py-1.5">
<input
:checked="field.preferences?.with_logo != false"
type="checkbox"
class="toggle toggle-xs"
@change="[field.preferences ||= {}, field.preferences.with_logo = field.preferences.with_logo == false, save()]"
>
<span class="label-text">{{ t('with_logo') }}</span>
</label>
</li>
<li
v-if="field.type == 'checkbox'"
@click.stop
@@ -244,7 +284,7 @@
</label>
</li>
<li
v-if="field.type === 'text' && !defaultField"
v-if="['text', 'number'].includes(field.type) && !defaultField"
@click.stop
>
<label class="cursor-pointer py-1.5">
@@ -257,8 +297,13 @@
<span class="label-text">{{ t('read_only') }}</span>
</label>
</li>
<hr class="pb-0.5 mt-0.5">
<li>
<hr
v-if="field.type != 'stamp'"
class="pb-0.5 mt-0.5"
>
<li
v-if="field.type != 'stamp'"
>
<label
class="label-text cursor-pointer text-center w-full flex items-center"
@click="isShowDescriptionModal = !isShowDescriptionModal"
@@ -271,7 +316,9 @@
</span>
</label>
</li>
<li>
<li
v-if="field.type != 'stamp'"
>
<label
class="label-text cursor-pointer text-center w-full flex items-center"
@click="isShowConditionsModal = !isShowConditionsModal"
@@ -664,7 +711,12 @@ export default {
},
removeOption (option) {
this.field.options.splice(this.field.options.indexOf(option), 1)
this.field.areas.splice(this.field.areas.findIndex((a) => a.option_uuid === option.uuid), 1)
const optionIndex = this.field.areas.findIndex((a) => a.option_uuid === option.uuid)
if (optionIndex !== -1) {
this.field.areas.splice(this.field.areas.findIndex((a) => a.option_uuid === option.uuid), 1)
}
this.save()
},
+5
View File
@@ -1,6 +1,11 @@
const en = {
align: 'Align',
left: 'Left',
right: 'Right',
center: 'Center',
description: 'Description',
display_title: 'Display title',
with_logo: 'With logo',
unchecked: 'Unchecked',
equal: 'Equal',
not_equal: 'Not equal',
+2 -3
View File
@@ -3,10 +3,9 @@
<head>
<title></title>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<style>
</style>
<style></style>
</head>
<body dir="<%= yield&.match?(TextUtils::RTL_REGEXP) ? 'rtl' : 'auto' %>">
<body dir="<%= TextUtils.rtl?(yield) ? 'rtl' : 'auto' %>">
<%= yield %>
<%= render partial: 'shared/mailer_attribution' %>
</body>
+7 -5
View File
@@ -1,11 +1,13 @@
<%= form_for '', url: template_submissions_path(template), html: { class: 'space-y-4', autocomplete: 'off' }, data: { turbo_frame: :_top } do |f| %>
<% if template.submitters.size == 1 %>
<submitter-item class="form-control">
<submitters-autocomplete data-field="email">
<autoresize-textarea>
<%= f.text_area :emails, required: true, class: 'base-textarea w-full', placeholder: 'Type emails here...' %>
</autoresize-textarea>
</submitters-autocomplete>
<emails-textarea data-bulk-enabled="<%= Docuseal.demo? || !Docuseal.multitenant? || can?(:manage, :bulk_send) %>" data-limit="<%= Docuseal.multitenant? ? (can?(:manage, :bulk_send) ? 50 : 1) : nil %>">
<submitters-autocomplete data-field="email" class="block relative">
<autoresize-textarea>
<%= f.text_area :emails, required: true, class: 'base-textarea w-full', placeholder: 'Type emails here...' %>
</autoresize-textarea>
</submitters-autocomplete>
</emails-textarea>
</submitter-item>
<% else %>
<dynamic-list class="space-y-4">
+2 -1
View File
@@ -1,4 +1,5 @@
<field-value dir="auto" class="flex absolute text-[1.5vw] lg:text-base" style="width: <%= area['w'] * 100 %>%; height: <%= area['h'] * 100 %>%; left: <%= area['x'] * 100 %>%; top: <%= area['y'] * 100 %>%">
<% align = field.dig('preferences', 'align') %>
<field-value dir="auto" class="flex absolute text-[1.5vw] lg:text-base <%= align == 'right' ? 'justify-end' : (align == 'center' ? 'justify-center' : '') %>" style="width: <%= area['w'] * 100 %>%; height: <%= area['h'] * 100 %>%; left: <%= area['x'] * 100 %>%; top: <%= area['y'] * 100 %>%">
<% if field['type'].in?(['signature', 'image', 'initials', 'stamp']) %>
<img class="object-contain mx-auto" src="<%= attachments_index[value].url %>" loading="lazy">
<% elsif field['type'].in?(['file', 'payment']) %>
@@ -1 +1 @@
<%= auto_link(simple_format(h(local_assigns[:content]))) %>
<%= auto_link(simple_format(h(ReplaceEmailVariables.call(local_assigns[:content], submitter: local_assigns[:submitter], sig: local_assigns[:sig])))) %>
@@ -1,5 +1,5 @@
<% if @email_config %>
<%= render 'custom_content', content: ReplaceEmailVariables.call(@email_config.value['body'], submitter: @submitter) %>
<%= render 'custom_content', content: @email_config.value['body'], submitter: @submitter %>
<% else %>
<p>Hi there,</p>
<p>"<%= @submitter.submission.template.name %>" form has been completed by <%= @submitter.submission.submitters.order(:completed_at).map { |e| e.name || e.email || e.phone }.join(', ') %>.</p>
@@ -1,5 +1,5 @@
<% if @email_config %>
<%= render 'custom_content', content: ReplaceEmailVariables.call(@email_config.value['body'], submitter: @submitter, sig: @sig) %>
<%= render 'custom_content', content: @email_config.value['body'], submitter: @submitter, sig: @sig %>
<% else %>
<p>Hi there,</p>
<p>Please check the copy of your "<%= @submitter.submission.template.name %>" submission in the email attachments.</p>
@@ -1,6 +1,6 @@
<% if @email_config || @body.present? %>
<% body = (@body.presence || @email_config.value['body']).to_s %>
<%= render 'custom_content', content: ReplaceEmailVariables.call(body, submitter: @submitter) %>
<%= render 'custom_content', content: body, submitter: @submitter %>
<% if !body.include?(ReplaceEmailVariables::SUBMITTER_LINK) && !body.include?(ReplaceEmailVariables::SUBMITTER_ID) && !body.include?(ReplaceEmailVariables::SUBMISSION_LINK) && !body.include?(ReplaceEmailVariables::TEMPLATE_ID) && !@submitter.submission.source.in?(%w[api embed]) %>
<p><%= link_to nil, submit_form_url(slug: @submitter.slug, t: SubmissionEvents.build_tracking_param(@submitter, 'click_email')) %></p>
<% end %>
+1 -1
View File
@@ -128,7 +128,7 @@ Rails.application.configure do
config.lograge.formatter = ->(data) { data.except(:path, :location).to_json }
config.lograge.custom_payload do |controller|
params = controller.request.params || {}
params = controller.request.try(:params) || {}
{
fwd: controller.request.ip,
+30 -1
View File
@@ -1,8 +1,8 @@
# frozen_string_literal: true
# fix NoMethodError: undefined method `field_value' for #<HexaPDF::Type::AcroForm::Field
module HexaPDF
module Type
# fix NoMethodError: undefined method `field_value' for #<HexaPDF::Type::AcroForm::Field
module AcroForm
class Field
def field_value
@@ -10,5 +10,34 @@ module HexaPDF
end
end
end
# comparison of Integer with HexaPDF::PDFArray failed
class CIDFont < Font
private
def widths
cache(:widths) do
result = {}
index = 0
array = self[:W] || []
while index < array.size
entry = array[index]
value = array[index + 1]
if value.is_a?(Array) || value.is_a?(HexaPDF::PDFArray)
value.each_with_index { |width, i| result[entry + i] = width }
index += 2
else
width = array[index + 2]
entry.upto(value) { |cid| result[cid] = width }
index += 3
end
end
result
end
end
end
end
end
+1
View File
@@ -55,6 +55,7 @@ Rails.application.routes.draw do
resource :user_signature, only: %i[edit update destroy]
resources :submissions, only: %i[show destroy]
resources :console_redirect, only: %i[index]
resources :upgrade, only: %i[index], controller: 'console_redirect'
resource :testing_account, only: %i[show destroy]
resources :testing_api_settings, only: %i[index]
resource :templates_upload, only: %i[create]
+1 -1
View File
@@ -32,7 +32,7 @@ module Accounts
end
def find_or_create_testing_user(account)
user = User.find_by(account: account.testing_accounts)
user = User.where(role: :admin).order(:id).find_by(account: account.testing_accounts)
return user if user
+19
View File
@@ -0,0 +1,19 @@
# frozen_string_literal: true
module RateLimit
LimitApproached = Class.new(StandardError)
STORE = ActiveSupport::Cache::MemoryStore.new
module_function
def call(key, limit:, ttl:, enabled: Docuseal.multitenant?)
return true unless enabled
value = STORE.increment(key, 1, expires_in: ttl)
raise LimitApproached if value > limit
true
end
end
+26 -16
View File
@@ -20,25 +20,25 @@ module ReplaceEmailVariables
module_function
# rubocop:disable Metrics
def call(text, submitter:, tracking_event_type: 'click_email', sig: nil)
text = text.gsub(TEMPLATE_NAME) { submitter.template.name }
text = text.gsub(TEMPLATE_ID) { submitter.template.id }
text = text.gsub(SUBMITTER_ID) { submitter.id }
text = text.gsub(SUBMITTER_SLUG) { submitter.slug }
text = text.gsub(SUBMISSION_ID) { submitter.submission.id }
text = text.gsub(SUBMITTER_EMAIL) { submitter.email }
text = text.gsub(SUBMITTER_NAME) { submitter.name || submitter.email || submitter.phone }
text = text.gsub(SUBMITTER_LINK) { build_submitter_link(submitter, tracking_event_type) }
text = text.gsub(SUBMISSION_LINK) do
def call(text, submitter:, tracking_event_type: 'click_email', html_escape: false, sig: nil)
text = replace(text, TEMPLATE_NAME, html_escape:) { submitter.template.name }
text = replace(text, TEMPLATE_ID, html_escape:) { submitter.template.id }
text = replace(text, SUBMITTER_ID, html_escape:) { submitter.id }
text = replace(text, SUBMITTER_SLUG, html_escape:) { submitter.slug }
text = replace(text, SUBMISSION_ID, html_escape:) { submitter.submission.id }
text = replace(text, SUBMITTER_EMAIL, html_escape:) { submitter.email }
text = replace(text, SUBMITTER_NAME, html_escape:) { submitter.name || submitter.email || submitter.phone }
text = replace(text, SUBMITTER_LINK, html_escape:) { build_submitter_link(submitter, tracking_event_type) }
text = replace(text, SUBMISSION_LINK, html_escape:) do
submitter.submission ? build_submission_link(submitter.submission) : ''
end
text = text.gsub(SUBMISSION_SUBMITTERS) { build_submission_submitters(submitter.submission) }
text = text.gsub(DOCUMENTS_LINKS) { build_documents_links_text(submitter, sig) }
text = text.gsub(DOCUMENTS_LINK) { build_documents_links_text(submitter, sig) }
text = text.gsub(ACCOUNT_NAME) { submitter.account.name }
text = text.gsub(SENDER_NAME) { submitter.submission.created_by_user&.full_name }
text = replace(text, SUBMISSION_SUBMITTERS, html_escape:) { build_submission_submitters(submitter.submission) }
text = replace(text, DOCUMENTS_LINKS, html_escape:) { build_documents_links_text(submitter, sig) }
text = replace(text, DOCUMENTS_LINK, html_escape:) { build_documents_links_text(submitter, sig) }
text = replace(text, ACCOUNT_NAME, html_escape:) { submitter.submission.account.name }
text = replace(text, SENDER_NAME, html_escape:) { submitter.submission.created_by_user&.full_name }
text.gsub(SENDER_EMAIL) { submitter.submission.created_by_user&.email.to_s.sub(/\+\w+@/, '@') }
replace(text, SENDER_EMAIL, html_escape:) { submitter.submission.created_by_user&.email.to_s.sub(/\+\w+@/, '@') }
end
# rubocop:enable Metrics
@@ -48,6 +48,16 @@ module ReplaceEmailVariables
)
end
def replace(text, var, html_escape: false)
text.gsub(var) do
if html_escape
ERB::Util.html_escape(yield)
else
yield
end
end
end
def build_submitter_link(submitter, tracking_event_type)
if tracking_event_type == 'click_email'
Rails.application.routes.url_helpers.submit_form_url(
+8 -3
View File
@@ -125,9 +125,14 @@ module Submissions
end
def find_submitter_uuid(template, attrs, index)
attrs[:uuid].presence ||
template.submitters.find { |e| e['name'] == attrs[:role] }&.dig('uuid') ||
template.submitters[index]&.dig('uuid')
uuid = attrs[:uuid].presence
uuid ||= template.submitters.find { |e| e['name'].to_s.casecmp(attrs[:role].to_s).zero? }&.dig('uuid')
if attrs[:role].present? && uuid.blank? && defined?(Rollbar)
Rollbar.error("Role doesn't existng: #{attrs[:role]}, #{template.id}")
end
uuid || template.submitters[index]&.dig('uuid')
end
def build_submitter(submission:, attrs:, uuid:, is_order_sent:, mark_as_sent:, user:, preferences:)
+2 -1
View File
@@ -41,6 +41,8 @@ module Submissions
composer = HexaPDF::Composer.new(skip_page_creation: true)
composer.document.fonts.add(FONT_BOLD_NAME, variant: :bold)
composer.document.config['font.on_missing_glyph'] =
Submissions::GenerateResultAttachments.method(:on_missing_glyph).to_proc
divider = HexaPDF::Layout::Box.create(
margin: [0, 0, 15, 0],
@@ -245,7 +247,6 @@ module Submissions
end
value = value.join(', ') if value.is_a?(Array)
value = Submissions::GenerateResultAttachments::REPLACE_EMOJI[value] || value
composer.formatted_text_box([{ text: TextUtils.maybe_rtl_reverse(value.to_s.presence || 'n/a') }],
text_align: value.to_s.match?(RTL_REGEXP) ? :right : :left,
+69 -24
View File
@@ -21,9 +21,18 @@ module Submissions
A4_SIZE = [595, 842].freeze
SUPPORTED_IMAGE_TYPES = ['image/png', 'image/jpeg'].freeze
REPLACE_EMOJI = {
MISSING_GLYPH_REPLACE = {
'▪' => '-',
'✔️' => 'V',
'✔' => 'V'
'✔' => 'V',
'✅' => 'V'
}.freeze
MISSING_GLYPH_REPLACE_TYPE1 = {
'▪' => :bullet,
'✔️' => :V,
'✔' => :V,
'✅' => :V
}.freeze
module_function
@@ -62,12 +71,14 @@ module Submissions
width = page.box.width
height = page.box.height
font_size = ((page.box.width / A4_SIZE[0].to_f) * FONT_SIZE).to_i
font_size = (([page.box.width, page.box.height].min / A4_SIZE[0].to_f) * FONT_SIZE).to_i
value = submitter.values[field['uuid']]
layouter = HexaPDF::Layout::TextLayouter.new(text_valign: :center,
text_align: value.to_s.match?(RTL_REGEXP) ? :right : :left,
text_align = field.dig('preferences', 'align').to_s.to_sym.presence ||
(value.to_s.match?(RTL_REGEXP) ? :right : :left)
layouter = HexaPDF::Layout::TextLayouter.new(text_valign: :center, text_align:,
font: pdf.fonts.add(FONT_NAME), font_size:)
next if Array.wrap(value).compact_blank.blank?
@@ -181,7 +192,6 @@ module Submissions
end
value = TextUtils.maybe_rtl_reverse(Array.wrap(value).join(', '))
value = REPLACE_EMOJI[value] || value
text = HexaPDF::Layout::TextFragment.create(value, font: pdf.fonts.add(FONT_NAME),
font_size:)
@@ -194,15 +204,23 @@ module Submissions
font: pdf.fonts.add(FONT_NAME),
font_size: (font_size / 1.4).to_i)
lines = layouter.fit([text], area['w'] * width, height).lines
lines = layouter.fit([text], field['type'].in?(%w[date number]) ? width : area['w'] * width, height).lines
box_height = lines.sum(&:height)
end
height_diff = [0, box_height - (area['h'] * height)].max
layouter.fit([text], area['w'] * width, height_diff.positive? ? box_height : area['h'] * height)
.draw(canvas, (area['x'] * width) + TEXT_LEFT_MARGIN,
right_align_x_adjustment =
if field['type'].in?(%w[date number]) && text_align != :left
(width - (area['w'] * width)) / (text_align == :center ? 2.0 : 1)
else
0
end
layouter.fit([text], field['type'].in?(%w[date number]) ? width : area['w'] * width,
height_diff.positive? ? box_height : area['h'] * height)
.draw(canvas, (area['x'] * width) - right_align_x_adjustment + TEXT_LEFT_MARGIN,
height - (area['y'] * height) + height_diff - TEXT_TOP_MARGIN)
end
end
@@ -211,28 +229,28 @@ module Submissions
image_pdfs = []
original_documents = template.documents.preload(:blob)
results =
result_attachments =
submitter.submission.template_schema.map do |item|
pdf = pdfs_index[item['attachment_uuid']]
attachment = save_pdf(pdf:, submitter:, pkcs:, tsa_url:,
uuid: item['attachment_uuid'],
name: item['name'])
attachment = build_pdf_attachment(pdf:, submitter:, pkcs:, tsa_url:,
uuid: item['attachment_uuid'],
name: item['name'])
image_pdfs << pdf if original_documents.find { |a| a.uuid == item['attachment_uuid'] }.image?
attachment
end
return results if image_pdfs.size < 2
return result_attachments.map { |e| e.tap(&:save!) } if image_pdfs.size < 2
images_pdf =
image_pdfs.each_with_object(HexaPDF::Document.new) do |pdf, doc|
pdf.pages.each { |page| doc.pages << doc.import(page) }
end
images_pdf_result =
save_pdf(
images_pdf_attachment =
build_pdf_attachment(
pdf: images_pdf,
submitter:,
tsa_url:,
@@ -241,11 +259,10 @@ module Submissions
name: template.name
)
results + [images_pdf_result]
(result_attachments + [images_pdf_attachment]).map { |e| e.tap(&:save!) }
end
# rubocop:enable Metrics
def save_pdf(pdf:, submitter:, pkcs:, tsa_url:, uuid:, name:)
def build_pdf_attachment(pdf:, submitter:, pkcs:, tsa_url:, uuid:, name:)
io = StringIO.new
pdf.trailer.info[:Creator] = info_creator
@@ -265,12 +282,25 @@ module Submissions
sign_params[:signature_size] = 10_000
end
pdf.sign(io, write_options: { validate: false }, **sign_params)
else
pdf.write(io, incremental: true, validate: false)
end
begin
pdf.sign(io, write_options: { validate: false }, **sign_params)
rescue HexaPDF::MalformedPDFError => e
Rollbar.error(e) if defined?(Rollbar)
ActiveStorage::Attachment.create!(
pdf.sign(io, write_options: { validate: false, incremental: false }, **sign_params)
end
else
begin
pdf.write(io, incremental: true, validate: false)
rescue HexaPDF::MalformedPDFError => e
Rollbar.error(e) if defined?(Rollbar)
pdf.write(io, incremental: false, validate: false)
end
end
# rubocop:enable Metrics
ActiveStorage::Attachment.new(
blob: ActiveStorage::Blob.create_and_upload!(io: StringIO.new(io.string), filename: "#{name}.pdf"),
metadata: { original_uuid: uuid,
analyzed: true,
@@ -306,10 +336,25 @@ module Submissions
Rollbar.error(e) if defined?(Rollbar)
end
pdf.config['font.on_missing_glyph'] = method(:on_missing_glyph).to_proc
[attachment.metadata['original_uuid'] || attachment.uuid, pdf]
end
end
def on_missing_glyph(character, font_wrapper)
Rollbar.error("Missing glyph: #{character}") if character.present? && defined?(Rollbar)
replace_with =
if font_wrapper.font_type == :Type1
MISSING_GLYPH_REPLACE_TYPE1[character] || :space
else
(MISSING_GLYPH_REPLACE[character] || ' ').bytes.first - 29
end
font_wrapper.custom_glyph(replace_with, character)
end
def find_last_submitter(submitter)
submitter.submission.submitters
.select(&:completed_at?)
+9 -4
View File
@@ -13,8 +13,8 @@ module Submitters
module_function
def call(submitter)
image = generate_stamp_image(submitter)
def call(submitter, with_logo: true)
image = generate_stamp_image(submitter, with_logo:)
image_data = image.write_to_buffer('.png')
@@ -30,8 +30,13 @@ module Submitters
)
end
def generate_stamp_image(submitter)
logo = Vips::Image.new_from_buffer(load_logo(submitter).read, '')
def generate_stamp_image(submitter, with_logo: true)
logo =
if with_logo
Vips::Image.new_from_buffer(load_logo(submitter).read, '')
else
Vips::Image.new_from_buffer(TRANSPARENT_PIXEL, '').resize(WIDTH)
end
logo = logo.resize([WIDTH / logo.width.to_f, HEIGHT / logo.height.to_f].min)
+3 -1
View File
@@ -78,7 +78,9 @@ module Submitters
next if field['submitter_uuid'] != submitter.uuid
if field['type'] == 'stamp'
acc[field['uuid']] ||= Submitters::CreateStampAttachment.call(submitter).uuid
acc[field['uuid']] ||=
Submitters::CreateStampAttachment.call(submitter,
with_logo: field.dig('preferences', 'with_logo') != false).uuid
next
end
+6 -8
View File
@@ -5,12 +5,10 @@ module Templates
module_function
def call(data)
pdf = PDF::Reader.new(StringIO.new(data))
pdf = HexaPDF::Document.new(io: StringIO.new(data))
pdf.pages.flat_map.with_index do |page, index|
annotations = page.objects.deref!(page.attributes[:Annots]) || []
annotations.filter_map do |annot|
next if annot.nil?
(page[:Annots] || []).filter_map do |annot|
next if annot[:A].blank? || annot[:A][:URI].blank?
next unless annot[:Subtype] == :Link
next if !annot[:A][:URI].starts_with?('https://') && !annot[:A][:URI].starts_with?('http://')
@@ -30,10 +28,10 @@ module Templates
{
'type' => 'external_link',
'value' => annot[:A][:URI],
'x' => left / page.width.to_f,
'y' => (page.height - top) / page.height.to_f,
'w' => (right - left) / page.width.to_f,
'h' => (top - bottom) / page.height.to_f
'x' => left / page.box.width.to_f,
'y' => (page.box.height - top) / page.box.height.to_f,
'w' => (right - left) / page.box.width.to_f,
'h' => (top - bottom) / page.box.height.to_f
}
end
end
+8
View File
@@ -5,6 +5,14 @@ module TextUtils
module_function
def rtl?(text)
return false if text.blank?
text.match?(TextUtils::RTL_REGEXP)
rescue Encoding::CompatibilityError
false
end
def maybe_rtl_reverse(text)
if text.match?(RTL_REGEXP)
TwitterCldr::Shared::Bidi
+1
View File
@@ -8,6 +8,7 @@ module.exports = {
'./app/views/start_form/**/*.erb',
'./app/views/shared/_button_title.html.erb',
'./app/views/shared/_attribution.html.erb',
'./app/views/scripts/_autosize_field.html.erb',
'./app/views/send_submission_email/**/*.erb'
]
}