Merge from docusealco/wip

pull/493/head 2.0.1
Alex Turchyn 5 months ago committed by GitHub
commit c6eae66127
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -157,7 +157,10 @@ jobs:
bundle install --jobs 4 --retry 4
yarn install
sudo apt-get update
sudo apt-get install libvips
sudo apt-get install -y libvips
wget -O pdfium-linux.tgz "https://github.com/docusealco/pdfium-binaries/releases/latest/download/pdfium-linux-$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/').tgz"
sudo tar -xzf pdfium-linux.tgz --strip-components=1 -C /usr/lib lib/libpdfium.so
rm -f pdfium-linux.tgz
- name: Run
env:
RAILS_ENV: test

@ -0,0 +1 @@
--require rails_helper

@ -1,8 +1,17 @@
FROM ruby:3.4.2-alpine AS fonts
FROM ruby:3.4.2-alpine AS download
WORKDIR /fonts
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 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 && \
wget -O pdfium-linux.tgz "https://github.com/docusealco/pdfium-binaries/releases/latest/download/pdfium-linux-$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/').tgz" && \
mkdir -p /pdfium-linux && \
tar -xzf pdfium-linux.tgz -C /pdfium-linux
RUN fontforge -lang=py -c 'font1 = fontforge.open("FreeSans.ttf"); font2 = fontforge.open("NotoSansSymbols2-Regular.ttf"); font1.mergeFonts(font2); font1.generate("FreeSans.ttf")'
@ -41,7 +50,7 @@ ENV OPENSSL_CONF=/app/openssl_legacy.cnf
WORKDIR /app
RUN echo '@edge https://dl-cdn.alpinelinux.org/alpine/edge/community' >> /etc/apk/repositories && apk add --no-cache sqlite-dev libpq-dev mariadb-dev vips-dev vips-poppler poppler-utils redis libheif@edge vips-heif gcompat ttf-freefont && mkdir /fonts && rm /usr/share/fonts/freefont/FreeSans.otf
RUN echo '@edge https://dl-cdn.alpinelinux.org/alpine/edge/community' >> /etc/apk/repositories && apk add --no-cache sqlite-dev libpq-dev mariadb-dev vips-dev@edge redis libheif@edge vips-heif gcompat ttf-freefont && mkdir /fonts && rm /usr/share/fonts/freefont/FreeSans.otf
RUN echo $'.include = /etc/ssl/openssl.cnf\n\
\n\
@ -70,8 +79,10 @@ COPY ./tmp ./tmp
COPY LICENSE README.md Rakefile config.ru .version ./
COPY .version ./public/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=download /fonts/GoNotoKurrent-Regular.ttf /fonts/GoNotoKurrent-Bold.ttf /fonts/DancingScript-Regular.otf /fonts/OFL.txt /fonts
COPY --from=download /fonts/FreeSans.ttf /usr/share/fonts/freefont
COPY --from=download /pdfium-linux/lib/libpdfium.so /usr/lib/libpdfium.so
COPY --from=download /pdfium-linux/licenses/pdfium.txt /usr/lib/libpdfium-LICENSE.txt
COPY --from=webpack /app/public/packs ./public/packs
RUN ln -s /fonts /app/public/fonts

@ -1,8 +1,10 @@
# frozen_string_literal: true
class AccountConfigsController < ApplicationController
before_action :load_account_config
authorize_resource :account_config
before_action :load_account_config, only: :create
authorize_resource :account_config, only: :create
load_and_authorize_resource :account_config, only: :destroy
ALLOWED_KEYS = [
AccountConfig::ALLOW_TYPED_SIGNATURE,
@ -30,6 +32,14 @@ class AccountConfigsController < ApplicationController
head :ok
end
def destroy
raise InvalidKey unless ALLOWED_KEYS.include?(@account_config.key)
@account_config.destroy!
redirect_back(fallback_location: root_path)
end
private
def load_account_config

@ -10,16 +10,17 @@ class SendSubmissionEmailController < ApplicationController
SEND_DURATION = 30.minutes
def create
@submitter =
if params[:template_slug]
Submitter.joins(submission: :template).find_by!(email: params[:email].to_s.downcase,
template: { slug: params[:template_slug] })
elsif params[:submission_slug]
Submitter.joins(:submission).find_by!(email: params[:email].to_s.downcase,
submission: { slug: params[:submission_slug] })
else
Submitter.find_by!(slug: params[:submitter_slug])
end
if params[:template_slug]
@submitter = Submitter.joins(submission: :template).find_by!(email: params[:email].to_s.downcase,
template: { slug: params[:template_slug] })
elsif params[:submission_slug]
@submitter = Submitter.joins(:submission).find_by(email: params[:email].to_s.downcase,
submission: { slug: params[:submission_slug] })
return redirect_to submissions_preview_completed_path(params[:submission_slug], status: :error) unless @submitter
else
@submitter = Submitter.find_by!(slug: params[:submitter_slug])
end
RateLimit.call("send-email-#{@submitter.id}", limit: 2, ttl: 5.minutes)

@ -1,6 +1,7 @@
# frozen_string_literal: true
class SubmissionsPreviewController < ApplicationController
around_action :with_browser_locale
skip_before_action :authenticate_user!
skip_authorization_check

@ -1279,7 +1279,7 @@ export default {
area.h = (pageMask.clientWidth / 35 / pageMask.clientWidth)
}
},
onDraw (area) {
onDraw ({ area, isTooSmall }) {
if (this.drawField) {
if (this.drawOption) {
const areaWithoutOption = this.drawField.areas?.find((a) => !a.option_uuid)
@ -1374,7 +1374,7 @@ export default {
area.y -= area.h / 2
}
if (area.w) {
if (area.w && (type !== 'checkbox' || this.drawFieldType || !isTooSmall)) {
this.addField(type, area)
this.selectedAreaRef.value = area

@ -23,7 +23,7 @@
@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 })"
@draw="$emit('draw', { area: {...$event.area, attachment_uuid: document.uuid }, isTooSmall: $event.isTooSmall })"
/>
</div>
</template>

@ -290,7 +290,12 @@ export default {
area.cell_w = this.newArea.cell_w
}
this.$emit('draw', area)
const dx = Math.abs(e.offsetX - this.$refs.mask.clientWidth * this.newArea.initialX)
const dy = Math.abs(e.offsetY - this.$refs.mask.clientHeight * this.newArea.initialY)
const isTooSmall = dx < 8 && dy < 8
this.$emit('draw', { area, isTooSmall })
}
this.showMask = false

@ -20,7 +20,7 @@ class ApplicationMailer < ActionMailer::Base
end
def set_message_metadata
message.instance_variable_set(:@message_metadata, @message_metadata)
message.instance_variable_set(:@message_metadata, @message_metadata || {})
end
def set_message_uuid
@ -28,10 +28,14 @@ class ApplicationMailer < ActionMailer::Base
end
def assign_message_metadata(tag, record)
@message_metadata = {
@message_metadata = (@message_metadata || {}).merge(
'tag' => tag,
'record_id' => record.id,
'record_type' => record.class.name
}
)
end
def put_metadata(attrs)
@message_metadata = (@message_metadata || {}).merge(attrs)
end
end

@ -233,9 +233,17 @@ class SubmitterMailer < ApplicationMailer
def from_address_for_submitter(submitter)
if submitter.submission.source.in?(%w[api embed]) &&
(from_email = AccountConfig.find_by(account: submitter.account, key: 'integration_from_email')&.value.presence)
user = submitter.account.users.find_by(email: from_email)
put_metadata('from_user_id' => user.id)
from_email
else
(submitter.submission.created_by_user || submitter.submission.template.author).friendly_name
user = submitter.submission.created_by_user || submitter.submission.template.author
put_metadata('from_user_id' => user.id)
user.friendly_name
end
end
end

@ -71,7 +71,7 @@ class Submission < ApplicationRecord
.and(Submitter.arel_table[:completed_at].eq(nil))).select(1).arel.exists)
}
scope :declined, -> { joins(:submitters).where.not(submitters: { declined_at: nil }).group(:id) }
scope :expired, -> { where(expire_at: ..Time.current) }
scope :expired, -> { pending.where(expire_at: ..Time.current) }
enum :source, {
invite: 'invite',

@ -28,6 +28,11 @@
<%= f.hidden_field :submission_slug, value: @submission.slug %>
<%= f.label :email, t('email'), class: 'label' %>
<%= f.email_field :email, value: current_user&.email || params[:email], required: true, class: 'base-input', placeholder: t('send_copy_to_email') %>
<% if params[:status] == 'error' %>
<span class="label-text-alt text-red-400 mt-1">
<%= t('please_enter_your_email_address_associated_with_the_completed_submission') %>
</span>
<% end %>
</div>
<toggle-submit dir="auto" class="form-control">
<%= f.button button_title(title: t('send_copy_to_email'), disabled_with: t('starting')), class: 'base-button' %>

@ -34,7 +34,7 @@
<% submitter = submitters.first %>
<div class="flex items-center space-x-4">
<span class="flex flex-col md:flex-row md:items-center gap-3">
<% if submission.expired? %>
<% if submission.expired? && !submitter.completed_at? && !submitter.declined_at? %>
<div class="tooltip flex" data-tip="<%= l(submission.expire_at.in_time_zone(current_account.timezone), format: :short, locale: current_account.locale) %>">
<span class="badge badge-error md:w-32 bg-opacity-50 badge-lg uppercase text-sm font-semibold">
<%= t('expired') %>

@ -22,6 +22,9 @@ en: &en
hi_there: Hi there
thanks: Thanks
private: Private
stripe_integration: Stripe Integration
stripe_account_has_been_connected: Stripe account has been connected.
re_connect_stripe: Re-connect Stripe
bcc_recipients: BCC recipients
resend_pending: Re-send pending
always_enforce_signing_order: Always enforce the signing order
@ -46,7 +49,7 @@ en: &en
team_member_permissions: Team member permissions
entire_team: Entire team
admin_only: Admin only
accessiable_by: Accessiable by
accessiable_by: Accessible by
team_access: Team access
document_download_filename_format: Document download filename format
document_name: Document Name
@ -655,7 +658,7 @@ en: &en
completed_at: Completed at
edit_recipient: Edit Recipient
update_recipient: Update Recipient
use_international_format_1xxx_: 'Use internatioanl format: +1xxx...'
use_international_format_1xxx_: 'Use international format: +1xxx...'
submitter_cannot_be_updated: Submitter cannot be updated.
at_least_one_field_must_be_filled: At least one field must be filled.
archived_users: Archived Users
@ -741,11 +744,12 @@ en: &en
two_months: 2 months
three_months: 3 months
eu_data_residency: EU data residency
please_enter_your_email_address_associated_with_the_completed_submission: Please enter your email address associated with the completed submission.
submission_sources:
api: API
bulk: Bulk Send
embed: Embedding
invie: Invite
invite: Invite
link: Link
submission_event_names:
send_email_to_html: '<b>Email sent</b> to %{submitter_name}'
@ -827,6 +831,9 @@ en: &en
read: Read your data
es: &es
stripe_integration: Integración con Stripe
stripe_account_has_been_connected: La cuenta de Stripe ha sido conectada.
re_connect_stripe: Volver a conectar Stripe
private: Privado
resend_pending: Reenviar pendiente
ensure_unique_recipients: Asegurar destinatarios únicos
@ -1549,6 +1556,7 @@ es: &es
two_months: 2 meses
three_months: 3 meses
eu_data_residency: Datos alojados UE
please_enter_your_email_address_associated_with_the_completed_submission: Por favor, introduce tu dirección de correo electrónico asociada con el envío completado.
submission_sources:
api: API
bulk: Envío masivo
@ -1635,6 +1643,9 @@ es: &es
read: Leer tus datos
it: &it
stripe_integration: Integrazione Stripe
stripe_account_has_been_connected: L'account Stripe è stato collegato.
re_connect_stripe: Ricollega Stripe
private: Privato
resend_pending: Reinvia in sospeso
ensure_unique_recipients: Assicurarsi destinatari unici
@ -2355,6 +2366,7 @@ it: &it
two_months: 2 mesi
three_months: 3 mesi
eu_data_residency: "Dati nell'UE"
please_enter_your_email_address_associated_with_the_completed_submission: "Inserisci il tuo indirizzo email associato all'invio completato."
submission_sources:
api: API
bulk: Invio massivo
@ -2441,6 +2453,9 @@ it: &it
read: Leggi i tuoi dati
fr: &fr
stripe_integration: Intégration Stripe
stripe_account_has_been_connected: Le compte Stripe a été connecté.
re_connect_stripe: Reconnecter Stripe
private: Privé
resend_pending: Renvoyer en attente
ensure_unique_recipients: Assurer l'unicité des destinataires
@ -3164,6 +3179,7 @@ fr: &fr
two_months: 2 mois
three_months: 3 mois
eu_data_residency: "Données dans l'UE"
please_enter_your_email_address_associated_with_the_completed_submission: "Veuillez saisir l'adresse e-mail associée à l'envoi complété."
submission_sources:
api: API
bulk: Envoi en masse
@ -3250,6 +3266,9 @@ fr: &fr
read: Lire vos données
pt: &pt
stripe_integration: Integração com Stripe
stripe_account_has_been_connected: Conta Stripe foi conectada.
re_connect_stripe: Reconectar Stripe
private: Privado
resend_pending: Re-enviar pendente
ensure_unique_recipients: Garantir destinatários únicos
@ -3972,6 +3991,7 @@ pt: &pt
two_months: 2 meses
three_months: 3 meses
eu_data_residency: Dados na UE
please_enter_your_email_address_associated_with_the_completed_submission: Por favor, insira seu e-mail associado ao envio concluído.
submission_sources:
api: API
bulk: Envio em massa
@ -4059,6 +4079,9 @@ pt: &pt
read: Ler seus dados
de: &de
stripe_integration: Stripe-Integration
stripe_account_has_been_connected: Stripe-Konto wurde verbunden.
re_connect_stripe: Stripe erneut verbinden
private: Privat
resend_pending: Ausstehende erneut senden
ensure_unique_recipients: Stellen Sie einzigartige Empfänger sicher
@ -4164,9 +4187,9 @@ de: &de
sending: Senden
resubmit: Erneut einreichen
or: oder
download_documents: Dokumente herunterladen
download_documents: Dokumente downloaden
downloading: Lädt herunter
download: Laden
download: Download
decline: Ablehnen
declined: Abgelehnt
decline_reason: Ablehnungsgrund
@ -4781,6 +4804,7 @@ de: &de
two_months: 2 Monate
three_months: 3 Monate
eu_data_residency: EU-Datenspeicher
please_enter_your_email_address_associated_with_the_completed_submission: Bitte gib deine E-Mail-Adresse ein, die mit der abgeschlossenen Übermittlung verknüpft ist.
submission_sources:
api: API
bulk: Massenversand
@ -4933,6 +4957,7 @@ pl:
count_documents_signed_with_html: '<b>%{count}</b> dokumentów podpisanych za pomocą'
open_source_documents_software: 'oprogramowanie do dokumentów open source'
eu_data_residency: Dane w UE
please_enter_your_email_address_associated_with_the_completed_submission: Wprowadź adres e-mail powiązany z ukończonym zgłoszeniem.
uk:
require_phone_2fa_to_open: Вимагати двофакторну автентифікацію через телефон для відкриття
@ -5001,6 +5026,7 @@ uk:
count_documents_signed_with_html: '<b>%{count}</b> документів підписано за допомогою'
open_source_documents_software: 'відкрите програмне забезпечення для документів'
eu_data_residency: 'Зберігання даних в ЄС'
please_enter_your_email_address_associated_with_the_completed_submission: "Введіть адресу електронної пошти, пов'язану із завершеним поданням."
cs:
require_phone_2fa_to_open: Vyžadovat otevření pomocí telefonního 2FA
@ -5069,6 +5095,7 @@ cs:
count_documents_signed_with_html: '<b>%{count}</b> dokumentů podepsáno pomocí'
open_source_documents_software: 'open source software pro dokumenty'
eu_data_residency: 'Uložení dat v EU'
please_enter_your_email_address_associated_with_the_completed_submission: Zadejte e-mailovou adresu spojenou s dokončeným odesláním.
he:
require_phone_2fa_to_open: דרוש אימות דו-שלבי באמצעות טלפון לפתיחה
@ -5137,6 +5164,7 @@ he:
count_documents_signed_with_html: '<b>%{count}</b> מסמכים נחתמו באמצעות'
open_source_documents_software: 'תוכנה בקוד פתוח למסמכים'
eu_data_residency: 'נתונים באיחוד האירופי '
please_enter_your_email_address_associated_with_the_completed_submission: 'אנא הזן את כתובת הדוא"ל המשויכת למשלוח שהושלם.'
nl:
require_phone_2fa_to_open: Vereis telefoon 2FA om te openen
@ -5205,6 +5233,7 @@ nl:
count_documents_signed_with_html: '<b>%{count}</b> documenten ondertekend met'
open_source_documents_software: 'open source documenten software'
eu_data_residency: Gegevens EU
please_enter_your_email_address_associated_with_the_completed_submission: Voer het e-mailadres in dat is gekoppeld aan de voltooide indiening.
ar:
require_phone_2fa_to_open: "تطلب فتح عبر تحقق الهاتف ذو العاملين"
@ -5273,6 +5302,7 @@ ar:
count_documents_signed_with_html: '<b>%{count}</b> مستندات تم توقيعها باستخدام'
open_source_documents_software: 'برنامج مستندات مفتوح المصدر'
eu_data_residency: 'بيانات في الاتحاد الأوروبي'
please_enter_your_email_address_associated_with_the_completed_submission: 'يرجى إدخال عنوان البريد الإلكتروني المرتبط بالإرسال المكتمل.'
ko:
require_phone_2fa_to_open: 휴대폰 2FA를 열 때 요구함
@ -5341,6 +5371,7 @@ ko:
count_documents_signed_with_html: '<b>%{count}</b>개의 문서가 다음을 통해 서명됨'
open_source_documents_software: '오픈소스 문서 소프트웨어'
eu_data_residency: 'EU 데이터 보관'
please_enter_your_email_address_associated_with_the_completed_submission: '완료된 제출과 연결된 이메일 주소를 입력하세요.'
ja:
require_phone_2fa_to_open: 電話による2段階認証が必要です
@ -5409,6 +5440,7 @@ ja:
count_documents_signed_with_html: '<b>%{count}</b> 件のドキュメントが以下で署名されました'
open_source_documents_software: 'オープンソースのドキュメントソフトウェア'
eu_data_residency: 'EU データ居住'
please_enter_your_email_address_associated_with_the_completed_submission: '完了した提出に関連付けられたメールアドレスを入力してください。'
en-US:
<<: *en

@ -57,7 +57,7 @@ Rails.application.routes.draw do
resources :verify_pdf_signature, only: %i[create]
resource :mfa_setup, only: %i[show new edit create destroy], controller: 'mfa_setup'
resources :account_configs, only: %i[create]
resources :account_configs, only: %i[create destroy]
resources :user_configs, only: %i[create]
resources :encrypted_user_configs, only: %i[destroy]
resources :timestamp_server, only: %i[create]

@ -0,0 +1,187 @@
# frozen_string_literal: true
module LoadBmp
module_function
# rubocop:disable Metrics
def call(bmp_bytes)
bmp_bytes = bmp_bytes.b
header_data = parse_bmp_headers(bmp_bytes)
raw_pixel_data_from_file = extract_raw_pixel_data_blob(
bmp_bytes,
header_data[:pixel_data_offset],
header_data[:bmp_stride],
header_data[:height]
)
final_pixel_data = prepare_unpadded_pixel_data_string(
raw_pixel_data_from_file,
header_data[:bpp],
header_data[:width],
header_data[:height],
header_data[:bmp_stride]
)
bands = header_data[:bpp] / 8
unless header_data[:bpp] == 24 || header_data[:bpp] == 32
raise ArgumentError, "Conversion for #{header_data[:bpp]}-bpp BMP not implemented."
end
image = Vips::Image.new_from_memory(final_pixel_data, header_data[:width], header_data[:height], bands, :uchar)
image = image.flip(:vertical) if header_data[:orientation] == -1
image_rgb =
if bands == 3
image.recomb(band3_recomb)
elsif bands == 4
image.recomb(band4_recomb)
end
image_rgb = image_rgb.copy(interpretation: :srgb) if image_rgb.interpretation != :srgb
image_rgb
end
def parse_bmp_headers(bmp_bytes)
raise ArgumentError, 'BMP data too short for file header (14 bytes).' if bmp_bytes.bytesize < 14
signature, pixel_data_offset = bmp_bytes.unpack('a2@10L<')
raise ArgumentError, "Not a valid BMP file (invalid signature 'BM')." if signature != 'BM'
raise ArgumentError, 'BMP data too short for info header size field (4 bytes).' if bmp_bytes.bytesize < (14 + 4)
info_header_size = bmp_bytes.unpack1('@14L<')
min_expected_info_header_size = 40
if info_header_size < min_expected_info_header_size
raise ArgumentError,
"Unsupported BMP info header size: #{info_header_size}. Expected at least #{min_expected_info_header_size}."
end
header_and_info_header_min_bytes = 14 + min_expected_info_header_size
if bmp_bytes.bytesize < header_and_info_header_min_bytes
raise ArgumentError,
'BMP data too short for essential BITMAPINFOHEADER fields ' \
"(requires #{header_and_info_header_min_bytes} bytes total)."
end
_header_size_check, width, raw_height_from_header, planes, bpp, compression =
bmp_bytes.unpack('@14L<l<l<S<S<L<')
height = 0
orientation = -1
if raw_height_from_header.negative?
height = -raw_height_from_header
orientation = 1
else
height = raw_height_from_header
end
raise ArgumentError, 'BMP width must be positive.' if width <= 0
raise ArgumentError, 'BMP height must be positive.' if height <= 0
if compression != 0
raise ArgumentError,
"Unsupported BMP compression type: #{compression}. Only uncompressed (0) is supported."
end
unless [24, 32].include?(bpp)
raise ArgumentError, "Unsupported BMP bits per pixel: #{bpp}. Only 24-bit and 32-bit are supported."
end
raise ArgumentError, "Unsupported BMP planes: #{planes}. Expected 1." if planes != 1
bytes_per_pixel = bpp / 8
row_size_unpadded = width * bytes_per_pixel
bmp_stride = (row_size_unpadded + 3) & ~3
{
width:,
height:,
bpp:,
pixel_data_offset:,
bmp_stride:,
orientation:
}
end
def extract_raw_pixel_data_blob(bmp_bytes, pixel_data_offset, bmp_stride, height)
expected_pixel_data_size = bmp_stride * height
if pixel_data_offset + expected_pixel_data_size > bmp_bytes.bytesize
actual_available = bmp_bytes.bytesize - pixel_data_offset
actual_available = 0 if actual_available.negative?
raise ArgumentError,
"Pixel data segment (offset #{pixel_data_offset}, expected size #{expected_pixel_data_size}) " \
"exceeds BMP file size (#{bmp_bytes.bytesize}). " \
"Only #{actual_available} bytes available after offset."
end
raw_pixel_data_from_file = bmp_bytes.byteslice(pixel_data_offset, expected_pixel_data_size)
if raw_pixel_data_from_file.nil? || raw_pixel_data_from_file.bytesize < expected_pixel_data_size
raise ArgumentError,
"Extracted pixel data is smaller (#{raw_pixel_data_from_file&.bytesize || 0} bytes) " \
"than expected (#{expected_pixel_data_size} bytes based on stride and height)."
end
raw_pixel_data_from_file
end
def prepare_unpadded_pixel_data_string(raw_pixel_data_from_file, bpp, width, height, bmp_stride)
bytes_per_pixel = bpp / 8
actual_row_width_bytes = width * bytes_per_pixel
unpadded_rows = Array.new(height)
current_offset_in_blob = 0
height.times do |i|
if current_offset_in_blob + actual_row_width_bytes > raw_pixel_data_from_file.bytesize
raise ArgumentError,
"Not enough data in pixel blob for row #{i}. Offset #{current_offset_in_blob}, " \
"row width #{actual_row_width_bytes}, blob size #{raw_pixel_data_from_file.bytesize}"
end
unpadded_row_slice = raw_pixel_data_from_file.byteslice(current_offset_in_blob, actual_row_width_bytes)
if unpadded_row_slice.nil? || unpadded_row_slice.bytesize < actual_row_width_bytes
raise ArgumentError, "Failed to slice a full unpadded row from pixel data blob for row #{i}."
end
unpadded_rows[i] = unpadded_row_slice
current_offset_in_blob += bmp_stride
end
unpadded_rows.join
end
def band3_recomb
@band3_recomb ||=
Vips::Image.new_from_array(
[
[0, 0, 1],
[0, 1, 0],
[1, 0, 0]
]
)
end
def band4_recomb
@band4_recomb ||= Vips::Image.new_from_array(
[
[0, 0, 1, 0],
[0, 1, 0, 0],
[1, 0, 0, 0]
]
)
end
# rubocop:enable Metrics
end

@ -0,0 +1,209 @@
# frozen_string_literal: true
module LoadIco
BI_RGB = 0
module_function
# rubocop:disable Metrics
def call(ico_bytes)
io = StringIO.new(ico_bytes)
_reserved, type, count = io.read(6)&.unpack('S<S<S<')
raise ArgumentError, 'Unable to load' unless type == 1 && count&.positive?
ico_entries_parsed = []
count.times do
entry_bytes = io.read(16)
raise ArgumentError, 'Unable to load' unless entry_bytes && entry_bytes.bytesize == 16
width_byte, height_byte, _num_colors_palette, _rsvd_entry, _planes_icon_entry, bpp_icon_entry,
img_data_size, img_data_offset = entry_bytes.unpack('CCCCS<S<L<L<')
width = width_byte.zero? ? 256 : width_byte
height = height_byte.zero? ? 256 : height_byte
sort_bpp = bpp_icon_entry.zero? ? 32 : bpp_icon_entry
ico_entries_parsed << {
width: width, height: height,
sort_bpp: sort_bpp,
size: img_data_size, offset: img_data_offset
}
end
best_entry = ico_entries_parsed.min_by { |e| [-e[:width] * e[:height], -e[:sort_bpp]] }
raise ArgumentError, 'Unable to load' unless best_entry
io.seek(best_entry[:offset])
image_data_bytes = io.read(best_entry[:size])
raise ArgumentError, 'Unable to load' unless image_data_bytes && image_data_bytes.bytesize == best_entry[:size]
image = load_image_entry(image_data_bytes, best_entry[:width], best_entry[:height])
raise ArgumentError, 'Unable to load' unless image
image
end
def load_image_entry(image_data_bytes, ico_entry_width, ico_entry_height)
dib_io = StringIO.new(image_data_bytes)
dib_header_size_arr = dib_io.read(4)&.unpack('L<')
return nil unless dib_header_size_arr
dib_header_size = dib_header_size_arr.first
return nil unless dib_header_size && dib_header_size >= 40
dib_params_bytes = dib_io.read(36)
return nil unless dib_params_bytes && dib_params_bytes.bytesize == 36
dib_width, dib_actual_height_field, dib_planes, dib_bpp,
dib_compression, _dib_image_size, _xpels, _ypels,
dib_clr_used, _dib_clr_important = dib_params_bytes.unpack('l<l<S<S<L<L<l<l<L<L<')
return nil unless dib_width && dib_actual_height_field && dib_planes && dib_bpp && dib_compression && dib_clr_used
return nil unless dib_width == ico_entry_width
image_pixel_height = ico_entry_height
expected_dib_height_no_mask = image_pixel_height
expected_dib_height_with_mask = image_pixel_height * 2
actual_dib_pixel_rows_abs = dib_actual_height_field.abs
unless actual_dib_pixel_rows_abs == expected_dib_height_no_mask ||
actual_dib_pixel_rows_abs == expected_dib_height_with_mask
return nil
end
return nil unless dib_planes == 1
return nil unless dib_compression == BI_RGB
return nil unless [1, 4, 8, 24, 32].include?(dib_bpp)
has_and_mask = (actual_dib_pixel_rows_abs == expected_dib_height_with_mask) && (dib_bpp < 32)
dib_io.seek(dib_header_size, IO::SEEK_SET)
palette = []
if dib_bpp <= 8
num_palette_entries = dib_clr_used.zero? ? (1 << dib_bpp) : dib_clr_used
num_palette_entries.times do
palette_color_bytes = dib_io.read(4)
return nil unless palette_color_bytes && palette_color_bytes.bytesize == 4
b, g, r, _a_reserved = palette_color_bytes.unpack('CCCC')
palette << [r, g, b, 255]
end
end
xor_mask_data_offset = dib_io.pos
xor_scanline_stride = (((dib_width * dib_bpp) + 31) / 32) * 4
and_mask_data_offset = 0
and_scanline_stride = 0
if has_and_mask
and_mask_data_offset = xor_mask_data_offset + (image_pixel_height * xor_scanline_stride)
and_scanline_stride = (((dib_width * 1) + 31) / 32) * 4
end
flat_rgba_pixels = []
(0...image_pixel_height).each do |y_row|
y_dib_row = image_pixel_height - 1 - y_row
dib_io.seek(xor_mask_data_offset + (y_dib_row * xor_scanline_stride))
xor_scanline_bytes = dib_io.read(xor_scanline_stride)
min_xor_bytes_needed = ((dib_width * dib_bpp) + 7) / 8
return nil unless xor_scanline_bytes && xor_scanline_bytes.bytesize >= min_xor_bytes_needed
and_mask_bits_for_row = []
if has_and_mask
dib_io.seek(and_mask_data_offset + (y_dib_row * and_scanline_stride))
and_mask_scanline_bytes = dib_io.read(and_scanline_stride)
min_and_bytes_needed = ((dib_width * 1) + 7) / 8
return nil unless and_mask_scanline_bytes && and_mask_scanline_bytes.bytesize >= min_and_bytes_needed
(0...dib_width).each do |x_pixel|
byte_index = x_pixel / 8
bit_index_in_byte = 7 - (x_pixel % 8)
byte_val = and_mask_scanline_bytes.getbyte(byte_index)
and_mask_bits_for_row << ((byte_val >> bit_index_in_byte) & 1)
end
end
(0...dib_width).each do |x_pixel|
r = 0
g = 0
b = 0
a = 255
case dib_bpp
when 32
offset = x_pixel * 4
blue = xor_scanline_bytes.getbyte(offset)
green = xor_scanline_bytes.getbyte(offset + 1)
red = xor_scanline_bytes.getbyte(offset + 2)
alpha_val = xor_scanline_bytes.getbyte(offset + 3)
r = red
g = green
b = blue
a = alpha_val
when 24
offset = x_pixel * 3
blue = xor_scanline_bytes.getbyte(offset)
green = xor_scanline_bytes.getbyte(offset + 1)
red = xor_scanline_bytes.getbyte(offset + 2)
r = red
g = green
b = blue
when 8
idx = xor_scanline_bytes.getbyte(x_pixel)
r_p, g_p, b_p, a_p = palette[idx] || [0, 0, 0, 0]
r = r_p
g = g_p
b = b_p
a = a_p
when 4
byte_val = xor_scanline_bytes.getbyte(x_pixel / 2)
idx = (x_pixel.even? ? (byte_val >> 4) : (byte_val & 0x0F))
r_p, g_p, b_p, a_p = palette[idx] || [0, 0, 0, 0]
r = r_p
g = g_p
b = b_p
a = a_p
when 1
byte_val = xor_scanline_bytes.getbyte(x_pixel / 8)
idx = (byte_val >> (7 - (x_pixel % 8))) & 1
r_p, g_p, b_p, a_p = palette[idx] || [0, 0, 0, 0]
r = r_p
g = g_p
b = b_p
a = a_p
end
if has_and_mask && !and_mask_bits_for_row.empty?
a = and_mask_bits_for_row[x_pixel] == 1 ? 0 : 255
end
flat_rgba_pixels.push(r, g, b, a)
end
end
pixel_data_string = flat_rgba_pixels.pack('C*')
expected_bytes = dib_width * image_pixel_height * 4
return nil unless pixel_data_string.bytesize == expected_bytes && expected_bytes.positive?
Vips::Image.new_from_memory(
pixel_data_string,
dib_width,
image_pixel_height,
4,
:uchar
)
end
# rubocop:enable Metrics
end

@ -0,0 +1,412 @@
# frozen_string_literal: true
class Pdfium
extend FFI::Library
LIB_NAME = 'pdfium'
begin
ffi_lib case FFI::Platform::OS
when 'darwin'
[
"lib#{LIB_NAME}.dylib",
'/Applications/LibreOffice.app/Contents/Frameworks/libpdfiumlo.dylib'
]
else
"lib#{LIB_NAME}.so"
end
rescue LoadError => e
raise "Could not load libpdfium library. Make sure it's installed and in your library path. Error: #{e.message}"
end
typedef :pointer, :FPDF_STRING
typedef :pointer, :FPDF_DOCUMENT
typedef :pointer, :FPDF_PAGE
typedef :pointer, :FPDF_BITMAP
typedef :pointer, :FPDF_FORMHANDLE
MAX_SIZE = 32_767
FPDF_ANNOT = 0x01
FPDF_LCD_TEXT = 0x02
FPDF_NO_NATIVETEXT = 0x04
FPDF_GRAYSCALE = 0x08
FPDF_REVERSE_BYTE_ORDER = 0x10
FPDF_RENDER_LIMITEDIMAGECACHE = 0x200
FPDF_RENDER_FORCEHALFTONE = 0x400
FPDF_PRINTING = 0x800
# rubocop:disable Naming/ClassAndModuleCamelCase
class FPDF_LIBRARY_CONFIG < FFI::Struct
layout :version, :int,
:m_pUserFontPaths, :pointer,
:m_pIsolate, :pointer,
:m_v8EmbedderSlot, :uint,
:m_pPlatform, :pointer,
:m_RendererType, :int
end
# rubocop:enable Naming/ClassAndModuleCamelCase
attach_function :FPDF_InitLibraryWithConfig, [:pointer], :void
attach_function :FPDF_DestroyLibrary, [], :void
attach_function :FPDF_LoadDocument, %i[string FPDF_STRING], :FPDF_DOCUMENT
attach_function :FPDF_LoadMemDocument, %i[pointer int FPDF_STRING], :FPDF_DOCUMENT
attach_function :FPDF_CloseDocument, [:FPDF_DOCUMENT], :void
attach_function :FPDF_GetPageCount, [:FPDF_DOCUMENT], :int
attach_function :FPDF_GetLastError, [], :ulong
attach_function :FPDF_LoadPage, %i[FPDF_DOCUMENT int], :FPDF_PAGE
attach_function :FPDF_ClosePage, [:FPDF_PAGE], :void
attach_function :FPDF_GetPageWidthF, [:FPDF_PAGE], :float
attach_function :FPDF_GetPageHeightF, [:FPDF_PAGE], :float
attach_function :FPDFBitmap_Create, %i[int int int], :FPDF_BITMAP
attach_function :FPDFBitmap_CreateEx, %i[int int int pointer int], :FPDF_BITMAP
attach_function :FPDFBitmap_Destroy, [:FPDF_BITMAP], :void
attach_function :FPDFBitmap_GetBuffer, [:FPDF_BITMAP], :pointer
attach_function :FPDFBitmap_GetWidth, [:FPDF_BITMAP], :int
attach_function :FPDFBitmap_GetHeight, [:FPDF_BITMAP], :int
attach_function :FPDFBitmap_GetStride, [:FPDF_BITMAP], :int
attach_function :FPDFBitmap_FillRect, %i[FPDF_BITMAP int int int int ulong], :void
attach_function :FPDF_RenderPageBitmap, %i[FPDF_BITMAP FPDF_PAGE int int int int int int], :void
typedef :int, :FPDF_BOOL
typedef :pointer, :IPDF_JSPLATFORM
# rubocop:disable Naming/ClassAndModuleCamelCase
class FPDF_FORMFILLINFO_V2 < FFI::Struct
layout :version, :int,
:Release, :pointer,
:FFI_Invalidate, :pointer,
:FFI_OutputSelectedRect, :pointer,
:FFI_SetCursor, :pointer,
:FFI_SetTimer, :pointer,
:FFI_KillTimer, :pointer,
:FFI_GetLocalTime, :pointer,
:FFI_OnChange, :pointer,
:FFI_GetPage, :pointer,
:FFI_GetCurrentPage, :pointer,
:FFI_GetRotation, :pointer,
:FFI_ExecuteNamedAction, :pointer,
:FFI_SetTextFieldFocus, :pointer,
:FFI_DoURIAction, :pointer,
:FFI_DoGoToAction, :pointer,
:m_pJsPlatform, :IPDF_JSPLATFORM,
:xfa_disabled, :FPDF_BOOL,
:FFI_DisplayCaret, :pointer,
:FFI_GetCurrentPageIndex, :pointer,
:FFI_SetCurrentPage, :pointer,
:FFI_GotoURL, :pointer,
:FFI_GetPageViewRect, :pointer,
:FFI_PageEvent, :pointer,
:FFI_PopupMenu, :pointer,
:FFI_OpenFile, :pointer,
:FFI_EmailTo, :pointer,
:FFI_UploadTo, :pointer,
:FFI_GetPlatform, :pointer,
:FFI_GetLanguage, :pointer,
:FFI_DownloadFromURL, :pointer,
:FFI_PostRequestURL, :pointer,
:FFI_PutRequestURL, :pointer,
:FFI_OnFocusChange, :pointer,
:FFI_DoURIActionWithKeyboardModifier, :pointer
end
# rubocop:enable Naming/ClassAndModuleCamelCase
attach_function :FPDFDOC_InitFormFillEnvironment, %i[FPDF_DOCUMENT pointer], :FPDF_FORMHANDLE
attach_function :FPDFDOC_ExitFormFillEnvironment, [:FPDF_FORMHANDLE], :void
attach_function :FPDF_FFLDraw, %i[FPDF_FORMHANDLE FPDF_BITMAP FPDF_PAGE int int int int int int], :void
FPDF_ERR_SUCCESS = 0
FPDF_ERR_UNKNOWN = 1
FPDF_ERR_FILE = 2
FPDF_ERR_FORMAT = 3
FPDF_ERR_PASSWORD = 4
FPDF_ERR_SECURITY = 5
FPDF_ERR_PAGE = 6
PDFIUM_ERRORS = {
FPDF_ERR_SUCCESS => 'Success',
FPDF_ERR_UNKNOWN => 'Unknown error',
FPDF_ERR_FILE => 'Error open file',
FPDF_ERR_FORMAT => 'Invalid format',
FPDF_ERR_PASSWORD => 'Incorrect password',
FPDF_ERR_SECURITY => 'Security scheme error',
FPDF_ERR_PAGE => 'Page not found'
}.freeze
class PdfiumError < StandardError; end
def self.error_message(code)
PDFIUM_ERRORS[code] || "Unknown error code: #{code}"
end
def self.check_last_error(context_message = 'PDFium operation failed')
error_code = FPDF_GetLastError()
return if error_code == FPDF_ERR_SUCCESS
raise PdfiumError, "#{context_message}: #{error_message(error_code)} (Code: #{error_code})"
end
class Document
attr_reader :document_ptr, :form_handle
def initialize(document_ptr, source_buffer = nil)
raise ArgumentError, 'document_ptr cannot be nil' if document_ptr.nil? || document_ptr.null?
@document_ptr = document_ptr
@pages = {}
@closed = false
@source_buffer = source_buffer
@form_handle = FFI::Pointer::NULL
@form_fill_info_mem = FFI::Pointer::NULL
init_form_fill_environment
end
def init_form_fill_environment
return if @document_ptr.null?
@form_fill_info_mem = FFI::MemoryPointer.new(FPDF_FORMFILLINFO_V2.size)
form_fill_info_struct = FPDF_FORMFILLINFO_V2.new(@form_fill_info_mem)
form_fill_info_struct[:version] = 2
@form_handle = Pdfium.FPDFDOC_InitFormFillEnvironment(@document_ptr, @form_fill_info_mem)
end
def page_count
@page_count ||= Pdfium.FPDF_GetPageCount(@document_ptr)
end
def self.open_file(file_path, password = nil)
doc_ptr = Pdfium.FPDF_LoadDocument(file_path, password)
if doc_ptr.null?
Pdfium.check_last_error("Failed to load document from file '#{file_path}'")
raise PdfiumError, "Failed to load document from file '#{file_path}', pointer is NULL."
end
doc = new(doc_ptr)
return doc unless block_given?
begin
yield doc
ensure
doc.close
end
end
def self.open_bytes(bytes, password = nil)
buffer = FFI::MemoryPointer.new(:char, bytes.bytesize)
buffer.put_bytes(0, bytes)
doc_ptr = Pdfium.FPDF_LoadMemDocument(buffer, bytes.bytesize, password)
if doc_ptr.null?
Pdfium.check_last_error('Failed to load document from memory')
raise PdfiumError, 'Failed to load document from memory, pointer is NULL.'
end
doc = new(doc_ptr, buffer)
return doc unless block_given?
begin
yield doc
ensure
doc.close
end
end
def closed?
@closed
end
def ensure_not_closed!
raise PdfiumError, 'Document is closed.' if closed?
end
def get_page(page_index)
ensure_not_closed!
unless page_index.is_a?(Integer) && page_index >= 0 && page_index < page_count
raise PdfiumError, "Page index #{page_index} out of range (0..#{page_count - 1})"
end
@pages[page_index] ||= Page.new(self, page_index)
end
def close
return if closed?
@pages.each_value { |page| page.close unless page.closed? }
@pages.clear
unless @form_handle.null?
Pdfium.FPDFDOC_ExitFormFillEnvironment(@form_handle)
@form_handle = FFI::Pointer::NULL
end
if @form_fill_info_mem && !@form_fill_info_mem.null?
@form_fill_info_mem.free
@form_fill_info_mem = FFI::Pointer::NULL
end
Pdfium.FPDF_CloseDocument(@document_ptr) unless @document_ptr.null?
@document_ptr = FFI::Pointer::NULL
@source_buffer = nil
@closed = true
end
end
class Page
attr_reader :document, :page_index, :page_ptr
def initialize(document, page_index)
raise ArgumentError, 'Document object is required' unless document.is_a?(Pdfium::Document)
@document = document
@document.ensure_not_closed!
@page_index = page_index
@page_ptr = Pdfium.FPDF_LoadPage(document.document_ptr, page_index)
if @page_ptr.null?
Pdfium.check_last_error("Failed to load page #{page_index}")
raise PdfiumError, "Failed to load page #{page_index}, pointer is NULL."
end
@closed = false
end
def width
@width ||= Pdfium.FPDF_GetPageWidthF(@page_ptr)
end
def height
@height ||= Pdfium.FPDF_GetPageHeightF(@page_ptr)
end
def closed?
@closed
end
def form_handle
@document.form_handle
end
def ensure_not_closed!
raise PdfiumError, 'Page is closed.' if closed?
@document.ensure_not_closed!
end
def render_to_bitmap(width: nil, height: nil, scale: nil, background_color: 0xFFFFFFFF,
flags: FPDF_ANNOT | FPDF_LCD_TEXT | FPDF_NO_NATIVETEXT | FPDF_REVERSE_BYTE_ORDER)
ensure_not_closed!
render_width, render_height = calculate_render_dimensions(width, height, scale)
bitmap_ptr = Pdfium.FPDFBitmap_Create(render_width, render_height, 1)
if bitmap_ptr.null?
Pdfium.check_last_error('Failed to create bitmap (potential pre-existing error)')
raise PdfiumError, 'Failed to create bitmap (FPDFBitmap_Create returned NULL)'
end
Pdfium.FPDFBitmap_FillRect(bitmap_ptr, 0, 0, render_width, render_height, background_color)
Pdfium.FPDF_RenderPageBitmap(bitmap_ptr, page_ptr, 0, 0, render_width, render_height, 0, flags)
Pdfium.check_last_error('Failed to render page to bitmap')
unless form_handle.null?
Pdfium.FPDF_FFLDraw(form_handle, bitmap_ptr, page_ptr, 0, 0, render_width, render_height, 0, flags)
Pdfium.check_last_error('Call to FPDF_FFLDraw completed (check for rendering issues if any)')
end
buffer_ptr = Pdfium.FPDFBitmap_GetBuffer(bitmap_ptr)
stride = Pdfium.FPDFBitmap_GetStride(bitmap_ptr)
bitmap_data = buffer_ptr.read_bytes(stride * render_height)
[bitmap_data, render_width, render_height]
ensure
Pdfium.FPDFBitmap_Destroy(bitmap_ptr) if bitmap_ptr && !bitmap_ptr.null?
end
def close
return if closed?
Pdfium.FPDF_ClosePage(@page_ptr) unless @page_ptr.null?
@page_ptr = FFI::Pointer::NULL
@closed = true
end
private
def calculate_render_dimensions(width_param, height_param, scale_param)
if scale_param
render_width = (width * scale_param).round
render_height = (height * scale_param).round
elsif width_param || height_param
if width_param && height_param
render_width = width_param
render_height = height_param
elsif width_param
scale_factor = width_param.to_f / width
render_width = width_param
render_height = (height * scale_factor).round
else
scale_factor = height_param.to_f / height
render_width = (width * scale_factor).round
render_height = height_param
end
else
render_width = width.to_i
render_height = height.to_i
end
[render_width.clamp(1, MAX_SIZE), render_height.clamp(1, MAX_SIZE)]
end
end
def self.initialize_library
config_mem = FFI::MemoryPointer.new(FPDF_LIBRARY_CONFIG.size)
config_struct = FPDF_LIBRARY_CONFIG.new(config_mem)
config_struct[:version] = 2
config_struct[:m_pUserFontPaths] = FFI::Pointer::NULL
config_struct[:m_pIsolate] = FFI::Pointer::NULL
config_struct[:m_v8EmbedderSlot] = 0
FPDF_InitLibraryWithConfig(config_mem)
end
def self.cleanup_library
FPDF_DestroyLibrary()
end
initialize_library
at_exit do
cleanup_library
end
end

@ -120,6 +120,8 @@ module Submissions
email = email.to_s.tr('/', ',')
return email.downcase.sub(/@gmail?\z/i, '@gmail.com') if email.match?(/@gmail?\z/i)
return email.downcase if email.include?(',') ||
email.match?(/\.(?:gob|om|mm|cm|et|mo|nz|za|ie)\z/) ||
email.exclude?('.')

@ -309,7 +309,7 @@ module Submissions
image =
begin
Vips::Image.new_from_buffer(attachment.download, '').autorot
Submissions::GenerateResultAttachments.load_vips_image(attachment).autorot
rescue Vips::Error
next unless attachment.content_type.starts_with?('image/')
next if attachment.byte_size.zero?

@ -11,6 +11,9 @@ module Submissions
'Helvetica'
end
ICO_REGEXP = %r{\Aimage/(?:x-icon|vnd\.microsoft\.icon)\z}
BMP_REGEXP = %r{\Aimage/(?:bmp|x-bmp|x-ms-bmp)\z}
FONT_BOLD_NAME = if File.exist?(FONT_BOLD_PATH)
FONT_BOLD_PATH
else
@ -250,9 +253,7 @@ module Submissions
when ->(type) { type == 'signature' && (with_signature_id || field.dig('preferences', 'reason_field_uuid')) }
attachment = submitter.attachments.find { |a| a.uuid == value }
attachments_data_cache[attachment.uuid] ||= attachment.download
image = Vips::Image.new_from_buffer(attachments_data_cache[attachment.uuid], '').autorot
image = load_vips_image(attachment, attachments_data_cache).autorot
reason_value = submitter.values[field.dig('preferences', 'reason_field_uuid')].presence
@ -360,11 +361,9 @@ module Submissions
when 'image', 'signature', 'initials', 'stamp'
attachment = submitter.attachments.find { |a| a.uuid == value }
attachments_data_cache[attachment.uuid] ||= attachment.download
image =
begin
Vips::Image.new_from_buffer(attachments_data_cache[attachment.uuid], '').autorot
load_vips_image(attachment, attachments_data_cache).autorot
rescue Vips::Error
next unless attachment.content_type.starts_with?('image/')
next if attachment.byte_size.zero?
@ -736,14 +735,16 @@ module Submissions
page = pdf.pages.add
scale = [A4_SIZE.first / attachment.metadata['width'].to_f,
A4_SIZE.last / attachment.metadata['height'].to_f].min
image = attachment.preview_images.first
page.box.width = attachment.metadata['width'] * scale
page.box.height = attachment.metadata['height'] * scale
scale = [A4_SIZE.first / image.metadata['width'].to_f,
A4_SIZE.last / image.metadata['height'].to_f].min
page.box.width = image.metadata['width'] * scale
page.box.height = image.metadata['height'] * scale
page.canvas.image(
StringIO.new(attachment.preview_images.first.download),
StringIO.new(image.download),
at: [0, 0],
width: page.box.width,
height: page.box.height
@ -804,6 +805,20 @@ module Submissions
[]
end
def load_vips_image(attachment, cache = {})
cache[attachment.uuid] ||= attachment.download
data = cache[attachment.uuid]
if ICO_REGEXP.match?(attachment.content_type)
LoadIco.call(data)
elsif BMP_REGEXP.match?(attachment.content_type)
LoadBmp.call(data)
else
Vips::Image.new_from_buffer(data, '')
end
end
def h
Rails.application.routes.url_helpers
end

@ -6,6 +6,7 @@ module Templates
FORMAT = '.png'
ATTACHMENT_NAME = 'preview_images'
BMP_REGEXP = %r{\Aimage/(?:bmp|x-bmp|x-ms-bmp)\z}
PDF_CONTENT_TYPE = 'application/pdf'
CONCURRENCY = 2
Q = 95
@ -38,7 +39,13 @@ module Templates
def generate_preview_image(attachment, data)
ActiveStorage::Attachment.where(name: ATTACHMENT_NAME, record: attachment).destroy_all
image = Vips::Image.new_from_buffer(data, '')
image =
if BMP_REGEXP.match?(attachment.content_type)
LoadBmp.call(data)
else
Vips::Image.new_from_buffer(data, '')
end
image = image.autorot.resize(MAX_WIDTH / image.width.to_f)
bitdepth = 2**image.stats.to_a[1..3].pluck(2).uniq.size
@ -77,11 +84,13 @@ module Templates
end
def generate_document_preview_images(attachment, data, range, concurrency: CONCURRENCY)
doc = Pdfium::Document.open_bytes(data)
pool = Concurrent::FixedThreadPool.new(concurrency)
promises =
range.map do |page_number|
Concurrent::Promise.execute(executor: pool) { build_and_upload_blob(data, page_number) }
Concurrent::Promise.execute(executor: pool) { build_and_upload_blob(doc, page_number) }
end
Concurrent::Promise.zip(*promises).value!.each do |blob|
@ -95,31 +104,42 @@ module Templates
)
end
end
pool.kill
ensure
doc&.close
pool&.kill
end
def build_and_upload_blob(data, page_number)
page = Vips::Image.new_from_buffer(data, '', dpi: DPI, page: page_number)
page = page.resize(MAX_WIDTH / page.width.to_f)
def build_and_upload_blob(doc, page_number, format = FORMAT)
doc_page = doc.get_page(page_number)
data, width, height = doc_page.render_to_bitmap(width: MAX_WIDTH)
page = Vips::Image.new_from_memory(data, width, height, 4, :uchar)
page = page.copy(interpretation: :srgb)
bitdepth = 2**page.stats.to_a[1..3].pluck(2).uniq.size
io = StringIO.new(page.write_to_buffer(FORMAT, compression: 7, filter: 0, bitdepth:,
palette: true, Q: bitdepth == 8 ? Q : 5, dither: 0))
data =
if format == FORMAT
page.write_to_buffer(format, compression: 7, filter: 0, bitdepth:,
palette: true, Q: bitdepth == 8 ? Q : 5, dither: 0)
else
page.write_to_buffer(format, interlace: true, Q: JPEG_Q)
end
blob = ActiveStorage::Blob.new(
filename: "#{page_number}#{FORMAT}",
filename: "#{page_number}#{format}",
metadata: { analyzed: true, identified: true, width: page.width, height: page.height }
)
blob.upload(io)
blob.upload(StringIO.new(data))
blob
rescue Vips::Error => e
rescue Vips::Error, Pdfium::PdfiumError => e
Rollbar.warning(e) if defined?(Rollbar)
nil
ensure
doc_page&.close
end
def maybe_flatten_form(data, pdf)
@ -162,35 +182,19 @@ module Templates
end
def generate_pdf_preview_from_file(attachment, file_path, page_number)
io = StringIO.new
command = [
'pdftocairo', '-jpeg', '-jpegopt', "progressive=y,quality=#{JPEG_Q},optimize=y",
'-scale-to-x', MAX_WIDTH, '-scale-to-y', '-1',
'-r', DPI, '-f', page_number + 1, '-l', page_number + 1,
'-singlefile', Shellwords.escape(file_path), '-'
].join(' ')
Open3.popen3(command) do |_, stdout, _, _|
io.write(stdout.read)
io.rewind
end
page = Vips::Image.new_from_buffer(io.read, '')
doc = Pdfium::Document.open_file(file_path)
io.rewind
blob = build_and_upload_blob(doc, page_number, '.jpeg')
ApplicationRecord.no_touching do
ActiveStorage::Attachment.create!(
blob: ActiveStorage::Blob.create_and_upload!(
io:, filename: "#{page_number}.jpg",
metadata: { analyzed: true, identified: true, width: page.width, height: page.height }
),
blob: blob,
name: ATTACHMENT_NAME,
record: attachment
)
end
ensure
doc&.close
end
end
end

@ -0,0 +1,8 @@
# frozen_string_literal: true
FactoryBot.define do
factory :template_access do
template
user
end
end

@ -14,6 +14,7 @@ FactoryBot.define do
%w[text date checkbox radio signature number multiple select initials image file stamp cells phone payment]
end
except_field_types { [] }
private_access_user { nil }
end
after(:create) do |template, ev|
@ -343,5 +344,17 @@ FactoryBot.define do
template.save!
end
trait :with_admin_only_access do
after(:create) do |template|
create(:template_access, template:, user_id: TemplateAccess::ADMIN_USER_ID)
end
end
trait :with_private_access do
after(:create) do |template, ev|
create(:template_access, template:, user: ev.private_access_user || template.author)
end
end
end
end

@ -1,7 +1,5 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe ProcessSubmitterCompletionJob do
let(:account) { create(:account) }
let(:user) { create(:user, account:) }

@ -1,7 +1,5 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe SendFormCompletedWebhookRequestJob do
let(:account) { create(:account) }
let(:user) { create(:user, account:) }

@ -1,7 +1,5 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe SendFormDeclinedWebhookRequestJob do
let(:account) { create(:account) }
let(:user) { create(:user, account:) }

@ -1,7 +1,5 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe SendFormStartedWebhookRequestJob do
let(:account) { create(:account) }
let(:user) { create(:user, account:) }

@ -1,7 +1,5 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe SendFormViewedWebhookRequestJob do
let(:account) { create(:account) }
let(:user) { create(:user, account:) }

@ -1,7 +1,5 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe SendSubmissionArchivedWebhookRequestJob do
let(:account) { create(:account) }
let(:user) { create(:user, account:) }

@ -1,7 +1,5 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe SendSubmissionCompletedWebhookRequestJob do
let(:account) { create(:account) }
let(:user) { create(:user, account:) }

@ -1,7 +1,5 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe SendSubmissionCreatedWebhookRequestJob do
let(:account) { create(:account) }
let(:user) { create(:user, account:) }

@ -1,7 +1,5 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe SendTemplateCreatedWebhookRequestJob do
let(:account) { create(:account) }
let(:user) { create(:user, account:) }

@ -1,7 +1,5 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe SendTemplateUpdatedWebhookRequestJob do
let(:account) { create(:account) }
let(:user) { create(:user, account:) }

@ -1,7 +1,5 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Params::BaseValidator do
let(:validator) { described_class.new({}) }

@ -1,8 +1,6 @@
# frozen_string_literal: true
require 'rails_helper'
describe 'Submission API', type: :request do
describe 'Submission API' do
let(:account) { create(:account, :with_testing_account) }
let(:testing_account) { account.testing_accounts.first }
let(:author) { create(:user, account:) }

@ -1,8 +1,6 @@
# frozen_string_literal: true
require 'rails_helper'
describe 'Submitter API', type: :request do
describe 'Submitter API' do
let(:account) { create(:account, :with_testing_account) }
let(:testing_account) { account.testing_accounts.first }
let(:author) { create(:user, account:) }

@ -1,8 +1,6 @@
# frozen_string_literal: true
require 'rails_helper'
describe 'Templates API', type: :request do
describe 'Templates API' do
let(:account) { create(:account, :with_testing_account) }
let(:testing_account) { account.testing_accounts.first }
let(:author) { create(:user, account:) }

@ -1,8 +1,6 @@
# frozen_string_literal: true
require 'rails_helper'
describe 'Tools API', type: :request do
describe 'Tools API' do
let(:account) { create(:account) }
let(:author) { create(:user, account:) }
let(:file_path) { Rails.root.join('spec/fixtures/sample-document.pdf') }

@ -1,7 +1,5 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Account Settings' do
let!(:account) { create(:account) }
let!(:user) { create(:user, account:) }

@ -1,7 +1,5 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'API Settings' do
let!(:account) { create(:account) }
let!(:user) { create(:user, account:) }

@ -1,7 +1,5 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Dashboard Page' do
let!(:account) { create(:account) }
let!(:user) { create(:user, account:) }

@ -1,7 +1,5 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Email Settings' do
let!(:account) { create(:account) }
let!(:user) { create(:user, account:) }

@ -1,7 +1,5 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'PDF Signature Settings' do
let!(:account) { create(:account) }
let!(:user) { create(:user, account:) }

@ -1,7 +1,5 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Newsletter' do
let(:user) { create(:user, account: create(:account)) }

@ -1,7 +1,5 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Notifications Settings' do
let(:user) { create(:user, account: create(:account)) }

@ -1,7 +1,5 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Personalization Settings', :js do
let!(:account) { create(:account) }
let!(:user) { create(:user, account:) }

@ -1,7 +1,5 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Personalization' do
let!(:account) { create(:account) }
let!(:user) { create(:user, account:) }

@ -1,7 +1,5 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Profile Settings' do
let(:user) { create(:user, account: create(:account)) }

@ -1,7 +1,5 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'App Setup' do
let(:form_data) do
{

@ -1,8 +1,6 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Sign In', type: :system do
RSpec.describe 'Sign In' do
let(:account) { create(:account) }
let!(:user) { create(:user, account:, email: 'john.dou@example.com', password: 'strong_password') }

@ -1,8 +1,6 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Signing Form', type: :system do
RSpec.describe 'Signing Form' do
let(:account) { create(:account) }
let(:author) { create(:user, account:) }
@ -13,7 +11,7 @@ RSpec.describe 'Signing Form', type: :system do
visit start_form_path(slug: template.slug)
end
it 'shows the email step', type: :system do
it 'shows the email step' do
expect(page).to have_content('You have been invited to submit a form')
expect(page).to have_content("Invited by #{account.name}")
expect(page).to have_field('Email', type: 'email')

@ -1,7 +1,5 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Storage Settings' do
let!(:account) { create(:account) }
let!(:user) { create(:user, account:) }

@ -1,25 +1,59 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Submission Preview' do
let(:account) { create(:account) }
let(:user) { create(:user, account:) }
let(:template) { create(:template, account:, author: user) }
context 'when not submitted' do
let(:submission) { create(:submission, template:, created_by_user: user) }
let(:submission) { create(:submission, :with_submitters, template:, created_by_user: user) }
before do
template.submitters.map { |s| create(:submitter, submission:, uuid: s['uuid']) }
context 'when user is signed in' do
before do
sign_in(user)
sign_in(user)
visit submissions_preview_path(slug: submission.slug)
end
visit submissions_preview_path(slug: submission.slug)
it 'completes the form' do
expect(page).to have_content('Not completed')
end
end
it 'completes the form' do
expect(page).to have_content('Not completed')
context 'when user is not signed in' do
context 'when submission is not completed' do
before do
create(:encrypted_config, account:, key: EncryptedConfig::EMAIL_SMTP_KEY, value: '{}')
submission.submitters.each { |s| s.update(completed_at: 1.day.ago) }
visit submissions_preview_path(slug: submission.slug)
end
it "sends a copy to the submitter's email" do
fill_in 'Email', with: submission.submitters.first.email
click_button 'Send copy to Email'
expect(page).to have_content('Email has been sent.')
end
it 'shows an error for an email not associated with the submission' do
fill_in 'Email', with: 'john.due@example.com'
click_button 'Send copy to Email'
expect(page).to have_content('Please enter your email address associated with the completed submission.')
end
end
it "doesn't display the email form if SMTP is not configured" do
submission.submitters.each { |s| s.update(completed_at: 1.day.ago) }
visit submissions_preview_path(slug: submission.slug)
expect(page).to have_content(template.name)
expect(page).not_to have_field('Email')
expect(page).not_to have_content('Send copy to Email')
end
end
end
end

@ -1,7 +1,5 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Team Settings' do
let(:account) { create(:account) }
let(:second_account) { create(:account) }

@ -1,7 +1,5 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Template Builder' do
let(:account) { create(:account) }
let(:author) { create(:user, account:) }

@ -1,7 +1,5 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Template' do
let!(:account) { create(:account) }
let!(:user) { create(:user, account:) }

@ -1,7 +1,5 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Webhook Settings' do
let!(:account) { create(:account) }
let!(:user) { create(:user, account:) }

Loading…
Cancel
Save