diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 40dc470d..ae923e59 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 4.0.1 + ruby-version: 4.0.5 - name: Cache gems uses: actions/cache@v4 with: @@ -37,7 +37,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 4.0.1 + ruby-version: 4.0.5 - name: Cache gems uses: actions/cache@v4 with: @@ -89,7 +89,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 4.0.1 + ruby-version: 4.0.5 - name: Cache gems uses: actions/cache@v4 with: @@ -104,7 +104,7 @@ jobs: bundle install --jobs 4 --retry 4 yarn install sudo apt-get update - sudo apt-get install libvips + sudo apt-get install libvips liblept5 - name: Run Brakeman run: bundle exec brakeman -q --exit-on-warn @@ -132,7 +132,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 4.0.1 + ruby-version: 4.0.5 - name: Set up Node uses: actions/setup-node@v1 with: @@ -162,8 +162,8 @@ jobs: bundle install --jobs 4 --retry 4 yarn install sudo apt-get update - 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 apt-get install -y libvips liblept5 + wget -O pdfium-linux.tgz "https://github.com/bblanchon/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 diff --git a/Dockerfile b/Dockerfile index f8c4398f..e3346423 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,19 +1,23 @@ -FROM ruby:4.0.1-alpine AS download +FROM ruby:4.0.5-alpine AS download WORKDIR /fonts -RUN apk --no-cache add wget && \ +RUN apk --no-cache add wget unzip && \ 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://raw.githubusercontent.com/impallari/DancingScript/master/OFL.txt && \ wget https://raw.githubusercontent.com/notofonts/noto-fonts/refs/heads/main/LICENSE && \ wget -O /model.onnx "https://github.com/docusealco/fields-detection/releases/download/2.0.0/model_704_int8.onnx" && \ - wget -O pdfium-linux.tgz "https://github.com/bblanchon/pdfium-binaries/releases/latest/download/pdfium-linux-musl-$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/').tgz" && \ + wget -O pdfium-linux.zip "https://github.com/docusealco/pdfium-binaries/releases/download/20260613/pdfium-musl-$(uname -m).zip" && \ + case "$(uname -m)" in \ + x86_64) echo "2c953ff72ee2dda07e7fc577e25841cc3d6464468a7c5adfaea574efcbc3b90b pdfium-linux.zip" ;; \ + aarch64) echo "23bbe287d2753fdb05741c7660647eb0ef0d2e4da2ce0722bfa9d9d455bd64e2 pdfium-linux.zip" ;; \ + esac | sha256sum -c - && \ mkdir -p /pdfium-linux && \ - tar -xzf pdfium-linux.tgz -C /pdfium-linux + unzip -q pdfium-linux.zip -d /pdfium-linux -FROM ruby:4.0.1-alpine AS webpack +FROM ruby:4.0.5-alpine AS webpack ENV RAILS_ENV=production ENV NODE_ENV=production @@ -40,7 +44,7 @@ COPY ./app/views ./app/views RUN echo "gem 'shakapacker'" > Gemfile && ./bin/shakapacker -FROM ruby:4.0.1-alpine AS app +FROM ruby:4.0.5-alpine AS app ENV RAILS_ENV=production ENV BUNDLE_WITHOUT="development:test" @@ -48,7 +52,8 @@ ENV OPENSSL_CONF=/etc/openssl_legacy.cnf WORKDIR /app -RUN apk add --no-cache libpq vips redis vips-heif onnxruntime +RUN apk add --no-cache libpq vips redis onnxruntime leptonica && \ + rm -f /usr/bin/onnx_test_runner /usr/bin/onnxruntime_test RUN addgroup -g 2000 docuseal && adduser -u 2000 -G docuseal -s /bin/sh -D -h /home/docuseal docuseal @@ -81,7 +86,7 @@ COPY --chown=docuseal:docuseal .version ./public/version COPY --chown=docuseal:docuseal --from=download /fonts/GoNotoKurrent-Regular.ttf /fonts/GoNotoKurrent-Bold.ttf /fonts/DancingScript-Regular.otf /fonts/OFL.txt /fonts/LICENSE /fonts/ 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=download /pdfium-linux/licenses/ /usr/lib/libpdfium-licenses/ COPY --chown=docuseal:docuseal --from=download /model.onnx /app/tmp/model.onnx COPY --chown=docuseal:docuseal --from=webpack /app/public/packs ./public/packs @@ -94,6 +99,7 @@ WORKDIR /data/docuseal ENV HOME=/home/docuseal ENV WORKDIR=/data/docuseal ENV VIPS_MAX_COORD=17000 +ENV VIPS_BLOCK_UNTRUSTED=1 EXPOSE 3000 CMD ["/app/bin/bundle", "exec", "puma", "-C", "/app/config/puma.rb", "--dir", "/app"] diff --git a/Gemfile b/Gemfile index a4bda694..e2d74068 100644 --- a/Gemfile +++ b/Gemfile @@ -2,7 +2,7 @@ source 'https://rubygems.org' -ruby '~> 4.0' +ruby '4.0.5' gem 'addressable' gem 'arabic-letter-connector', require: false @@ -21,11 +21,9 @@ gem 'faraday' gem 'faraday-follow_redirects' gem 'google-cloud-storage', require: false gem 'hexapdf' -gem 'image_processing' gem 'jwt', require: false gem 'lograge' gem 'numo-narray-alt', require: false -gem 'oj' gem 'onnxruntime', require: false gem 'pagy' gem 'pg', require: false diff --git a/Gemfile.lock b/Gemfile.lock index 13217069..8f63e42e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -116,7 +116,7 @@ GEM erubi (~> 1.4) parser (>= 2.4) smart_properties - bigdecimal (4.1.0) + bigdecimal (4.1.2) bindex (0.8.1) bootsnap (1.23.0) msgpack (~> 1.2) @@ -144,7 +144,7 @@ GEM cldr-plurals-runtime-rb (1.1.0) cmdparse (3.0.7) coderay (1.1.3) - concurrent-ruby (1.3.6) + concurrent-ruby (1.3.7) connection_pool (3.0.2) crack (1.0.1) bigdecimal @@ -161,7 +161,7 @@ GEM irb (~> 1.10) reline (>= 0.3.8) declarative (0.0.20) - devise (5.0.3) + devise (5.0.4) bcrypt (~> 3.0) orm_adapter (~> 0.1) railties (>= 7.0) @@ -195,13 +195,13 @@ GEM railties (>= 6.1.0) faker (3.6.1) i18n (>= 1.8.11, < 2) - faraday (2.14.1) + faraday (2.14.3) faraday-net_http (>= 2.0, < 3.5) json logger faraday-follow_redirects (0.5.0) faraday (>= 1, < 3) - faraday-net_http (3.4.2) + faraday-net_http (3.4.4) net-http (~> 0.5) ferrum (0.17.2) addressable (~> 2.5) @@ -264,18 +264,15 @@ GEM strscan (>= 3.1.2) i18n (1.14.8) concurrent-ruby (~> 1.0) - image_processing (1.14.0) - mini_magick (>= 4.9.5, < 6) - ruby-vips (>= 2.0.17, < 3) io-console (0.8.2) - irb (1.17.0) + irb (1.18.0) pp (>= 0.6.0) prism (>= 1.3.0) rdoc (>= 4.0.0) reline (>= 0.4.2) jmespath (1.6.2) - json (2.19.3) - jwt (3.1.2) + json (2.19.9) + jwt (3.2.0) base64 language_server-protocol (3.17.0.5) launchy (3.1.1) @@ -308,17 +305,15 @@ GEM marcel (1.1.0) matrix (0.4.3) method_source (1.1.0) - mini_magick (5.3.1) - logger mini_mime (1.1.5) - minitest (6.0.3) + minitest (6.0.6) drb (~> 2.0) prism (~> 1.5) msgpack (1.8.0) multi_json (1.19.1) net-http (0.9.1) uri (>= 0.11.1) - net-imap (0.6.4) + net-imap (0.6.4.1) date net-protocol net-pop (0.1.2) @@ -328,20 +323,17 @@ GEM net-smtp (0.5.1) net-protocol nio4r (2.7.5) - nokogiri (1.19.3-aarch64-linux-gnu) + nokogiri (1.19.4-aarch64-linux-gnu) racc (~> 1.4) - nokogiri (1.19.3-aarch64-linux-musl) + nokogiri (1.19.4-aarch64-linux-musl) racc (~> 1.4) - nokogiri (1.19.3-arm64-darwin) + nokogiri (1.19.4-arm64-darwin) racc (~> 1.4) - nokogiri (1.19.3-x86_64-linux-gnu) + nokogiri (1.19.4-x86_64-linux-gnu) racc (~> 1.4) - nokogiri (1.19.3-x86_64-linux-musl) + nokogiri (1.19.4-x86_64-linux-musl) racc (~> 1.4) numo-narray-alt (0.10.3) - oj (3.16.16) - bigdecimal (>= 3.0) - ostruct (>= 0.2) onnxruntime (0.10.1-aarch64-linux) ffi onnxruntime (0.10.1-arm64-darwin) @@ -351,7 +343,6 @@ GEM openssl (4.0.1) orm_adapter (0.5.0) os (1.1.4) - ostruct (0.6.3) package_json (0.2.0) pagy (43.4.4) json @@ -382,7 +373,7 @@ GEM date stringio public_suffix (7.0.5) - puma (7.2.0) + puma (8.0.2) nio4r (~> 2.0) racc (1.8.1) rack (3.2.6) @@ -429,12 +420,12 @@ GEM tsort (>= 0.2) zeitwerk (~> 2.6) rainbow (3.1.1) - rake (13.3.1) + rake (13.4.2) rdoc (7.2.0) erb psych (>= 4.0.0) tsort - redis-client (0.28.0) + redis-client (0.29.0) connection_pool regexp_parser (2.11.3) reline (0.6.3) @@ -516,12 +507,12 @@ GEM rack-proxy (>= 0.6.1) railties (>= 5.2) semantic_range (>= 2.3.0) - sidekiq (8.1.2) + sidekiq (8.1.6) connection_pool (>= 3.0.0) json (>= 2.16.0) logger (>= 1.7.0) rack (>= 3.2.0) - redis-client (>= 0.26.0) + redis-client (>= 0.29.0) signet (0.21.0) addressable (~> 2.8) faraday (>= 0.17.5, < 3.a) @@ -624,12 +615,10 @@ DEPENDENCIES foreman google-cloud-storage hexapdf - image_processing jwt letter_opener_web lograge numo-narray-alt - oj onnxruntime pagy pg @@ -662,7 +651,7 @@ DEPENDENCIES webmock RUBY VERSION - ruby 4.0.1 + ruby 4.0.5 BUNDLED WITH 4.0.3 diff --git a/app/controllers/api/active_storage_blobs_proxy_controller.rb b/app/controllers/api/active_storage_blobs_proxy_controller.rb index 8ade86c6..4198f380 100644 --- a/app/controllers/api/active_storage_blobs_proxy_controller.rb +++ b/app/controllers/api/active_storage_blobs_proxy_controller.rb @@ -9,7 +9,9 @@ module Api before_action :set_cors_headers before_action :set_noindex_headers + before_action :set_security_headers + # rubocop:disable Metrics def show blob_uuid, purp, exp = ApplicationRecord.signed_id_verifier.verified(params[:signed_uuid]) @@ -21,6 +23,12 @@ module Api blob = ActiveStorage::Blob.find_by!(uuid: blob_uuid) + if Submitters::DANGEROUS_EXTENSIONS.include?(blob.filename.extension.to_s.downcase) + Rollbar.error('Dangerous extension') if defined?(Rollbar) + + return head :unprocessable_content + end + attachment = blob.attachments.take @record = attachment.record @@ -45,6 +53,7 @@ module Api end end end + # rubocop:enable Metrics private diff --git a/app/controllers/api/active_storage_blobs_proxy_legacy_controller.rb b/app/controllers/api/active_storage_blobs_proxy_legacy_controller.rb index 77ad2c6a..8bac4ce9 100644 --- a/app/controllers/api/active_storage_blobs_proxy_legacy_controller.rb +++ b/app/controllers/api/active_storage_blobs_proxy_legacy_controller.rb @@ -9,6 +9,7 @@ module Api before_action :set_cors_headers before_action :set_noindex_headers + before_action :set_security_headers # rubocop:disable Metrics def show @@ -18,6 +19,12 @@ module Api return head :not_found unless blob + if Submitters::DANGEROUS_EXTENSIONS.include?(blob.filename.extension.to_s.downcase) + Rollbar.error('Dangerous extension') if defined?(Rollbar) + + return head :unprocessable_content + end + is_permitted = blob.attachments.any? do |a| (current_user && a.record.account.id == current_user.account_id) || a.record.account.account_configs.any? { |e| e.key == 'legacy_blob_proxy' } || diff --git a/app/controllers/api/api_base_controller.rb b/app/controllers/api/api_base_controller.rb index ff01fc8f..6d9e2185 100644 --- a/app/controllers/api/api_base_controller.rb +++ b/app/controllers/api/api_base_controller.rb @@ -102,6 +102,10 @@ module Api headers['X-Robots-Tag'] = 'noindex' end + def set_security_headers + response.headers['X-Content-Type-Options'] = 'nosniff' + end + def set_cors_headers headers['Access-Control-Allow-Origin'] = '*' headers['Access-Control-Allow-Methods'] = 'POST, GET, PUT, PATCH, DELETE, OPTIONS' diff --git a/app/controllers/api/attachments_controller.rb b/app/controllers/api/attachments_controller.rb index 2f615878..2bb0db92 100644 --- a/app/controllers/api/attachments_controller.rb +++ b/app/controllers/api/attachments_controller.rb @@ -11,13 +11,13 @@ module Api @submitter = Submitter.find_by!(slug: params[:submitter_slug]) unless can_upload?(@submitter) - Rollbar.error("Can't upload: #{@submitter.id}") if defined?(Rollbar) - return render json: { error: I18n.t('form_has_been_archived') }, status: :unprocessable_content end + file = params[:file] + if params[:type].in?(%w[initials signature]) - image = Vips::Image.new_from_file(params[:file].path) + image = ImageUtils.load_vips(file.read, content_type: file.content_type) if ImageUtils.blank?(image) Rollbar.error("Empty signature: #{@submitter.id}") if defined?(Rollbar) @@ -31,9 +31,11 @@ module Api return render json: { error: "#{params[:type]} error, try to sign on another device" }, status: :unprocessable_content end + + metadata = { analyzed: true, identified: true, width: image.width, height: image.height } end - attachment = Submitters.create_attachment!(@submitter, params) + attachment = Submitters.create_attachment!(@submitter, file, metadata:) if params[:remember_signature] == 'true' && @submitter.email.present? cookies.encrypted[:signature_uuids] = build_new_cookie_signatures_json(@submitter, attachment) diff --git a/app/controllers/api/submissions_controller.rb b/app/controllers/api/submissions_controller.rb index b8ffa5ff..ba64cbc6 100644 --- a/app/controllers/api/submissions_controller.rb +++ b/app/controllers/api/submissions_controller.rb @@ -2,6 +2,10 @@ module Api class SubmissionsController < ApiBaseController + SUBMISSION_COLUMNS = %i[id name slug source submitters_order expire_at created_at updated_at + archived_at variables template_id template_submitters created_by_user_id].freeze + TEMPLATE_COLUMNS = %i[id name external_id created_at updated_at folder_id submitters].freeze + load_and_authorize_resource :template, only: :create load_and_authorize_resource :submission, only: %i[show index destroy] @@ -13,10 +17,22 @@ module Api submissions = Submissions.search(current_user, @submissions, params[:q]) submissions = filter_submissions(submissions, params) - submissions = paginate(submissions.preload(:created_by_user, :submitters, - template: { folder: :parent_folder }, - combined_document_attachment: :blob, - audit_trail_attachment: :blob)) + with_fields = params[:include].to_s.include?('fields') || params[:include].to_s.include?('combined_document_url') + + submissions = paginate( + submissions.select(with_fields ? nil : SUBMISSION_COLUMNS) + .preload(:created_by_user, :submitters, combined_document_attachment: :blob, + audit_trail_attachment: :blob) + ) + + ActiveRecord::Associations::Preloader.new( + records: submissions, + associations: :template, + scope: with_fields ? nil : Template.select(TEMPLATE_COLUMNS) + ).call + + ActiveRecord::Associations::Preloader.new(records: submissions.filter_map(&:template), + associations: { folder: :parent_folder }).call expires_at = Accounts.link_expires_at(current_account) @@ -54,6 +70,12 @@ module Api return render json: { error: 'Template not found' }, status: :unprocessable_content if @template.nil? + if @template.archived_at? + Rollbar.warning("Archived template submission: #{@template.id}") if defined?(Rollbar) + + return render json: { error: 'Template has been archived' }, status: :unprocessable_content + end + if @template.fields.blank? Rollbar.warning("Template does not contain fields: #{@template.id}") if defined?(Rollbar) diff --git a/app/controllers/api/submitters_controller.rb b/app/controllers/api/submitters_controller.rb index fbaf3477..15b3e119 100644 --- a/app/controllers/api/submitters_controller.rb +++ b/app/controllers/api/submitters_controller.rb @@ -104,7 +104,7 @@ module Api private - def maybe_filder_by_completed_at(submitters, params) + def maybe_filter_by_completed_at(submitters, params) if params[:completed_after].present? submitters = submitters.where(completed_at: Time.zone.parse(params[:completed_after])..) end @@ -177,7 +177,7 @@ module Api submitters = submitters.joins(:submission).where(submissions: { template_id: params[:template_id] }) end - maybe_filder_by_completed_at(submitters, params) + maybe_filter_by_completed_at(submitters, params) end def assign_external_id(submitter, attrs) diff --git a/app/controllers/api/templates_controller.rb b/app/controllers/api/templates_controller.rb index 87b756ea..2de64605 100644 --- a/app/controllers/api/templates_controller.rb +++ b/app/controllers/api/templates_controller.rb @@ -9,20 +9,7 @@ module Api templates = paginate(templates.preload(:author, folder: :parent_folder)) - schema_documents = - ActiveStorage::Attachment.where(record_id: templates.map(&:id), - record_type: 'Template', - name: :documents, - uuid: templates.flat_map { |t| t.schema.pluck('attachment_uuid') }) - .preload(:blob) - - preview_image_attachments = - ActiveStorage::Attachment.joins(:blob) - .where(blob: { filename: ['0.png', '0.jpg'] }) - .where(record_id: schema_documents.map(&:id), - record_type: 'ActiveStorage::Attachment', - name: :preview_images) - .preload(:blob) + schema_documents, dynamic_documents, preview_image_attachments = preload_relations(templates) expires_at = Accounts.link_expires_at(current_account) @@ -30,6 +17,7 @@ module Api data: templates.map do |t| Templates::SerializeForApi.call(t, schema_documents: schema_documents.select { |e| e.record_id == t.id }, + dynamic_documents:, preview_image_attachments:, expires_at:) end, @@ -60,7 +48,7 @@ module Api archived = params.key?(:archived) ? params[:archived] : params.dig(:template, :archived) - if archived.in?([true, false]) + if archived.in?([true, false]) && current_ability.can?(:destroy, @template) @template.archived_at = archived == true ? Time.current : nil end @@ -69,7 +57,10 @@ module Api SearchEntries.enqueue_reindex(@template) WebhookUrls.enqueue_events(@template, 'template.updated') - WebhookUrls.enqueue_events(@template, 'template.archived') if archived == true + + if @template.saved_change_to_archived_at? && @template.archived_at? + WebhookUrls.enqueue_events(@template, 'template.archived') + end render json: @template.as_json(only: %i[id updated_at]) end @@ -88,6 +79,41 @@ module Api private + def preload_relations(templates) + schema_documents = + ActiveStorage::Attachment.where(record_id: templates.map(&:id), + record_type: 'Template', + name: :documents, + uuid: templates.flat_map { |t| t.schema.pluck('attachment_uuid') }) + .preload(:blob) + + dynamic_document_uuids = + templates.flat_map { |t| t.schema.select { |item| item['dynamic'] }.pluck('attachment_uuid') } + + dynamic_documents = + if dynamic_document_uuids.present? + DynamicDocument.where(template: templates.map(&:id)) + .where(uuid: dynamic_document_uuids) + .preload(current_version: { document_attachment: :blob }) + .select(:id, :uuid, :template_id, :sha1, :created_at, :updated_at) + else + DynamicDocument.none + end + + preview_attachment_ids = + schema_documents.map(&:id) + dynamic_documents.filter_map { |d| d.current_version&.document_attachment&.id } + + preview_image_attachments = + ActiveStorage::Attachment.joins(:blob) + .where(blob: { filename: ['0.png', '0.jpg'] }) + .where(record_id: preview_attachment_ids, + record_type: 'ActiveStorage::Attachment', + name: :preview_images) + .preload(:blob) + + [schema_documents, dynamic_documents, preview_image_attachments] + end + def filter_templates(templates, params) templates = Templates.search(current_user, templates, params[:q]) templates = params[:archived].in?(['true', true]) ? templates.archived : templates.active diff --git a/app/controllers/preview_document_page_controller.rb b/app/controllers/preview_document_page_controller.rb index 2befe3bf..5f5a8554 100644 --- a/app/controllers/preview_document_page_controller.rb +++ b/app/controllers/preview_document_page_controller.rb @@ -25,7 +25,10 @@ class PreviewDocumentPageController < ActionController::API preview_image = attachment.preview_images.joins(:blob) .find_by(blob: { filename: ["#{params[:id]}.png", "#{params[:id]}.jpg"] }) - return redirect_to preview_image.url, allow_other_host: true if preview_image + if preview_image + return redirect_to preview_image.url(time: ActiveStorage::Attachment.service_url_time), + allow_other_host: true + end file_path = if attachment.service.name == :disk @@ -37,7 +40,7 @@ class PreviewDocumentPageController < ActionController::API preview_image = Templates::ProcessDocument.generate_pdf_preview_from_file(attachment, file_path, params[:id].to_i) - redirect_to preview_image.url, allow_other_host: true + redirect_to preview_image.url(time: ActiveStorage::Attachment.service_url_time), allow_other_host: true end def find_or_create_document_tempfile_path(attachment) diff --git a/app/controllers/start_form_controller.rb b/app/controllers/start_form_controller.rb index ed9c2629..4a0d7890 100644 --- a/app/controllers/start_form_controller.rb +++ b/app/controllers/start_form_controller.rb @@ -101,12 +101,23 @@ class StartFormController < ApplicationController def load_resubmit_submitter @resubmit_submitter = if params[:resubmit].present? && !params[:resubmit].in?([true, 'true']) - Submitter.find_by(slug: params[:resubmit]) + submitter = Submitter.find_by(slug: params[:resubmit]) + + submitter if submitter && can_resubmit?(submitter) end end + def can_resubmit?(submitter) + submitter.completed_at? && submitter.completed_at > 14.days.ago && + %w[api embed mcp].exclude?(submitter.submission.source) && + submitter.account.account_configs.find_or_initialize_by(key: AccountConfig::ALLOW_TO_RESUBMIT).value != false + end + def authorize_start! - return redirect_to start_form_path(@template.slug) if @template.archived_at? + is_archived = @template.archived_at? || @template.account.archived_at? + + return redirect_to submit_form_path(@resubmit_submitter.slug) if @resubmit_submitter && is_archived + return redirect_to start_form_path(@template.slug) if is_archived return if @resubmit_submitter return if @template.shared_link? || (current_user && current_ability.can?(:read, @template)) diff --git a/app/controllers/submissions_archived_controller.rb b/app/controllers/submissions_archived_controller.rb index 793da755..662e0950 100644 --- a/app/controllers/submissions_archived_controller.rb +++ b/app/controllers/submissions_archived_controller.rb @@ -7,7 +7,7 @@ class SubmissionsArchivedController < ApplicationController @submissions = @submissions.left_joins(:template) @submissions = @submissions.where.not(archived_at: nil) .or(@submissions.where.not(templates: { archived_at: nil })) - .preload(:template_accesses, :created_by_user, template: :author) + .preload(:template_accesses, :created_by_user) @submissions = Submissions.search(current_user, @submissions, params[:q], search_template: true) @submissions = Submissions::Filter.call(@submissions, current_user, params) @@ -18,6 +18,15 @@ class SubmissionsArchivedController < ApplicationController @submissions.order(id: :desc) end - @pagy, @submissions = pagy_auto(@submissions.preload(submitters: :start_form_submission_events)) + @pagy, @submissions = pagy_auto(@submissions.select_for_list.preload(submitters: :start_form_submission_events)) + + template_scope = @submissions.all?(&:template_submitters) ? Template.select_for_list : nil + + ActiveRecord::Associations::Preloader.new(records: @submissions, + associations: :template, + scope: template_scope).call + + ActiveRecord::Associations::Preloader.new(records: @submissions.filter_map(&:template), + associations: :author).call end end diff --git a/app/controllers/submissions_controller.rb b/app/controllers/submissions_controller.rb index 2e1bba67..04bc5e94 100644 --- a/app/controllers/submissions_controller.rb +++ b/app/controllers/submissions_controller.rb @@ -1,9 +1,7 @@ # frozen_string_literal: true class SubmissionsController < ApplicationController - before_action :load_template, only: %i[new create] - authorize_resource :template, only: %i[new create] - + load_and_authorize_resource :template, only: %i[new create] load_and_authorize_resource :submission, only: %i[show destroy] prepend_before_action :maybe_redirect_com, only: %i[show] @@ -38,6 +36,8 @@ class SubmissionsController < ApplicationController end def create + return redirect_to template_path(@template), alert: I18n.t('template_has_been_archived') if @template.archived_at? + save_template_message(@template, params) if params[:save_message] == '1' [params.delete(:subject), params.delete(:body)] if params[:is_custom_message] != '1' @@ -111,8 +111,4 @@ class SubmissionsController < ApplicationController def submissions_params params.permit(submission: { submitters: [:uuid, :email, :phone, :name, { values: {} }] }) end - - def load_template - @template = Template.accessible_by(current_ability).find(params[:template_id]) - end end diff --git a/app/controllers/submissions_dashboard_controller.rb b/app/controllers/submissions_dashboard_controller.rb index f0851741..f04e9bea 100644 --- a/app/controllers/submissions_dashboard_controller.rb +++ b/app/controllers/submissions_dashboard_controller.rb @@ -8,7 +8,7 @@ class SubmissionsDashboardController < ApplicationController @submissions = @submissions.where(archived_at: nil) .where(templates: { archived_at: nil }) - .preload(:template_accesses, :created_by_user, template: :author) + .preload(:template_accesses, :created_by_user) @submissions = Submissions.search(current_user, @submissions, params[:q], search_template: true) @submissions = Submissions::Filter.call(@submissions, current_user, params) @@ -19,6 +19,15 @@ class SubmissionsDashboardController < ApplicationController @submissions.order(id: :desc) end - @pagy, @submissions = pagy_auto(@submissions.preload(submitters: :start_form_submission_events)) + @pagy, @submissions = pagy_auto(@submissions.select_for_list.preload(submitters: :start_form_submission_events)) + + template_scope = @submissions.all?(&:template_submitters) ? Template.select_for_list : nil + + ActiveRecord::Associations::Preloader.new(records: @submissions, + associations: :template, + scope: template_scope).call + + ActiveRecord::Associations::Preloader.new(records: @submissions.filter_map(&:template), + associations: :author).call end end diff --git a/app/controllers/submissions_resend_email_controller.rb b/app/controllers/submissions_resend_email_controller.rb new file mode 100644 index 00000000..a68c7ad4 --- /dev/null +++ b/app/controllers/submissions_resend_email_controller.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +class SubmissionsResendEmailController < ApplicationController + load_and_authorize_resource :submission + + before_action do + authorize!(:manage, :resend_all) + authorize!(:update, @submission) + end + + def create + submitters = @submission.submitters.reject(&:completed_at?).select { |s| s.email.present? && !s.declined_at? } + + if Docuseal.multitenant? + recent_submitter_ids = + SubmissionEvent.where(submitter_id: submitters.map(&:id), + event_type: 'send_email', + created_at: 10.hours.ago..Time.current).pluck(:submitter_id).to_set + + submitters = submitters.reject { |s| recent_submitter_ids.include?(s.id) } + end + + submitters.each do |submitter| + SendSubmitterInvitationEmailJob.perform_async('submitter_id' => submitter.id) + + submitter.sent_at ||= Time.current + submitter.save! + end + + notice = + if submitters.empty? + I18n.t('email_has_been_sent_already') + else + I18n.t('emails_have_been_sent_to_n_recipients', count: submitters.size) + end + + redirect_back(fallback_location: submission_path(@submission), notice:) + end +end diff --git a/app/controllers/submissions_unarchive_controller.rb b/app/controllers/submissions_unarchive_controller.rb index 5a60a60b..8bd320a6 100644 --- a/app/controllers/submissions_unarchive_controller.rb +++ b/app/controllers/submissions_unarchive_controller.rb @@ -4,6 +4,8 @@ class SubmissionsUnarchiveController < ApplicationController load_and_authorize_resource :submission def create + authorize!(:update, @submission) + @submission.update!(archived_at: nil) redirect_to submission_path(@submission), notice: I18n.t('submission_has_been_unarchived') diff --git a/app/controllers/submit_form_decline_controller.rb b/app/controllers/submit_form_decline_controller.rb index a55590f1..73139f79 100644 --- a/app/controllers/submit_form_decline_controller.rb +++ b/app/controllers/submit_form_decline_controller.rb @@ -7,7 +7,8 @@ class SubmitFormDeclineController < ApplicationController before_action :load_submitter def create - return redirect_to submit_form_path(@submitter.slug) if @submitter.declined_at? || + return redirect_to submit_form_path(@submitter.slug) if declining_disabled? || + @submitter.declined_at? || @submitter.completed_at? || @submitter.submission.archived_at? || @submitter.submission.expired? || @@ -35,6 +36,10 @@ class SubmitFormDeclineController < ApplicationController private + def declining_disabled? + @submitter.account.account_configs.find_by(key: AccountConfig::ALLOW_TO_DECLINE_KEY)&.value == false + end + def load_submitter @submitter = Submitter.find_by!(slug: params[:submit_form_slug]) end diff --git a/app/controllers/submitters_controller.rb b/app/controllers/submitters_controller.rb index 697b3dc1..1eae06d8 100644 --- a/app/controllers/submitters_controller.rb +++ b/app/controllers/submitters_controller.rb @@ -13,7 +13,7 @@ class SubmittersController < ApplicationController def update submission = @submitter.submission - if @submitter.submission_events.exists?(event_type: 'start_form') || submission.archived_at? || submission.expired? + unless submitter_editable?(submission) return redirect_back fallback_location: submission_path(submission), alert: I18n.t('submitter_cannot_be_updated') end @@ -48,6 +48,12 @@ class SubmittersController < ApplicationController private + def submitter_editable?(submission) + !@submitter.submission_events.exists?(event_type: 'start_form') && + !@submitter.completed_at? && !@submitter.declined_at? && + !submission.archived_at? && !submission.expired? && !submission.template&.archived_at? + end + def maybe_resend_email_sms(submitter, params) if params[:send_email] == '1' && submitter.email.present? is_sent_recently = Docuseal.multitenant? && diff --git a/app/controllers/submitters_resubmit_controller.rb b/app/controllers/submitters_resubmit_controller.rb index 6ab28731..c34f83b3 100644 --- a/app/controllers/submitters_resubmit_controller.rb +++ b/app/controllers/submitters_resubmit_controller.rb @@ -5,6 +5,8 @@ class SubmittersResubmitController < ApplicationController def update return redirect_to submit_form_path(slug: @submitter.slug) if @submitter.email != current_user.email + return redirect_to submit_form_path(slug: @submitter.slug) if @submitter.completed_at.blank? || + @submitter.completed_at < 1.month.ago submission = @submitter.account.submissions.new(created_by_user: current_user, submitters_order: :preserved, diff --git a/app/controllers/submitters_send_email_controller.rb b/app/controllers/submitters_send_email_controller.rb index 51c7c1b7..f616c976 100644 --- a/app/controllers/submitters_send_email_controller.rb +++ b/app/controllers/submitters_send_email_controller.rb @@ -4,6 +4,8 @@ class SubmittersSendEmailController < ApplicationController load_and_authorize_resource :submitter def create + authorize!(:update, @submitter) + if Docuseal.multitenant? && SubmissionEvent.exists?(submitter: @submitter, event_type: 'send_email', created_at: 10.hours.ago..Time.current) diff --git a/app/controllers/template_documents_controller.rb b/app/controllers/template_documents_controller.rb index db8ba66c..fafa6c14 100644 --- a/app/controllers/template_documents_controller.rb +++ b/app/controllers/template_documents_controller.rb @@ -10,6 +10,8 @@ class TemplateDocumentsController < ApplicationController end def create + authorize!(:update, @template) + if params[:blobs].blank? && params[:files].blank? return render json: { error: I18n.t('file_is_missing') }, status: :unprocessable_content end diff --git a/app/controllers/template_documents_crop_controller.rb b/app/controllers/template_documents_crop_controller.rb new file mode 100644 index 00000000..c09b084c --- /dev/null +++ b/app/controllers/template_documents_crop_controller.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +class TemplateDocumentsCropController < ApplicationController + load_and_authorize_resource :template + before_action :load_attachment + + rescue_from Leptonica::LeptonicaError do + render json: { error: I18n.t(:unable_to_save) }, status: :unprocessable_content + end + + def index + render json: { corners: Leptonica.detect_document_corners(@attachment.download) } + end + + def create + authorize!(:update, @template) + + document = Templates::CreateDocumentCrop.call(@template, @attachment, crop_params) + + render json: { + document: document.as_json( + methods: %i[metadata signed_key], + include: { + preview_images: { methods: %i[url metadata filename] } + } + ) + } + end + + private + + def load_attachment + @attachment = @template.documents_attachments.find_by!(uuid: params[:attachment_uuid]) + end + + def crop_params + params.permit(:scan, :rotate, :flip_h, :flip_v, corners: [%i[x y]]) + end +end diff --git a/app/controllers/template_documents_modify_controller.rb b/app/controllers/template_documents_modify_controller.rb new file mode 100644 index 00000000..69de6e6d --- /dev/null +++ b/app/controllers/template_documents_modify_controller.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +class TemplateDocumentsModifyController < ApplicationController + load_and_authorize_resource :template + + def create + authorize!(:update, @template) + + documents_layout = + params.require(:documents).map do |item| + item.permit(:attachment_uuid, + pages: [:attachment_uuid, :page, :rotate, + { redact: [%i[x y w h color]], replaced_page: %i[attachment_uuid page] }]).to_h + end + + Templates::ModifyDocuments.call(@template, documents_layout) + + render json: { + schema: @template.schema, + fields: @template.fields, + submitters: @template.submitters, + documents: @template.schema_documents.reload.preload(:blob, preview_images_attachments: :blob).as_json( + methods: %i[metadata signed_key], + include: { + preview_images: { methods: %i[url metadata filename] } + } + ) + } + rescue Templates::ModifyDocuments::InvalidLayout + render json: { error: I18n.t(:unable_to_save) }, status: :unprocessable_content + end +end diff --git a/app/controllers/template_documents_page_objects_controller.rb b/app/controllers/template_documents_page_objects_controller.rb new file mode 100644 index 00000000..de9aa0b3 --- /dev/null +++ b/app/controllers/template_documents_page_objects_controller.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class TemplateDocumentsPageObjectsController < ApplicationController + load_and_authorize_resource :template + + def index + attachment = @template.documents_attachments.find_by!(uuid: params[:attachment_uuid]) + + render json: Templates::ModifyDocuments.page_objects(attachment, params[:page].to_i) + end +end diff --git a/app/controllers/template_folders_controller.rb b/app/controllers/template_folders_controller.rb index 25e489ce..a315b167 100644 --- a/app/controllers/template_folders_controller.rb +++ b/app/controllers/template_folders_controller.rb @@ -30,9 +30,11 @@ class TemplateFoldersController < ApplicationController (@template_folders.size < 7 ? 9 : 6) end - @pagy, @templates = pagy_auto(@templates, limit:) + @pagy, @templates = pagy_auto(@templates.select_for_list, limit:) - load_related_submissions if params[:q].present? && @templates.blank? + if params[:q].present? && @templates.blank? + @related_submissions_pagy, @related_submissions = load_related_submissions(@template_folder) + end else @pagy, @template_folders = pagy(@template_folders, limit: FOLDERS_PER_PAGE) @@ -55,11 +57,10 @@ class TemplateFoldersController < ApplicationController def selected_order @selected_order ||= - if cookies.permanent[:dashboard_templates_order].blank? || - (cookies.permanent[:dashboard_templates_order] == 'used_at' && can?(:manage, :countless)) + if can?(:manage, :countless) 'created_at' else - cookies.permanent[:dashboard_templates_order] + cookies.permanent[:dashboard_templates_order].presence || 'created_at' end end @@ -67,20 +68,20 @@ class TemplateFoldersController < ApplicationController params.require(:template_folder).permit(:name) end - def load_related_submissions - @related_submissions = + def load_related_submissions(template_folder) + related_submissions = Submission.accessible_by(current_ability) .where(archived_at: nil) .where(template_id: current_account.templates.active - .where(folder: [@template_folder, *@template_folder.subfolders]) + .where(folder: [template_folder, *template_folder.subfolders]) .select(:id)) .preload(:template_accesses, :created_by_user, template: :author, submitters: :start_form_submission_events) - @related_submissions = Submissions.search(current_user, @related_submissions, params[:q]) - .order(id: :desc) + related_submissions = Submissions.search(current_user, related_submissions, params[:q]) + .order(id: :desc) - @related_submissions_pagy, @related_submissions = pagy_auto(@related_submissions, limit: 5) + pagy_auto(related_submissions.select_for_list, limit: 5) end end diff --git a/app/controllers/templates_archived_controller.rb b/app/controllers/templates_archived_controller.rb index 69b16936..84d40074 100644 --- a/app/controllers/templates_archived_controller.rb +++ b/app/controllers/templates_archived_controller.rb @@ -10,11 +10,17 @@ class TemplatesArchivedController < ApplicationController @templates = Templates.search(current_user, @templates, params[:q]) - @pagy, @templates = pagy_auto(@templates, limit: 12) + @pagy, @templates = pagy_auto(@templates.select_for_list, limit: 12) return unless params[:q].present? && @templates.blank? - @related_submissions = + @related_submissions_pagy, @related_submissions = load_related_submissions + end + + private + + def load_related_submissions + related_submissions = Submission.accessible_by(current_ability) .joins(:template) .where.not(templates: { archived_at: nil }) @@ -22,9 +28,9 @@ class TemplatesArchivedController < ApplicationController template: :author, submitters: :start_form_submission_events) - @related_submissions = Submissions.search(current_user, @related_submissions, params[:q]) - .order(id: :desc) + related_submissions = Submissions.search(current_user, related_submissions, params[:q]) + .order(id: :desc) - @related_submissions_pagy, @related_submissions = pagy_auto(@related_submissions, limit: 5) + pagy_auto(related_submissions.select_for_list, limit: 5) end end diff --git a/app/controllers/templates_archived_submissions_controller.rb b/app/controllers/templates_archived_submissions_controller.rb index 39aa0877..cbe7e45a 100644 --- a/app/controllers/templates_archived_submissions_controller.rb +++ b/app/controllers/templates_archived_submissions_controller.rb @@ -15,7 +15,7 @@ class TemplatesArchivedSubmissionsController < ApplicationController @submissions.order(id: :desc) end - @pagy, @submissions = pagy_auto(@submissions.preload(submitters: :start_form_submission_events)) + @pagy, @submissions = pagy_auto(@submissions.select_for_list.preload(submitters: :start_form_submission_events)) rescue ActiveRecord::RecordNotFound redirect_to root_path end diff --git a/app/controllers/templates_clone_and_replace_controller.rb b/app/controllers/templates_clone_and_replace_controller.rb index de64d86d..015d1830 100644 --- a/app/controllers/templates_clone_and_replace_controller.rb +++ b/app/controllers/templates_clone_and_replace_controller.rb @@ -13,6 +13,9 @@ class TemplatesCloneAndReplaceController < ApplicationController cloned_template = Templates::Clone.call(@template, author: current_user) cloned_template.name = File.basename(params[:files].first.original_filename, '.*') + + authorize!(:create, cloned_template) + cloned_template.save! documents = Templates::ReplaceAttachments.call(cloned_template, params, extract_fields: true) diff --git a/app/controllers/templates_controller.rb b/app/controllers/templates_controller.rb index 11968e4f..edf73faa 100644 --- a/app/controllers/templates_controller.rb +++ b/app/controllers/templates_controller.rb @@ -1,10 +1,6 @@ # frozen_string_literal: true class TemplatesController < ApplicationController - TEMPLATE_FIELDS = %i[id author_id folder_id external_id name slug - schema fields submitters variables_schema preferences - shared_link source archived_at created_at updated_at].freeze - load_and_authorize_resource :template def show @@ -23,7 +19,8 @@ class TemplatesController < ApplicationController submissions.order(id: :desc) end - @pagy, @submissions = pagy_auto(submissions.preload(:template_accesses, submitters: :start_form_submission_events)) + @pagy, @submissions = + pagy_auto(submissions.select_for_list.preload(:template_accesses, submitters: :start_form_submission_events)) rescue ActiveRecord::RecordNotFound redirect_to root_path end @@ -31,19 +28,7 @@ class TemplatesController < ApplicationController def new; end def edit - ActiveRecord::Associations::Preloader.new( - records: [@template], - associations: [{ schema_documents: [:blob, { preview_images_attachments: :blob }] }] - ).call - - @template_data = - @template.as_json(only: TEMPLATE_FIELDS).merge( - documents: @template.schema_documents.as_json( - only: %i[id uuid], - methods: %i[metadata signed_key], - include: { preview_images: { only: %i[id], methods: %i[url metadata filename] } } - ) - ).to_json + @template_data = Templates.serialize_for_builder(@template) render :edit, layout: 'plain' end @@ -77,8 +62,6 @@ class TemplatesController < ApplicationController WebhookUrls.enqueue_events(@template, 'template.updated') - TemplateVersions.find_or_create_for(@template, author: current_user) if params[:revision] - head :ok end diff --git a/app/controllers/templates_dashboard_controller.rb b/app/controllers/templates_dashboard_controller.rb index 4497038e..d9a683c9 100644 --- a/app/controllers/templates_dashboard_controller.rb +++ b/app/controllers/templates_dashboard_controller.rb @@ -11,86 +11,126 @@ class TemplatesDashboardController < ApplicationController helper_method :selected_order def index + @default_folder = current_account.default_template_folder + @template_folders = TemplateFolders.filter_active_folders(@template_folders.where(parent_folder_id: nil), @templates) + @template_folders = @template_folders.where.not(id: @default_folder.id) if params[:q].blank? + @template_folders = TemplateFolders.search(@template_folders, params[:q]) @template_folders = TemplateFolders.sort(@template_folders, current_user, selected_order) - @pagy, @template_folders = pagy( - @template_folders, - limit: FOLDERS_PER_PAGE, - page: @template_folders.count > SHOW_TEMPLATES_FOLDERS_THRESHOLD ? params[:page] : 1 - ) + @shared_templates = Templates.shared(current_user).active + + @pagy, @template_folders, @show_default_folder, @show_shared_folder, @show_shared_inline = + load_folders(@template_folders, @templates, @shared_templates) if @pagy.count > SHOW_TEMPLATES_FOLDERS_THRESHOLD @templates = @templates.none else - @template_folders = @template_folders.reject { |e| e.name == TemplateFolder::DEFAULT_NAME } - @templates = filter_templates(@templates).preload(:author, :template_accesses) - @templates = Templates::Order.call(@templates, current_user, selected_order) - - limit = - if @template_folders.size < 4 - TEMPLATES_PER_PAGE - else - (@template_folders.size < 7 ? 9 : 6) - end + if @show_shared_inline + @templates = @shared_templates.preload(:template_sharings) + else + @templates = @templates.active + @templates = @templates.where(folder_id: @default_folder.id) if params[:q].blank? + end - @pagy, @templates = pagy_auto(@templates, limit:) + @pagy, @templates = load_templates(@templates.select_for_list, @pagy.count, + show_shared_inline: @show_shared_inline) - load_related_submissions if params[:q].present? && @templates.blank? + if params[:q].present? && @templates.blank? + @related_submissions_pagy, @related_submissions = load_related_submissions + end end end private - def filter_templates(templates) - rel = templates.active + def load_templates(templates, folders_count, show_shared_inline: false) + templates = templates.preload(:author, :template_accesses) - if params[:q].blank? - if Docuseal.multitenant? ? current_account.testing? : current_account.linked_account_account - shared_account_ids = [current_user.account_id] - shared_account_ids << TemplateSharing::ALL_ID if !Docuseal.multitenant? && !current_account.testing? + templates = + if show_shared_inline + Templates.search_shared(current_user, templates, params[:q]) + else + Templates.search(current_user, templates, params[:q]) + end - shared_template_ids = TemplateSharing.where(account_id: shared_account_ids).select(:template_id) + templates = Templates::Order.call(templates, current_user, selected_order) - rel = Template.where( - Template.arel_table[:id].in( - rel.where(folder_id: current_account.default_template_folder.id).select(:id).arel - .union(:all, shared_template_ids.arel) - ) - ) + limit = + if folders_count < 4 + TEMPLATES_PER_PAGE else - rel = rel.where(folder_id: current_account.default_template_folder.id) + (folders_count < 7 ? 9 : 6) end + + pagy_auto(templates, limit:) + end + + def load_folders(template_folders, templates, shared_templates) + if params[:q].present? + pagy(template_folders, limit: FOLDERS_PER_PAGE, + page: template_folders.count > SHOW_TEMPLATES_FOLDERS_THRESHOLD ? params[:page] : 1) + else + load_folders_with_pinned(template_folders, templates, shared_templates) end + end + + def load_folders_with_pinned(template_folders, templates, shared_templates) + folders_count = template_folders.count + + shared_exists = shared_templates.exists? + default_has_templates = templates.active.exists?(folder_id: current_account.default_template_folder.id) + + show_inline_folders = + folders_count + (shared_exists && default_has_templates ? 1 : 0) <= SHOW_TEMPLATES_FOLDERS_THRESHOLD + + show_shared_inline = shared_exists && !default_has_templates && show_inline_folders + + show_shared_in_grid = shared_exists && !show_shared_inline + show_default_in_grid = !show_inline_folders && default_has_templates + + pinned_count = (show_default_in_grid ? 1 : 0) + (show_shared_in_grid ? 1 : 0) + + pagy = Pagy::Offset.new(count: folders_count + pinned_count, + page: show_inline_folders ? 1 : [params[:page].to_s.to_i, 1].max, + limit: FOLDERS_PER_PAGE, + raise_range_error: true) + + show_default_folder = show_default_in_grid && pagy.page == 1 + show_shared_folder = show_shared_in_grid && pagy.page == 1 + + folder_offset = pagy.page == 1 ? 0 : pagy.offset - pinned_count + folder_limit = pagy.page == 1 ? FOLDERS_PER_PAGE - pinned_count : FOLDERS_PER_PAGE + + template_folders = template_folders.offset(folder_offset).limit(folder_limit) - Templates.search(current_user, rel, params[:q]) + [pagy, template_folders, show_default_folder, show_shared_folder, show_shared_inline] end def selected_order @selected_order ||= - if cookies.permanent[:dashboard_templates_order].blank? || - (cookies.permanent[:dashboard_templates_order] == 'used_at' && can?(:manage, :countless)) + if can?(:manage, :countless) 'created_at' else - cookies.permanent[:dashboard_templates_order] + cookies.permanent[:dashboard_templates_order].presence || 'created_at' end end def load_related_submissions - @related_submissions = Submission.accessible_by(current_ability) - .left_joins(:template) - .where(archived_at: nil) - .where(templates: { archived_at: nil }) - .preload(:template_accesses, :created_by_user, - template: :author, - submitters: :start_form_submission_events) - - @related_submissions = Submissions.search(current_user, @related_submissions, params[:q]) - .order(id: :desc) - - @related_submissions_pagy, @related_submissions = pagy_auto(@related_submissions, limit: 5) + related_submissions = Submission.accessible_by(current_ability) + .left_joins(:template) + .where(archived_at: nil) + .where(templates: { archived_at: nil }) + .preload(:template_accesses, :created_by_user, + template: :author, + submitters: :start_form_submission_events) + + related_submissions = Submissions.search(current_user, related_submissions, params[:q]) + .order(id: :desc) + + pagy_auto(related_submissions.select_for_list, limit: 5) end end diff --git a/app/controllers/templates_detect_fields_controller.rb b/app/controllers/templates_detect_fields_controller.rb index 56b06cd5..a0cd54ff 100644 --- a/app/controllers/templates_detect_fields_controller.rb +++ b/app/controllers/templates_detect_fields_controller.rb @@ -16,7 +16,12 @@ class TemplatesDetectFieldsController < ApplicationController page_number = params[:page].presence&.to_i documents.each do |document| - io = StringIO.new(document.download) + io = + if document.image? + StringIO.new(document.preview_images.joins(:blob).find_by(blob: { filename: ['0.png', '0.jpg'] }).download) + else + StringIO.new(document.download) + end Templates::DetectFields.call(io, attachment: document, page_number:) do |(attachment_uuid, page, fields)| sse.write({ attachment_uuid:, page:, fields: }) diff --git a/app/controllers/templates_folders_controller.rb b/app/controllers/templates_folders_controller.rb index 3e83023a..05ee62d8 100644 --- a/app/controllers/templates_folders_controller.rb +++ b/app/controllers/templates_folders_controller.rb @@ -6,6 +6,8 @@ class TemplatesFoldersController < ApplicationController def edit; end def update + authorize!(:update, @template) + name = [params[:parent_name], params[:name]].compact_blank.join(' / ') @template.folder = TemplateFolders.find_or_create_by_name(current_user, name) diff --git a/app/controllers/templates_preview_controller.rb b/app/controllers/templates_preview_controller.rb index e132b131..602fc622 100644 --- a/app/controllers/templates_preview_controller.rb +++ b/app/controllers/templates_preview_controller.rb @@ -4,18 +4,7 @@ class TemplatesPreviewController < ApplicationController load_and_authorize_resource :template def show - ActiveRecord::Associations::Preloader.new( - records: [@template], - associations: [{ schema_documents: { preview_images_attachments: :blob } }] - ).call - - @template_data = - @template.as_json.merge( - documents: @template.schema_documents.as_json( - methods: %i[metadata signed_key], - include: { preview_images: { methods: %i[url metadata filename] } } - ) - ).to_json + @template_data = Templates.serialize_for_builder(@template) render :show, layout: 'plain' end diff --git a/app/controllers/templates_restore_controller.rb b/app/controllers/templates_restore_controller.rb index d6d0505e..64afea11 100644 --- a/app/controllers/templates_restore_controller.rb +++ b/app/controllers/templates_restore_controller.rb @@ -4,6 +4,8 @@ class TemplatesRestoreController < ApplicationController load_and_authorize_resource :template def create + authorize!(:destroy, @template) + @template.update!(archived_at: nil) WebhookUrls.enqueue_events(@template, 'template.updated') diff --git a/app/controllers/templates_shared_controller.rb b/app/controllers/templates_shared_controller.rb new file mode 100644 index 00000000..8855d94d --- /dev/null +++ b/app/controllers/templates_shared_controller.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +class TemplatesSharedController < ApplicationController + def index + authorize!(:read, Template) + + @is_archived = params[:archived] == 'true' + + @templates = Templates.shared(current_user) + + @has_archived = !@is_archived && @templates.archived.exists? + + @templates = @is_archived ? @templates.archived : @templates.active + + @templates = @templates.preload(:author, :template_accesses, :template_sharings) + .order(id: :desc) + + @templates = Templates.search_shared(current_user, @templates, params[:q]) + + @pagy, @templates = pagy_auto(@templates.select_for_list, limit: 12) + + return unless params[:q].present? && @templates.blank? + + @related_submissions_pagy, @related_submissions = load_related_submissions(is_archived: @is_archived) + end + + private + + def load_related_submissions(is_archived:) + shared_templates = Templates.shared(current_user) + shared_templates = is_archived ? shared_templates.archived : shared_templates.active + + related_submissions = + Submission.accessible_by(current_ability) + .where(template_id: shared_templates.select(:id)) + .preload(:template_accesses, :created_by_user, + template: :author, + submitters: :start_form_submission_events) + + related_submissions = related_submissions.where(archived_at: nil) unless is_archived + + related_submissions = Submissions.search(current_user, related_submissions, params[:q]) + .order(id: :desc) + + pagy_auto(related_submissions.select_for_list, limit: 5) + end +end diff --git a/app/controllers/templates_uploads_controller.rb b/app/controllers/templates_uploads_controller.rb index cbc1531a..615e6214 100644 --- a/app/controllers/templates_uploads_controller.rb +++ b/app/controllers/templates_uploads_controller.rb @@ -5,7 +5,9 @@ class TemplatesUploadsController < ApplicationController layout 'plain' - def show; end + def show + redirect_to root_path if params[:url].blank? + end def create url_params = create_file_params_from_url if params[:url].present? diff --git a/app/controllers/templates_versions_controller.rb b/app/controllers/templates_versions_controller.rb index c9a67b8f..0dbd20aa 100644 --- a/app/controllers/templates_versions_controller.rb +++ b/app/controllers/templates_versions_controller.rb @@ -14,4 +14,12 @@ class TemplatesVersionsController < ApplicationController render json: TemplateVersions.serialize(version) end + + def create + authorize!(:update, @template) + + TemplateVersions.find_or_create_for(@template, author: current_user) + + head :ok + end end diff --git a/app/controllers/user_initials_controller.rb b/app/controllers/user_initials_controller.rb index b2db409b..f6b87daa 100644 --- a/app/controllers/user_initials_controller.rb +++ b/app/controllers/user_initials_controller.rb @@ -11,6 +11,12 @@ class UserInitialsController < ApplicationController return redirect_to settings_profile_index_path, notice: I18n.t('unable_to_save_initials') if file.blank? + extension = File.extname(file.original_filename).delete_prefix('.').downcase + + if Submitters::DANGEROUS_EXTENSIONS.include?(extension) + raise Submitters::MaliciousFileExtension, "File type '.#{extension}' is not allowed." + end + blob = ActiveStorage::Blob.create_and_upload!(io: file.open, filename: file.original_filename, content_type: file.content_type) diff --git a/app/controllers/user_signatures_controller.rb b/app/controllers/user_signatures_controller.rb index 1200acff..f6511d00 100644 --- a/app/controllers/user_signatures_controller.rb +++ b/app/controllers/user_signatures_controller.rb @@ -11,6 +11,12 @@ class UserSignaturesController < ApplicationController return redirect_to settings_profile_index_path, notice: I18n.t('unable_to_save_signature') if file.blank? + extension = File.extname(file.original_filename).delete_prefix('.').downcase + + if Submitters::DANGEROUS_EXTENSIONS.include?(extension) + raise Submitters::MaliciousFileExtension, "File type '.#{extension}' is not allowed." + end + blob = ActiveStorage::Blob.create_and_upload!(io: file.open, filename: file.original_filename, content_type: file.content_type) diff --git a/app/javascript/application.js b/app/javascript/application.js index af25f2b4..6aa39fc8 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -21,6 +21,7 @@ import SubmittersAutocomplete from './elements/submitter_autocomplete' import FolderAutocomplete from './elements/folder_autocomplete' import SignatureForm from './elements/signature_form' import SubmitForm from './elements/submit_form' +import ConvertUpload from './elements/convert_upload' import PromptPassword from './elements/prompt_password' import EmailsTextarea from './elements/emails_textarea' import ToggleSubmit from './elements/toggle_submit' @@ -47,13 +48,13 @@ import ScrollTo from './elements/scroll_to' import SetValue from './elements/set_value' import ReviewForm from './elements/review_form' import ShowOnValue from './elements/show_on_value' -import CustomValidation from './elements/custom_validation' import ToggleClasses from './elements/toggle_classes' import AutosizeField from './elements/autosize_field' import GoogleDriveFilePicker from './elements/google_drive_file_picker' import OpenModal from './elements/open_modal' import BarChart from './elements/bar_chart' import FieldCondition from './elements/field_condition' +import ConfirmUpload from './elements/confirm_upload' import * as TurboInstantClick from './lib/turbo_instant_click' @@ -112,6 +113,7 @@ safeRegisterElement('submitters-autocomplete', SubmittersAutocomplete) safeRegisterElement('folder-autocomplete', FolderAutocomplete) safeRegisterElement('signature-form', SignatureForm) safeRegisterElement('submit-form', SubmitForm) +safeRegisterElement('convert-upload', ConvertUpload) safeRegisterElement('prompt-password', PromptPassword) safeRegisterElement('emails-textarea', EmailsTextarea) safeRegisterElement('toggle-cookies', ToggleCookies) @@ -139,13 +141,13 @@ safeRegisterElement('scroll-to', ScrollTo) safeRegisterElement('set-value', SetValue) safeRegisterElement('review-form', ReviewForm) safeRegisterElement('show-on-value', ShowOnValue) -safeRegisterElement('custom-validation', CustomValidation) safeRegisterElement('toggle-classes', ToggleClasses) safeRegisterElement('autosize-field', AutosizeField) safeRegisterElement('google-drive-file-picker', GoogleDriveFilePicker) safeRegisterElement('open-modal', OpenModal) safeRegisterElement('bar-chart', BarChart) safeRegisterElement('field-condition', FieldCondition) +safeRegisterElement('confirm-upload', ConfirmUpload) safeRegisterElement('template-builder', class extends HTMLElement { connectedCallback () { @@ -197,10 +199,35 @@ safeRegisterElement('template-builder', class extends HTMLElement { } onSubmit = (e) => { - if (e.detail.success && e.detail?.formSubmission?.formElement?.id === 'submitters_form') { - e.detail.fetchResponse.response.json().then((data) => { - this.component.template.submitters = data.submitters - }) + if (e.detail.success) { + if (e.detail?.formSubmission?.formElement?.id === 'submitters_form') { + e.detail.fetchResponse.response.json().then((data) => { + this.component.template.submitters = data.submitters + }) + } + + if (e.detail?.formSubmission?.formElement?.action?.endsWith('/prefillable_fields')) { + e.detail.fetchResponse.response.text().then((data) => { + const doc = new DOMParser().parseFromString(data, 'text/html') + const fragment = doc.querySelector('turbo-stream template').content + + const prefillableUuidsIndex = {} + + fragment.querySelectorAll('[name="field_uuid"]').forEach((field) => { + prefillableUuidsIndex[field.value] = true + }) + + this.component.template.fields.forEach((field) => { + if (prefillableUuidsIndex[field.uuid]) { + field.prefillable = true + field.readonly = true + } else if (field.prefillable) { + delete field.prefillable + delete field.readonly + } + }) + }) + } } } diff --git a/app/javascript/elements/confirm_upload.js b/app/javascript/elements/confirm_upload.js new file mode 100644 index 00000000..aa39601d --- /dev/null +++ b/app/javascript/elements/confirm_upload.js @@ -0,0 +1,27 @@ +import { target, targetable } from '@github/catalyst/lib/targetable' + +export default targetable(class extends HTMLElement { + static [target.static] = [ + 'prompt', + 'processing', + 'logo' + ] + + connectedCallback () { + this.form.addEventListener('submit', this.onSubmit) + } + + disconnectedCallback () { + this.form.removeEventListener('submit', this.onSubmit) + } + + onSubmit = () => { + this.prompt.classList.add('hidden') + this.processing.classList.remove('hidden') + this.logo.classList.add('animate-bounce') + } + + get form () { + return this.querySelector('form') + } +}) diff --git a/app/javascript/elements/convert_upload.js b/app/javascript/elements/convert_upload.js new file mode 100644 index 00000000..a00cd666 --- /dev/null +++ b/app/javascript/elements/convert_upload.js @@ -0,0 +1,73 @@ +export function convertImage (sourceFile, targetType, quality) { + 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 ext = targetType === 'image/jpeg' ? '.jpg' : '.png' + const newFile = new File([blob], sourceFile.name.replace(/\.\w+$/, ext), { type: targetType }) + resolve(newFile) + }, targetType, quality) + } + + img.onerror = () => reject(new Error(`browser cannot decode ${sourceFile.type || sourceFile.name}`)) + + img.src = event.target.result + } + reader.onerror = reject + reader.readAsDataURL(sourceFile) + }) +} + +export async function convertImagesInInput (input) { + if (!input.files || input.files.length === 0) return + + const dt = new DataTransfer() + let didConvert = false + + for (const file of Array.from(input.files)) { + let converted = file + + try { + if (['image/bmp', 'image/vnd.microsoft.icon', 'image/svg+xml', 'image/gif'].includes(file.type)) { + converted = await convertImage(file, 'image/png') + didConvert = true + } else if (['image/heic', 'image/heif', 'image/heic-sequence', 'image/heif-sequence', 'image/avif', 'image/avif-sequence', 'image/webp'].includes(file.type)) { + converted = await convertImage(file, 'image/jpeg', 0.9) + didConvert = true + } + } catch (e) { + alert(e.message) + } + + dt.items.add(converted) + } + + if (didConvert) { + input.files = dt.files + } +} + +export default class extends HTMLElement { + connectedCallback () { + const input = this.querySelector('input[type="file"]') + const form = input.form + + input.addEventListener('change', async () => { + await convertImagesInInput(input) + + form.querySelector('[type="submit"]')?.setAttribute('disabled', true) + + form.requestSubmit() + }) + } +} diff --git a/app/javascript/elements/custom_validation.js b/app/javascript/elements/custom_validation.js deleted file mode 100644 index ac738679..00000000 --- a/app/javascript/elements/custom_validation.js +++ /dev/null @@ -1,14 +0,0 @@ -export default class extends HTMLElement { - connectedCallback () { - const input = this.querySelector('input') - const invalidMessage = this.dataset.invalidMessage || '' - - input.addEventListener('invalid', () => { - input.setCustomValidity(input.value ? invalidMessage : '') - }) - - input.addEventListener('input', () => { - input.setCustomValidity('') - }) - } -} diff --git a/app/javascript/elements/dashboard_dropzone.js b/app/javascript/elements/dashboard_dropzone.js index 886aae37..434b33b7 100644 --- a/app/javascript/elements/dashboard_dropzone.js +++ b/app/javascript/elements/dashboard_dropzone.js @@ -1,4 +1,5 @@ import { target, targets, targetable } from '@github/catalyst/lib/targetable' +import { convertImagesInInput } from './convert_upload' const loadingIconHtml = ` @@ -150,12 +151,16 @@ export default targetable(class extends HTMLElement { if (!this.isLoading) this.hideDraghover() } - uploadFiles (files, url) { + async uploadFiles (files, url) { this.isLoading = true this.form.action = url - this.form.querySelector('[type="file"]').files = files + const input = this.form.querySelector('[type="file"]') + + input.files = files + + await convertImagesInInput(input) this.form.querySelector('[type="submit"]').click() } diff --git a/app/javascript/elements/file_dropzone.js b/app/javascript/elements/file_dropzone.js index 12ef253d..fb8c1ae7 100644 --- a/app/javascript/elements/file_dropzone.js +++ b/app/javascript/elements/file_dropzone.js @@ -1,5 +1,6 @@ import { actionable } from '@github/catalyst/lib/actionable' import { target, targetable } from '@github/catalyst/lib/targetable' +import { convertImagesInInput } from './convert_upload' export default actionable(targetable(class extends HTMLElement { static [target.static] = [ @@ -38,17 +39,21 @@ export default actionable(targetable(class extends HTMLElement { this.classList.add('border-base-300', 'hover:bg-base-200/30') } - onDrop (e) { + async onDrop (e) { e.preventDefault() this.input.files = e.dataTransfer.files - this.uploadFiles(e.dataTransfer.files) + await convertImagesInInput(this.input) + + this.uploadFiles(this.input.files) } - onSelectFiles (e) { + async onSelectFiles (e) { e.preventDefault() + await convertImagesInInput(this.input) + this.uploadFiles(this.input.files) } diff --git a/app/javascript/form.js b/app/javascript/form.js index c8d5790e..75158250 100644 --- a/app/javascript/form.js +++ b/app/javascript/form.js @@ -52,7 +52,9 @@ safeRegisterElement('submission-form', class extends HTMLElement { completedMessage: JSON.parse(this.dataset.completedMessage || '{}'), completedRedirectUrl: this.dataset.completedRedirectUrl, attachments: reactive(JSON.parse(this.dataset.attachments)), - fields: JSON.parse(this.dataset.fields) + fields: JSON.parse(this.dataset.fields), + completeButtonContainer: document.getElementById('complete_button_container'), + completeButtonScrollContainer: document.getElementById('complete_button_container_scroll') }) this.app.mount(this.appElem) diff --git a/app/javascript/submission_form/area.vue b/app/javascript/submission_form/area.vue index 69a453ac..d73aee07 100644 --- a/app/javascript/submission_form/area.vue +++ b/app/javascript/submission_form/area.vue @@ -528,7 +528,7 @@ export default { return this.formatDate( this.modelValue === '{{date}}' ? new Date() : new Date(this.modelValue), this.field.preferences?.format || (this.locale.endsWith('-US') ? 'MM/DD/YYYY' : 'DD/MM/YYYY'), - { withTimePlaceholders: this.modelValue === '{{date}}' } + { withTimePlaceholders: this.modelValue === '{{date}}', utc: this.modelValue !== '{{date}}' } ) } catch { return this.modelValue @@ -643,11 +643,15 @@ export default { return new Intl.NumberFormat('de-DE').format(number) } else if (format === 'space') { return new Intl.NumberFormat('fr-FR').format(number) + } else if (format === 'percent') { + return `${number}%` + } else if (format === 'percent_space') { + return `${String(number).replace('.', ',')} %` } else { return number } }, - formatDate (date, format, { withTimePlaceholders = false } = {}) { + formatDate (date, format, { withTimePlaceholders = false, utc = true } = {}) { const monthFormats = { M: 'numeric', MM: '2-digit', MMM: 'short', MMMM: 'long' } const dayFormats = { D: 'numeric', DD: '2-digit' } const yearFormats = { YYYY: 'numeric', YYY: 'numeric', YY: '2-digit' } @@ -669,7 +673,7 @@ export default { if (format.match(/m+/)) opts.minute = minuteFormats[format.match(/m+/)[0]] if (format.match(/s+/)) opts.second = secondFormats[format.match(/s+/)[0]] if (/z/.test(format)) opts.timeZoneName = 'short' - if (!hasTime) opts.timeZone = 'UTC' + if (!hasTime && utc) opts.timeZone = 'UTC' const partTypes = { M: 'month', diff --git a/app/javascript/submission_form/date_step.vue b/app/javascript/submission_form/date_step.vue index 245573da..f60fd6eb 100644 --- a/app/javascript/submission_form/date_step.vue +++ b/app/javascript/submission_form/date_step.vue @@ -27,6 +27,7 @@ + + + + + +