Compare commits

...
14 Commits
23 changed files with 711 additions and 415 deletions

No files matched your search

+3 -2
View File
@@ -36,14 +36,15 @@ FROM ruby:3.3.1-alpine3.18 as app
ENV RAILS_ENV=production
ENV BUNDLE_WITHOUT="development:test"
ENV LD_PRELOAD=/lib/libgcompat.so.0
WORKDIR /app
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
RUN apk add --no-cache sqlite-dev libpq-dev mariadb-dev vips-dev vips-poppler poppler-utils vips-heif gcompat ttf-freefont && mkdir /fonts && rm /usr/share/fonts/freefont/FreeSans.otf
COPY ./Gemfile ./Gemfile.lock ./
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
RUN apk add --no-cache build-base && 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
+1 -1
View File
@@ -39,7 +39,7 @@ gem 'ruby-vips'
gem 'rubyXL'
gem 'shakapacker'
gem 'sidekiq', require: ENV.key?('REDIS_URL')
gem 'sqlite3', require: false
gem 'sqlite3', require: false, force_ruby_platform: true
gem 'strip_attributes'
gem 'turbo-rails'
gem 'twitter_cldr', require: false
+5 -3
View File
@@ -333,7 +333,9 @@ GEM
nokogiri (1.16.4)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
nokogiri (1.16.4-arm64-darwin)
nokogiri (1.16.4-aarch64-linux)
racc (~> 1.4)
nokogiri (1.16.4-x86_64-linux)
racc (~> 1.4)
oj (3.16.3)
bigdecimal (>= 3.0)
@@ -529,7 +531,6 @@ GEM
smart_properties (1.17.0)
sqlite3 (1.7.3)
mini_portile2 (~> 2.8.0)
sqlite3 (1.7.3-arm64-darwin)
stringio (3.1.0)
strip_attributes (1.13.0)
activemodel (>= 3.0, < 8.0)
@@ -573,8 +574,9 @@ GEM
zeitwerk (2.6.13)
PLATFORMS
arm64-darwin-22
aarch64-linux
ruby
x86_64-linux
DEPENDENCIES
annotate
+9
View File
@@ -3,3 +3,12 @@
If you come across any security concern or vulnarability, please report the information via email to security@docuseal.co instead of opening a GitHub issue. We will promptly respond and will collaborate with you to validate the issue, and resolve it ASAP.
**We have a bug bounty program to reward security researchers.**
Out of scope vulnerabilities:
- CSRF
- DNSSEC, CAA, CSP headers
- DNS or email security related
- Rate Limiting
Note: We reserve the right to classify any reported vulnerability as out of scope for the bug bounty program.
+28 -7
View File
@@ -104,20 +104,41 @@ module Api
end
def assign_submitter_attrs(submitter, attrs)
submitter.email = Submissions.normalize_email(attrs[:email]) if attrs.key?(:email)
submitter.phone = attrs[:phone].to_s.gsub(/[^0-9+]/, '') if attrs.key?(:phone)
submitter.values = submitter.values.merge(attrs[:values].to_unsafe_h) if attrs[:values].present?
submitter.completed_at = attrs[:completed] ? Time.current : submitter.completed_at
submitter.external_id = attrs[:application_key] if attrs.key?(:application_key)
submitter.external_id = attrs[:external_id] if attrs.key?(:external_id)
submitter.metadata = attrs[:metadata] if attrs.key?(:metadata)
values = attrs[:values]&.to_unsafe_h || {}
assign_submission_fields(submitter.submission)
phone_field_uuid = submitter.submission.template_fields.find do |f|
values[f['uuid']].present? && f['type'] == 'phone'
end&.dig('uuid')
submitter.email = Submissions.normalize_email(attrs[:email]) if attrs.key?(:email)
if attrs.key?(:phone)
submitter.phone = attrs[:phone].to_s.gsub(/[^0-9+]/, '')
elsif values[phone_field_uuid].present?
submitter.phone = values[phone_field_uuid].to_s.gsub(/[^0-9+]/, '')
end
values = values.except(phone_field_uuid)
submitter.values = submitter.values.merge(values) if values.present?
submitter.completed_at = attrs[:completed] ? Time.current : submitter.completed_at
submitter.metadata = attrs[:metadata] if attrs.key?(:metadata)
assign_external_id(submitter, attrs)
assign_preferences(submitter, attrs)
submitter
end
def assign_external_id(submitter, attrs)
submitter.external_id = attrs[:application_key] if attrs.key?(:application_key)
submitter.external_id = attrs[:external_id] if attrs.key?(:external_id)
submitter
end
def assign_submission_fields(submission)
submission.template_fields ||= submission.template.fields
submission.template_schema ||= submission.template.schema
@@ -27,7 +27,8 @@ class VerifyPdfSignatureController < ApplicationController
trusted_certs = [default_pkcs.certificate,
*default_pkcs.ca_certs,
*custom_certs.map(&:certificate),
*custom_certs.flat_map(&:ca_certs).compact]
*custom_certs.flat_map(&:ca_certs).compact,
*Docuseal.trusted_certs]
render turbo_stream: turbo_stream.replace('result', partial: 'result',
locals: { pdfs:, files: params[:files], trusted_certs: })
+1 -1
View File
@@ -3,7 +3,7 @@
class="field-area flex absolute lg:text-base -outline-offset-1"
dir="auto"
:style="computedStyle"
:class="{ 'text-[1.5vw] lg:text-base': !textOverflowChars, 'text-[1.0vw] lg:text-xs': textOverflowChars, 'cursor-default': !submittable, 'border border-red-100 bg-red-100 cursor-pointer': submittable, 'border border-red-100': !isActive && submittable, 'bg-opacity-80': !isActive && !isValueSet && submittable, 'outline-red-500 outline-dashed outline-2 z-10': isActive && submittable, 'bg-opacity-40': (isActive || isValueSet) && submittable }"
:class="{ 'text-[1.5vw] lg:text-base': !textOverflowChars, 'text-[1.0vw] lg:text-xs': textOverflowChars, 'cursor-default': !submittable, 'border border-red-100 bg-red-100 cursor-pointer': submittable, 'border border-red-100': !isActive && submittable, 'bg-opacity-80': !isActive && !isValueSet && submittable, 'field-area-active outline-red-500 outline-dashed outline-2 z-10': isActive && submittable, 'bg-opacity-40': (isActive || isValueSet) && submittable }"
>
<div
v-if="!isActive && !isValueSet && field.type !== 'checkbox' && submittable && !area.option_uuid"
+31 -1
View File
@@ -104,9 +104,13 @@ export default {
this.isLoading = true
return await Promise.all(
Array.from(files).map((file) => {
Array.from(files).map(async (file) => {
const formData = new FormData()
if (file.type === 'image/bmp') {
file = await this.convertBmpToPng(file)
}
formData.append('file', file)
formData.append('submitter_slug', this.submitterSlug)
formData.append('name', 'attachments')
@@ -122,6 +126,32 @@ export default {
}).finally(() => {
this.isLoading = false
})
},
convertBmpToPng (bmpFile) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = function (event) {
const img = new Image()
img.onload = function () {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
canvas.width = img.width
canvas.height = img.height
ctx.drawImage(img, 0, 0)
canvas.toBlob(function (blob) {
const newFile = new File([blob], bmpFile.name.replace(/\.\w+$/, '.png'), { type: 'image/png' })
resolve(newFile)
}, 'image/png')
}
img.src = event.target.result
}
reader.onerror = reject
reader.readAsDataURL(bmpFile)
})
}
}
}
+105 -3
View File
@@ -70,7 +70,7 @@
@blur="onNameBlur"
>{{ optionIndexText }} {{ (defaultField ? (field.title || field.name) : field.name) || defaultName }}</span>
<div
v-if="isNameFocus && !['checkbox', 'phone'].includes(field.type)"
v-if="isSettingsFocus || (isNameFocus && !['checkbox', 'phone'].includes(field.type))"
class="flex items-center ml-1.5"
>
<input
@@ -86,6 +86,47 @@
@click.prevent="field.required = !field.required"
@mousedown.prevent
>{{ t('required') }}</label>
<span
v-if="field.type !== 'payment'"
class="dropdown dropdown-end"
@mouseenter="renderDropdown = true"
@touchstart="renderDropdown = true"
>
<label
ref="settingsButton"
tabindex="0"
:title="t('settings')"
class="cursor-pointer flex items-center"
style="height: 25px"
@focus="isSettingsFocus = true"
@blur="maybeBlurSettings"
>
<IconDotsVertical class="w-5 h-5" />
</label>
<ul
v-if="renderDropdown"
ref="settingsDropdown"
tabindex="0"
class="dropdown-content menu menu-xs px-2 pb-2 pt-1 shadow rounded-box w-52 z-10 rounded-t-none"
:style="{ backgroundColor: 'white' }"
@dragstart.prevent.stop
@click="closeDropdown"
@focusout="maybeBlurSettings"
>
<FieldSettings
:field="field"
:default-field="defaultField"
:editable="editable"
:background-color="'white'"
:with-required="false"
:with-areas="false"
@click-formula="isShowFormulaModal = true"
@click-description="isShowDescriptionModal = true"
@click-condition="isShowConditionsModal = true"
@scroll-to="[selectedAreaRef.value = $event, $emit('scroll-to', $event)]"
/>
</ul>
</span>
</div>
<button
v-else-if="editable"
@@ -145,6 +186,38 @@
@mousedown.stop="startResize"
@touchstart="startTouchResize"
/>
<Teleport
v-if="isShowFormulaModal"
:to="modalContainerEl"
>
<FormulaModal
:field="field"
:editable="editable && !defaultField"
:build-default-name="buildDefaultName"
@close="isShowFormulaModal = false"
/>
</Teleport>
<Teleport
v-if="isShowConditionsModal"
:to="modalContainerEl"
>
<ConditionsModal
:field="field"
:build-default-name="buildDefaultName"
@close="isShowConditionsModal = false"
/>
</Teleport>
<Teleport
v-if="isShowDescriptionModal"
:to="modalContainerEl"
>
<DescriptionModal
:field="field"
:editable="editable && !defaultField"
:build-default-name="buildDefaultName"
@close="isShowDescriptionModal = false"
/>
</Teleport>
</div>
</template>
@@ -152,7 +225,11 @@
import FieldSubmitter from './field_submitter'
import FieldType from './field_type'
import Field from './field'
import { IconX, IconCheck } from '@tabler/icons-vue'
import FieldSettings from './field_settings'
import FormulaModal from './formula_modal'
import ConditionsModal from './conditions_modal'
import DescriptionModal from './description_modal'
import { IconX, IconCheck, IconDotsVertical } from '@tabler/icons-vue'
import { v4 } from 'uuid'
export default {
@@ -160,6 +237,11 @@ export default {
components: {
FieldType,
IconCheck,
FieldSettings,
FormulaModal,
IconDotsVertical,
DescriptionModal,
ConditionsModal,
FieldSubmitter,
IconX
},
@@ -195,11 +277,16 @@ export default {
default: null
}
},
emits: ['start-resize', 'stop-resize', 'start-drag', 'stop-drag', 'remove'],
emits: ['start-resize', 'stop-resize', 'start-drag', 'stop-drag', 'remove', 'scroll-to'],
data () {
return {
isShowFormulaModal: false,
isShowConditionsModal: false,
isSettingsFocus: false,
isShowDescriptionModal: false,
isResize: false,
isDragged: false,
renderDropdown: false,
isNameFocus: false,
textOverflowChars: 0,
dragFrom: { x: 0, y: 0 }
@@ -208,6 +295,9 @@ export default {
computed: {
fieldNames: FieldType.computed.fieldNames,
fieldIcons: FieldType.computed.fieldIcons,
modalContainerEl () {
return this.$el.getRootNode().querySelector('#docuseal_modal_container')
},
defaultName () {
return this.buildDefaultName(this.field, this.template.fields)
},
@@ -324,6 +414,14 @@ export default {
},
methods: {
buildDefaultName: Field.methods.buildDefaultName,
closeDropdown () {
document.activeElement.blur()
},
maybeBlurSettings (e) {
if (!e.relatedTarget || !this.$refs.settingsDropdown.contains(e.relatedTarget)) {
this.isSettingsFocus = false
}
},
onNameFocus (e) {
this.selectedAreaRef.value = this.area
@@ -379,6 +477,10 @@ export default {
})
},
onNameBlur (e) {
if (e.relatedTarget === this.$refs.settingsButton) {
this.isSettingsFocus = true
}
const text = this.$refs.name.innerText.trim()
this.isNameFocus = false
+1 -1
View File
@@ -857,7 +857,7 @@ export default {
type = this.fieldTypes[0]
}
if (type === 'checkbox' && !this.drawFieldType) {
if (type === 'checkbox' && !this.drawFieldType && (this.template.fields[this.template.fields.length - 1]?.type === 'checkbox' || area.w)) {
const previousField = [...this.template.fields].reverse().find((f) => f.type === type)
const previousArea = previousField?.areas?.[previousField.areas.length - 1]
@@ -14,10 +14,7 @@
@focus="$emit('focus', $event)"
@blur="onBlur"
>
<template
v-for="(item, index) in (value || '').split(/(_)/)"
:key="index"
>{{ item }}<wbr></template>
{{ value }}
</span>
<span
v-if="withRequired"
+4 -1
View File
@@ -17,6 +17,7 @@
:image="image"
@drop-field="$emit('drop-field', {...$event, attachment_uuid: document.uuid })"
@remove-area="$emit('remove-area', $event)"
@scroll-to="scrollToArea"
@draw="$emit('draw', {...$event, attachment_uuid: document.uuid })"
/>
</div>
@@ -125,7 +126,9 @@ export default {
},
methods: {
scrollToArea (area) {
this.pageRefs[area.page].areaRefs.find((e) => e.area === area).$el.scrollIntoView({ behavior: 'smooth', block: 'center' })
this.$nextTick(() => {
this.pageRefs[area.page].areaRefs.find((e) => e.area === area).$el.scrollIntoView({ behavior: 'smooth', block: 'center' })
})
},
setPageRefs (el) {
if (el) {
+14 -354
View File
@@ -118,280 +118,17 @@
@dragstart.prevent.stop
@click="closeDropdown"
>
<div
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 bg-transparent"
@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: dropdownBgColor }"
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"
:placeholder="t('default_value')"
dir="auto"
:type="field.type"
class="input input-bordered input-xs w-full max-w-xs h-7 !outline-0 bg-transparent"
@blur="save"
>
<label
v-if="field.default_value"
:style="{ backgroundColor: dropdownBgColor }"
class="absolute -top-1 left-2.5 px-1 h-4"
style="font-size: 8px"
>
{{ t('default_value') }}
</label>
</div>
<div
v-if="field.type === 'date'"
class="py-1.5 px-1 relative"
@click.stop
>
<select
v-model="field.preferences.format"
:placeholder="t('format')"
class="select select-bordered select-xs font-normal w-full max-w-xs !h-7 !outline-0 bg-transparent"
@change="save"
>
<option
v-for="format in dateFormats"
:key="format"
:value="format"
>
{{ formatDate(new Date(), format) }}
</option>
</select>
<label
:style="{ backgroundColor: dropdownBgColor }"
class="absolute -top-1 left-2.5 px-1 h-4"
style="font-size: 8px"
>
{{ t('format') }}
</label>
</div>
<div
v-if="field.type === 'signature'"
class="py-1.5 px-1 relative"
@click.stop
>
<select
:placeholder="t('format')"
class="select select-bordered select-xs font-normal w-full max-w-xs !h-7 !outline-0 bg-transparent"
@change="[field.preferences.format = $event.target.value, save()]"
>
<option
value="any"
:selected="!field.preferences?.format || field.preferences.format === 'any'"
>
{{ t('any') }}
</option>
<option
value="drawn"
:selected="field.preferences?.format === 'drawn'"
>
{{ t('drawn') }}
</option>
<option
value="typed"
:selected="field.preferences?.format === 'typed'"
>
{{ t('typed') }}
</option>
</select>
<label
:style="{ backgroundColor: dropdownBgColor }"
class="absolute -top-1 left-2.5 px-1 h-4"
style="font-size: 8px"
>
{{ t('format') }}
</label>
</div>
<li
v-if="field.type != 'phone' && field.type != 'stamp'"
@click.stop
>
<label class="cursor-pointer py-1.5">
<input
v-model="field.required"
type="checkbox"
:disabled="!editable || defaultField"
class="toggle toggle-xs"
@update:model-value="save"
>
<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
>
<label class="cursor-pointer py-1.5">
<input
v-model="field.default_value"
type="checkbox"
class="toggle toggle-xs"
@update:model-value="[field.default_value = $event, field.readonly = $event, save()]"
>
<span class="label-text">{{ t('checked') }}</span>
</label>
</li>
<li
v-if="field.type == 'date'"
@click.stop
>
<label class="cursor-pointer py-1.5">
<input
v-model="field.readonly"
type="checkbox"
class="toggle toggle-xs"
@update:model-value="[field.default_value = $event ? '{{date}}' : null, field.readonly = $event, save()]"
>
<span class="label-text">{{ t('set_signing_date') }}</span>
</label>
</li>
<li
v-if="['text', 'number'].includes(field.type) && !defaultField"
@click.stop
>
<label class="cursor-pointer py-1.5">
<input
v-model="field.readonly"
type="checkbox"
class="toggle toggle-xs"
@update:model-value="save"
>
<span class="label-text">{{ t('read_only') }}</span>
</label>
</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"
>
<IconInfoCircle
width="18"
/>
<span class="text-sm">
{{ t('description') }}
</span>
</label>
</li>
<li
v-if="field.type != 'stamp'"
>
<label
class="label-text cursor-pointer text-center w-full flex items-center"
@click="isShowConditionsModal = !isShowConditionsModal"
>
<IconRouteAltLeft
width="18"
/>
<span class="text-sm">
{{ t('condition') }}
</span>
</label>
</li>
<li v-if="field.type == 'number'">
<label
class="label-text cursor-pointer text-center w-full flex items-center"
@click="isShowFormulaModal = true"
>
<IconMathFunction
width="18"
/>
<span class="text-sm">
{{ t('formula') }}
</span>
</label>
</li>
<hr class="pb-0.5 mt-0.5">
<li
v-for="(area, index) in sortedAreas"
:key="index"
>
<a
href="#"
class="text-sm py-1 px-2"
@click.prevent="$emit('scroll-to', area)"
>
<IconShape
:width="20"
:stroke-width="1.6"
/>
{{ t('page') }}
<template v-if="template.schema.length > 1">{{ template.schema.findIndex((item) => item.attachment_uuid === area.attachment_uuid) + 1 }}-</template>{{ area.page + 1 }}
</a>
</li>
<li v-if="!field.areas?.length || !['radio', 'multiple'].includes(field.type)">
<a
href="#"
class="text-sm py-1 px-2"
@click.prevent="$emit('set-draw', { field })"
>
<IconNewSection
:width="20"
:stroke-width="1.6"
/>
{{ t('draw_new_area') }}
</a>
</li>
<li v-if="field.areas?.length === 1 && ['date', 'signature', 'initials', 'text', 'cells'].includes(field.type)">
<a
href="#"
class="text-sm py-1 px-2"
@click.prevent="copyToAllPages(field)"
>
<IconCopy
:width="20"
:stroke-width="1.6"
/>
{{ t('copy_to_all_pages') }}
</a>
</li>
<FieldSettings
:field="field"
:default-field="defaultField"
:editable="editable"
:background-color="dropdownBgColor"
@click-formula="isShowFormulaModal = true"
@click-description="isShowDescriptionModal = true"
@click-condition="isShowConditionsModal = true"
@set-draw="$emit('set-draw', $event)"
@scroll-to="$emit('scroll-to', $event)"
/>
</ul>
</span>
<button
@@ -518,10 +255,11 @@
import Contenteditable from './contenteditable'
import FieldType from './field_type'
import PaymentSettings from './payment_settings'
import FieldSettings from './field_settings'
import FormulaModal from './formula_modal'
import ConditionsModal from './conditions_modal'
import DescriptionModal from './description_modal'
import { IconInfoCircle, IconRouteAltLeft, IconMathFunction, IconShape, IconNewSection, IconTrashX, IconCopy, IconSettings } from '@tabler/icons-vue'
import { IconRouteAltLeft, IconMathFunction, IconNewSection, IconTrashX, IconSettings } from '@tabler/icons-vue'
import { v4 } from 'uuid'
export default {
@@ -529,17 +267,15 @@ export default {
components: {
Contenteditable,
IconSettings,
IconShape,
FieldSettings,
PaymentSettings,
IconNewSection,
IconInfoCircle,
FormulaModal,
DescriptionModal,
ConditionsModal,
IconRouteAltLeft,
IconTrashX,
IconMathFunction,
IconCopy,
FieldType
},
inject: ['template', 'save', 'backgroundColor', 'selectedAreaRef', 't'],
@@ -590,29 +326,6 @@ export default {
modalContainerEl () {
return this.$el.getRootNode().querySelector('#docuseal_modal_container')
},
dateFormats () {
const formats = [
'MM/DD/YYYY',
'DD/MM/YYYY',
'YYYY-MM-DD',
'DD-MM-YYYY',
'DD.MM.YYYY',
'MMM D, YYYY',
'MMMM D, YYYY',
'D MMM YYYY',
'D MMMM YYYY'
]
if (Intl.DateTimeFormat().resolvedOptions().timeZone?.includes('Seoul') || navigator.language?.startsWith('ko')) {
formats.push('YYYY년 MM월 DD일')
}
if (this.field.preferences?.format && !formats.includes(this.field.preferences.format)) {
formats.unshift(this.field.preferences.format)
}
return formats
},
defaultName () {
return this.buildDefaultName(this.field, this.template.fields)
},
@@ -647,54 +360,6 @@ export default {
return `${this.fieldNames[field.type]} ${suffix} ${typeIndex + 1}`
}
},
formatDate (date, format) {
const monthFormats = {
M: 'numeric',
MM: '2-digit',
MMM: 'short',
MMMM: 'long'
}
const dayFormats = {
D: 'numeric',
DD: '2-digit'
}
const yearFormats = {
YYYY: 'numeric',
YY: '2-digit'
}
const parts = new Intl.DateTimeFormat([], {
day: dayFormats[format.match(/D+/)],
month: monthFormats[format.match(/M+/)],
year: yearFormats[format.match(/Y+/)]
}).formatToParts(date)
return format
.replace(/D+/, parts.find((p) => p.type === 'day').value)
.replace(/M+/, parts.find((p) => p.type === 'month').value)
.replace(/Y+/, parts.find((p) => p.type === 'year').value)
},
copyToAllPages (field) {
const areaString = JSON.stringify(field.areas[0])
this.template.documents.forEach((attachment) => {
const numberOfPages = attachment.metadata?.pdf?.number_of_pages || attachment.preview_images.length
for (let page = 0; page <= numberOfPages - 1; page++) {
if (!field.areas.find((area) => area.attachment_uuid === attachment.uuid && area.page === page)) {
field.areas.push({ ...JSON.parse(areaString), attachment_uuid: attachment.uuid, page })
}
}
})
this.$nextTick(() => {
this.$emit('scroll-to', this.field.areas[this.field.areas.length - 1])
})
this.save()
},
onNameFocus (e) {
this.isNameFocus = true
@@ -770,11 +435,6 @@ export default {
this.isNameFocus = false
this.save()
},
removeArea (area) {
this.field.areas.splice(this.field.areas.indexOf(area), 1)
this.save()
}
}
@@ -0,0 +1,416 @@
<template>
<div
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 bg-transparent"
@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 }"
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"
:placeholder="t('default_value')"
dir="auto"
:type="field.type"
class="input input-bordered input-xs w-full max-w-xs h-7 !outline-0 bg-transparent"
@blur="save"
>
<label
v-if="field.default_value"
:style="{ backgroundColor }"
class="absolute -top-1 left-2.5 px-1 h-4"
style="font-size: 8px"
>
{{ t('default_value') }}
</label>
</div>
<div
v-if="field.type === 'date'"
class="py-1.5 px-1 relative"
@click.stop
>
<select
v-model="field.preferences.format"
:placeholder="t('format')"
class="select select-bordered select-xs font-normal w-full max-w-xs !h-7 !outline-0 bg-transparent"
@change="save"
>
<option
v-for="format in dateFormats"
:key="format"
:value="format"
>
{{ formatDate(new Date(), format) }}
</option>
</select>
<label
:style="{ backgroundColor }"
class="absolute -top-1 left-2.5 px-1 h-4"
style="font-size: 8px"
>
{{ t('format') }}
</label>
</div>
<div
v-if="field.type === 'signature'"
class="py-1.5 px-1 relative"
@click.stop
>
<select
:placeholder="t('format')"
class="select select-bordered select-xs font-normal w-full max-w-xs !h-7 !outline-0 bg-transparent"
@change="[field.preferences.format = $event.target.value, save()]"
>
<option
value="any"
:selected="!field.preferences?.format || field.preferences.format === 'any'"
>
{{ t('any') }}
</option>
<option
value="drawn"
:selected="field.preferences?.format === 'drawn'"
>
{{ t('drawn') }}
</option>
<option
value="typed"
:selected="field.preferences?.format === 'typed'"
>
{{ t('typed') }}
</option>
</select>
<label
:style="{ backgroundColor }"
class="absolute -top-1 left-2.5 px-1 h-4"
style="font-size: 8px"
>
{{ t('format') }}
</label>
</div>
<li
v-if="withRequired && field.type != 'phone' && field.type != 'stamp'"
@click.stop
>
<label class="cursor-pointer py-1.5">
<input
v-model="field.required"
type="checkbox"
:disabled="!editable || defaultField"
class="toggle toggle-xs"
@update:model-value="save"
>
<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
>
<label class="cursor-pointer py-1.5">
<input
v-model="field.default_value"
type="checkbox"
class="toggle toggle-xs"
@update:model-value="[field.default_value = $event, field.readonly = $event, save()]"
>
<span class="label-text">{{ t('checked') }}</span>
</label>
</li>
<li
v-if="field.type == 'date'"
@click.stop
>
<label class="cursor-pointer py-1.5">
<input
v-model="field.readonly"
type="checkbox"
class="toggle toggle-xs"
@update:model-value="[field.default_value = $event ? '{{date}}' : null, field.readonly = $event, save()]"
>
<span class="label-text">{{ t('set_signing_date') }}</span>
</label>
</li>
<li
v-if="['text', 'number'].includes(field.type) && !defaultField"
@click.stop
>
<label class="cursor-pointer py-1.5">
<input
v-model="field.readonly"
type="checkbox"
class="toggle toggle-xs"
@update:model-value="save"
>
<span class="label-text">{{ t('read_only') }}</span>
</label>
</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="$emit('click-description')"
>
<IconInfoCircle
width="18"
/>
<span class="text-sm">
{{ t('description') }}
</span>
</label>
</li>
<li
v-if="field.type != 'stamp'"
>
<label
class="label-text cursor-pointer text-center w-full flex items-center"
@click="$emit('click-condition')"
>
<IconRouteAltLeft
width="18"
/>
<span class="text-sm">
{{ t('condition') }}
</span>
</label>
</li>
<li v-if="field.type == 'number'">
<label
class="label-text cursor-pointer text-center w-full flex items-center"
@click="$emit('click-formula')"
>
<IconMathFunction
width="18"
/>
<span class="text-sm">
{{ t('formula') }}
</span>
</label>
</li>
<hr class="pb-0.5 mt-0.5">
<template v-if="withAreas">
<li
v-for="(area, index) in sortedAreas"
:key="index"
>
<a
href="#"
class="text-sm py-1 px-2"
@click.prevent="$emit('scroll-to', area)"
>
<IconShape
:width="20"
:stroke-width="1.6"
/>
{{ t('page') }}
<template v-if="template.schema.length > 1">{{ template.schema.findIndex((item) => item.attachment_uuid === area.attachment_uuid) + 1 }}-</template>{{ area.page + 1 }}
</a>
</li>
<li v-if="!field.areas?.length || !['radio', 'multiple'].includes(field.type)">
<a
href="#"
class="text-sm py-1 px-2"
@click.prevent="$emit('set-draw', { field })"
>
<IconNewSection
:width="20"
:stroke-width="1.6"
/>
{{ t('draw_new_area') }}
</a>
</li>
</template>
<li v-if="field.areas?.length === 1 && ['date', 'signature', 'initials', 'text', 'cells'].includes(field.type)">
<a
href="#"
class="text-sm py-1 px-2"
@click.prevent="copyToAllPages(field)"
>
<IconCopy
:width="20"
:stroke-width="1.6"
/>
{{ t('copy_to_all_pages') }}
</a>
</li>
</template>
<script>
import { IconRouteAltLeft, IconShape, IconMathFunction, IconNewSection, IconInfoCircle, IconCopy } from '@tabler/icons-vue'
export default {
name: 'FieldSettings',
components: {
IconShape,
IconInfoCircle,
IconMathFunction,
IconRouteAltLeft,
IconCopy,
IconNewSection
},
inject: ['template', 'save', 't'],
props: {
field: {
type: Object,
required: true
},
backgroundColor: {
type: String,
required: false,
default: null
},
defaultField: {
type: Object,
required: false,
default: null
},
withRequired: {
type: Boolean,
required: false,
default: true
},
withAreas: {
type: Boolean,
required: false,
default: true
},
editable: {
type: Boolean,
required: false,
default: true
}
},
emits: ['set-draw', 'scroll-to', 'click-formula', 'click-description', 'click-condition'],
data () {
return {
}
},
computed: {
schemaAttachmentsIndexes () {
return (this.template.schema || []).reduce((acc, item, index) => {
acc[item.attachment_uuid] = index
return acc
}, {})
},
dateFormats () {
const formats = [
'MM/DD/YYYY',
'DD/MM/YYYY',
'YYYY-MM-DD',
'DD-MM-YYYY',
'DD.MM.YYYY',
'MMM D, YYYY',
'MMMM D, YYYY',
'D MMM YYYY',
'D MMMM YYYY'
]
if (Intl.DateTimeFormat().resolvedOptions().timeZone?.includes('Seoul') || navigator.language?.startsWith('ko')) {
formats.push('YYYY년 MM월 DD일')
}
if (this.field.preferences?.format && !formats.includes(this.field.preferences.format)) {
formats.unshift(this.field.preferences.format)
}
return formats
},
sortedAreas () {
return (this.field.areas || []).sort((a, b) => {
return this.schemaAttachmentsIndexes[a.attachment_uuid] - this.schemaAttachmentsIndexes[b.attachment_uuid]
})
}
},
methods: {
copyToAllPages (field) {
const areaString = JSON.stringify(field.areas[0])
this.template.documents.forEach((attachment) => {
const numberOfPages = attachment.metadata?.pdf?.number_of_pages || attachment.preview_images.length
for (let page = 0; page <= numberOfPages - 1; page++) {
if (!field.areas.find((area) => area.attachment_uuid === attachment.uuid && area.page === page)) {
field.areas.push({ ...JSON.parse(areaString), attachment_uuid: attachment.uuid, page })
}
}
})
this.$emit('scroll-to', this.field.areas[this.field.areas.length - 1])
this.save()
},
formatDate (date, format) {
const monthFormats = {
M: 'numeric',
MM: '2-digit',
MMM: 'short',
MMMM: 'long'
}
const dayFormats = {
D: 'numeric',
DD: '2-digit'
}
const yearFormats = {
YYYY: 'numeric',
YY: '2-digit'
}
const parts = new Intl.DateTimeFormat([], {
day: dayFormats[format.match(/D+/)],
month: monthFormats[format.match(/M+/)],
year: yearFormats[format.match(/Y+/)]
}).formatToParts(date)
return format
.replace(/D+/, parts.find((p) => p.type === 'day').value)
.replace(/M+/, parts.find((p) => p.type === 'month').value)
.replace(/Y+/, parts.find((p) => p.type === 'year').value)
}
}
}
</script>
+1 -1
View File
@@ -35,7 +35,7 @@
@set-draw="$emit('set-draw', $event)"
/>
</div>
<div v-if="submitterDefaultFields.length">
<div v-if="submitterDefaultFields.length && editable">
<hr class="mb-2">
<template v-if="isShowFieldSearch">
<input
+2 -1
View File
@@ -30,6 +30,7 @@
@start-drag="isMove = true"
@stop-drag="isMove = false"
@remove="$emit('remove-area', item.area)"
@scroll-to="$emit('scroll-to', $event)"
/>
<FieldArea
v-if="newArea"
@@ -116,7 +117,7 @@ export default {
required: true
}
},
emits: ['draw', 'drop-field', 'remove-area'],
emits: ['draw', 'drop-field', 'remove-area', 'scroll-to'],
data () {
return {
areaRefs: [],
+20
View File
@@ -1,6 +1,18 @@
# frozen_string_literal: true
module HexaPDF
module Encryption
class SecurityHandler
def encrypt_string(str, obj)
return str.dup if str.empty? || obj == document.trailer[:Encrypt] || obj.type == :XRef ||
(obj.type == :Sig && obj[:Contents].equal?(str))
key = object_key(obj.oid, obj.gen, string_algorithm)
string_algorithm.encrypt(key, str).dup
end
end
end
module Type
# fix NoMethodError: undefined method `field_value' for #<HexaPDF::Type::AcroForm::Field
module AcroForm
@@ -8,6 +20,14 @@ module HexaPDF
def field_value
''
end
def terminal_field?
kids = self[:Kids]
# rubocop:disable Rails/Blank
kids.nil? || kids.empty? || kids.none? { |kid| kid&.key?(:T) }
# rubocop:enable Rails/Blank
end
end
end
+5 -1
View File
@@ -90,7 +90,11 @@ module Accounts
def load_signing_pkcs(account)
cert_data =
if Docuseal.multitenant?
EncryptedConfig.find_by(account:, key: EncryptedConfig::ESIGN_CERTS_KEY)&.value || Docuseal::CERTS
data = EncryptedConfig.find_by(account:, key: EncryptedConfig::ESIGN_CERTS_KEY)&.value
return Docuseal.default_pkcs if data.blank?
data
else
EncryptedConfig.find_by(account:, key: EncryptedConfig::ESIGN_CERTS_KEY)&.value ||
EncryptedConfig.find_by(key: EncryptedConfig::ESIGN_CERTS_KEY).value
+11
View File
@@ -60,6 +60,17 @@ module Docuseal
ENV['ACTIVE_STORAGE_PUBLIC'] == 'true'
end
def default_pkcs
@default_pkcs ||= GenerateCertificate.load_pkcs(Docuseal::CERTS)
end
def trusted_certs
@trusted_certs ||=
ENV['TRUSTED_CERTS'].to_s.gsub('\\n', "\n").split("\n\n").map do |base64|
OpenSSL::X509::Certificate.new(base64)
end
end
def default_url_options
return DEFAULT_URL_OPTIONS if multitenant?
+5 -1
View File
@@ -3,6 +3,8 @@
module GenerateCertificate
SIZE = 2**11
Pkcs12Struct = Struct.new(:certificate, :ca_certs, keyword_init: true)
module_function
def call(name = Docuseal.product_name)
@@ -89,10 +91,12 @@ module GenerateCertificate
def load_pkcs(cert_data)
cert = OpenSSL::X509::Certificate.new(cert_data['cert'])
key = OpenSSL::PKey::RSA.new(cert_data['key'])
key = OpenSSL::PKey::RSA.new(cert_data['key']) if cert_data['key'].present?
sub_ca = OpenSSL::X509::Certificate.new(cert_data['sub_ca'])
root_ca = OpenSSL::X509::Certificate.new(cert_data['root_ca'])
return Pkcs12Struct.new(certificate: cert, ca_certs: [sub_ca, root_ca]) unless key
OpenSSL::PKCS12.create(
'',
'',
+8 -2
View File
@@ -150,15 +150,21 @@ module Submissions
def build_submitter(submission:, attrs:, uuid:, is_order_sent:, mark_as_sent:, user:, preferences:)
email = Submissions.normalize_email(attrs[:email])
submitter_preferences = Submitters.normalize_preferences(submission.account, user, attrs)
values = attrs[:values] || {}
phone_field_uuid =
(submission.template_fields || submission.template.fields).find do |f|
values[f['uuid']].present? && f['type'] == 'phone'
end&.dig('uuid')
submission.submitters.new(
email:,
phone: attrs[:phone].to_s.gsub(/[^0-9+]/, ''),
phone: (attrs[:phone] || values[phone_field_uuid]).to_s.gsub(/[^0-9+]/, ''),
name: attrs[:name],
external_id: attrs[:external_id].presence || attrs[:application_key],
completed_at: attrs[:completed].present? ? Time.current : nil,
sent_at: mark_as_sent && email.present? && is_order_sent ? Time.current : nil,
values: attrs[:values] || {},
values: values.except(phone_field_uuid),
metadata: attrs[:metadata] || {},
preferences: preferences.merge(submitter_preferences)
.merge({ default_values: attrs[:values] }.compact_blank)
+21 -22
View File
@@ -7,15 +7,10 @@ module Submissions
FONT_PATH = '/fonts/GoNotoKurrent-Regular.ttf'
FONT_BOLD_PATH = '/fonts/GoNotoKurrent-Bold.ttf'
FONT_NAME = if File.exist?(FONT_PATH)
FONT_PATH
'GoNotoKurrent'
else
'Helvetica'
end
FONT_BOLD_NAME = if File.exist?(FONT_BOLD_PATH)
FONT_BOLD_PATH
else
'Helvetica'
end
SIGN_REASON = 'Signed with DocuSeal.co'
VERIFIED_TEXT = 'Verified'
@@ -48,7 +43,19 @@ module Submissions
end
composer = HexaPDF::Composer.new(skip_page_creation: true)
composer.document.fonts.add(FONT_BOLD_NAME, variant: :bold)
composer.document.task(:pdfa) if FONT_NAME == 'GoNotoKurrent'
composer.document.config['font.map'] = {
'Helvetica' => {
none: FONT_PATH,
bold: FONT_BOLD_PATH
},
FONT_NAME => {
none: FONT_PATH,
bold: FONT_BOLD_PATH
}
}
composer.document.config['font.on_missing_glyph'] =
Submissions::GenerateResultAttachments.method(:on_missing_glyph).to_proc
@@ -119,13 +126,13 @@ module Submissions
),
composer.document.layout.formatted_text_box(
[
{ text: "Original SHA256:\n", font: [FONT_BOLD_NAME, { variant: :bold }] },
{ text: "Original SHA256:\n", font: [FONT_NAME, { variant: :bold }] },
original_documents.map { |d| d.metadata['sha256'] || d.checksum }.join("\n"),
"\n",
{ text: "Result SHA256:\n", font: [FONT_BOLD_NAME, { variant: :bold }] },
{ text: "Result SHA256:\n", font: [FONT_NAME, { variant: :bold }] },
document.metadata['sha256'] || document.checksum,
"\n",
{ text: 'Generated at: ', font: [FONT_BOLD_NAME, { variant: :bold }] },
{ text: 'Generated at: ', font: [FONT_NAME, { variant: :bold }] },
"#{I18n.l(document.created_at.in_time_zone(account.timezone), format: :long, locale: account.locale)} " \
"#{TimeUtils.timezone_abbr(account.timezone, document.created_at)}"
], line_spacing: 1.3
@@ -162,7 +169,7 @@ module Submissions
composer.document.layout.formatted_text_box(
[
submission.template_submitters.size > 1 && { text: "#{item['name']}\n" },
submitter.email && { text: "#{submitter.email}\n", font: [FONT_BOLD_NAME, { variant: :bold }] },
submitter.email && { text: "#{submitter.email}\n", font: [FONT_NAME, { variant: :bold }] },
submitter.name && { text: "#{TextUtils.maybe_rtl_reverse(submitter.name)}\n" },
submitter.phone && { text: "#{submitter.phone}\n" }
].compact_blank, line_spacing: 1.3, padding: [0, 20, 0, 0]
@@ -276,7 +283,7 @@ module Submissions
composer.document.layout.formatted_text_box(
[
{ text: SubmissionEvents::EVENT_NAMES[event.event_type.to_sym],
font: [FONT_BOLD_NAME, { variant: :bold }] },
font: [FONT_NAME, { variant: :bold }] },
event.event_type.include?('send_') ? ' to ' : ' by ',
if event.event_type.include?('sms') || event.event_type.include?('phone')
event.data['phone'] || submitter.phone
@@ -296,17 +303,9 @@ module Submissions
sign_params = {
reason: SIGN_REASON,
certificate: pkcs.certificate,
doc_mdp_permissions: :no_changes,
key: pkcs.key,
certificate_chain: pkcs.ca_certs || []
**Submissions::GenerateResultAttachments.build_signing_params(pkcs, tsa_url)
}
if tsa_url
sign_params[:timestamp_handler] = Submissions::TimestampHandler.new(tsa_url:)
sign_params[:signature_size] = 10_000
end
composer.document.sign(io, **sign_params)
ActiveStorage::Attachment.create!(
@@ -324,7 +323,7 @@ module Submissions
column.formatted_text([{ text: 'DocuSeal',
link: Docuseal::PRODUCT_URL }],
font_size: 20,
font: [FONT_BOLD_NAME, { variant: :bold }],
font: [FONT_NAME, { variant: :bold }],
width: 100,
padding: [5, 0, 0, 8],
position: :float, text_align: :left)
+17 -8
View File
@@ -304,16 +304,9 @@ module Submissions
if sign_reason
sign_params = {
reason: sign_reason,
certificate: pkcs.certificate,
key: pkcs.key,
certificate_chain: pkcs.ca_certs || []
**build_signing_params(pkcs, tsa_url)
}
if tsa_url
sign_params[:timestamp_handler] = Submissions::TimestampHandler.new(tsa_url:)
sign_params[:signature_size] = 10_000
end
begin
pdf.sign(io, write_options: { validate: false }, **sign_params)
rescue HexaPDF::MalformedPDFError => e
@@ -342,6 +335,21 @@ module Submissions
)
end
def build_signing_params(pkcs, tsa_url)
params = {
certificate: pkcs.certificate,
key: pkcs.key,
certificate_chain: pkcs.ca_certs || []
}
if tsa_url
params[:timestamp_handler] = Submissions::TimestampHandler.new(tsa_url:)
params[:signature_size] = 10_000
end
params
end
def images_pdf_uuid(attachments)
Digest::UUID.uuid_v5(Digest::UUID::OID_NAMESPACE, attachments.map(&:uuid).sort.join(':'))
end
@@ -420,6 +428,7 @@ module Submissions
def build_pdf_from_image(attachment)
pdf = HexaPDF::Document.new
page = pdf.pages.add
scale = [A4_SIZE.first / attachment.metadata['width'].to_f,