diff --git a/.eslintrc b/.eslintrc index d0836600..e7045bb5 100644 --- a/.eslintrc +++ b/.eslintrc @@ -8,8 +8,7 @@ }, "rules": { "vue/no-deprecated-html-element-is": 0, - "vue/no-mutating-props": 0, - "vue/no-v-html": 0 + "vue/no-mutating-props": 0 }, "parserOptions": { "ecmaVersion": 2022, diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 14699880..585aea41 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,7 +59,7 @@ jobs: - name: Install Node.js uses: actions/setup-node@v1 with: - node-version: 16.13.1 + node-version: 20.9.0 - name: Cache directory path id: yarn-cache-dir-path run: echo "::set-output name=dir::$(yarn cache dir)" @@ -104,7 +104,7 @@ jobs: - name: Set up Node uses: actions/setup-node@v1 with: - node-version: 16.13.1 + node-version: 20.9.0 - name: Install Chrome uses: browser-actions/setup-chrome@latest - name: Cache node_modules diff --git a/Dockerfile b/Dockerfile index 211cc491..df78a26d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,9 @@ FROM ruby:3.2.2-alpine3.18 as fonts WORKDIR /fonts -RUN apk --no-cache add wget && wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Regular.ttf && wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Bold.ttf && wget https://github.com/impallari/DancingScript/raw/master/fonts/DancingScript-Regular.otf && wget https://github.com/impallari/DancingScript/raw/master/OFL.txt +RUN apk --no-cache add fontforge wget && wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Regular.ttf && wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Bold.ttf && wget https://github.com/impallari/DancingScript/raw/master/fonts/DancingScript-Regular.otf && wget https://cdn.jsdelivr.net/gh/notofonts/notofonts.github.io/fonts/NotoSansSymbols2/hinted/ttf/NotoSansSymbols2-Regular.ttf && wget https://github.com/Maxattax97/gnu-freefont/raw/master/ttf/FreeSans.ttf && wget https://github.com/impallari/DancingScript/raw/master/OFL.txt + +RUN fontforge -lang=py -c 'font1 = fontforge.open("FreeSans.ttf"); font2 = fontforge.open("NotoSansSymbols2-Regular.ttf"); font1.mergeFonts(font2); font1.generate("FreeSans.ttf")' FROM ruby:3.2.2-alpine3.18 as webpack @@ -34,14 +36,15 @@ FROM ruby:3.2.2-alpine3.18 as app ENV RAILS_ENV=production ENV BUNDLE_WITHOUT="development:test" +ENV PIDFILE=/dev/null WORKDIR /app -RUN apk add --no-cache build-base sqlite-dev libpq-dev mariadb-dev vips-dev vips-poppler poppler-utils vips-heif libc6-compat ttf-freefont && mkdir /fonts +RUN apk add --no-cache sqlite-dev libpq-dev mariadb-dev vips-dev vips-poppler poppler-utils vips-heif libc6-compat ttf-freefont && mkdir /fonts && rm /usr/share/fonts/freefont/FreeSans.otf COPY ./Gemfile ./Gemfile.lock ./ -RUN bundle update --bundler && bundle install && rm -rf ~/.bundle /usr/local/bundle/cache && ruby -e "puts Dir['/usr/local/bundle/**/{spec,rdoc,resources/shared,resources/collation,resources/locales}']" | xargs rm -rf +RUN apk add --no-cache build-base && bundle update --bundler && bundle install && apk del build-base && rm -rf ~/.bundle /usr/local/bundle/cache && ruby -e "puts Dir['/usr/local/bundle/**/{spec,rdoc,resources/shared,resources/collation,resources/locales}']" | xargs rm -rf COPY ./bin ./bin COPY ./app ./app @@ -54,6 +57,7 @@ COPY ./tmp ./tmp COPY LICENSE README.md Rakefile config.ru .version ./ COPY --from=fonts /fonts/GoNotoKurrent-Regular.ttf /fonts/GoNotoKurrent-Bold.ttf /fonts/DancingScript-Regular.otf /fonts/OFL.txt /fonts +COPY --from=fonts /fonts/FreeSans.ttf /usr/share/fonts/freefont COPY --from=webpack /app/public/packs ./public/packs RUN ln -s /fonts /app/public/fonts diff --git a/Gemfile b/Gemfile index d4983509..e1d86028 100644 --- a/Gemfile +++ b/Gemfile @@ -32,7 +32,6 @@ gem 'rack' gem 'rails' gem 'rails_autolink' gem 'rails-i18n' -gem 'rollbar', require: ENV.key?('ROLLBAR_ACCESS_TOKEN') gem 'rotp' gem 'rqrcode' gem 'ruby-vips' diff --git a/Gemfile.lock b/Gemfile.lock index be3a7959..68327e93 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -433,7 +433,6 @@ GEM railties (>= 5.2) retriable (3.1.2) rexml (3.2.6) - rollbar (3.4.2) rotp (6.3.0) rqrcode (2.2.0) chunky_png (~> 1.0) @@ -605,7 +604,6 @@ DEPENDENCIES rails rails-i18n rails_autolink - rollbar rotp rqrcode rspec-rails diff --git a/README.md b/README.md index 6595bd97..4f2a7e12 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ DocuSeal is an open source platform that provides secure and efficient digital d - PDF signature verification - Users management - Mobile-optimized +- Signing available in 12 Languages - API and Webhooks for integrations - Easy to deploy in minutes @@ -50,9 +51,10 @@ DocuSeal is an open source platform that provides secure and efficient digital d - User roles - Automated reminders - Invitation and identify verification via SMS +- Conditional fields and formulas - SSO / SAML - Template creation with HTML API ([Guide](https://www.docuseal.co/guides/create-pdf-document-fillable-form-with-html-api)) -- Template creation with PDF or DOCX and text tags API ([Guide](https://www.docuseal.co/guides/use-embedded-text-field-tags-in-the-pdf-to-create-a-fillable-form)) +- Template creation with PDF or DOCX and field tags API ([Guide](https://www.docuseal.co/guides/use-embedded-text-field-tags-in-the-pdf-to-create-a-fillable-form)) - Embedded signing form ([React](https://github.com/docusealco/docuseal-react), [Vue](https://github.com/docusealco/docuseal-vue) or [JavaScript](https://www.docuseal.co/docs/embedded)) - Embedded document form builder ([React](https://github.com/docusealco/docuseal-react), [Vue](https://github.com/docusealco/docuseal-vue) or [JavaScript](https://www.docuseal.co/docs/embedded)) - [Learn more](https://www.docuseal.co/pricing) @@ -69,7 +71,6 @@ DocuSeal is an open source platform that provides secure and efficient digital d |**RepoCloud**| | | [Deploy on RepoCloud](https://repocloud.io/details/?app_id=252) | | - #### Docker ```sh @@ -101,3 +102,9 @@ At DocuSeal we have expertise and technologies to make documents creation, filli Distributed under the AGPLv3 License. See [LICENSE](https://github.com/docusealco/docuseal/blob/master/LICENSE) for more information. Unless otherwise noted, all files © 2023 DocuSeal LLC. + +## Tools + +- [Signature Maker](https://www.docuseal.co/online-signature) +- [Sign Document Online](https://www.docuseal.co/sign-documents-online) +- [Fill PDF Online](https://www.docuseal.co/fill-pdf) diff --git a/app/controllers/account_configs_controller.rb b/app/controllers/account_configs_controller.rb index 5d33a547..3bc36334 100644 --- a/app/controllers/account_configs_controller.rb +++ b/app/controllers/account_configs_controller.rb @@ -8,9 +8,12 @@ class AccountConfigsController < ApplicationController AccountConfig::ALLOW_TYPED_SIGNATURE, AccountConfig::FORCE_MFA, AccountConfig::ALLOW_TO_RESUBMIT, - AccountConfig::ESIGNING_PREFERENCE_KEY + AccountConfig::ESIGNING_PREFERENCE_KEY, + AccountConfig::FORM_WITH_CONFETTI_KEY ].freeze + InvalidKey = Class.new(StandardError) + def create @account_config.update!(account_config_params) @@ -20,7 +23,7 @@ class AccountConfigsController < ApplicationController private def load_account_config - return head :not_found unless ALLOWED_KEYS.include?(account_config_params[:key]) + raise InvalidKey unless ALLOWED_KEYS.include?(account_config_params[:key]) @account_config = AccountConfig.find_or_initialize_by(account: current_account, key: account_config_params[:key]) diff --git a/app/controllers/api/active_storage_blobs_proxy_controller.rb b/app/controllers/api/active_storage_blobs_proxy_controller.rb index 4f2968cc..0a9c68c1 100644 --- a/app/controllers/api/active_storage_blobs_proxy_controller.rb +++ b/app/controllers/api/active_storage_blobs_proxy_controller.rb @@ -10,9 +10,9 @@ module Api before_action :set_cors_headers def show - blob_uuid, = ApplicationRecord.signed_id_verifier.verified(params[:signed_uuid]) + blob_uuid, purp, exp = ApplicationRecord.signed_id_verifier.verified(params[:signed_uuid]) - if blob_uuid.blank? + if blob_uuid.blank? || (purp.present? && purp != 'blob') || (exp && exp < Time.current.to_i) Rollbar.error('Blob not found') if defined?(Rollbar) return head :not_found 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 fca401f1..54c3ed72 100644 --- a/app/controllers/api/active_storage_blobs_proxy_legacy_controller.rb +++ b/app/controllers/api/active_storage_blobs_proxy_legacy_controller.rb @@ -11,7 +11,9 @@ module Api def show Rollbar.info('Blob legacy') if defined?(Rollbar) - blob = ActiveStorage::Blob.find_signed!(params[:signed_blob_id] || params[:signed_id]) + blob = ActiveStorage::Blob.find_signed(params[:signed_blob_id] || params[:signed_id]) + + return head :not_found unless blob is_permitted = blob.attachments.any? do |a| (current_user && a.record.account.id == current_user.account_id) || diff --git a/app/controllers/api/api_base_controller.rb b/app/controllers/api/api_base_controller.rb index b5375445..86ead819 100644 --- a/app/controllers/api/api_base_controller.rb +++ b/app/controllers/api/api_base_controller.rb @@ -19,6 +19,12 @@ module Api render json: { error: e.message }, status: :unprocessable_entity end + rescue_from RateLimit::LimitApproached do |e| + Rollbar.error(e) if defined?(Rollbar) + + render json: { error: 'Too many requests' }, status: :too_many_requests + end + if Rails.env.production? rescue_from CanCan::AccessDenied do |e| Rollbar.warning(e) if defined?(Rollbar) @@ -39,8 +45,8 @@ module Api result = relation.order(id: :desc) .limit([params.fetch(:limit, DEFAULT_LIMIT).to_i, MAX_LIMIT].min) - result = result.where(relation.arel_table[:id].lt(params[:after])) if params[:after].present? - result = result.where(relation.arel_table[:id].gt(params[:before])) if params[:before].present? + result = result.where(id: ...params[:after].to_i) if params[:after].present? + result = result.where(id: (params[:before].to_i + 1)...) if params[:before].present? result end diff --git a/app/controllers/api/submitters_autocomplete_controller.rb b/app/controllers/api/submitters_autocomplete_controller.rb index ff7dc2e6..e28e25c7 100644 --- a/app/controllers/api/submitters_autocomplete_controller.rb +++ b/app/controllers/api/submitters_autocomplete_controller.rb @@ -24,9 +24,9 @@ module Api if SELECT_COLUMNS.include?(params[:field]) column = Submitter.arel_table[params[:field].to_sym] - term = "%#{params[:q].downcase}%" + term = "#{params[:q].downcase}%" - submitters.where(column.lower.matches(term)) + submitters.where(column.matches(term, false, true)) else Submitters.search(submitters, params[:q]) end diff --git a/app/controllers/api/submitters_controller.rb b/app/controllers/api/submitters_controller.rb index f69f43f3..3d2c3567 100644 --- a/app/controllers/api/submitters_controller.rb +++ b/app/controllers/api/submitters_controller.rb @@ -10,6 +10,7 @@ module Api submitters = submitters.where(external_id: params[:application_key]) if params[:application_key].present? submitters = submitters.where(external_id: params[:external_id]) if params[:external_id].present? submitters = submitters.where(submission_id: params[:submission_id]) if params[:submission_id].present? + submitters = maybe_filder_by_completed_at(submitters, params) submitters = paginate( submitters.preload(:template, :submission, :submission_events, @@ -81,6 +82,18 @@ module Api private + def maybe_filder_by_completed_at(submitters, params) + if params[:completed_after].present? + submitters = submitters.where(completed_at: Time.zone.parse(params[:completed_after])..) + end + + if params[:completed_before].present? + submitters = submitters.where(completed_at: ..Time.zone.parse(params[:completed_before])) + end + + submitters + end + def assign_submitter_attrs(submitter, attrs) submitter.email = Submissions.normalize_email(attrs[:email]) if attrs.key?(:email) submitter.phone = attrs[:phone].to_s.gsub(/[^0-9+]/, '') if attrs.key?(:phone) diff --git a/app/controllers/api/templates_clone_controller.rb b/app/controllers/api/templates_clone_controller.rb index ebba87bb..a07fa310 100644 --- a/app/controllers/api/templates_clone_controller.rb +++ b/app/controllers/api/templates_clone_controller.rb @@ -20,16 +20,7 @@ module Api Templates::CloneAttachments.call(template: cloned_template, original_template: @template) - render json: cloned_template.as_json(serialize_params) - end - - private - - def serialize_params - { - include: { author: { only: %i[id email first_name last_name] }, - documents: { only: %i[id uuid], methods: %i[url filename] } } - } + render json: Templates::SerializeForApi.call(cloned_template) end end end diff --git a/app/controllers/api/templates_controller.rb b/app/controllers/api/templates_controller.rb index 61d45a8b..8e35e1ae 100644 --- a/app/controllers/api/templates_controller.rb +++ b/app/controllers/api/templates_controller.rb @@ -7,10 +7,31 @@ module Api def index templates = filter_templates(@templates, params) - templates = paginate(templates.preload(:author, documents_attachments: :blob)) + templates = paginate(templates.preload(:author, :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.jpg' }) + .where(record_id: schema_documents.map(&:id), + record_type: 'ActiveStorage::Attachment', + name: :preview_images) + .preload(:blob) render json: { - data: templates.as_json(serialize_params), + data: templates.map do |t| + Templates::SerializeForApi.call( + t, + schema_documents.select { |e| e.record_id == t.id }, + preview_image_attachments + ) + end, pagination: { count: templates.size, next: templates.last&.id, @@ -20,7 +41,7 @@ module Api end def show - render json: @template.as_json(serialize_params) + render json: Templates::SerializeForApi.call(@template) end def update @@ -61,22 +82,18 @@ module Api templates end - def serialize_params - { - methods: %i[application_key], - include: { author: { only: %i[id email first_name last_name] }, - documents: { only: %i[id uuid], methods: %i[url preview_image_url filename] } } - } - end - def template_params permit_params = [ :name, { schema: [%i[attachment_uuid name]], submitters: [%i[name uuid]], - fields: [[:uuid, :submitter_uuid, :name, :type, :required, :readonly, :default_value, + fields: [[:uuid, :submitter_uuid, :name, :type, + :required, :readonly, :default_value, + :title, :description, { preferences: {}, + conditions: [%i[field_uuid value action]], options: [%i[value uuid]], + validation: %i[message pattern], areas: [%i[x y w h cell_w attachment_uuid option_uuid page]] }]] } ] diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 19547214..0b3dd112 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -22,6 +22,12 @@ class ApplicationController < ActionController::Base redirect_to request.path end + rescue_from RateLimit::LimitApproached do |e| + Rollbar.error(e) if defined?(Rollbar) + + redirect_to request.referer, alert: 'Too many requests', status: :too_many_requests + end + if Rails.env.production? rescue_from CanCan::AccessDenied do |e| Rollbar.warning(e) if defined?(Rollbar) diff --git a/app/controllers/console_redirect_controller.rb b/app/controllers/console_redirect_controller.rb index f6042225..84fc0c89 100644 --- a/app/controllers/console_redirect_controller.rb +++ b/app/controllers/console_redirect_controller.rb @@ -5,6 +5,10 @@ class ConsoleRedirectController < ApplicationController skip_authorization_check def index + if request.path == '/upgrade' + params[:redir] = Docuseal.multitenant? ? "#{Docuseal::CONSOLE_URL}/plans" : "#{Docuseal::CONSOLE_URL}/on_premise" + end + return redirect_to(new_user_session_path({ redir: params[:redir] }.compact)) if true_user.blank? auth = JsonWebToken.encode(uuid: true_user.uuid, diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index 0f5ab590..4047dda3 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -54,10 +54,14 @@ class DashboardController < ApplicationController rel = templates.active.preload(:author).order(id: :desc) if params[:q].blank? - shared_template_ids = - TemplateSharing.where(account_id: [current_account.id, TemplateSharing::ALL_ID]).select(:template_id) - - rel = rel.where(folder_id: current_account.default_template_folder.id).or(rel.where(id: shared_template_ids)) + if Docuseal.multitenant? && !current_account.testing? + rel = rel.where(folder_id: current_account.default_template_folder.id) + else + shared_template_ids = + TemplateSharing.where(account_id: [current_account.id, TemplateSharing::ALL_ID]).select(:template_id) + + rel = rel.where(folder_id: current_account.default_template_folder.id).or(rel.where(id: shared_template_ids)) + end end Templates.search(rel, params[:q]) diff --git a/app/controllers/errors_controller.rb b/app/controllers/errors_controller.rb index 8d422205..b3619b70 100644 --- a/app/controllers/errors_controller.rb +++ b/app/controllers/errors_controller.rb @@ -22,6 +22,8 @@ class ErrorsController < ActionController::Base respond_to do |f| f.json do + set_cors_headers + render json: { status: error_status_code }, status: error_status_code end @@ -31,6 +33,14 @@ class ErrorsController < ActionController::Base private + def set_cors_headers + headers['Access-Control-Allow-Origin'] = '*' + headers['Access-Control-Allow-Methods'] = 'POST, GET, PUT, PATCH, DELETE, OPTIONS' + headers['Access-Control-Allow-Headers'] = '*' + headers['Access-Control-Max-Age'] = '1728000' + headers['Access-Control-Allow-Credentials'] = true + end + def error_status_code @error_status_code ||= ActionDispatch::ExceptionWrapper.new(request.env, diff --git a/app/controllers/personalization_settings_controller.rb b/app/controllers/personalization_settings_controller.rb index 58a641de..76716fbd 100644 --- a/app/controllers/personalization_settings_controller.rb +++ b/app/controllers/personalization_settings_controller.rb @@ -1,26 +1,51 @@ # frozen_string_literal: true class PersonalizationSettingsController < ApplicationController + ALLOWED_KEYS = [ + AccountConfig::FORM_COMPLETED_BUTTON_KEY, + AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY, + AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY, + AccountConfig::SUBMITTER_COMPLETED_EMAIL_KEY + ].freeze + + InvalidKey = Class.new(StandardError) + + before_action :load_and_authorize_account_config, only: :create + def show authorize!(:read, AccountConfig) end def create - account_config = - current_account.account_configs.find_or_initialize_by(key: account_config_params[:key]) - - authorize!(:create, account_config) - - account_config.update!(account_config_params) + if @account_config.value != false && @account_config.value.blank? + @account_config.destroy! + else + @account_config.save! + end redirect_back(fallback_location: settings_personalization_path, notice: 'Settings have been saved.') end private + def load_and_authorize_account_config + @account_config = + current_account.account_configs.find_or_initialize_by(key: account_config_params[:key]) + + @account_config.assign_attributes(account_config_params) + + authorize!(:create, @account_config) + + raise InvalidKey unless ALLOWED_KEYS.include?(@account_config.key) + + @account_config + end + def account_config_params attrs = params.require(:account_config).permit! + return attrs if attrs[:value].is_a?(String) + attrs[:value]&.transform_values! do |value| if value.in?(%w[true false]) value == 'true' diff --git a/app/controllers/preview_document_page_controller.rb b/app/controllers/preview_document_page_controller.rb index cfee948a..271d6169 100644 --- a/app/controllers/preview_document_page_controller.rb +++ b/app/controllers/preview_document_page_controller.rb @@ -8,7 +8,7 @@ class PreviewDocumentPageController < ActionController::API def show attachment_uuid = ApplicationRecord.signed_id_verifier.verified(params[:signed_uuid], purpose: :attachment) - attachment = ActiveStorage::Attachment.find_by(uuid: attachment_uuid, name: :preview_images) if attachment_uuid + attachment = ActiveStorage::Attachment.find_by(uuid: attachment_uuid) if attachment_uuid return head :not_found unless attachment diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb deleted file mode 100644 index d0b4a403..00000000 --- a/app/controllers/registrations_controller.rb +++ /dev/null @@ -1,62 +0,0 @@ -# frozen_string_literal: true - -class RegistrationsController < Devise::RegistrationsController - prepend_before_action :require_no_authentication, only: [:show] - prepend_before_action :maybe_redirect_if_signed_in, only: [:show] - - def show; end - - def create - super - - Accounts.create_default_template(resource.account) if resource.account.persisted? - end - - private - - def after_sign_up_path_for(...) - if params[:redir].present? - return console_redirect_index_path(redir: params[:redir]) if params[:redir].starts_with?(Docuseal::CONSOLE_URL) - - return params[:redir] - end - - super - end - - def maybe_redirect_if_signed_in - return unless signed_in? - return if params[:redir].blank? - - redirect_to after_sign_up_path_for(current_user), allow_other_host: true - end - - def set_flash_message(key, kind, options = {}) - return if key == :alert && kind == 'already_authenticated' - - super - end - - def build_resource(_hash = {}) - account = Account.new(account_params) - account.timezone = Accounts.normalize_timezone(account.timezone) - - self.resource = account.users.new(user_params) - - account.name ||= resource.full_name if params[:action] == 'create' - end - - def user_params - return {} if params[:user].blank? - - params.require(:user).permit(:first_name, :last_name, :email, :password).compact_blank.tap do |attrs| - attrs[:password] ||= SecureRandom.hex if params[:action] == 'create' - end - end - - def account_params - return {} if params[:account].blank? - - params.require(:account).permit(:name, :timezone).compact_blank - end -end diff --git a/app/controllers/send_submission_email_controller.rb b/app/controllers/send_submission_email_controller.rb index 28e2a364..8e74c348 100644 --- a/app/controllers/send_submission_email_controller.rb +++ b/app/controllers/send_submission_email_controller.rb @@ -12,13 +12,18 @@ class SendSubmissionEmailController < ApplicationController def create @submitter = if params[:template_slug] - Submitter.joins(submission: :template).find_by!(email: params[:email], + 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 - SubmitterMailer.documents_copy_email(@submitter).deliver_later! + RateLimit.call("send-email-#{@submitter.id}", limit: 2, ttl: 5.minutes) + + SubmitterMailer.documents_copy_email(@submitter, sig: true).deliver_later! respond_to do |f| f.html { redirect_to success_send_submission_email_index_path } diff --git a/app/controllers/submissions_controller.rb b/app/controllers/submissions_controller.rb index fcc7b90b..69f3aa49 100644 --- a/app/controllers/submissions_controller.rb +++ b/app/controllers/submissions_controller.rb @@ -6,23 +6,8 @@ class SubmissionsController < ApplicationController load_and_authorize_resource :submission, only: %i[show destroy] - PRELOAD_ALL_PAGES_AMOUNT = 200 - def show - ActiveRecord::Associations::Preloader.new( - records: [@submission], - associations: [:template, { template_schema_documents: :blob }] - ).call - - total_pages = - @submission.template_schema_documents.sum { |e| e.metadata.dig('pdf', 'number_of_pages').to_i } - - if total_pages < PRELOAD_ALL_PAGES_AMOUNT - ActiveRecord::Associations::Preloader.new( - records: @submission.template_schema_documents, - associations: [:blob, { preview_images_attachments: :blob }] - ).call - end + @submission = Submissions.preload_with_pages(@submission) render :show, layout: 'plain' end diff --git a/app/controllers/submissions_download_controller.rb b/app/controllers/submissions_download_controller.rb index e9ce7adc..334204f8 100644 --- a/app/controllers/submissions_download_controller.rb +++ b/app/controllers/submissions_download_controller.rb @@ -4,10 +4,20 @@ class SubmissionsDownloadController < ApplicationController skip_before_action :authenticate_user! skip_authorization_check - TTL = 20.minutes + TTL = 40.minutes + FILES_TTL = 5.minutes def index - submitter = Submitter.find_by!(slug: params[:submitter_slug]) + submitter = Submitter.find_signed(params[:sig], purpose: :download_completed) if params[:sig].present? + + signature_valid = + if submitter&.slug == params[:submitter_slug] + true + else + submitter = nil + end + + submitter ||= Submitter.find_by!(slug: params[:submitter_slug]) Submissions::EnsureResultGenerated.call(submitter) @@ -17,18 +27,24 @@ class SubmissionsDownloadController < ApplicationController return head :not_found unless last_submitter.completed_at? - if last_submitter.completed_at < TTL.ago && - (current_user.nil? || !current_user.account.submitters.exists?(id: last_submitter.id)) + if last_submitter.completed_at < TTL.ago && !signature_valid && !current_user_submitter?(last_submitter) Rollbar.info("TTL: #{last_submitter.id}") if defined?(Rollbar) return head :not_found end - urls = - Submitters.select_attachments_for_download(last_submitter).map do |attachment| - ActiveStorage::Blob.proxy_url(attachment.blob) - end + render json: build_urls(last_submitter) + end - render json: urls + private + + def current_user_submitter?(submitter) + current_user && current_user.account.submitters.exists?(id: submitter.id) + end + + def build_urls(submitter) + Submitters.select_attachments_for_download(submitter).map do |attachment| + ActiveStorage::Blob.proxy_url(attachment.blob, expires_at: FILES_TTL.minutes.from_now.to_i) + end end end diff --git a/app/controllers/submissions_preview_controller.rb b/app/controllers/submissions_preview_controller.rb index 12db79cf..dbf29294 100644 --- a/app/controllers/submissions_preview_controller.rb +++ b/app/controllers/submissions_preview_controller.rb @@ -4,37 +4,31 @@ class SubmissionsPreviewController < ApplicationController skip_before_action :authenticate_user! skip_authorization_check - PRELOAD_ALL_PAGES_AMOUNT = 200 - - TTL = 20.minutes + TTL = 40.minutes def show - @submission = Submission.find_by!(slug: params[:slug]) + submitter = Submitter.find_signed(params[:sig], purpose: :download_completed) if params[:sig].present? + + signature_valid = + if submitter && submitter.submission.slug == params[:slug] + @submission = submitter.submission + + true + end + + @submission ||= Submission.find_by!(slug: params[:slug]) if !@submission.submitters.all?(&:completed_at?) && current_user.blank? raise ActionController::RoutingError, 'Not Found' end - unless submission_valid_ttl?(@submission) + if !submission_valid_ttl?(@submission) && !signature_valid Rollbar.info("TTL: #{@submission.id}") if defined?(Rollbar) return redirect_to submissions_preview_completed_path(@submission.slug) end - ActiveRecord::Associations::Preloader.new( - records: [@submission], - associations: [:template, { template_schema_documents: :blob }] - ).call - - total_pages = - @submission.template_schema_documents.sum { |e| e.metadata.dig('pdf', 'number_of_pages').to_i } - - if total_pages < PRELOAD_ALL_PAGES_AMOUNT - ActiveRecord::Associations::Preloader.new( - records: @submission.template_schema_documents, - associations: [:blob, { preview_images_attachments: :blob }] - ).call - end + @submission = Submissions.preload_with_pages(@submission) render 'submissions/show', layout: 'plain' end @@ -42,7 +36,7 @@ class SubmissionsPreviewController < ApplicationController def completed @submission = Submission.find_by!(slug: params[:submissions_preview_slug]) - render :completed, layout: 'plain' + render :completed, layout: 'form' end private diff --git a/app/controllers/user_configs_controller.rb b/app/controllers/user_configs_controller.rb new file mode 100644 index 00000000..834cc1d9 --- /dev/null +++ b/app/controllers/user_configs_controller.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +class UserConfigsController < ApplicationController + before_action :load_user_config + authorize_resource :user_config + + ALLOWED_KEYS = [ + UserConfig::RECEIVE_COMPLETED_EMAIL + ].freeze + + InvalidKey = Class.new(StandardError) + + def create + @user_config.update!(user_config_params) + + head :ok + end + + private + + def load_user_config + raise InvalidKey unless ALLOWED_KEYS.include?(user_config_params[:key]) + + @user_config = + UserConfig.find_or_initialize_by(user: current_user, key: user_config_params[:key]) + end + + def user_config_params + params.required(:user_config).permit!.tap do |attrs| + attrs[:value] = attrs[:value] == '1' if attrs[:value].in?(%w[1 0]) + end + end +end diff --git a/app/javascript/application.js b/app/javascript/application.js index dc2df889..0a9629fa 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -20,6 +20,7 @@ import FolderAutocomplete from './elements/folder_autocomplete' import SignatureForm from './elements/signature_form' import SubmitForm from './elements/submit_form' import PromptPassword from './elements/prompt_password' +import EmailsTextarea from './elements/emails_textarea' import * as TurboInstantClick from './lib/turbo_instant_click' @@ -53,6 +54,7 @@ window.customElements.define('folder-autocomplete', FolderAutocomplete) window.customElements.define('signature-form', SignatureForm) window.customElements.define('submit-form', SubmitForm) window.customElements.define('prompt-password', PromptPassword) +window.customElements.define('emails-textarea', EmailsTextarea) document.addEventListener('turbo:before-fetch-request', encodeMethodIntoRequestBody) document.addEventListener('turbo:submit-end', async (event) => { @@ -90,6 +92,8 @@ window.customElements.define('template-builder', class extends HTMLElement { withLogo: this.dataset.withLogo !== 'false', editable: this.dataset.editable !== 'false', withPayment: this.dataset.withPayment === 'true', + withFormula: this.dataset.withFormula === 'true', + withConditions: this.dataset.withConditions === 'true', currencies: (this.dataset.currencies || '').split(',').filter(Boolean), acceptFileTypes: this.dataset.acceptFileTypes, isDirectUpload: this.dataset.isDirectUpload === 'true' diff --git a/app/javascript/application.scss b/app/javascript/application.scss index 9a220258..f0d6429a 100644 --- a/app/javascript/application.scss +++ b/app/javascript/application.scss @@ -75,16 +75,16 @@ button[disabled] .enabled { @apply select base-input w-full font-normal; } -.tooltip-bottom-start:before { - transform: translateX(-30%); +.tooltip-bottom-end:before { + transform: translateX(-95%); top: var(--tooltip-offset); left: 100%; right: auto; bottom: auto; } -.tooltip-bottom-start:after { - transform: translateX(-75%); +.tooltip-bottom-end:after { + transform: translateX(-25%); border-color: transparent transparent var(--tooltip-color) transparent; top: var(--tooltip-tail-offset); left: 50%; @@ -125,3 +125,7 @@ button[disabled] .enabled { outline-offset: 3px; outline-color: hsl(var(--bc) / 0.2); } + +select:required:invalid { + color: gray !important; +} diff --git a/app/javascript/elements/emails_textarea.js b/app/javascript/elements/emails_textarea.js new file mode 100644 index 00000000..dde8109f --- /dev/null +++ b/app/javascript/elements/emails_textarea.js @@ -0,0 +1,56 @@ +const emailRegexp = /([^@;,<>\s]+@[^@;,<>\s]+)/g + +export default class extends HTMLElement { + connectedCallback () { + if (this.dataset.limit) { + this.textarea.addEventListener('input', () => { + const emails = this.textarea.value.match(emailRegexp) || [] + + this.updateCounter(emails.length) + }) + } + } + + updateCounter (count) { + let counter = document.getElementById('emails_counter') + let bulkMessage = document.getElementById('bulk_message') + + if (count < 2) { + counter?.remove() + + return + } + + if ((count + 10) > this.dataset.limit) { + if (!counter) { + counter = document.createElement('span') + + counter.id = 'emails_counter' + counter.classList.add('text-xs', 'right-0', 'absolute') + counter.style.bottom = '-15px' + + this.textarea.parentNode.append(counter) + } + + counter.innerText = `${count} / ${this.dataset.limit}` + } + + if (this.dataset.bulkEnabled !== 'true') { + if (!bulkMessage) { + bulkMessage = document.createElement('span') + + bulkMessage.id = 'bulk_message' + bulkMessage.classList.add('text-xs', 'left-0', 'absolute') + bulkMessage.style.bottom = '-15px' + + this.textarea.parentNode.append(bulkMessage) + } + + bulkMessage.innerHTML = 'Upgrade to bulk send multiple recipients' + } + } + + get textarea () { + return this.querySelector('textarea') + } +} diff --git a/app/javascript/elements/submitter_autocomplete.js b/app/javascript/elements/submitter_autocomplete.js index e5820d8e..d13f3014 100644 --- a/app/javascript/elements/submitter_autocomplete.js +++ b/app/javascript/elements/submitter_autocomplete.js @@ -7,6 +7,7 @@ export default class extends HTMLElement { preventSubmit: 1, minLength: 1, showOnFocus: true, + debounceWaitMs: 200, onSelect: this.onSelect, render: this.render, fetch: this.fetch @@ -27,6 +28,8 @@ export default class extends HTMLElement { if (textarea && item[field]) { textarea.value = textarea.value.replace(/[^;,\s]+$/, item[field] + ' ') + + textarea.dispatchEvent(new Event('input', { bubbles: true })) } }) } @@ -37,7 +40,9 @@ export default class extends HTMLElement { if (q) { const queryParams = new URLSearchParams({ q, field: this.dataset.field }) - fetch('/api/submitters_autocomplete?' + queryParams).then(async (resp) => { + this.currentFetch ||= fetch('/api/submitters_autocomplete?' + queryParams) + + this.currentFetch.then(async (resp) => { const items = await resp.json() if (q.length < 3) { @@ -47,6 +52,8 @@ export default class extends HTMLElement { } }).catch(() => { resolve([]) + }).finally(() => { + this.currentFetch = null }) } else { resolve([]) diff --git a/app/javascript/submission_form/area.vue b/app/javascript/submission_form/area.vue index 54fbb618..219b49b6 100644 --- a/app/javascript/submission_form/area.vue +++ b/app/javascript/submission_form/area.vue @@ -157,6 +157,7 @@ ref="textContainer" dir="auto" class="flex items-center px-0.5 w-full" + :class="alignClasses[field.preferences?.align]" > {{ modelValue.join(', ') }} @@ -167,13 +168,14 @@ {{ modelValue }} diff --git a/app/javascript/submission_form/i18n.js b/app/javascript/submission_form/i18n.js index c82e8764..593a9da8 100644 --- a/app/javascript/submission_form/i18n.js +++ b/app/javascript/submission_form/i18n.js @@ -3,6 +3,7 @@ const en = { signature: 'Signature', initials: 'Initials', date: 'Date', + number: 'Number', image: 'Image', take_photo: 'Take photo', file: 'File', @@ -16,6 +17,7 @@ const en = { payment: 'Payment', phone: 'Phone', submit_form: 'Submit Form', + sign_now: 'Sign Now', type_here_: 'Type here...', optional: 'optional', option: 'Option', @@ -68,6 +70,7 @@ const es = { signature: 'Firma', initials: 'Iniciales', date: 'Fecha', + number: 'Número', image: 'Imagen', file: 'Archivo', select: 'Seleccionar', @@ -80,6 +83,7 @@ const es = { phone: 'Teléfono', take_photo: 'Tomar foto', submit_form: 'Enviar Formulario', + sign_now: 'Firmar ahora', type_here_: 'Escribe aquí...', optional: 'opcional', appears_on: 'Aparece en', @@ -131,6 +135,7 @@ const it = { signature: 'Firma', initials: 'Iniziali', date: 'Data', + number: 'Numero', image: 'Immagine', file: 'File', select: 'Seleziona', @@ -142,6 +147,7 @@ const it = { payment: 'Pagamento', phone: 'Telefono', submit_form: 'Invia Modulo', + sign_now: 'Firma ora', type_here_: 'Digita qui...', optional: 'opzionale', appears_on: 'Compare su', @@ -194,6 +200,7 @@ const de = { signature: 'Unterschrift', initials: 'Initialen', date: 'Datum', + number: 'Nummer', image: 'Bild', file: 'Datei', select: 'Auswählen', @@ -205,6 +212,7 @@ const de = { payment: 'Zahlung', phone: 'Telefon', submit_form: 'Formular absenden', + sign_now: 'Jetzt unterschreiben', type_here_: 'Hier eingeben...', optional: 'optional', appears_on: 'Erscheint auf', @@ -257,6 +265,7 @@ const fr = { signature: 'Signature', initials: 'Initiales', date: 'Date', + number: 'Numéro', image: 'Image', file: 'Fichier', select: 'Choisir', @@ -268,6 +277,7 @@ const fr = { payment: 'Paiement', phone: 'Téléphone', submit_form: 'Envoyer le Formulaire', + sign_now: 'Signer maintenant', type_here_: 'Tapez ici...', optional: 'facultatif', appears_on: 'Apparaît sur', @@ -320,6 +330,7 @@ const pl = { signature: 'Podpis', initials: 'Inicjały', date: 'Data', + number: 'Numer', image: 'Obraz', file: 'Plik', select: 'Wybierz', @@ -331,6 +342,7 @@ const pl = { payment: 'Płatność', phone: 'Telefon', submit_form: 'Wyślij Formularz', + sign_now: 'Podpisz teraz', type_here_: 'Wpisz tutaj...', optional: 'opcjonalny', appears_on: 'Pojawia się na', @@ -383,6 +395,7 @@ const uk = { signature: 'Підпис', initials: 'Ініціали', date: 'Дата', + number: 'Число', image: 'Зображення', file: 'Файл', select: 'Вибрати', @@ -394,6 +407,7 @@ const uk = { payment: 'Платіж', phone: 'Телефон', submit_form: 'Надіслати Форму', + sign_now: 'Підписати зараз', type_here_: 'Введіть тут', optional: 'необов’язково', appears_on: "З'являється на", @@ -446,6 +460,7 @@ const cs = { signature: 'Podpis', initials: 'Iniciály', date: 'Datum', + number: 'Číslo', image: 'Obrázek', file: 'Soubor', select: 'Vybrat', @@ -457,6 +472,7 @@ const cs = { payment: 'Platba', phone: 'Telefon', submit_form: 'Odeslat formulář', + sign_now: 'Podepsat nyní', type_here_: 'Zadejte zde', optional: 'volitelné', appears_on: 'Zobrazuje se na', @@ -509,6 +525,7 @@ const pt = { signature: 'Assinatura', initials: 'Iniciais', date: 'Data', + number: 'Número', image: 'Imagem', file: 'Arquivo', select: 'Selecionar', @@ -520,6 +537,7 @@ const pt = { payment: 'Pagamento', phone: 'Telefone', submit_form: 'Enviar Formulário', + sign_now: 'Assinar agora', type_here_: 'Digite aqui', optional: 'opcional', appears_on: 'Aparece em', @@ -570,8 +588,9 @@ const he = { minimize: 'לקטן', text: 'טקסט', signature: 'חתימה', - initials: 'ראשי תיקיות', + initials: 'ראשי תיבות', date: 'תאריך', + number: 'מספר', image: 'תמונה', file: 'קובץ', select: 'בחר', @@ -583,6 +602,7 @@ const he = { payment: 'תשלום', phone: 'טלפון', submit_form: 'שלח טופס', + sign_now: 'חתום כעת', type_here_: 'הקלד כאן', optional: 'אופציונלי', option: 'אפשרות', @@ -636,6 +656,7 @@ const nl = { signature: 'Handtekening', initials: 'Initialen', date: 'Datum', + number: 'Nummer', image: 'Afbeelding', take_photo: 'Maak een foto', file: 'Bestand', @@ -648,6 +669,7 @@ const nl = { payment: 'Betaling', phone: 'Telefoon', submit_form: 'Formulier verzenden', + sign_now: 'Nu ondertekenen', type_here_: 'Typ hier...', optional: 'Optioneel', option: 'Optie', @@ -694,6 +716,72 @@ const nl = { files: 'Bestanden' } -const i18n = { en, es, it, de, fr, pl, uk, cs, pt, he, nl } +const ar = { + text: 'نص', + signature: 'توقيع', + initials: 'الاختصارات', + date: 'تاريخ', + number: 'رقم', + image: 'صورة', + take_photo: 'التقاط صورة', + file: 'ملف', + select: 'اختيار', + checkbox: 'خانة اختيار', + multiple: 'متعدد', + radio: 'راديو', + cells: 'خلايا', + stamp: 'ختم', + minimize: 'تصغير', + payment: 'الدفع', + phone: 'هاتف', + submit_form: 'إرسال النموذج', + sign_now: 'وقع الآن', + type_here_: 'اكتب هنا', + optional: 'اختياري', + option: 'خيار', + appears_on: 'يظهر على', + page: 'صفحة', + select_your_option: 'اختر خيارك', + complete_hightlighted_checkboxes_and_click: 'أكمل الخانات المميزة وانقر', + submit: 'إرسال', + next: 'التالي', + click_to_upload: 'انقر للتحميل', + or_drag_and_drop_files: 'أو اسحب وأسقط الملفات', + send_copy_via_email: 'إرسال نسخة عبر البريد الإلكتروني', + download: 'تحميل', + clear: 'مسح', + redraw: 'إعادة الرسم', + draw_initials: 'ارسم الاختصارات', + type_signature_here: 'اكتب التوقيع هنا', + type_initial_here: 'اكتب الاختصارات هنا', + form_has_been_completed: 'تم إكمال النموذج!', + create_a_free_account: 'إنشاء حساب مجاني', + signed_with: 'تم التوقيع بواسطة', + please_check_the_box_to_continue: 'الرجاء التحقق من الخانة للمتابعة', + open_source_documents_software: 'برنامج وثائق مفتوح المصدر', + verified_phone_number: 'تحقق من رقم الهاتف', + use_international_format: 'استخدم الشكل الدولي: +1xxx', + six_digits_code: 'رمز مكون من 6 أرقام', + change_phone_number: 'تغيير رقم الهاتف', + sending: 'جارٍ الإرسال', + resend_code: 'إعادة إرسال الرمز', + verification_code_has_been_resent: 'تم إعادة إرسال رمز التحقق عبر الرسائل القصيرة', + please_fill_all_required_fields: 'الرجاء ملء جميع الحقول المطلوبة', + set_today: 'تعيين اليوم', + toggle_multiline_text: 'تبديل النصوص متعددة الأسطر', + draw_signature: 'ارسم التوقيع', + type_initial: 'اكتب الاختصارات', + draw: 'ارسم', + type: 'اكتب', + type_text: 'اكتب نصًا', + email_has_been_sent: 'تم إرسال البريد الإلكتروني', + processing: 'جارٍ المعالجة', + pay_with_strip: 'الدفع بواسطة Stripe', + reupload: 'إعادة التحميل', + upload: 'تحميل', + files: 'الملفات' +} + +const i18n = { en, es, it, de, fr, pl, uk, cs, pt, he, nl, ar } export default i18n diff --git a/app/javascript/submission_form/image_step.vue b/app/javascript/submission_form/image_step.vue index faed6dac..63cefa1b 100644 --- a/app/javascript/submission_form/image_step.vue +++ b/app/javascript/submission_form/image_step.vue @@ -24,9 +24,17 @@ :name="`values[${field.uuid}]`" > -
+
+
+ +
import FileDropzone from './dropzone' import { IconReload } from '@tabler/icons-vue' +import MarkdownContent from './markdown_content' export default { name: 'ImageStep', components: { FileDropzone, - IconReload + IconReload, + MarkdownContent }, inject: ['t'], props: { diff --git a/app/javascript/submission_form/initials_step.vue b/app/javascript/submission_form/initials_step.vue index 6ee5b2bf..274a7401 100644 --- a/app/javascript/submission_form/initials_step.vue +++ b/app/javascript/submission_form/initials_step.vue @@ -1,9 +1,20 @@ + + diff --git a/app/javascript/submission_form/multi_select_step.vue b/app/javascript/submission_form/multi_select_step.vue index b3368e04..ba9d07b0 100644 --- a/app/javascript/submission_form/multi_select_step.vue +++ b/app/javascript/submission_form/multi_select_step.vue @@ -1,10 +1,23 @@ diff --git a/app/javascript/template_builder/description_modal.vue b/app/javascript/template_builder/description_modal.vue new file mode 100644 index 00000000..2234f8a0 --- /dev/null +++ b/app/javascript/template_builder/description_modal.vue @@ -0,0 +1,107 @@ +