diff --git a/app/controllers/api/submitter_email_clicks_controller.rb b/app/controllers/api/submitter_email_clicks_controller.rb index cef265422..9c52fc5b6 100644 --- a/app/controllers/api/submitter_email_clicks_controller.rb +++ b/app/controllers/api/submitter_email_clicks_controller.rb @@ -1,10 +1,7 @@ # frozen_string_literal: true module Api - class SubmitterEmailClicksController < ApiBaseController - skip_before_action :authenticate_user! - skip_authorization_check - + class SubmitterEmailClicksController < ActionController::API def create @submitter = Submitter.find_by!(slug: params[:submitter_slug]) diff --git a/app/controllers/api/submitter_form_views_controller.rb b/app/controllers/api/submitter_form_views_controller.rb index d9ac441ef..263af0726 100644 --- a/app/controllers/api/submitter_form_views_controller.rb +++ b/app/controllers/api/submitter_form_views_controller.rb @@ -1,10 +1,7 @@ # frozen_string_literal: true module Api - class SubmitterFormViewsController < ApiBaseController - skip_before_action :authenticate_user! - skip_authorization_check - + class SubmitterFormViewsController < ActionController::API def create @submitter = Submitter.find_by!(slug: params[:submitter_slug]) diff --git a/app/controllers/submit_form_click_email_controller.rb b/app/controllers/submit_form_click_email_controller.rb new file mode 100644 index 000000000..bebacde43 --- /dev/null +++ b/app/controllers/submit_form_click_email_controller.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class SubmitFormClickEmailController < ActionController::API + def create + @submitter = Submitter.find_by!(slug: params[:submit_form_slug]) + + if params[:t].present? && params[:t] == SubmissionEvents.build_tracking_param(@submitter, 'click_email') + SubmissionEvents.create_with_tracking_data(@submitter, 'click_email', request) + end + + render json: {} + end +end diff --git a/app/controllers/submit_form_completed_download_controller.rb b/app/controllers/submit_form_completed_download_controller.rb index 7e244fe66..180d6f294 100644 --- a/app/controllers/submit_form_completed_download_controller.rb +++ b/app/controllers/submit_form_completed_download_controller.rb @@ -29,7 +29,9 @@ class SubmitFormCompletedDownloadController < ApplicationController Submissions::EnsureResultGenerated.call(last_submitter) if !signature_valid && !current_user_submitter?(last_submitter) - return head :not_found unless Submitters::AuthorizedForForm.call(@submitter, current_user, request) + unless Submitters::AuthorizedForForm.call(@submitter, current_user, request, with_order: true) + return head :not_found + end if last_submitter.completed_at < TTL.ago Rollbar.info("TTL: #{last_submitter.id}") if defined?(Rollbar) diff --git a/app/controllers/submit_form_delegate_controller.rb b/app/controllers/submit_form_delegate_controller.rb index da0e4ed1e..b46dd8d57 100644 --- a/app/controllers/submit_form_delegate_controller.rb +++ b/app/controllers/submit_form_delegate_controller.rb @@ -15,7 +15,8 @@ class SubmitFormDelegateController < ApplicationController @submitter.viewer? || !Submitters::AuthorizedForForm.call(@submitter, current_user, - request) + request, + with_order: true) @submitter.account.account_configs.find_by!(key: AccountConfig::ALLOW_TO_DELEGATE_KEY, value: true) diff --git a/app/controllers/submit_form_download_controller.rb b/app/controllers/submit_form_download_controller.rb index 821267b21..6758b2c7a 100644 --- a/app/controllers/submit_form_download_controller.rb +++ b/app/controllers/submit_form_download_controller.rb @@ -19,7 +19,8 @@ class SubmitFormDownloadController < ApplicationController AccountConfig.exists?(account_id: @submitter.account_id, key: AccountConfig::ALLOW_TO_PARTIAL_DOWNLOAD_KEY, value: false) || - !Submitters::AuthorizedForForm.call(@submitter, current_user, request) + !Submitters::AuthorizedForForm.call(@submitter, current_user, request, + with_order: true) last_completed_submitter = @submitter.submission.submitters .where.not(id: @submitter.id) diff --git a/app/controllers/submit_form_draw_signature_controller.rb b/app/controllers/submit_form_draw_signature_controller.rb index 54faa7d2b..667fe42ad 100644 --- a/app/controllers/submit_form_draw_signature_controller.rb +++ b/app/controllers/submit_form_draw_signature_controller.rb @@ -15,7 +15,7 @@ class SubmitFormDrawSignatureController < ApplicationController return redirect_to submit_form_path(@submitter.slug) if @submitter.viewer? if @submitter.submission.template&.archived_at? || @submitter.submission.archived_at? || - !Submitters::AuthorizedForForm.call(@submitter, current_user, request) + !Submitters::AuthorizedForForm.call(@submitter, current_user, request, with_order: true) return redirect_to submit_form_path(@submitter.slug) end diff --git a/app/controllers/submit_form_metadata_controller.rb b/app/controllers/submit_form_metadata_controller.rb index 62a73d9f2..55c95c830 100644 --- a/app/controllers/submit_form_metadata_controller.rb +++ b/app/controllers/submit_form_metadata_controller.rb @@ -39,6 +39,6 @@ class SubmitFormMetadataController < ApplicationController !submitter.submission.expired? && !submitter.submission.template&.archived_at? && !submitter.account.archived_at? && - Submitters::AuthorizedForForm.call(submitter, current_user, request) + Submitters::AuthorizedForForm.call(submitter, current_user, request, with_order: true) end end diff --git a/app/controllers/submit_form_upload_controller.rb b/app/controllers/submit_form_upload_controller.rb new file mode 100644 index 000000000..e25f42207 --- /dev/null +++ b/app/controllers/submit_form_upload_controller.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +class SubmitFormUploadController < ActionController::API + include ActionController::Cookies + include ActiveStorage::SetCurrent + + before_action :load_submitter + before_action :authorize_upload! + + COOKIE_STORE_LIMIT = 10 + + def create + file = params[:file] + + if params[:type].in?(%w[initials signature]) + image = ImageUtils.load_vips(file.read, content_type: file.content_type) + + if ImageUtils.blank?(image) + Rollbar.error("Empty signature: #{@submitter.id}") if defined?(Rollbar) + + return render json: { error: "#{params[:type]} is empty" }, status: :unprocessable_content + end + + if ImageUtils.error?(image) + Rollbar.error("Error signature: #{@submitter.id}") if defined?(Rollbar) + + 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, file, metadata:) + + if params[:remember_signature] == 'true' && @submitter.email.present? + cookies.encrypted[:signature_uuids] = build_new_cookie_signatures_json(@submitter, attachment) + end + + render json: attachment.as_json(only: %i[uuid created_at], methods: %i[url filename content_type]) + rescue Submitters::MaliciousFileExtension => e + Rollbar.error(e) if defined?(Rollbar) + + render json: { error: e.message }, status: :unprocessable_content + end + + private + + def load_submitter + @submitter = Submitter.find_by!(slug: params[:submit_form_slug]) + end + + def authorize_upload! + unless can_upload?(@submitter) + return render json: { error: I18n.t('form_has_been_archived') }, status: :unprocessable_content + end + + return if Submitters::AuthorizedForForm.call(@submitter, current_user, request) + + render json: { error: I18n.t('verification_required_refresh_the_page_and_pass_2fa') }, + status: :unprocessable_content + end + + def can_upload?(submitter) + !submitter.declined_at? && + !submitter.completed_at? && + !submitter.submission.archived_at? && + !submitter.submission.expired? && + !submitter.submission.template&.archived_at? + end + + def build_new_cookie_signatures_json(submitter, attachment) + values = + begin + JSON.parse(cookies.encrypted[:signature_uuids].presence || '{}') + rescue JSON::ParserError + {} + end + + values[submitter.email] = attachment.uuid + + values = values.to_a.last(COOKIE_STORE_LIMIT).to_h if values.size > COOKIE_STORE_LIMIT + + values.to_json + end +end diff --git a/app/controllers/submit_form_view_controller.rb b/app/controllers/submit_form_view_controller.rb new file mode 100644 index 000000000..c5f31a1d6 --- /dev/null +++ b/app/controllers/submit_form_view_controller.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class SubmitFormViewController < ActionController::API + def create + @submitter = Submitter.find_by!(slug: params[:submit_form_slug]) + + return head :not_found if Submitter.signed_id_verifier.verified(params[:v], purpose: :view_form) != @submitter.slug + + @submitter.opened_at = Time.current + @submitter.save + + SubmissionEvents.create_with_tracking_data(@submitter, 'view_form', request) + + WebhookUrls.enqueue_events(@submitter, 'form.viewed') + + render json: {} + end +end diff --git a/app/javascript/form.js b/app/javascript/form.js index 83bce1a52..96aba8b45 100644 --- a/app/javascript/form.js +++ b/app/javascript/form.js @@ -41,6 +41,7 @@ safeRegisterElement('submission-form', class extends HTMLElement { language: this.dataset.language, paymentProvider: this.dataset.paymentProvider, dryRun: this.dataset.dryRun === 'true', + viewToken: this.dataset.viewToken, expand: ['true', 'false'].includes(this.dataset.expand) ? this.dataset.expand === 'true' : null, withSignatureId: this.dataset.withSignatureId === 'true', requireSigningReason: this.dataset.requireSigningReason === 'true', diff --git a/app/javascript/submission_form/dropzone.vue b/app/javascript/submission_form/dropzone.vue index 95b287b61..c3fddf57e 100644 --- a/app/javascript/submission_form/dropzone.vue +++ b/app/javascript/submission_form/dropzone.vue @@ -87,7 +87,7 @@ export default { IconCloudUpload, IconInnerShadowTop }, - inject: ['baseUrl', 't'], + inject: ['baseUrl', 'fetchOptions', 't'], props: { message: { type: String, @@ -199,6 +199,7 @@ export default { return fetch(this.baseUrl + '/api/attachments', { method: 'POST', + headers: { ...this.fetchOptions.headers }, body: formData }).then(async (resp) => { const data = await resp.json() diff --git a/app/javascript/submission_form/form.vue b/app/javascript/submission_form/form.vue index 0bb530634..a3cb83ad4 100644 --- a/app/javascript/submission_form/form.vue +++ b/app/javascript/submission_form/form.vue @@ -775,6 +775,7 @@ export default { provide () { return { baseUrl: this.baseUrl, + fetchOptions: this.fetchOptions, scrollIntoArea: this.scrollIntoArea, scrollIntoField: this.scrollIntoField, t: this.t @@ -1002,6 +1003,11 @@ export default { required: false, default: false }, + viewToken: { + type: String, + required: false, + default: '' + }, attribution: { type: Boolean, required: false, @@ -1531,15 +1537,10 @@ export default { const newUrl = [window.location.pathname, queryParams.toString()].filter(Boolean).join('?') window.history.replaceState({}, document.title, newUrl) - return fetch(this.baseUrl + '/api/submitter_email_clicks', { + return fetch(this.baseUrl + `/s/${this.submitterSlug}/click_email`, { method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - t, - submitter_slug: this.submitterSlug - }) + headers: { 'Content-Type': 'application/json', ...this.fetchOptions.headers }, + body: JSON.stringify({ t }) }) } else { return Promise.resolve({}) @@ -1555,29 +1556,20 @@ export default { const newUrl = [window.location.pathname, queryParams.toString()].filter(Boolean).join('?') window.history.replaceState({}, document.title, newUrl) - return fetch(this.baseUrl + '/api/submitter_sms_clicks', { + return fetch(this.baseUrl + `/s/${this.submitterSlug}/click_sms`, { method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - c, - submitter_slug: this.submitterSlug - }) + headers: { 'Content-Type': 'application/json', ...this.fetchOptions.headers }, + body: JSON.stringify({ c }) }) } else { return Promise.resolve({}) } }, trackViewForm () { - fetch(this.baseUrl + '/api/submitter_form_views', { + fetch(this.baseUrl + `/s/${this.submitterSlug}/view`, { method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - submitter_slug: this.submitterSlug - }) + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ v: this.viewToken }) }) }, previousSignatureValueFor (field) { diff --git a/app/javascript/submission_form/initials_step.vue b/app/javascript/submission_form/initials_step.vue index 46e560c53..65cf82897 100644 --- a/app/javascript/submission_form/initials_step.vue +++ b/app/javascript/submission_form/initials_step.vue @@ -202,7 +202,7 @@ export default { MarkdownContent, IconArrowsDiagonalMinimize2 }, - inject: ['baseUrl', 't'], + inject: ['baseUrl', 'fetchOptions', 't'], props: { field: { type: Object, @@ -443,6 +443,7 @@ export default { return fetch(this.baseUrl + '/api/attachments', { method: 'POST', + headers: { ...this.fetchOptions.headers }, body: formData }).then(async (resp) => { if (resp.status === 422 || resp.status === 500) { diff --git a/app/javascript/submission_form/signature_step.vue b/app/javascript/submission_form/signature_step.vue index c22e463f6..e9eaafd4e 100644 --- a/app/javascript/submission_form/signature_step.vue +++ b/app/javascript/submission_form/signature_step.vue @@ -376,7 +376,7 @@ export default { IconSignature, IconArrowsDiagonalMinimize2 }, - inject: ['baseUrl', 't'], + inject: ['baseUrl', 'fetchOptions', 't'], props: { field: { type: Object, @@ -952,6 +952,7 @@ export default { return fetch(this.baseUrl + '/api/attachments', { method: 'POST', + headers: { ...this.fetchOptions.headers }, body: formData }).then(async (resp) => { if (resp.status === 422 || resp.status === 500) { diff --git a/app/views/submit_form/_submission_form.html.erb b/app/views/submit_form/_submission_form.html.erb index a590b33df..b7290509a 100644 --- a/app/views/submit_form/_submission_form.html.erb +++ b/app/views/submit_form/_submission_form.html.erb @@ -2,4 +2,4 @@ <% data_fields = Submissions.filtered_conditions_fields(submitter).to_json %> <% invite_submitters = (submitter.submission.template_submitters || submitter.submission.template.submitters).select { |s| s['invite_by_uuid'] == submitter.uuid && submitter.submission.submitters.none? { |e| e.uuid == s['uuid'] } }.to_json %> <% optional_invite_submitters = (submitter.submission.template_submitters || submitter.submission.template.submitters).select { |s| s['optional_invite_by_uuid'] == submitter.uuid && submitter.submission.submitters.none? { |e| e.uuid == s['uuid'] } }.to_json %> - + diff --git a/app/views/submit_form/email_2fa.html.erb b/app/views/submit_form/email_2fa.html.erb index 856b0926f..b79479d2e 100644 --- a/app/views/submit_form/email_2fa.html.erb +++ b/app/views/submit_form/email_2fa.html.erb @@ -59,7 +59,7 @@ <% else %> <% if params[:t] %> - <%= button_to '', api_submitter_email_clicks_path, params: { submitter_slug: @submitter.slug, t: params[:t] }, method: :post, class: 'hidden' %> + <%= button_to '', submit_form_click_email_path(@submitter.slug), params: { t: params[:t] }, method: :post, class: 'hidden' %> <% end %> diff --git a/config/routes.rb b/config/routes.rb index 57f6023a5..a0c9869f9 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -160,6 +160,9 @@ Rails.application.routes.draw do resources :delegate, only: %i[create], controller: 'submit_form_delegate' resources :invite, only: %i[create], controller: 'submit_form_invite' resources :metadata, only: %i[index], controller: 'submit_form_metadata' + resource :click_email, only: %i[create], controller: 'submit_form_click_email' + resources :view, only: %i[create], controller: 'submit_form_view' + resources :upload, only: %i[create], controller: 'submit_form_upload' resources :debug, only: %i[index], controller: 'submissions_debug' if Rails.env.development? get :completed get :delegated diff --git a/lib/submitters/authorized_for_form.rb b/lib/submitters/authorized_for_form.rb index 81048a162..4e7cacaa4 100644 --- a/lib/submitters/authorized_for_form.rb +++ b/lib/submitters/authorized_for_form.rb @@ -6,8 +6,24 @@ module Submitters module_function - def call(submitter, current_user, request) - pass_email_2fa?(submitter, request) && pass_link_2fa?(submitter, current_user, request) + def call(submitter, current_user, request, with_order: false) + pass_email_2fa?(submitter, request) && + pass_link_2fa?(submitter, current_user, request) && + (!with_order || pass_submitters_order?(submitter)) + end + + def pass_submitters_order?(submitter) + return false unless submitter + + submission = submitter.submission + + return true if submission.submitters_order_random? + return true if (submission.template_submitters || submission.template.submitters).size < 2 + return true if submission.template&.preferences&.dig('submitters_order') != 'preserved' && + !AccountConfig.exists?(account_id: submitter.account_id, + key: AccountConfig::ENFORCE_SIGNING_ORDER_KEY, value: true) + + Submitters.current_submitter_order?(submitter) end def pass_email_2fa?(submitter, request)