Compare commits

..
1 Commits
Author SHA1 Message Date
Alex Turchyn 6fbc81fae2 increase tokens length 2023-07-08 21:05:29 +03:00
943 changed files with 4590 additions and 130165 deletions

No files matched your search

-58
View File
@@ -1,58 +0,0 @@
---
:position: before
:position_in_additional_file_patterns: before
:position_in_class: before
:position_in_factory: before
:position_in_fixture: before
:position_in_routes: before
:position_in_serializer: before
:position_in_test: before
:classified_sort: true
:exclude_controllers: true
:exclude_factories: true
:exclude_fixtures: false
:exclude_helpers: true
:exclude_scaffolds: true
:exclude_serializers: false
:exclude_sti_subclasses: false
:exclude_tests: false
:force: false
:format_markdown: false
:format_rdoc: false
:format_yard: false
:frozen: false
:ignore_model_sub_dir: false
:ignore_unknown_models: false
:include_version: false
:show_check_constraints: false
:show_complete_foreign_keys: false
:show_foreign_keys: true
:show_indexes: true
:simple_indexes: false
:sort: false
:timestamp: false
:trace: false
:with_comment: true
:with_column_comments: true
:with_table_comments: true
:active_admin: false
:command:
:debug: false
:hide_default_column_types: ''
:hide_limit_column_types: ''
:ignore_columns:
:ignore_routes:
:models: true
:routes: false
:skip_on_db_migrate: false
:target_action: :do_annotations
:wrapper:
:wrapper_close:
:wrapper_open:
:classes_default_to_s: []
:additional_file_patterns: []
:model_dir:
- app/models
:require: []
:root_dir:
- ''
+18
View File
@@ -0,0 +1,18 @@
spec:
name: docuseal
services:
- name: docuseal
dockerfile_path: Dockerfile
git:
repo_clone_url: https://github.com/docusealco/docuseal.git
branch: master
envs:
- key: SECRET_KEY_BASE
value: "- 64 characters string 64 characters string 64 characters string -"
type: SECRET
scope: RUN_TIME
- key: DATABASE_URL
scope: RUN_TIME
value: ${docuseal-db.DATABASE_URL}
databases:
- name: docuseal-db
-2
View File
@@ -1,6 +1,5 @@
/db/*.sqlite3 /db/*.sqlite3
**/node_modules
/node_modules /node_modules
/coverage /coverage
/doc /doc
@@ -28,4 +27,3 @@
/attachments /attachments
/docuseal /docuseal
.DS_Store .DS_Store
.env
-2
View File
@@ -30,5 +30,3 @@ linters:
Enabled: false Enabled: false
Style/ZeroLengthPredicate: Style/ZeroLengthPredicate:
Enabled: false Enabled: false
Style/RedundantConstantBase:
Enabled: false
+1 -2
View File
@@ -8,8 +8,7 @@
}, },
"rules": { "rules": {
"vue/no-deprecated-html-element-is": 0, "vue/no-deprecated-html-element-is": 0,
"vue/no-mutating-props": 0, "vue/no-mutating-props": 0
"vue/one-component-per-file": 0
}, },
"parserOptions": { "parserOptions": {
"ecmaVersion": 2022, "ecmaVersion": 2022,
+16 -55
View File
@@ -6,15 +6,14 @@ jobs:
rubocop: rubocop:
name: Rubocop name: Rubocop
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v2
- name: Install Ruby - name: Install Ruby
uses: ruby/setup-ruby@v1 uses: ruby/setup-ruby@v1
with: with:
ruby-version: 4.0.1 ruby-version: 3.2.2
- name: Cache gems - name: Cache gems
uses: actions/cache@v4 uses: actions/cache@v1
with: with:
path: vendor/bundle path: vendor/bundle
key: ${{ runner.os }}-gem-${{ hashFiles('**/Gemfile.lock') }} key: ${{ runner.os }}-gem-${{ hashFiles('**/Gemfile.lock') }}
@@ -31,15 +30,14 @@ jobs:
erblint: erblint:
name: Erblint name: Erblint
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v2
- name: Install Ruby - name: Install Ruby
uses: ruby/setup-ruby@v1 uses: ruby/setup-ruby@v1
with: with:
ruby-version: 4.0.1 ruby-version: 3.2.2
- name: Cache gems - name: Cache gems
uses: actions/cache@v4 uses: actions/cache@v1
with: with:
path: vendor/bundle path: vendor/bundle
key: ${{ runner.os }}-gem-${{ hashFiles('**/Gemfile.lock') }} key: ${{ runner.os }}-gem-${{ hashFiles('**/Gemfile.lock') }}
@@ -51,22 +49,21 @@ jobs:
bundle config path vendor/bundle bundle config path vendor/bundle
bundle install --jobs 4 --retry 4 bundle install --jobs 4 --retry 4
- name: Run Erblint - name: Run Erblint
run: bundle exec erb_lint ./app run: bundle exec erblint ./app
eslint: eslint:
name: ESLint name: ESLint
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v2
- name: Install Node.js - name: Install Node.js
uses: actions/setup-node@v1 uses: actions/setup-node@v1
with: with:
node-version: 20.19.0 node-version: 16.13.1
- name: Cache directory path - name: Cache directory path
id: yarn-cache-dir-path id: yarn-cache-dir-path
run: echo "::set-output name=dir::$(yarn cache dir)" run: echo "::set-output name=dir::$(yarn cache dir)"
- uses: actions/cache@v4 - uses: actions/cache@v1
id: yarn-cache id: yarn-cache
with: with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }} path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
@@ -80,38 +77,9 @@ jobs:
run: | run: |
./node_modules/eslint/bin/eslint.js "app/javascript/**/*.js" ./node_modules/eslint/bin/eslint.js "app/javascript/**/*.js"
brakeman:
name: Brakeman
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Install Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: 4.0.1
- name: Cache gems
uses: actions/cache@v4
with:
path: vendor/bundle
key: ${{ runner.os }}-gem-${{ hashFiles('**/Gemfile.lock') }}
restore-keys: |
${{ runner.os }}-gem-
- name: Install gems
run: |
gem install bundler
bundle config path vendor/bundle
bundle install --jobs 4 --retry 4
yarn install
sudo apt-get update
sudo apt-get install libvips
- name: Run Brakeman
run: bundle exec brakeman -q --exit-on-warn
rspec: rspec:
name: RSpec name: RSpec
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10
services: services:
postgres: postgres:
@@ -128,26 +96,24 @@ jobs:
--health-retries 5 --health-retries 5
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v2
- name: Install Ruby - name: Install Ruby
uses: ruby/setup-ruby@v1 uses: ruby/setup-ruby@v1
with: with:
ruby-version: 4.0.1 ruby-version: 3.2.2
- name: Set up Node - name: Set up Node
uses: actions/setup-node@v1 uses: actions/setup-node@v1
with: with:
node-version: 20.19.0 node-version: 16.13.1
- name: Install Chrome - name: Install Chrome
uses: browser-actions/setup-chrome@latest uses: browser-actions/setup-chrome@latest
with:
chrome-version: 125
- name: Cache node_modules - name: Cache node_modules
uses: actions/cache@v4 uses: actions/cache@v1
with: with:
path: node_modules path: node_modules
key: ${{ runner.os }}-node-${{ hashFiles('**/yarn.lock') }} key: ${{ runner.os }}-node-${{ hashFiles('**/yarn.lock') }}
- name: Cache gems - name: Cache gems
uses: actions/cache@v4 uses: actions/cache@v1
with: with:
path: vendor/bundle path: vendor/bundle
key: ${{ runner.os }}-gem-${{ hashFiles('**/Gemfile.lock') }} key: ${{ runner.os }}-gem-${{ hashFiles('**/Gemfile.lock') }}
@@ -161,11 +127,7 @@ jobs:
bundle config path vendor/bundle bundle config path vendor/bundle
bundle install --jobs 4 --retry 4 bundle install --jobs 4 --retry 4
yarn install yarn install
sudo apt-get update sudo apt-get install libvips
sudo apt-get install -y libvips
wget -O pdfium-linux.tgz "https://github.com/docusealco/pdfium-binaries/releases/latest/download/pdfium-linux-$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/').tgz"
sudo tar -xzf pdfium-linux.tgz --strip-components=1 -C /usr/lib lib/libpdfium.so
rm -f pdfium-linux.tgz
- name: Run - name: Run
env: env:
RAILS_ENV: test RAILS_ENV: test
@@ -175,5 +137,4 @@ jobs:
run: | run: |
bundle exec rake db:create bundle exec rake db:create
bundle exec rake db:migrate bundle exec rake db:migrate
bundle exec rake assets:precompile
bundle exec rspec bundle exec rspec
+11 -13
View File
@@ -7,8 +7,7 @@ on:
jobs: jobs:
build: build:
runs-on: ubuntu-24.04-arm runs-on: ubuntu-latest
timeout-minutes: 30
steps: steps:
- name: Checkout code - name: Checkout code
@@ -16,30 +15,29 @@ jobs:
with: with:
submodules: recursive submodules: recursive
- name: Docker meta -
name: Docker meta
id: meta id: meta
uses: docker/metadata-action@v4 uses: docker/metadata-action@v4
with: with:
images: docuseal/docuseal images: |
tags: type=semver,pattern={{version}} docuseal/docuseal
tags: |
type=semver,pattern={{version}}
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v2
- name: Create .version file
run: echo ${{ github.ref_name }} > .version
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@v3 uses: docker/login-action@v2
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push Docker image - name: Build and push Docker image
uses: docker/build-push-action@v6 uses: docker/build-push-action@v4
with: with:
context: . context: .
push: true push: true
-3
View File
@@ -35,6 +35,3 @@ yarn-debug.log*
/coverage /coverage
/attachments /attachments
/docuseal /docuseal
/ee
dump.rdb
*.onnx
-1
View File
@@ -1 +0,0 @@
--require rails_helper
+7 -57
View File
@@ -1,4 +1,4 @@
plugins: require:
- rubocop-performance - rubocop-performance
- rubocop-rails - rubocop-rails
- rubocop-rspec - rubocop-rspec
@@ -10,8 +10,7 @@ AllCops:
- node_modules/**/* - node_modules/**/*
- bin/* - bin/*
- vendor/**/* - vendor/**/*
TargetRubyVersion: '4.0' TargetRubyVersion: '3.2'
SuggestExtensions: false
Metrics/BlockLength: Metrics/BlockLength:
Exclude: Exclude:
@@ -27,14 +26,10 @@ Style/Documentation:
Lint/MissingSuper: Lint/MissingSuper:
Enabled: false Enabled: false
Metrics/ParameterLists:
Max: 10
Metrics/MethodLength: Metrics/MethodLength:
Max: 30 Max: 20
Exclude: Exclude:
- 'db/migrate/**' - 'db/migrate/**'
- 'spec/**/*'
Metrics/CyclomaticComplexity: Metrics/CyclomaticComplexity:
Max: 15 Max: 15
@@ -42,74 +37,29 @@ Metrics/CyclomaticComplexity:
Metrics/PerceivedComplexity: Metrics/PerceivedComplexity:
Max: 15 Max: 15
Style/MultipleComparison:
Enabled: false
Style/NumericPredicate:
Enabled: false
Style/MinMaxComparison:
Enabled: false
Naming/PredicateMethod:
Enabled: false
Layout/LineLength: Layout/LineLength:
AllowedPatterns: ['\A\s*#'] AllowedPatterns: ['\A\s*#']
Metrics/AbcSize: Metrics/AbcSize:
Max: 45 Max: 35
Exclude:
- spec/**/*
Metrics/ModuleLength: Metrics/ModuleLength:
Max: 1000 Max: 500
Metrics/ClassLength: Metrics/ClassLength:
Max: 1000 Max: 500
RSpec/NestedGroups: RSpec/NestedGroups:
Max: 6 Max: 6
RSpec/MultipleExpectations: RSpec/MultipleExpectations:
Max: 25 Max: 7
RSpec/ExampleLength: RSpec/ExampleLength:
Max: 500
RSpec/MultipleMemoizedHelpers:
Max: 15 Max: 15
RSpec/AnyInstance:
Enabled: false
Metrics/BlockNesting:
Max: 6
Rails/I18nLocaleTexts: Rails/I18nLocaleTexts:
Enabled: false Enabled: false
Rails/FindEach:
Enabled: false
Rails/SkipsModelValidations:
Enabled: false
Rails/ApplicationController: Rails/ApplicationController:
Enabled: false Enabled: false
Rails/Output:
Enabled: false
Rails/StrongParametersExpect:
Enabled: false
Rails/RedirectBackOrTo:
Enabled: false
Rails/UnknownEnv:
Environments:
- development
- test
- production
- local
View File
Whitespace-only changes.
+13 -66
View File
@@ -1,22 +1,4 @@
FROM ruby:4.0.1-alpine AS download FROM ruby:3.2.2-alpine as webpack
WORKDIR /fonts
RUN apk --no-cache add fontforge wget && \
wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Regular.ttf && \
wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Bold.ttf && \
wget https://github.com/impallari/DancingScript/raw/master/fonts/DancingScript-Regular.otf && \
wget https://cdn.jsdelivr.net/gh/notofonts/notofonts.github.io/fonts/NotoSansSymbols2/hinted/ttf/NotoSansSymbols2-Regular.ttf && \
wget https://github.com/Maxattax97/gnu-freefont/raw/master/ttf/FreeSans.ttf && \
wget https://github.com/impallari/DancingScript/raw/master/OFL.txt && \
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/docusealco/pdfium-binaries/releases/latest/download/pdfium-linux-$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/').tgz" && \
mkdir -p /pdfium-linux && \
tar -xzf pdfium-linux.tgz -C /pdfium-linux
RUN fontforge -lang=py -c 'font1 = fontforge.open("FreeSans.ttf"); font2 = fontforge.open("NotoSansSymbols2-Regular.ttf"); font1.mergeFonts(font2); font1.generate("FreeSans.ttf")'
FROM ruby:4.0.1-alpine AS webpack
ENV RAILS_ENV=production ENV RAILS_ENV=production
ENV NODE_ENV=production ENV NODE_ENV=production
@@ -33,71 +15,36 @@ RUN yarn install --network-timeout 1000000
COPY ./bin/shakapacker ./bin/shakapacker COPY ./bin/shakapacker ./bin/shakapacker
COPY ./config/webpack ./config/webpack COPY ./config/webpack ./config/webpack
COPY ./config/shakapacker.yml ./config/shakapacker.yml COPY ./config/shakapacker.yml ./config/shakapacker.yml
COPY ./postcss.config.js ./postcss.config.js COPY ./postcss.config.js ./postcss.config.js ./
COPY ./tailwind.config.js ./tailwind.config.js COPY ./tailwind.config.js ./tailwind.config.js ./
COPY ./tailwind.form.config.js ./tailwind.form.config.js COPY ./tailwind.form.config.js ./tailwind.form.config.js ./
COPY ./tailwind.dynamic.config.js ./tailwind.dynamic.config.js COPY ./tailwind.application.config.js ./tailwind.application.config.js ./
COPY ./tailwind.application.config.js ./tailwind.application.config.js
COPY ./app/javascript ./app/javascript COPY ./app/javascript ./app/javascript
COPY ./app/views ./app/views COPY ./app/views ./app/views
RUN echo "gem 'shakapacker'" > Gemfile && ./bin/shakapacker RUN echo "gem 'shakapacker'" > Gemfile && ./bin/shakapacker
FROM ruby:4.0.1-alpine AS app FROM ruby:3.2.2-alpine as app
ENV RAILS_ENV=production ENV RAILS_ENV=production
ENV BUNDLE_WITHOUT="development:test" ENV BUNDLE_WITHOUT="development:test"
ENV LD_PRELOAD=/lib/libgcompat.so.0
ENV OPENSSL_CONF=/etc/openssl_legacy.cnf
ENV VIPS_MAX_COORD=15000
WORKDIR /app WORKDIR /app
RUN apk add --no-cache sqlite-dev libpq-dev vips-dev yaml-dev redis libheif vips-heif gcompat ttf-freefont onnxruntime && mkdir /fonts && rm /usr/share/fonts/freefont/FreeSans.otf RUN apk add --no-cache build-base sqlite-dev libpq-dev mariadb-dev vips-dev vips-poppler vips-heif libc6-compat ttf-freefont
RUN addgroup -g 2000 docuseal && adduser -u 2000 -G docuseal -s /bin/sh -D -h /home/docuseal docuseal COPY ./Gemfile ./Gemfile.lock ./
RUN echo $'.include = /etc/ssl/openssl.cnf\n\ RUN bundle update --bundler && bundle install && rm -rf ~/.bundle
\n\
[provider_sect]\n\
default = default_sect\n\
legacy = legacy_sect\n\
\n\
[default_sect]\n\
activate = 1\n\
\n\
[legacy_sect]\n\
activate = 1' >> /etc/openssl_legacy.cnf
COPY --chown=docuseal:docuseal ./Gemfile ./Gemfile.lock ./ COPY . ./
RUN apk add --no-cache build-base git && bundle install && apk del --no-cache build-base git && 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 && ln -sf /usr/lib/libonnxruntime.so.1 $(ruby -e "print Dir[Gem::Specification.find_by_name('onnxruntime').gem_dir + '/vendor/*.so'].first") COPY --from=webpack /app/public/packs ./public/packs
COPY --chown=docuseal:docuseal ./bin ./bin RUN bundle exec bootsnap precompile --gemfile app/ lib/
COPY --chown=docuseal:docuseal ./app ./app
COPY --chown=docuseal:docuseal ./config ./config
COPY --chown=docuseal:docuseal ./db/migrate ./db/migrate
COPY --chown=docuseal:docuseal ./log ./log
COPY --chown=docuseal:docuseal ./lib ./lib
COPY --chown=docuseal:docuseal ./public ./public
COPY --chown=docuseal:docuseal ./tmp ./tmp
COPY --chown=docuseal:docuseal LICENSE LICENSE_ADDITIONAL_TERMS README.md Rakefile config.ru .version ./
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
COPY --from=download /fonts/FreeSans.ttf /usr/share/fonts/freefont
COPY --from=download /pdfium-linux/lib/libpdfium.so /usr/lib/libpdfium.so
COPY --from=download /pdfium-linux/licenses/pdfium.txt /usr/lib/libpdfium-LICENSE.txt
COPY --chown=docuseal:docuseal --from=download /model.onnx /app/tmp/model.onnx
COPY --chown=docuseal:docuseal --from=webpack /app/public/packs ./public/packs
RUN ln -s /fonts /app/public/fonts && \
bundle exec bootsnap precompile -j 1 --gemfile app/ lib/ && \
chown -R docuseal:docuseal /app/tmp/cache
WORKDIR /data/docuseal WORKDIR /data/docuseal
ENV HOME=/home/docuseal
ENV WORKDIR=/data/docuseal ENV WORKDIR=/data/docuseal
EXPOSE 3000 EXPOSE 3000
CMD ["/app/bin/bundle", "exec", "puma", "-C", "/app/config/puma.rb", "--dir", "/app"] CMD ["/app/bin/rails", "server"]
+6 -26
View File
@@ -2,53 +2,36 @@
source 'https://rubygems.org' source 'https://rubygems.org'
ruby '4.0.1' ruby '3.2.2'
gem 'arabic-letter-connector', require: false
gem 'aws-sdk-s3', require: false gem 'aws-sdk-s3', require: false
gem 'aws-sdk-secretsmanager', require: false gem 'azure-storage-blob', require: false
gem 'azure-blob', require: false
gem 'bootsnap', require: false gem 'bootsnap', require: false
gem 'cancancan'
gem 'csv', require: false
gem 'csv-safe', require: false
gem 'devise' gem 'devise'
gem 'devise-two-factor'
gem 'dotenv', require: false gem 'dotenv', require: false
gem 'email_typo'
gem 'faraday' gem 'faraday'
gem 'faraday-follow_redirects'
gem 'google-cloud-storage', require: false gem 'google-cloud-storage', require: false
gem 'hexapdf' gem 'hexapdf'
gem 'image_processing' gem 'image_processing'
gem 'jwt', require: false
gem 'lograge' gem 'lograge'
gem 'numo-narray-alt', require: false gem 'mysql2', require: false
gem 'oj' gem 'oj'
gem 'onnxruntime', require: false
gem 'pagy' gem 'pagy'
gem 'pg', require: false gem 'pg', require: false
gem 'premailer-rails' gem 'premailer-rails'
gem 'pretender' gem 'puma'
gem 'puma', require: false
gem 'rack'
gem 'rails' gem 'rails'
gem 'rails-i18n' gem 'rails-i18n'
gem 'rotp' gem 'rollbar', require: ENV.key?('ROLLBAR_ACCESS_TOKEN')
gem 'rouge', require: false
gem 'rqrcode', require: false
gem 'ruby-vips' gem 'ruby-vips'
gem 'rubyXL', require: false
gem 'shakapacker' gem 'shakapacker'
gem 'sidekiq'
gem 'sqlite3', require: false gem 'sqlite3', require: false
gem 'strip_attributes' gem 'strip_attributes'
gem 'trilogy', github: 'trilogy-libraries/trilogy', glob: 'contrib/ruby/*.gemspec', require: false
gem 'turbo-rails' gem 'turbo-rails'
gem 'twitter_cldr', require: false
gem 'tzinfo-data' gem 'tzinfo-data'
group :development, :test do group :development, :test do
gem 'annotate'
gem 'better_html' gem 'better_html'
gem 'bullet' gem 'bullet'
gem 'debug' gem 'debug'
@@ -65,9 +48,6 @@ group :development, :test do
end end
group :development do group :development do
gem 'annotaterb'
gem 'brakeman', require: false
gem 'foreman', require: false
gem 'letter_opener_web' gem 'letter_opener_web'
gem 'web-console' gem 'web-console'
end end
+336 -482
View File
File diff suppressed because it is too large. Load diff
-5
View File
@@ -1,5 +0,0 @@
Additional Terms
In accordance with Section 7(b) of the GNU Affero General Public License,
a covered work must retain the original DocuSeal attribution in interactive
user interfaces.
+22 -55
View File
@@ -1,7 +1,7 @@
<h1 align="center" style="border-bottom: none"> <h1 align="center" style="border-bottom: none">
<div> <div>
<a href="https://www.docuseal.com"> <a href="https://www.docuseal.co">
<img alt="DocuSeal" src="https://github.com/user-attachments/assets/38b45682-ffa4-4919-abde-d2d422325c44" width="80" /> <img alt="DocuSeal" src="https://github.com/docusealco/docuseal/assets/5418788/c12cd051-81cd-4402-bc3a-92f2cfdc1b06" width="80" />
<br> <br>
</a> </a>
DocuSeal DocuSeal
@@ -14,7 +14,7 @@
<a href="https://hub.docker.com/r/docuseal/docuseal"> <a href="https://hub.docker.com/r/docuseal/docuseal">
<img alt="Docker releases" src="https://img.shields.io/docker/v/docuseal/docuseal"> <img alt="Docker releases" src="https://img.shields.io/docker/v/docuseal/docuseal">
</a> </a>
<a href="https://discord.gg/qygYCDGck9"> <a href="https://discord.com/invite/B5wg5wKk">
<img src="https://img.shields.io/discord/1125112641170448454?logo=discord"/> <img src="https://img.shields.io/discord/1125112641170448454?logo=discord"/>
</a> </a>
<a href="https://twitter.com/intent/follow?screen_name=docusealco"> <a href="https://twitter.com/intent/follow?screen_name=docusealco">
@@ -25,48 +25,29 @@
DocuSeal is an open source platform that provides secure and efficient digital document signing and processing. Create PDF forms to have them filled and signed online on any device with an easy-to-use, mobile-optimized web tool. DocuSeal is an open source platform that provides secure and efficient digital document signing and processing. Create PDF forms to have them filled and signed online on any device with an easy-to-use, mobile-optimized web tool.
</p> </p>
<h2 align="center"> <h2 align="center">
<a href="https://demo.docuseal.tech">✨ Live Demo</a> <a href="https://demo.docuseal.co">✨ Live Demo</a>
<span>|</span>
<a href="https://docuseal.com/sign_up">☁️ Try in Cloud</a>
</h2> </h2>
[![Demo](https://github.com/docusealco/docuseal/assets/5418788/d8703ea3-361a-423f-8bfe-eff1bd9dbe14)](https://demo.docuseal.tech) [![Demo](https://github.com/docusealco/docuseal/assets/5418788/d8703ea3-361a-423f-8bfe-eff1bd9dbe14)](https://demo.docuseal.co)
## Features ## Features
- PDF form fields builder (WYSIWYG) - [x] PDF form fields builder (WYSIWYG)
- 12 field types available (Signature, Date, File, Checkbox etc.) - [x] 10 field types available (Signature, Date, File, Checkbox etc.)
- Multiple submitters per document - [x] Multiple submitters per document
- Automated emails via SMTP - [x] Automated emails via SMTP
- Files storage on disk or AWS S3, Google Storage, Azure Cloud - [x] Files storage on AWS S3, Google Storage, or Azure
- Automatic PDF eSignature - [x] Automatic PDF eSignature
- PDF signature verification - [x] PDF signature verification
- Users management - [x] Users management
- Mobile-optimized - [x] Mobile-optimized
- 7 UI languages with signing available in 14 languages - [x] Easy to deploy in minutes
- API and Webhooks for integrations
- Easy to deploy in minutes
## Pro Features
- Company logo and white-label
- User roles
- Automated reminders
- Invitation and identify verification via SMS
- Conditional fields and formulas
- Bulk send with CSV, XLSX spreadsheet import
- SSO / SAML
- Template creation with HTML API ([Guide](https://www.docuseal.com/guides/create-pdf-document-fillable-form-with-html-api))
- Template creation with PDF or DOCX and field tags API ([Guide](https://www.docuseal.com/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), [Angular](https://github.com/docusealco/docuseal-angular) or [JavaScript](https://www.docuseal.com/docs/embedded))
- Embedded document form builder ([React](https://github.com/docusealco/docuseal-react), [Vue](https://github.com/docusealco/docuseal-vue), [Angular](https://github.com/docusealco/docuseal-angular) or [JavaScript](https://www.docuseal.com/docs/embedded))
- [Learn more](https://www.docuseal.com/pricing)
## Deploy ## Deploy
|Heroku|Railway| | Heroku | Railway | DigitalOcean |
|:--:|:---:| | ---------------------------------------------------------------------------- | --------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
| [<img alt="Deploy on Heroku" src="https://www.herokucdn.com/deploy/button.svg" height="40">](https://heroku.com/deploy?template=https://github.com/docusealco/docuseal-heroku) | [<img alt="Deploy on Railway" src="https://railway.app/button.svg" height="40">](https://railway.app/template/IGoDnc?referralCode=ruU7JR)| | [<img alt="Deploy on Heroku" src="https://www.herokucdn.com/deploy/button.svg" height="40">](https://heroku.com/deploy?template=https://github.com/docusealco/docuseal-heroku) | [<img alt="Deploy on Railway" src="https://railway.app/button.svg" height="40">](https://railway.app/template/IGoDnc?referralCode=ruU7JR) | [<img alt="Deploy on DigitalOcean" src="https://www.deploytodo.com/do-btn-blue.svg" height="40">](https://cloud.digitalocean.com/apps/new?repo=https://github.com/docusealco/docuseal/tree/master&refcode=421d50f53990) |
|**DigitalOcean**|**Render**|
| [<img alt="Deploy on DigitalOcean" src="https://www.deploytodo.com/do-btn-blue.svg" height="40">](https://cloud.digitalocean.com/apps/new?repo=https://github.com/docusealco/docuseal-digitalocean/tree/master&refcode=421d50f53990) | [<img alt="Deploy to Render" src="https://render.com/images/deploy-to-render-button.svg" height="40">](https://render.com/deploy?repo=https://github.com/docusealco/docuseal-render)
#### Docker #### Docker
@@ -83,25 +64,11 @@ Download docker-compose.yml into your private server:
curl https://raw.githubusercontent.com/docusealco/docuseal/master/docker-compose.yml > docker-compose.yml curl https://raw.githubusercontent.com/docusealco/docuseal/master/docker-compose.yml > docker-compose.yml
``` ```
Run the app under a custom domain over https using docker compose (make sure your DNS points to the server to automatically issue ssl certs with Caddy): Run docker compose under a custom domain over https (make sure your DNS points to the server to automatically issue ssl certs with Caddy):
```sh ```sh
sudo HOST=your-domain-name.com docker compose up HOST=your-domain-name.com docker-compose up
``` ```
## For Businesses
### Integrate seamless document signing into your web or mobile apps with DocuSeal
At DocuSeal we have expertise and technologies to make documents creation, filling, signing and processing seamlessly integrated with your product. We specialize in working with various industries, including **Banking, Healthcare, Transport, Real Estate, eCommerce, KYC, CRM, and other software products** that require bulk document signing. By leveraging DocuSeal, we can assist in reducing the overall cost of developing and processing electronic documents while ensuring security and compliance with local electronic document laws.
[Book a Meeting](https://www.docuseal.com/contact)
## License ## License
Distributed under the AGPLv3 License with Section 7(b) Additional Terms. See [LICENSE](https://github.com/docusealco/docuseal/blob/master/LICENSE) and [LICENSE_ADDITIONAL_TERMS](https://github.com/docusealco/docuseal/blob/master/LICENSE_ADDITIONAL_TERMS) for more information. DocuSeal is released under the GNU Affero General Public License v3.0.
Unless otherwise noted, all files © 2023-2026 DocuSeal LLC.
## Tools
- [Signature Maker](https://www.docuseal.com/online-signature)
- [Sign Document Online](https://www.docuseal.com/sign-documents-online)
- [Fill PDF Online](https://www.docuseal.com/fill-pdf)
-14
View File
@@ -1,14 +0,0 @@
# Reporting a Vulnerability
If you come across any security concern or vulnarability, please report the information via email to security@docuseal.com instead of opening a GitHub issue. We will promptly respond and will collaborate with you to validate the issue, and resolve it ASAP.
**We have a bug bounty program to reward security researchers.**
Out of scope vulnerabilities:
- CSRF
- DNSSEC, CAA, CSP headers
- DNS or email security related
- Rate Limiting
Note: We reserve the right to classify any reported vulnerability as out of scope for the bug bounty program.
@@ -1,60 +0,0 @@
# frozen_string_literal: true
class AccountConfigsController < ApplicationController
before_action :load_account_config, only: :create
authorize_resource :account_config, only: :create
load_and_authorize_resource :account_config, only: :destroy
ALLOWED_KEYS = [
AccountConfig::ALLOW_TYPED_SIGNATURE,
AccountConfig::FORCE_MFA,
AccountConfig::ALLOW_TO_RESUBMIT,
AccountConfig::ALLOW_TO_DECLINE_KEY,
AccountConfig::FORM_PREFILL_SIGNATURE_KEY,
AccountConfig::ESIGNING_PREFERENCE_KEY,
AccountConfig::FORM_WITH_CONFETTI_KEY,
AccountConfig::DOWNLOAD_LINKS_AUTH_KEY,
AccountConfig::DOWNLOAD_LINKS_EXPIRE_KEY,
AccountConfig::FORCE_SSO_AUTH_KEY,
AccountConfig::FLATTEN_RESULT_PDF_KEY,
AccountConfig::ENFORCE_SIGNING_ORDER_KEY,
AccountConfig::WITH_FILE_LINKS_KEY,
AccountConfig::WITH_SIGNATURE_ID,
AccountConfig::COMBINE_PDF_RESULT_KEY,
AccountConfig::REQUIRE_SIGNING_REASON_KEY,
AccountConfig::DOCUMENT_FILENAME_FORMAT_KEY,
AccountConfig::ENABLE_MCP_KEY
].freeze
InvalidKey = Class.new(StandardError)
def create
@account_config.update!(account_config_params)
head :ok
end
def destroy
raise InvalidKey unless ALLOWED_KEYS.include?(@account_config.key)
@account_config.destroy!
redirect_back(fallback_location: root_path)
end
private
def load_account_config
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])
end
def account_config_params
params.required(:account_config).permit(:key, :value, { value: {} }, { value: [] }).tap do |attrs|
attrs[:value] = attrs[:value] == '1' if attrs[:value].in?(%w[1 0])
end
end
end
@@ -1,33 +0,0 @@
# frozen_string_literal: true
class AccountCustomFieldsController < ApplicationController
before_action :load_account_config, only: :create
def create
authorize!(:create, Template)
@account_config.update!(account_config_params)
render json: @account_config.value
end
private
def load_account_config
@account_config =
AccountConfig.find_or_initialize_by(account: current_account, key: AccountConfig::TEMPLATE_CUSTOM_FIELDS_KEY)
end
def account_config_params
params.permit(
value: [[:uuid, :name, :type,
:required, :readonly, :default_value,
:title, :description,
{ preferences: {},
default_value: [],
options: [%i[value uuid]],
validation: %i[message pattern min max step],
areas: [%i[x y w h cell_w option_uuid]] }]]
)
end
end
+15 -46
View File
@@ -4,73 +4,42 @@ class AccountsController < ApplicationController
LOCALE_OPTIONS = { LOCALE_OPTIONS = {
'en-US' => 'English (United States)', 'en-US' => 'English (United States)',
'en-GB' => 'English (United Kingdom)', 'en-GB' => 'English (United Kingdom)',
'fr-FR' => 'Français', 'es-ES' => 'Spanish (Spain)',
'es-ES' => 'Español', 'pt-PT' => 'Portuguese (Portugal)',
'pt-PT' => 'Português', 'de-DE' => 'German (Germany)'
'de-DE' => 'Deutsch',
'it-IT' => 'Italiano',
'nl-NL' => 'Nederlands'
}.freeze }.freeze
before_action :load_account
authorize_resource :account
def show; end def show; end
def update def update
current_account.update!(account_params) current_account.update!(account_params)
unless Docuseal.multitenant? @encrypted_config = EncryptedConfig.find_or_initialize_by(account: current_account,
@encrypted_config = EncryptedConfig.find_or_initialize_by(account: current_account, key: EncryptedConfig::APP_URL_KEY)
key: EncryptedConfig::APP_URL_KEY) @encrypted_config.assign_attributes(app_url_params)
@encrypted_config.assign_attributes(app_url_params)
unless URI.parse(@encrypted_config.value.to_s).class.in?([URI::HTTP, URI::HTTPS]) unless URI.parse(@encrypted_config.value.to_s).class.in?([URI::HTTP, URI::HTTPS])
@encrypted_config.errors.add(:value, I18n.t('should_be_a_valid_url')) @encrypted_config.errors.add(:value, 'should be a valid URL')
return render :show, status: :unprocessable_content return render :show, status: :unprocessable_entity
end
@encrypted_config.save!
Docuseal.refresh_default_url_options!
end end
with_locale do @encrypted_config.save!
redirect_to settings_account_path, notice: I18n.t('account_information_has_been_updated')
end Docuseal.refresh_default_url_options!
redirect_to settings_account_path, notice: 'Account information has been updated'
rescue ActiveRecord::RecordInvalid rescue ActiveRecord::RecordInvalid
render :show, status: :unprocessable_content render :show, status: :unprocessable_entity
end
def destroy
authorize!(:manage, current_account)
true_user.skip_reconfirmation!
true_user.update!(locked_at: Time.current, email: true_user.email.sub('@', '+removed@'))
true_user.account.update!(archived_at: Time.current)
# rubocop:disable Layout/LineLength
render turbo_stream: turbo_stream.replace(
:account_delete_button,
html: helpers.tag.p(I18n.t('your_account_removal_request_will_be_processed_within_2_months_please_contact_us_if_you_want_to_keep_your_account'))
)
# rubocop:enable Layout/LineLength
end end
private private
def load_account
@account = current_account
end
def account_params def account_params
params.require(:account).permit(:name, :timezone, :locale) params.require(:account).permit(:name, :timezone, :locale)
end end
def app_url_params def app_url_params
return {} if params[:encrypted_config].blank?
params.require(:encrypted_config).permit(:value) params.require(:encrypted_config).permit(:value)
end end
end end
@@ -1,65 +0,0 @@
# frozen_string_literal: true
module Api
class ActiveStorageBlobsProxyController < ApiBaseController
include ActiveStorage::Streaming
skip_before_action :authenticate_user!
skip_authorization_check
before_action :set_cors_headers
before_action :set_noindex_headers
def show
blob_uuid, purp, exp = ApplicationRecord.signed_id_verifier.verified(params[:signed_uuid])
if blob_uuid.blank? || purp != 'blob'
Rollbar.error('Blob not found') if defined?(Rollbar)
return head :not_found
end
blob = ActiveStorage::Blob.find_by!(uuid: blob_uuid)
attachment = blob.attachments.take
@record = attachment.record
@record = @record.record if @record.is_a?(ActiveStorage::Attachment)
authorization_check!(attachment, @record, exp)
if request.headers['Range'].present?
send_blob_byte_range_data blob, request.headers['Range']
else
http_cache_forever public: true do
response.headers['Accept-Ranges'] = 'bytes'
response.headers['Content-Length'] = blob.byte_size.to_s
send_blob_stream blob, disposition: params[:disposition]
end
end
end
private
def authorization_check!(attachment, record, exp)
return if attachment.name == 'logo'
return if exp.to_i >= Time.current.to_i
return if current_user && current_ability.can?(:read, record)
if exp.blank?
configs = record.account.account_configs.where(key: [AccountConfig::DOWNLOAD_LINKS_AUTH_KEY,
AccountConfig::DOWNLOAD_LINKS_EXPIRE_KEY])
require_auth = configs.any? { |c| c.key == AccountConfig::DOWNLOAD_LINKS_AUTH_KEY && c.value }
require_ttl = configs.none? { |c| c.key == AccountConfig::DOWNLOAD_LINKS_EXPIRE_KEY && c.value == false }
return if !require_ttl && !require_auth
end
Rollbar.error('Blob unauthorized') if defined?(Rollbar)
raise CanCan::AccessDenied
end
end
end
@@ -1,46 +0,0 @@
# frozen_string_literal: true
module Api
class ActiveStorageBlobsProxyLegacyController < ApiBaseController
include ActiveStorage::Streaming
skip_before_action :authenticate_user!
skip_authorization_check
before_action :set_cors_headers
before_action :set_noindex_headers
# rubocop:disable Metrics
def show
Rollbar.info('Blob legacy') if defined?(Rollbar)
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) ||
a.record.account.account_configs.any? { |e| e.key == 'legacy_blob_proxy' } ||
a.name == 'logo'
end
unless is_permitted
Rollbar.error("Blob account not found: #{blob.id}") if defined?(Rollbar)
return render json: { error: 'Not authenticated' }, status: :unauthorized
end
if request.headers['Range'].present?
send_blob_byte_range_data blob, request.headers['Range']
else
http_cache_forever public: true do
response.headers['Accept-Ranges'] = 'bytes'
response.headers['Content-Length'] = blob.byte_size.to_s
send_blob_stream blob, disposition: params[:disposition]
end
end
end
# rubocop:enable Metrics
end
end
@@ -3,111 +3,13 @@
module Api module Api
class ApiBaseController < ActionController::API class ApiBaseController < ActionController::API
include ActiveStorage::SetCurrent include ActiveStorage::SetCurrent
include Pagy::Method
DEFAULT_LIMIT = 10
MAX_LIMIT = 100
impersonates :user, with: ->(uuid) { User.find_by(uuid:) }
wrap_parameters false
before_action :authenticate_user! before_action :authenticate_user!
check_authorization
rescue_from Params::BaseValidator::InvalidParameterError do |e|
render json: { error: e.message }, status: :unprocessable_content
end
rescue_from RateLimit::LimitApproached do |e|
Rollbar.error(e) if defined?(Rollbar)
render json: { error: 'Too many requests' }, status: :too_many_requests
end
unless Rails.env.development?
rescue_from CanCan::AccessDenied do |e|
render json: { error: access_denied_error_message(e) }, status: :forbidden
end
rescue_from JSON::ParserError do |e|
Rollbar.warning(e) if defined?(Rollbar)
render json: { error: "JSON parse error: #{e.message}" }, status: :unprocessable_content
end
end
private private
def access_denied_error_message(error)
return 'Not authorized' if request.headers['X-Auth-Token'].blank?
return 'Not authorized' unless error.subject.is_a?(ActiveRecord::Base)
return 'Not authorized' unless error.subject.respond_to?(:account_id)
linked_account_record_exists =
if current_user.account.testing?
current_user.account.linked_account_accounts.where(account_type: 'testing')
.exists?(account_id: error.subject.account_id)
else
current_user.account.testing_accounts.exists?(id: error.subject.account_id)
end
return 'Not authorized' unless linked_account_record_exists
object_name = error.subject.model_name.human
id = error.subject.id
if current_user.account.testing?
"#{object_name} #{id} not found using testing API key; Use production API key to " \
"access production #{object_name.downcase.pluralize}."
else
"#{object_name} #{id} not found using production API key; Use testing API key to " \
"access testing #{object_name.downcase.pluralize}."
end
end
def paginate(relation, field: :id)
result = relation.order(field => :desc)
.limit([params.fetch(:limit, DEFAULT_LIMIT).to_i, MAX_LIMIT].min)
if field == :id
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?
else
result = result.where(field => ...params[:after]) if params[:after].present?
result = result.where(field => (params[:before] + 1)...) if params[:before].present?
end
result
end
def authenticate_user!
render json: { error: 'Not authenticated' }, status: :unauthorized unless current_user
end
def current_user
super || @current_user ||=
if request.headers['X-Auth-Token'].present?
sha256 = Digest::SHA256.hexdigest(request.headers['X-Auth-Token'])
User.joins(:access_token).active.find_by(access_token: { sha256: })
end
end
def current_account def current_account
current_user&.account current_user&.account
end end
def set_noindex_headers
headers['X-Robots-Tag'] = 'noindex'
end
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
end end
end end
+15 -45
View File
@@ -1,58 +1,28 @@
# frozen_string_literal: true # frozen_string_literal: true
module Api module Api
class AttachmentsController < ActionController::API class AttachmentsController < ApiBaseController
include ActionController::Cookies skip_before_action :authenticate_user!
include ActiveStorage::SetCurrent
COOKIE_STORE_LIMIT = 10
def create def create
submitter = Submitter.find_by!(slug: params[:submitter_slug]) submitter = Submitter.find_by!(slug: params[:submitter_slug])
if params[:type].in?(%w[initials signature]) blob =
image = Vips::Image.new_from_file(params[:file].path) if (file = params[:file])
ActiveStorage::Blob.create_and_upload!(io: file.open,
if ImageUtils.blank?(image) filename: file.original_filename,
Rollbar.error("Empty signature: #{submitter.id}") if defined?(Rollbar) content_type: file.content_type)
else
return render json: { error: "#{params[:type]} is empty" }, status: :unprocessable_content ActiveStorage::Blob.find_signed(params[:blob_signed_id])
end end
if ImageUtils.error?(image) attachment = ActiveStorage::Attachment.create!(
Rollbar.error("Error signature: #{submitter.id}") if defined?(Rollbar) blob:,
name: params[:name],
record: submitter
)
return render json: { error: "#{params[:type]} error, try to sign on another device" }, render json: attachment.as_json(only: %i[uuid], methods: %i[url filename content_type])
status: :unprocessable_content
end
end
attachment = Submitters.create_attachment!(submitter, params)
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
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
end end
end end
@@ -1,39 +0,0 @@
# frozen_string_literal: true
module Api
class FormEventsController < ApiBaseController
load_and_authorize_resource :submitter, parent: false
def index
submitters = @submitters.where.not(completed_at: nil)
params[:after] = Time.zone.at(params[:after].to_i) if params[:after].present?
params[:before] = Time.zone.at(params[:before].to_i) if params[:before].present?
submitters = paginate(
submitters.preload(template: { folder: :parent_folder },
submission: [:submitters, { audit_trail_attachment: :blob,
combined_document_attachment: :blob }],
documents_attachments: :blob, attachments_attachments: :blob),
field: :completed_at
)
expires_at = Accounts.link_expires_at(current_account)
render json: {
data: submitters.map do |s|
{
event_type: 'form.completed',
timestamp: s.completed_at,
data: Submitters::SerializeForWebhook.call(s, expires_at:)
}
end,
pagination: {
count: submitters.size,
next: submitters.last&.completed_at&.to_i,
prev: submitters.first&.completed_at&.to_i
}
}
end
end
end
@@ -1,75 +0,0 @@
# frozen_string_literal: true
module Api
class SubmissionDocumentsController < ApiBaseController
load_and_authorize_resource :submission
def index
is_merge = params[:merge] == 'true' &&
(@submission.schema_documents || @submission.template.schema_documents).size > 1
documents =
if @submission.submitters.all?(&:completed_at?)
build_completed_documents(@submission, merge: is_merge)
else
build_preview_documents(@submission, merge: is_merge)
end
ActiveRecord::Associations::Preloader.new(records: documents, associations: [:blob]).call
expires_at = Accounts.link_expires_at(current_account)
render json: {
id: @submission.id,
documents: documents.map do |attachment|
{ name: attachment.filename.base, url: ActiveStorage::Blob.proxy_url(attachment.blob, expires_at:) }
end
}
end
private
def build_completed_documents(submission, merge: false)
last_submitter = submission.submitters.max_by(&:completed_at)
if merge
if submission.merged_document_attachment.blank?
submission.merged_document_attachment =
Submissions::GenerateCombinedAttachment.call(last_submitter, with_audit: false)
end
[submission.merged_document_attachment]
else
if last_submitter.documents_attachments.blank?
last_submitter.documents_attachments = Submissions::EnsureResultGenerated.call(last_submitter)
end
last_submitter.documents_attachments
end
end
def build_preview_documents(submission, merge: false)
values_hash = Submissions::GeneratePreviewAttachments.build_values_hash(submission)
if merge
if submission.preview_merged_document_attachment.present? &&
submission.preview_merged_document_attachment.metadata['values_hash'] == values_hash
[submission.preview_merged_document_attachment]
else
ApplicationRecord.no_touching { submission.preview_merged_document_attachment&.destroy }
Submissions::GeneratePreviewAttachments.call(submission, values_hash:, merge: true)
end
elsif submission.preview_documents.present? &&
submission.preview_documents.all? { |s| s.metadata['values_hash'] == values_hash }
submission.preview_documents
else
ApplicationRecord.no_touching do
submission.preview_documents.each(&:destroy)
end
Submissions::GeneratePreviewAttachments.call(submission, values_hash:)
end
end
end
end
@@ -1,55 +0,0 @@
# frozen_string_literal: true
module Api
class SubmissionEventsController < ApiBaseController
load_and_authorize_resource :submission, parent: false
def index
submissions = build_completed_query(@submissions)
params[:after] = Time.zone.at(params[:after].to_i) if params[:after].present?
params[:before] = Time.zone.at(params[:before].to_i) if params[:before].present?
submissions = paginate(submissions.preload(
:created_by_user, :submission_events,
template: :folder,
submitters: { documents_attachments: :blob, attachments_attachments: :blob },
audit_trail_attachment: :blob,
combined_document_attachment: :blob
),
field: :completed_at)
expires_at = Accounts.link_expires_at(current_account)
render json: {
data: submissions.map do |s|
{
event_type: 'submission.completed',
timestamp: s.completed_at,
data: Submissions::SerializeForApi.call(s, s.submitters, expires_at:)
}
end,
pagination: {
count: submissions.size,
next: submissions.last&.completed_at&.to_i,
prev: submissions.first&.completed_at&.to_i
}
}
end
private
def build_completed_query(submissions)
submissions = submissions.where(
Submitter.where(completed_at: nil).where(
Submitter.arel_table[:submission_id].eq(Submission.arel_table[:id])
).select(1).arel.exists.not
)
submissions.joins(:submitters)
.group(:id)
.select(Submission.arel_table[Arel.star],
Submitter.arel_table[:completed_at].maximum.as('completed_at'))
end
end
end
@@ -1,213 +0,0 @@
# frozen_string_literal: true
module Api
class SubmissionsController < ApiBaseController
load_and_authorize_resource :template, only: :create
load_and_authorize_resource :submission, only: %i[show index destroy]
before_action only: :create do
authorize!(:create, Submission)
end
def index
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))
expires_at = Accounts.link_expires_at(current_account)
render json: {
data: submissions.map do |s|
Submissions::SerializeForApi.call(s, s.submitters, params,
with_events: false, with_documents: false, with_values: false, expires_at:)
end,
pagination: {
count: submissions.size,
next: submissions.last&.id,
prev: submissions.first&.id
}
}
end
def show
submitters = @submission.submitters.preload(documents_attachments: :blob, attachments_attachments: :blob)
submitters.each do |submitter|
if submitter.completed_at? && submitter.documents_attachments.blank?
submitter.documents_attachments = Submissions::EnsureResultGenerated.call(submitter)
end
end
if @submission.audit_trail_attachment.blank? && submitters.all?(&:completed_at?)
@submission.audit_trail_attachment = Submissions::EnsureAuditGenerated.call(@submission)
end
render json: Submissions::SerializeForApi.call(@submission, submitters, params)
end
def create
Params::SubmissionCreateValidator.call(params)
return render json: { error: 'Template not found' }, status: :unprocessable_content if @template.nil?
if @template.fields.blank?
Rollbar.warning("Template does not contain fields: #{@template.id}") if defined?(Rollbar)
return render json: { error: 'Template does not contain fields' }, status: :unprocessable_content
end
params[:send_email] = true unless params.key?(:send_email)
params[:send_sms] = false unless params.key?(:send_sms)
submissions = create_submissions(@template, params)
WebhookUrls.enqueue_events(submissions, 'submission.created')
Submissions.send_signature_requests(submissions)
submissions.each do |submission|
submission.submitters.each do |submitter|
next unless submitter.completed_at?
ProcessSubmitterCompletionJob.perform_async('submitter_id' => submitter.id, 'send_invitation_email' => false)
end
end
SearchEntries.enqueue_reindex(submissions)
render json: build_create_json(submissions)
rescue Submitters::NormalizeValues::BaseError, Submissions::CreateFromSubmitters::BaseError,
DownloadUtils::UnableToDownload => e
Rollbar.warning(e) if defined?(Rollbar)
render json: { error: e.message }, status: :unprocessable_content
end
def destroy
if params[:permanently].in?(['true', true])
@submission.destroy!
else
@submission.update!(archived_at: Time.current)
WebhookUrls.enqueue_events(@submission, 'submission.archived')
end
render json: @submission.as_json(only: %i[id archived_at])
end
private
def filter_submissions(submissions, params)
submissions = submissions.where(template_id: params[:template_id]) if params[:template_id].present?
submissions = submissions.where(slug: params[:slug]) if params[:slug].present?
if params[:template_folder].present?
folders =
TemplateFolders.filter_by_full_name(TemplateFolder.accessible_by(current_ability), params[:template_folder])
submissions = submissions.joins(:template).where(template: { folder_id: folders.pluck(:id) })
end
if params.key?(:archived)
submissions = params[:archived].in?(['true', true]) ? submissions.archived : submissions.active
end
Submissions::Filter.call(submissions, current_user, params)
end
def build_create_json(submissions)
json = submissions.flat_map do |submission|
submission.submitters.map do |s|
Submitters::SerializeForApi.call(s, with_documents: false, with_urls: true, params:)
end
end
if request.path.ends_with?('/init')
json =
if submissions.size == 1
{
id: submissions.first.id,
submitters: json,
expire_at: submissions.first.expire_at,
created_at: submissions.first.created_at
}
else
{ submitters: json }
end
end
json
end
def create_submissions(template, params)
is_send_email = !params[:send_email].in?(['false', false])
if (emails = (params[:emails] || params[:email]).presence) &&
params[:submission].blank? && params[:submitters].blank?
Submissions.create_from_emails(template:,
user: current_user,
source: :api,
mark_as_sent: is_send_email,
emails:,
params:)
else
submissions_attrs, attachments =
Submissions::NormalizeParamUtils.normalize_submissions_params!(submissions_params, template)
submissions = Submissions.create_from_submitters(
template:,
user: current_user,
source: :api,
submitters_order: params[:submitters_order] || params[:order] || 'preserved',
submissions_attrs:,
params:
)
submitters = submissions.flat_map(&:submitters)
Submissions::NormalizeParamUtils.save_default_value_attachments!(attachments, submitters)
submitters.each do |submitter|
if submitter.completed_at?
Submitters::SubmitValues.maybe_invite_via_field(submitter, request)
SubmissionEvents.create_with_tracking_data(submitter, 'api_complete_form', request)
end
end
submissions
end
end
def submissions_params
permitted_attrs = [
:send_email, :send_sms, :bcc_completed, :completed_redirect_url, :reply_to, :go_to_last,
:require_phone_2fa, :require_email_2fa, :expire_at, :name,
{
variables: {},
message: %i[subject body],
submitters: [[:send_email, :send_sms, :completed_redirect_url, :uuid, :name, :email, :role,
:completed, :phone, :application_key, :external_id, :reply_to, :go_to_last,
:require_phone_2fa, :require_email_2fa, :order, :index, :invite_by,
{ metadata: {}, values: {}, roles: [], readonly_fields: [], message: %i[subject body],
fields: [:name, :uuid, :default_value, :value, :title, :description,
:readonly, :required, :validation_pattern, :invalid_message,
{ default_value: [], value: [], preferences: {}, validation: {} }] }]]
}
]
if params.key?(:submitters)
params.permit(*permitted_attrs)
else
key = params.key?(:submission) ? :submission : :submissions
params.permit(
{ key => [permitted_attrs] }, { key => permitted_attrs }
).fetch(key, [])
end
end
end
end
@@ -1,18 +0,0 @@
# frozen_string_literal: true
module Api
class SubmitterEmailClicksController < ApiBaseController
skip_before_action :authenticate_user!
skip_authorization_check
def create
@submitter = Submitter.find_by!(slug: params[:submitter_slug])
if params[:t] == SubmissionEvents.build_tracking_param(@submitter, 'click_email')
SubmissionEvents.create_with_tracking_data(@submitter, 'click_email', request)
end
render json: {}
end
end
end
@@ -1,21 +0,0 @@
# frozen_string_literal: true
module Api
class SubmitterFormViewsController < ApiBaseController
skip_before_action :authenticate_user!
skip_authorization_check
def create
@submitter = Submitter.find_by!(slug: params[: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
end
@@ -1,222 +0,0 @@
# frozen_string_literal: true
module Api
class SubmittersController < ApiBaseController
load_and_authorize_resource :submitter
def index
submitters = Submitters.search(current_user, @submitters, params[:q])
submitters = filter_submitters(submitters, params)
submitters = paginate(
submitters.preload(:template, :submission, :submission_events,
documents_attachments: :blob, attachments_attachments: :blob)
)
expires_at = Accounts.link_expires_at(current_account)
render json: {
data: submitters.map do |s|
Submitters::SerializeForApi.call(s, with_template: true, with_events: true, params:, expires_at:)
end,
pagination: {
count: submitters.size,
next: submitters.last&.id,
prev: submitters.first&.id
}
}
end
def show
Submissions::EnsureResultGenerated.call(@submitter) if @submitter.completed_at?
render json: Submitters::SerializeForApi.call(@submitter, with_template: true, with_events: true, params:)
end
# rubocop:disable Metrics/MethodLength
def update
if @submitter.completed_at?
return render json: { error: 'Submitter has already completed the submission.' }, status: :unprocessable_content
end
submission = @submitter.submission
role = submission.template_submitters.find { |e| e['uuid'] == @submitter.uuid }['name']
normalized_params, new_attachments = Submissions::NormalizeParamUtils.normalize_submitter_params!(
submitter_params.merge(role:),
@submitter.template || Template.new(submitters: submission.template_submitters, account: @submitter.account),
for_submitter: @submitter
)
Submissions::CreateFromSubmitters.maybe_set_template_fields(submission, [normalized_params],
default_submitter_uuid: @submitter.uuid)
assign_submitter_attrs(@submitter, normalized_params)
ApplicationRecord.transaction do
Submissions::NormalizeParamUtils.save_default_value_attachments!(new_attachments, [@submitter])
@submitter.save!
@submitter.submission.save!
if @submitter.completed_at?
Submitters::SubmitValues.maybe_invite_via_field(@submitter, request)
SubmissionEvents.create_with_tracking_data(@submitter, 'api_complete_form', request)
end
end
if @submitter.completed_at?
ProcessSubmitterCompletionJob.perform_async('submitter_id' => @submitter.id)
elsif normalized_params[:send_email] || normalized_params[:send_sms]
Submitters.send_signature_requests([@submitter])
end
SearchEntries.enqueue_reindex(@submitter)
render json: Submitters::SerializeForApi.call(@submitter, with_template: false, with_urls: true,
with_events: false, params:)
rescue Submitters::NormalizeValues::BaseError, DownloadUtils::UnableToDownload => e
Rollbar.warning(e) if defined?(Rollbar)
render json: { error: e.message }, status: :unprocessable_content
end
# rubocop:enable Metrics/MethodLength
def submitter_params
submitter_params = params.key?(:submitter) ? params.require(:submitter) : params
submitter_params.permit(
:send_email, :send_sms, :reply_to, :completed_redirect_url, :uuid, :name, :email, :role,
:completed, :phone, :application_key, :external_id, :go_to_last, :require_phone_2fa, :require_email_2fa,
{ metadata: {}, values: {}, readonly_fields: [], message: %i[subject body],
fields: [[:name, :uuid, :default_value, :value, :required,
:readonly, :validation_pattern, :invalid_message,
{ default_value: [], value: [], preferences: {} }]] }
)
end
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)
values = attrs[:values]&.to_unsafe_h || {}
assign_submission_fields(submitter.submission)
phone_field_uuid = submitter.submission.template_fields.find do |f|
values[f['uuid']].present? && f['type'] == 'phone'
end&.dig('uuid')
submitter.email = Submissions.normalize_email(attrs[:email]) if attrs.key?(:email)
submitter.name = attrs[:name] if attrs.key?(:name)
if attrs.key?(:phone)
submitter.phone = attrs[:phone].to_s.gsub(/[^0-9+]/, '')
elsif values[phone_field_uuid].present?
submitter.phone = values[phone_field_uuid].to_s.gsub(/[^0-9+]/, '')
end
values = values.except(phone_field_uuid)
submitter.values = submitter.values.merge(values) if values.present?
submitter.metadata = attrs[:metadata] if attrs.key?(:metadata)
maybe_assign_completed_attributes(submitter, attrs)
assign_external_id(submitter, attrs)
assign_preferences(submitter, attrs)
submitter
end
def maybe_assign_completed_attributes(submitter, attrs)
submitter.completed_at = attrs[:completed] ? Time.current : submitter.completed_at
if attrs[:completed]
submitter.values = Submitters::SubmitValues.merge_default_values(submitter)
submitter.values = Submitters::SubmitValues.maybe_remove_condition_values(submitter)
formula_values = Submitters::SubmitValues.build_formula_values(submitter)
if formula_values.present?
submitter.values = submitter.values.merge(formula_values)
submitter.values = Submitters::SubmitValues.maybe_remove_condition_values(submitter)
end
submitter.values = submitter.values.transform_values do |v|
v == '{{date}}' ? Time.current.in_time_zone(submitter.account.timezone).to_date.to_s : v
end
end
submitter
end
def filter_submitters(submitters, params)
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(slug: params[:slug]) if params[:slug].present?
submitters = submitters.where(submission_id: params[:submission_id]) if params[:submission_id].present?
if params[:template_id].present?
submitters = submitters.joins(:submission).where(submissions: { template_id: params[:template_id] })
end
maybe_filder_by_completed_at(submitters, params)
end
def assign_external_id(submitter, attrs)
submitter.external_id = attrs[:application_key] if attrs.key?(:application_key)
submitter.external_id = attrs[:external_id] if attrs.key?(:external_id)
submitter
end
def assign_submission_fields(submission)
submission.template_fields ||= submission.template.fields
submission.template_schema ||= submission.template.schema
end
def assign_preferences(submitter, attrs)
submitter_preferences = Submitters.normalize_preferences(submitter.account, current_user, attrs)
submitter.preferences['default_values'] = attrs[:values].to_unsafe_h if attrs[:values].present?
if submitter_preferences.key?('send_email')
submitter.preferences['send_email'] = submitter_preferences['send_email']
end
submitter.preferences['send_sms'] = submitter_preferences['send_sms'] if submitter_preferences.key?('send_sms')
submitter.preferences['reply_to'] = submitter_preferences['reply_to'] if submitter_preferences.key?('reply_to')
if submitter_preferences.key?('require_phone_2fa')
submitter.preferences['require_phone_2fa'] = submitter_preferences['require_phone_2fa']
end
if submitter_preferences.key?('go_to_last')
submitter.preferences['go_to_last'] = submitter_preferences['go_to_last']
end
if submitter_preferences.key?('completed_redirect_url')
submitter.preferences['completed_redirect_url'] = submitter_preferences['completed_redirect_url']
end
return unless submitter_preferences.key?('email_message_uuid')
submitter.preferences['email_message_uuid'] = submitter_preferences['email_message_uuid']
submitter
end
end
end
@@ -1,40 +0,0 @@
# frozen_string_literal: true
module Api
class TemplatesCloneController < ApiBaseController
load_and_authorize_resource :template
def create
authorize!(:create, @template)
ActiveRecord::Associations::Preloader.new(
records: [@template],
associations: [schema_documents: :preview_images_attachments]
).call
cloned_template = Templates::Clone.call(
@template,
author: current_user,
name: params[:name],
external_id: params[:external_id].presence || params[:application_key],
folder_name: params[:folder_name]
)
cloned_template.source = :api
schema_documents = Templates::CloneAttachments.call(template: cloned_template,
original_template: @template,
documents: params[:documents])
Templates.maybe_assign_access(cloned_template)
cloned_template.save!
WebhookUrls.enqueue_events(cloned_template, 'template.created')
SearchEntries.enqueue_reindex(cloned_template)
render json: Templates::SerializeForApi.call(cloned_template, schema_documents:)
end
end
end
+7 -115
View File
@@ -2,130 +2,22 @@
module Api module Api
class TemplatesController < ApiBaseController class TemplatesController < ApiBaseController
load_and_authorize_resource :template
def index
templates = filter_templates(@templates, params)
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)
expires_at = Accounts.link_expires_at(current_account)
render json: {
data: templates.map do |t|
Templates::SerializeForApi.call(t,
schema_documents: schema_documents.select { |e| e.record_id == t.id },
preview_image_attachments:,
expires_at:)
end,
pagination: {
count: templates.size,
next: templates.last&.id,
prev: templates.first&.id
}
}
end
def show
render json: Templates::SerializeForApi.call(@template)
end
def update def update
if (folder_name = params[:folder_name] || params.dig(:template, :folder_name)) @template = current_account.templates.find(params[:id])
@template.folder = TemplateFolders.find_or_create_by_name(current_user, folder_name)
end
Array.wrap(params[:roles].presence || params.dig(:template, :roles).presence).each_with_index do |role, index|
if (item = @template.submitters[index])
item['name'] = role
else
@template.submitters << { 'name' => role, 'uuid' => SecureRandom.uuid }
end
end
archived = params.key?(:archived) ? params[:archived] : params.dig(:template, :archived)
if archived.in?([true, false])
@template.archived_at = archived == true ? Time.current : nil
end
@template.update!(template_params) @template.update!(template_params)
SearchEntries.enqueue_reindex(@template) render :ok
WebhookUrls.enqueue_events(@template, 'template.updated')
render json: @template.as_json(only: %i[id updated_at])
end
def destroy
if params[:permanently].in?(['true', true])
@template.destroy!
else
@template.update!(archived_at: Time.current)
end
render json: @template.as_json(only: %i[id archived_at])
end end
private private
def filter_templates(templates, params)
templates = Templates.search(current_user, templates, params[:q])
templates = params[:archived].in?(['true', true]) ? templates.archived : templates.active
templates = templates.where(external_id: params[:application_key]) if params[:application_key].present?
templates = templates.where(external_id: params[:external_id]) if params[:external_id].present?
templates = templates.where(slug: params[:slug]) if params[:slug].present?
if params[:folder].present?
folders = TemplateFolders.filter_by_full_name(TemplateFolder.accessible_by(current_ability), params[:folder])
templates = templates.where(folder_id: folders.pluck(:id))
end
templates
end
def template_params def template_params
permitted_params = [ params.require(:template).permit(:name,
:name, schema: [%i[attachment_uuid name]],
:external_id, submitters: [%i[name uuid]],
:shared_link, fields: [[:uuid, :submitter_uuid, :name, :type, :required,
{ { options: [], areas: [%i[x y w h cell_w attachment_uuid page]] }]])
submitters: [%i[name uuid is_requester invite_by_uuid invite_via_field_uuid
optional_invite_by_uuid linked_to_uuid email order]],
fields: [[:uuid, :submitter_uuid, :name, :type,
:required, :readonly, :default_value,
:title, :description, :prefillable,
{ preferences: {},
default_value: [],
conditions: [%i[field_uuid value action operation]],
options: [%i[value uuid]],
validation: %i[message pattern min max step],
areas: [%i[uuid x y w h cell_w attachment_uuid option_uuid page]] }]]
}
]
if params.key?(:template)
params.require(:template).permit(permitted_params)
else
params.permit(permitted_params)
end
end end
end end
end end
@@ -0,0 +1,43 @@
# frozen_string_literal: true
module Api
class TemplatesDocumentsController < ApiBaseController
def create
@template = current_account.templates.find(params[:template_id])
documents =
find_or_create_blobs.map do |blob|
document = @template.documents.create!(blob:)
Templates::ProcessDocument.call(document)
end
schema = documents.map do |doc|
{ attachment_uuid: doc.uuid, name: doc.filename.base }
end
render json: {
schema:,
documents: documents.as_json(
include: {
preview_images: { methods: %i[url metadata filename] }
}
)
}
end
private
def find_or_create_blobs
blobs = params[:blobs]&.map do |attrs|
ActiveStorage::Blob.find_signed(attrs[:signed_id])
end
blobs || params[:files].map do |file|
ActiveStorage::Blob.create_and_upload!(io: file.open,
filename: file.original_filename,
content_type: file.content_type)
end
end
end
end
-41
View File
@@ -1,41 +0,0 @@
# frozen_string_literal: true
module Api
class ToolsController < ApiBaseController
skip_authorization_check
def merge
files = params[:files] || []
return render json: { error: 'Files are required' }, status: :unprocessable_content if files.blank?
return render json: { error: 'At least 2 files are required' }, status: :unprocessable_content if files.size < 2
render json: {
data: Base64.encode64(PdfUtils.merge(files.map { |base64| StringIO.new(Base64.decode64(base64)) }).string)
}
end
def verify
file = Base64.decode64(params[:file])
pdf = HexaPDF::Document.new(io: StringIO.new(file))
trusted_certs = Accounts.load_trusted_certs(current_account)
is_checksum_found = CompletedDocument.exists?(sha256: Base64.urlsafe_encode64(Digest::SHA256.digest(file)))
render json: {
checksum_status: is_checksum_found ? 'verified' : 'not_found',
signatures: pdf.signatures.map do |sig|
{
verification_result: sig.verify(trusted_certs:).messages,
signer_name: sig.signer_name,
signing_reason: sig.signing_reason,
signing_time: sig.signing_time,
signature_type: sig.signature_type
}
end
}
rescue HexaPDF::MalformedPDFError
render json: { error: 'Malformed PDF' }, status: :unprocessable_content
end
end
end
-11
View File
@@ -1,11 +0,0 @@
# frozen_string_literal: true
module Api
class UsersController < ApiBaseController
authorize_resource :current_user
def show
render json: current_user.as_json(only: %i[id first_name last_name email])
end
end
end
@@ -1,17 +0,0 @@
# frozen_string_literal: true
class ApiSettingsController < ApplicationController
def index
authorize!(:read, current_user.access_token)
end
def create
authorize!(:manage, current_user.access_token)
current_user.access_token.token = SecureRandom.base58(AccessToken::TOKEN_LENGTH)
current_user.access_token.save!
redirect_back(fallback_location: settings_api_index_path, notice: I18n.t('api_token_has_been_updated'))
end
end
+5 -107
View File
@@ -1,146 +1,44 @@
# frozen_string_literal: true # frozen_string_literal: true
class ApplicationController < ActionController::Base class ApplicationController < ActionController::Base
BROWSER_LOCALE_REGEXP = /\A\w{2}(?:-\w{2})?/
include ActiveStorage::SetCurrent include ActiveStorage::SetCurrent
include Pagy::Method include Pagy::Backend
check_authorization unless: :devise_controller?
around_action :with_locale
before_action :sign_in_for_demo, if: -> { Docuseal.demo? } before_action :sign_in_for_demo, if: -> { Docuseal.demo? }
before_action :maybe_redirect_to_setup, unless: :signed_in? before_action :maybe_redirect_to_setup, unless: :signed_in?
before_action :authenticate_user!, unless: :devise_controller? before_action :authenticate_user!, unless: :devise_controller?
before_action :set_csp, if: -> { request.get? && !request.headers['HTTP_X_TURBO'] }
helper_method :button_title, helper_method :button_title,
:current_account, :current_account,
:true_ability,
:form_link_host,
:svg_icon :svg_icon
impersonates :user, with: ->(uuid) { User.find_by(uuid:) } rescue_from Pagy::OverflowError do
rescue_from Pagy::RangeError do
redirect_to request.path redirect_to request.path
end 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? || Rails.env.test?
rescue_from CanCan::AccessDenied do |e|
Rollbar.warning(e) if defined?(Rollbar)
redirect_to root_path, alert: e.message
end
end
def default_url_options def default_url_options
Docuseal.default_url_options Docuseal.default_url_options
end end
def impersonate_user(user)
raise ArgumentError unless user
raise Pretender::Error unless true_user
@impersonated_user = user
request.session[:impersonated_user_id] = user.uuid
end
def pagy_auto(collection, **keyword_args)
if current_ability.can?(:manage, :countless)
pagy(:countless, collection, **keyword_args)
else
pagy(collection, **keyword_args)
end
end
private private
def with_locale(&)
return yield unless current_account
locale = params[:lang].presence if Rails.env.development?
locale ||= current_account.locale
I18n.with_locale(locale, &)
end
def with_browser_locale(&)
return yield if I18n.locale != :'en-US' && I18n.locale != :en
locale = params[:lang].presence
locale ||= request.env['HTTP_ACCEPT_LANGUAGE'].to_s[BROWSER_LOCALE_REGEXP].to_s
locale =
if locale.starts_with?('en-') && locale != 'en-US'
'en-GB'
else
locale.split('-').first.presence || 'en-GB'
end
locale = 'en-GB' unless I18n.locale_available?(locale)
I18n.with_locale(locale, &)
end
def sign_in_for_demo def sign_in_for_demo
sign_in(User.active.order('random()').take) unless signed_in? sign_in(User.first) unless signed_in?
end end
def current_account def current_account
current_user&.account current_user&.account
end end
def true_ability
@true_ability ||= Ability.new(true_user)
end
def maybe_redirect_to_setup def maybe_redirect_to_setup
redirect_to setup_index_path unless User.exists? redirect_to setup_index_path unless User.exists?
end end
def button_title(title: I18n.t('submit'), disabled_with: I18n.t('submitting'), title_class: '', icon: nil, def button_title(title: 'Submit', disabled_with: 'Submitting', icon: nil)
icon_disabled: nil) render_to_string(partial: 'shared/button_title', locals: { title:, disabled_with:, icon: })
render_to_string(partial: 'shared/button_title',
locals: { title:, disabled_with:, title_class:, icon:, icon_disabled: })
end end
def svg_icon(icon_name, class: '') def svg_icon(icon_name, class: '')
render_to_string(partial: "icons/#{icon_name}", locals: { class: }) render_to_string(partial: "icons/#{icon_name}", locals: { class: })
end end
def form_link_host
Docuseal.default_url_options[:host]
end
def maybe_redirect_com
return if request.domain != 'docuseal.co'
redirect_to request.url.gsub('.co/', '.com/'), allow_other_host: true, status: :moved_permanently
end
def set_csp
request.content_security_policy = current_content_security_policy.tap do |policy|
policy.default_src :self
policy.script_src :self
policy.style_src :self, :unsafe_inline
policy.img_src :self, :https, :http, :blob, :data
policy.font_src :self, :https, :http, :blob, :data
policy.manifest_src :self
policy.media_src :self
policy.frame_src :self
policy.worker_src :self, :blob
policy.connect_src :self
policy.directives['connect-src'] << 'ws:' if Rails.env.development?
end
end
end end
@@ -1,26 +0,0 @@
# frozen_string_literal: true
class ConsoleRedirectController < ApplicationController
skip_before_action :authenticate_user!
skip_authorization_check
def index
if request.path == '/upgrade'
params[:redir] = Docuseal.multitenant? ? "#{Docuseal::CONSOLE_URL}/plans" : "#{Docuseal::CONSOLE_URL}/on_premises"
end
params[:redir] = "#{Docuseal::CONSOLE_URL}/manage" if request.path == '/manage'
return redirect_to(new_user_session_path({ redir: params[:redir] }.compact)) if true_user.blank?
auth = JsonWebToken.encode(uuid: true_user.uuid,
scope: :console,
exp: 1.minute.from_now.to_i)
redir_uri = Addressable::URI.parse(params[:redir])
path = redir_uri.path if params[:redir].to_s.starts_with?(Docuseal::CONSOLE_URL)
redirect_to "#{Docuseal::CONSOLE_URL}#{path}?#{{ **redir_uri&.query_values, 'auth' => auth }.to_query}",
allow_other_host: true
end
end
+3 -34
View File
@@ -3,42 +3,11 @@
class DashboardController < ApplicationController class DashboardController < ApplicationController
skip_before_action :authenticate_user!, only: %i[index] skip_before_action :authenticate_user!, only: %i[index]
before_action :maybe_redirect_product_url
before_action :maybe_render_landing
before_action :maybe_redirect_mfa_setup
skip_authorization_check
def index def index
if cookies.permanent[:dashboard_view] == 'submissions' return render 'pages/landing' unless signed_in?
SubmissionsDashboardController.dispatch(:index, request, response)
else
TemplatesDashboardController.dispatch(:index, request, response)
end
end
private templates = current_account.templates.active.preload(:author).order(id: :desc)
def maybe_redirect_product_url @pagy, @templates = pagy(templates, items: 12)
return if !Docuseal.multitenant? || signed_in?
redirect_to Docuseal::PRODUCT_URL, allow_other_host: true
end
def maybe_redirect_mfa_setup
return unless signed_in?
return if current_user.otp_required_for_login
return if !current_user.otp_required_for_login && !AccountConfig.exists?(value: true,
account_id: current_user.account_id,
key: AccountConfig::FORCE_MFA)
redirect_to mfa_setup_path, notice: I18n.t('setup_2fa_to_continue')
end
def maybe_render_landing
return if signed_in?
render 'pages/landing'
end end
end end
@@ -0,0 +1,28 @@
# frozen_string_literal: true
class EmailSettingsController < ApplicationController
before_action :load_encrypted_config
def index; end
def create
if @encrypted_config.update(storage_configs)
redirect_to settings_email_index_path, notice: 'Changes have been saved'
else
render :index, status: :unprocessable_entity
end
end
private
def load_encrypted_config
@encrypted_config =
EncryptedConfig.find_or_initialize_by(account: current_account, key: EncryptedConfig::EMAIL_SMTP_KEY)
end
def storage_configs
params.require(:encrypted_config).permit(value: {}).tap do |e|
e[:value].compact_blank!
end
end
end
@@ -1,38 +0,0 @@
# frozen_string_literal: true
class EmailSmtpSettingsController < ApplicationController
before_action :load_encrypted_config
authorize_resource :encrypted_config, only: :index
authorize_resource :encrypted_config, parent: false, only: :create
def index; end
def create
if @encrypted_config.update(email_configs)
unless Docuseal.multitenant?
SettingsMailer.smtp_successful_setup(@encrypted_config.value['from_email'] || current_user.email).deliver_now!
end
redirect_to settings_email_index_path, notice: I18n.t('changes_have_been_saved')
else
render :index, status: :unprocessable_content
end
rescue StandardError => e
flash[:alert] = e.message
render :index, status: :unprocessable_content
end
private
def load_encrypted_config
@encrypted_config =
EncryptedConfig.find_or_initialize_by(account: current_account, key: EncryptedConfig::EMAIL_SMTP_KEY)
end
def email_configs
params.require(:encrypted_config).permit(value: {}).tap do |e|
e[:value].compact_blank!
end
end
end
@@ -1,39 +0,0 @@
# frozen_string_literal: true
class EmbedScriptsController < ActionController::Metal
DUMMY_SCRIPT = <<~JAVASCRIPT.freeze
const DummyBuilder = class extends HTMLElement {
connectedCallback() {
this.innerHTML = `
<div style="text-align: center; padding: 20px; font-family: Arial, sans-serif;">
<h2>Upgrade to Pro</h2>
<p>Unlock embedded components by upgrading to Pro</p>
<div style="margin-top: 40px;">
<a href="#{Docuseal::CONSOLE_URL}/on_premises" target="_blank" style="padding: 15px 25px; background-color: #222; color: white; text-decoration: none; border-radius: 5px; font-size: 16px; cursor: pointer;">
Learn More
</a>
</div>
</div>
`;
}
};
const DummyForm = class extends DummyBuilder {};
if (!window.customElements.get('docuseal-builder')) {
window.customElements.define('docuseal-builder', DummyBuilder);
}
if (!window.customElements.get('docuseal-form')) {
window.customElements.define('docuseal-form', DummyForm);
}
JAVASCRIPT
def show
headers['Content-Type'] = 'application/javascript'
self.response_body = DUMMY_SCRIPT
self.status = 200
end
end
@@ -1,11 +0,0 @@
# frozen_string_literal: true
class EncryptedUserConfigsController < ApplicationController
load_and_authorize_resource :encrypted_user_config
def destroy
@encrypted_user_config.destroy!
redirect_back(fallback_location: root_path)
end
end
-22
View File
@@ -1,22 +0,0 @@
# frozen_string_literal: true
class EnquiriesController < ApplicationController
skip_before_action :authenticate_user!
skip_authorization_check
def create
if params[:talk_to_sales] == 'on'
Faraday.post(Docuseal::ENQUIRIES_URL,
enquiry_params.merge(type: :talk_to_sales).to_json,
'Content-Type' => 'application/json')
end
head :ok
end
private
def enquiry_params
params.require(:user).permit(:email)
end
end
-62
View File
@@ -1,62 +0,0 @@
# frozen_string_literal: true
class ErrorsController < ActionController::Base
ENTERPRISE_FEATURE_MESSAGE =
'This feature is available in Pro Edition: https://www.docuseal.com/pricing'
ENTERPRISE_PATHS = [
'/submissions/html',
'/api/submissions/html',
'/templates/html',
'/api/templates/html',
'/submissions/pdf',
'/api/submissions/pdf',
'/templates/pdf',
'/api/templates/pdf',
'/templates/doc',
'/api/templates/doc',
'/templates/docx',
'/api/templates/docx'
].freeze
SAFE_ERROR_MESSAGE_CLASSES = [
ActionDispatch::Http::Parameters::ParseError,
JSON::ParserError
].freeze
def show
if request.original_fullpath.in?(ENTERPRISE_PATHS) && error_status_code == 404
return render json: { status: 404, message: ENTERPRISE_FEATURE_MESSAGE }, status: :not_found
end
respond_to do |f|
f.json do
set_cors_headers
exception = request.env['action_dispatch.exception']
error = exception.message if exception.class.in?(SAFE_ERROR_MESSAGE_CLASSES)
render json: { status: error_status_code, error: }.compact, status: error_status_code
end
f.html { render error_status_code.to_s, status: error_status_code }
end
end
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,
request.env['action_dispatch.exception']).status_code
end
end
+10 -114
View File
@@ -1,123 +1,19 @@
# frozen_string_literal: true # frozen_string_literal: true
class EsignSettingsController < ApplicationController class EsignSettingsController < ApplicationController
DEFAULT_CERT_NAME = 'DocuSeal Self-Host Autogenerated'
CertFormRecord = Struct.new(:name, :file, :password, keyword_init: true) do
include ActiveModel::Validations
def to_key
[]
end
end
prepend_before_action :maybe_redirect_com, only: %i[show]
before_action :load_encrypted_config
authorize_resource :encrypted_config, parent: false, only: %i[new create]
authorize_resource :encrypted_config, only: %i[update destroy show]
def show
cert_data = @encrypted_config.value || {}
default_pkcs = GenerateCertificate.load_pkcs(cert_data) if cert_data['cert'].present?
custom_pkcs_list = (cert_data['custom'] || []).map do |e|
pkcs = e['data'].present? ? OpenSSL::PKCS12.new(Base64.urlsafe_decode64(e['data']), e['password'].to_s) : nil
{ 'pkcs' => pkcs, 'name' => e['name'], 'status' => e['status'] }
end
@pkcs_list = [
if default_pkcs
{
'pkcs' => default_pkcs,
'name' => DEFAULT_CERT_NAME,
'status' => custom_pkcs_list.any? { |e| e['status'] == 'default' } ? 'validate' : 'default'
}
end,
*custom_pkcs_list
].compact.reverse
end
def new
@cert_record = CertFormRecord.new
end
def create def create
@cert_record = CertFormRecord.new(**cert_params) pdfs =
params[:files].map do |file|
HexaPDF::Document.new(io: file.open)
end
if (@encrypted_config.value && @encrypted_config.value['custom']&.any? { |e| e['name'] == @cert_record.name }) || certs = Accounts.load_signing_certs(current_account)
@cert_record.name == DEFAULT_CERT_NAME
@cert_record.errors.add(:name, I18n.t('already_exists')) trusted_certs = [certs[:cert], certs[:sub_ca], certs[:root_ca]]
return render turbo_stream: turbo_stream.replace(:modal, template: 'esign_settings/new'), render turbo_stream: turbo_stream.replace('result', partial: 'result',
status: :unprocessable_content locals: { pdfs:, files: params[:files], trusted_certs: })
end rescue HexaPDF::MalformedPDFError
render turbo_stream: turbo_stream.replace('result', html: helpers.tag.div('Invalid PDF', id: 'result'))
save_new_cert!(@encrypted_config, @cert_record)
redirect_to settings_esign_path, notice: I18n.t('certificate_has_been_successfully_added')
rescue OpenSSL::PKCS12::PKCS12Error => e
Rollbar.error(e) if defined?(Rollbar)
@cert_record.errors.add(:password, e.message)
render turbo_stream: turbo_stream.replace(:modal, template: 'esign_settings/new'), status: :unprocessable_content
end
def update
@encrypted_config.value['custom'].to_a.each { |e| e['status'] = 'validate' }
custom_cert_data = @encrypted_config.value['custom'].to_a.find { |e| e['name'] == params[:name] }
if custom_cert_data
custom_cert_data['status'] = 'default'
elsif params[:name] == Docuseal::AATL_CERT_NAME
@encrypted_config.value['custom'] ||= []
@encrypted_config.value['custom'] << { 'name' => params[:name], 'status' => 'default' }
end
@encrypted_config.save!
redirect_to settings_esign_path, notice: I18n.t('default_certificate_has_been_selected')
end
def destroy
@encrypted_config.value['custom'].reject! { |e| e['name'] == params[:name] }
@encrypted_config.save!
redirect_to settings_esign_path, notice: I18n.t('certificate_has_been_removed')
end
private
def load_encrypted_config
@encrypted_config = EncryptedConfig.find_or_initialize_by(account: current_account,
key: EncryptedConfig::ESIGN_CERTS_KEY)
end
def save_new_cert!(cert_configs, cert_record)
pkcs = OpenSSL::PKCS12.new(cert_record.file.read, cert_record.password)
cert_configs.value ||= {}
cert_configs.value['custom'] ||= []
cert_configs.value['custom'].each { |e| e['status'] = 'validate' }
cert_configs.value['custom'] << {
data: Base64.urlsafe_encode64(pkcs.to_der),
password: cert_record.password,
name: cert_record.name,
status: 'default'
}
cert_configs.save!
end
def cert_params
return {} if params[:esign_settings_controller_cert_form_record].blank?
params.require(:esign_settings_controller_cert_form_record).permit(:name, :file, :password)
end end
end end
@@ -1,11 +1,4 @@
# frozen_string_literal: true # frozen_string_literal: true
class InvitationsController < Devise::PasswordsController class InvitationsController < Devise::PasswordsController
def update
super do |resource|
resource.confirmed_at ||= Time.current if resource.errors.empty?
PasswordsController::Current.user = resource
end
end
end end
-58
View File
@@ -1,58 +0,0 @@
# frozen_string_literal: true
class McpController < ActionController::API
before_action :authenticate_user!
before_action :verify_mcp_enabled!
before_action do
authorize!(:manage, :mcp)
end
def call
return head :ok if request.raw_post.blank?
body = JSON.parse(request.raw_post)
result = Mcp::HandleRequest.call(body, current_user, current_ability)
if result
render json: result
else
head :accepted
end
rescue CanCan::AccessDenied
render json: { jsonrpc: '2.0', id: nil, error: { code: -32_603, message: 'Forbidden' } }, status: :forbidden
rescue JSON::ParserError
render json: { jsonrpc: '2.0', id: nil, error: { code: -32_700, message: 'Parse error' } }, status: :bad_request
end
private
def authenticate_user!
render json: { error: 'Not authenticated' }, status: :unauthorized unless current_user
end
def verify_mcp_enabled!
return if Docuseal.multitenant?
return if AccountConfig.exists?(account_id: current_user.account_id,
key: AccountConfig::ENABLE_MCP_KEY,
value: true)
render json: { error: 'MCP is disabled' }, status: :forbidden
end
def current_user
@current_user ||= user_from_api_key
end
def user_from_api_key
token = request.headers['Authorization'].to_s[/\ABearer\s+(.+)\z/, 1]
return if token.blank?
sha256 = Digest::SHA256.hexdigest(token)
User.joins(:mcp_tokens).active.find_by(mcp_tokens: { sha256:, archived_at: nil })
end
end
@@ -1,37 +0,0 @@
# frozen_string_literal: true
class McpSettingsController < ApplicationController
load_and_authorize_resource :mcp_token, parent: false
before_action do
authorize!(:manage, :mcp)
end
def index
@mcp_tokens = @mcp_tokens.active.order(id: :desc)
end
def create
@mcp_token = current_user.mcp_tokens.new(mcp_token_params)
if @mcp_token.save
@mcp_tokens = [@mcp_token]
render :index, status: :created
else
render turbo_stream: turbo_stream.replace(:modal, template: 'mcp_settings/new'), status: :unprocessable_content
end
end
def destroy
@mcp_token.update!(archived_at: Time.current)
redirect_back fallback_location: settings_mcp_index_path, notice: I18n.t('mcp_token_has_been_removed')
end
private
def mcp_token_params
params.require(:mcp_token).permit(:name)
end
end
-54
View File
@@ -1,54 +0,0 @@
# frozen_string_literal: true
class MfaSetupController < ApplicationController
before_action do
authorize!(:update, current_user)
end
before_action :set_provision_url, only: %i[show new]
def show; end
def new; end
def edit; end
def create
if current_user.validate_and_consume_otp!(params[:otp_attempt])
current_user.otp_required_for_login = true
current_user.save!
redirect_to settings_profile_index_path, notice: I18n.t('2fa_has_been_configured')
else
@provision_url = current_user.otp_provisioning_uri(current_user.email, issuer: Docuseal.product_name)
@error_message = I18n.t('code_is_invalid')
render turbo_stream: turbo_stream.replace(:mfa_form, partial: 'mfa_setup/form'), status: :unprocessable_content
end
end
def destroy
if current_user.validate_and_consume_otp!(params[:otp_attempt])
current_user.update!(otp_required_for_login: false, otp_secret: nil)
redirect_to settings_profile_index_path, notice: I18n.t('2fa_has_been_removed')
else
@error_message = I18n.t('code_is_invalid')
render turbo_stream: turbo_stream.replace(:modal, template: 'mfa_setup/edit'), status: :unprocessable_content
end
end
private
def set_provision_url
return redirect_to root_path, alert: I18n.t('2fa_has_been_set_up_already') if current_user.otp_required_for_login
current_user.otp_secret ||= User.generate_otp_secret
current_user.save!
@provision_url = current_user.otp_provisioning_uri(current_user.email, issuer: Docuseal.product_name)
end
end
@@ -1,8 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
class NewslettersController < ApplicationController class NewslettersController < ApplicationController
skip_authorization_check
def show; end def show; end
def update def update
@@ -1,46 +0,0 @@
# frozen_string_literal: true
class NotificationsSettingsController < ApplicationController
before_action :load_bcc_config, only: :index
before_action :load_reminder_config, only: :index
authorize_resource :bcc_config, only: :index
authorize_resource :reminder_config, only: :index
before_action :build_account_config, only: :create
authorize_resource :account_config, only: :create
def index; end
def create
if @account_config.value.present? ? @account_config.save : @account_config.delete
redirect_back fallback_location: settings_notifications_path, notice: I18n.t('changes_have_been_saved')
else
redirect_back fallback_location: settings_notifications_path, alert: I18n.t('unable_to_save')
end
end
private
def build_account_config
@account_config =
AccountConfig.find_or_initialize_by(account: current_account, key: email_config_params[:key])
@account_config.assign_attributes(email_config_params)
end
def load_bcc_config
@bcc_config =
AccountConfig.find_or_initialize_by(account: current_account, key: AccountConfig::BCC_EMAILS)
end
def load_reminder_config
@reminder_config =
AccountConfig.find_or_initialize_by(account: current_account, key: AccountConfig::SUBMITTER_REMINDERS)
end
def email_config_params
params.require(:account_config).permit(:key, :value, { value: {} }, { value: [] }).tap do |attrs|
attrs[:key] = nil unless attrs[:key].in?([AccountConfig::BCC_EMAILS, AccountConfig::SUBMITTER_REMINDERS])
end
end
end
-31
View File
@@ -1,31 +0,0 @@
# frozen_string_literal: true
class PasswordsController < Devise::PasswordsController
# rubocop:disable Rails/LexicallyScopedActionFilter
skip_before_action :require_no_authentication, only: %i[edit update]
# rubocop:enable Rails/LexicallyScopedActionFilter
around_action :with_browser_locale
class Current < ActiveSupport::CurrentAttributes
attribute :user
end
def create
super do |resource|
resource.errors.clear unless Docuseal.multitenant?
end
end
def update
super do |resource|
Current.user = resource
end
end
private
def after_resetting_password_path_for(_)
new_session_path(resource_name)
end
end
@@ -1,68 +0,0 @@
# frozen_string_literal: true
class PersonalizationSettingsController < ApplicationController
ALLOWED_KEYS = [
AccountConfig::FORM_COMPLETED_BUTTON_KEY,
AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY,
AccountConfig::SUBMITTER_INVITATION_REMINDER_EMAIL_KEY,
AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY,
AccountConfig::SUBMITTER_COMPLETED_EMAIL_KEY,
AccountConfig::FORM_COMPLETED_MESSAGE_KEY,
*(Docuseal.multitenant? ? [] : [AccountConfig::POLICY_LINKS_KEY])
].freeze
InvalidKey = Class.new(StandardError)
before_action :load_and_authorize_account_config, only: :create
def show
authorize!(:read, AccountConfig)
end
def create
if @account_config.value.is_a?(Hash)
@account_config.value = @account_config.value.reject do |_, v|
v.blank? && v != false
end
end
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: I18n.t('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(:key, :value, { value: {} }, { value: [] })
return attrs if attrs[:value].is_a?(String)
attrs[:value]&.transform_values! do |value|
if value.in?(%w[true false])
value == 'true'
else
value
end
end
attrs
end
end
@@ -1,60 +0,0 @@
# frozen_string_literal: true
class PreviewDocumentPageController < ActionController::API
include ActiveStorage::SetCurrent
FORMAT = Templates::ProcessDocument::FORMAT
def show
result_data =
ApplicationRecord.signed_id_verifier.verified(params[:signed_key], purpose: :attachment)
attachment =
if result_data.is_a?(Array) && result_data.compact_blank.size == 2
attachment_id, attachment_uuid = result_data
ActiveStorage::Attachment.find_by(id: attachment_id, uuid: attachment_uuid)
elsif result_data
ActiveStorage::Attachment.find_by(uuid: result_data)
end
return head :not_found unless attachment
@template = attachment.record
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
file_path =
if attachment.service.name == :disk
ActiveStorage::Blob.service.path_for(attachment.key)
else
find_or_create_document_tempfile_path(attachment)
end
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
end
def find_or_create_document_tempfile_path(attachment)
file_path = "#{Dir.tmpdir}/#{attachment.uuid}"
File.open(file_path, File::RDWR | File::CREAT, 0o644) do |f|
f.flock(File::LOCK_EX)
# rubocop:disable Style/ZeroLengthPredicate
if f.size.zero?
f.binmode
f.write(attachment.download)
end
# rubocop:enable Style/ZeroLengthPredicate
end
file_path
end
end
+6 -17
View File
@@ -1,33 +1,22 @@
# frozen_string_literal: true # frozen_string_literal: true
class ProfileController < ApplicationController class ProfileController < ApplicationController
before_action do
authorize!(:manage, current_user)
end
def index; end def index; end
def update_contact def update_contact
if current_user.update(contact_params) if current_user.update(contact_params)
if current_user.try(:pending_reconfirmation?) && current_user.previous_changes.key?(:unconfirmed_email) redirect_to settings_profile_index_path, notice: 'Contact information has been updated'
SendConfirmationInstructionsJob.perform_async('user_id' => current_user.id)
redirect_to settings_profile_index_path,
notice: I18n.t('a_confirmation_email_has_been_sent_to_the_new_email_address')
else
redirect_to settings_profile_index_path, notice: I18n.t('contact_information_has_been_update')
end
else else
render :index, status: :unprocessable_content render :index, status: :unprocessable_entity
end end
end end
def update_password def update_password
if current_user.update_with_password(password_params) if current_user.update(password_params)
bypass_sign_in(current_user) bypass_sign_in(current_user)
redirect_to settings_profile_index_path, notice: I18n.t('password_has_been_changed') redirect_to settings_profile_index_path, notice: 'Password has been changed'
else else
render :index, status: :unprocessable_content render :index, status: :unprocessable_entity
end end
end end
@@ -38,6 +27,6 @@ class ProfileController < ApplicationController
end end
def password_params def password_params
params.require(:user).permit(:password, :password_confirmation, :current_password) params.require(:user).permit(:password, :password_confirmation)
end end
end end
-4
View File
@@ -1,4 +0,0 @@
# frozen_string_literal: true
class PwaController < ActionController::Base
end
@@ -0,0 +1,24 @@
# frozen_string_literal: true
class RegistrationsController < Devise::RegistrationsController
private
def build_resource(_hash = {})
account = Account.new(account_params)
account.timezone = Accounts.normalize_timezone(account.timezone)
self.resource = account.users.new(user_params)
end
def user_params
return {} if params[:user].blank?
params.require(:user).permit(:first_name, :last_name, :email, :password)
end
def account_params
return {} if params[:account].blank?
params.require(:account).permit(:name, :timezone)
end
end
@@ -1,21 +0,0 @@
# frozen_string_literal: true
class RevealAccessTokenController < ApplicationController
def show
authorize!(:manage, current_user.access_token)
end
def create
authorize!(:manage, current_user.access_token)
if current_user.valid_password?(params[:password])
render turbo_stream: turbo_stream.replace(:access_token_container,
partial: 'reveal_access_token/access_token',
locals: { token: current_user.access_token.token })
else
render turbo_stream: turbo_stream.replace(:modal, template: 'reveal_access_token/show',
locals: { error_message: I18n.t('wrong_password') }),
status: :unprocessable_content
end
end
end
@@ -1,17 +0,0 @@
# frozen_string_literal: true
class SearchEntriesReindexController < ApplicationController
def create
authorize!(:manage, EncryptedConfig)
ReindexAllSearchEntriesJob.perform_async
AccountConfig.find_or_initialize_by(account_id: Account.minimum(:id), key: :fulltext_search)
.update!(value: true)
Docuseal.instance_variable_set(:@fulltext_search, nil)
redirect_back(fallback_location: settings_account_path,
notice: "Started building search index. Visit #{root_url}jobs/busy to check progress.")
end
end
@@ -5,45 +5,23 @@ class SendSubmissionEmailController < ApplicationController
skip_before_action :authenticate_user! skip_before_action :authenticate_user!
skip_before_action :verify_authenticity_token skip_before_action :verify_authenticity_token
skip_authorization_check
SEND_DURATION = 30.minutes def success; end
def create def create
if params[:template_slug] @submitter =
template = Template.find_by!(slug: params[:template_slug]) if params[:template_slug]
Submitter.joins(submission: :template).find_by!(email: params[:email],
@submitter = template: { slug: params[:template_slug] })
Submitter.completed.where(submission: template.submissions).find_by!(email: params[:email].to_s.downcase) else
elsif params[:submission_slug] Submitter.find_by!(slug: params[:submitter_slug])
submission = Submission.find_by(slug: params[:submission_slug])
if submission
@submitter = Submitter.completed.find_by(submission: submission, email: params[:email].to_s.downcase)
end end
return redirect_to submissions_preview_completed_path(params[:submission_slug], status: :error) unless @submitter SubmitterMailer.documents_copy_email(@submitter).deliver_later!
else
@submitter = Submitter.completed.find_by!(slug: params[:submitter_slug])
end
RateLimit.call("send-email-#{@submitter.id}", limit: 2, ttl: 5.minutes)
SubmitterMailer.documents_copy_email(@submitter, sig: true).deliver_later! if can_send?(@submitter)
respond_to do |f| respond_to do |f|
f.html { render :success } f.html { redirect_to success_send_submission_email_index_path }
f.json { head :ok } f.json { head :ok }
end end
end end
private
def can_send?(submitter)
return false if submitter.account.archived_at?
return false if EmailEvent.exists?(tag: :submitter_documents_copy, email: submitter.email, emailable: submitter,
event_type: :send, created_at: SEND_DURATION.ago..Time.current)
true
end
end end
-46
View File
@@ -1,46 +0,0 @@
# frozen_string_literal: true
class SessionsController < Devise::SessionsController
before_action :configure_permitted_parameters
around_action :with_browser_locale
def create
email = sign_in_params[:email].to_s.downcase
if Docuseal.multitenant? && !User.exists?(email:)
Rollbar.warning('Sign in new user') if defined?(Rollbar)
return redirect_to new_registration_path(sign_up: true, user: sign_in_params.slice(:email)),
notice: I18n.t('create_a_new_account')
end
if User.exists?(email:, otp_required_for_login: true) && sign_in_params[:otp_attempt].blank?
return render :otp, locals: { resource: User.new(sign_in_params) }, status: :unprocessable_content
end
super
end
private
def after_sign_in_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 configure_permitted_parameters
devise_parameter_sanitizer.permit(:sign_in, keys: [:otp_attempt])
end
def set_flash_message(key, kind, options = {})
return if key == :alert && kind == 'already_authenticated'
super
end
end
+8 -11
View File
@@ -3,7 +3,6 @@
class SetupController < ApplicationController class SetupController < ApplicationController
skip_before_action :maybe_redirect_to_setup skip_before_action :maybe_redirect_to_setup
skip_before_action :authenticate_user! skip_before_action :authenticate_user!
skip_authorization_check
before_action :redirect_to_root_if_signed, if: :signed_in? before_action :redirect_to_root_if_signed, if: :signed_in?
before_action :ensure_first_user_not_created! before_action :ensure_first_user_not_created!
@@ -17,24 +16,22 @@ class SetupController < ApplicationController
def create def create
@account = Account.new(account_params) @account = Account.new(account_params)
@account.timezone = Accounts.normalize_timezone(@account.timezone) @account.timezone = Accounts.normalize_timezone(@account.timezone)
@user = @account.users.new(user_params) @user = @account.users.new(user_params)
@encrypted_config = EncryptedConfig.new(encrypted_config_params)
unless URI.parse(encrypted_config_params[:value].to_s).class.in?([URI::HTTP, URI::HTTPS]) unless URI.parse(encrypted_config_params[:value].to_s).class.in?([URI::HTTP, URI::HTTPS])
@encrypted_config.errors.add(:value, I18n.t('should_be_a_valid_url')) @encrypted_config = EncryptedConfig.new(encrypted_config_params)
@encrypted_config.errors.add(:value, 'should be a valid URL')
return render :index, status: :unprocessable_content return render :index, status: :unprocessable_entity
end end
return render :index, status: :unprocessable_content unless @account.valid?
if @user.save if @user.save
encrypted_configs = [ encrypted_configs = [
{ key: EncryptedConfig::APP_URL_KEY, value: encrypted_config_params[:value] }, { key: EncryptedConfig::APP_URL_KEY, value: encrypted_config_params[:value] },
{ key: EncryptedConfig::ESIGN_CERTS_KEY, value: GenerateCertificate.call.transform_values(&:to_pem) } { key: EncryptedConfig::ESIGN_CERTS_KEY, value: GenerateCertificate.call.transform_values(&:to_pem) }
] ]
@account.encrypted_configs.create!(encrypted_configs) @account.encrypted_configs.create!(encrypted_configs)
@account.account_configs.create!(key: :fulltext_search, value: true) if SearchEntry.table_exists?
Docuseal.refresh_default_url_options! Docuseal.refresh_default_url_options!
@@ -42,7 +39,7 @@ class SetupController < ApplicationController
redirect_to newsletter_path redirect_to newsletter_path
else else
render :index, status: :unprocessable_content render :index, status: :unprocessable_entity
end end
end end
@@ -57,7 +54,7 @@ class SetupController < ApplicationController
def account_params def account_params
return {} unless params[:account] return {} unless params[:account]
params.require(:account).permit(:name, :timezone, :locale) params.require(:account).permit(:name, :timezone)
end end
def encrypted_config_params def encrypted_config_params
@@ -67,10 +64,10 @@ class SetupController < ApplicationController
end end
def redirect_to_root_if_signed def redirect_to_root_if_signed
redirect_to root_path, notice: I18n.t('you_are_already_signed_in') redirect_to root_path, notice: 'You are already signed in'
end end
def ensure_first_user_not_created! def ensure_first_user_not_created!
redirect_to new_user_session_path, notice: I18n.t('please_sign_in') if User.exists? redirect_to new_user_session_path, notice: 'Please sign in.' if User.exists?
end end
end end
@@ -1,16 +0,0 @@
# frozen_string_literal: true
class SmsSettingsController < ApplicationController
before_action :load_encrypted_config
authorize_resource :encrypted_config, only: :index
authorize_resource :encrypted_config, parent: false, except: :index
def index; end
private
def load_encrypted_config
@encrypted_config =
EncryptedConfig.find_or_initialize_by(account: current_account, key: 'sms_configs')
end
end
@@ -1,15 +0,0 @@
# frozen_string_literal: true
class SsoSettingsController < ApplicationController
before_action :load_encrypted_config
authorize_resource :encrypted_config, only: :index
def index; end
private
def load_encrypted_config
@encrypted_config =
EncryptedConfig.find_or_initialize_by(account: current_account, key: 'saml_configs')
end
end
+17 -210
View File
@@ -4,243 +4,50 @@ class StartFormController < ApplicationController
layout 'form' layout 'form'
skip_before_action :authenticate_user! skip_before_action :authenticate_user!
skip_authorization_check
around_action :with_browser_locale, only: %i[show update completed]
before_action :maybe_redirect_com, only: %i[show completed]
before_action :load_resubmit_submitter, only: :update
before_action :load_template before_action :load_template
before_action :authorize_start!, only: :update
COOKIES_TTL = 12.hours
COOKIES_DEFAULTS = { httponly: true, secure: Rails.env.production? }.freeze
def show def show
if @template.preferences['require_phone_2fa'] || @template.preferences['require_email_2fa'] @submitter = @template.submissions.new.submitters.new(uuid: @template.submitters.first['uuid'])
raise ActionController::RoutingError, I18n.t('not_found')
end
if @template.shared_link?
@submitter = @template.submissions.new(account_id: @template.account_id)
.submitters.new(account_id: @template.account_id,
uuid: (filter_undefined_submitters(@template).first ||
@template.submitters.first)['uuid'])
render :email_verification if params[:email_verification]
else
Rollbar.warning("Not shared template: #{@template.id}") if defined?(Rollbar)
return render :private if current_user && current_ability.can?(:read, @template)
raise ActionController::RoutingError, I18n.t('not_found')
end
end end
def update def update
@submitter = find_or_initialize_submitter(@template, submitter_params) @submitter = Submitter.where(submission: @template.submissions.where(deleted_at: nil))
.find_or_initialize_by(email: submitter_params[:email])
if @submitter.completed_at? if @submitter.completed_at?
redirect_to start_form_completed_path(@template.slug, submitter_params.compact_blank) redirect_to start_form_completed_path(@template.slug, email: submitter_params[:email])
else else
if filter_undefined_submitters(@template).size > 1 && @submitter.new_record? @submitter.assign_attributes(
@error_message = multiple_submitters_error_message uuid: @template.submitters.first['uuid'],
opened_at: Time.current,
ip: request.remote_ip,
ua: request.user_agent
)
return render :show, status: :unprocessable_content @submitter.submission ||= Submission.new(template: @template)
end
if (is_new_record = @submitter.new_record?)
assign_submission_attributes(@submitter, @template)
Submissions::AssignDefinedSubmitters.call(@submitter.submission)
else
@submitter.assign_attributes(ip: request.remote_ip, ua: request.user_agent)
end
if @template.preferences['shared_link_2fa'] == true
handle_require_2fa(@submitter, is_new_record:)
elsif @submitter.errors.blank? && @submitter.save
enqueue_new_submitter_jobs(@submitter) if is_new_record
if @submitter.save
redirect_to submit_form_path(@submitter.slug) redirect_to submit_form_path(@submitter.slug)
else else
render :show, status: :unprocessable_content render :show
end end
end end
end end
def completed def completed
return redirect_to start_form_path(@template.slug) if !@template.shared_link? || @template.archived_at? @submitter = Submitter.where(submission: @template.submissions).find_by!(email: params[:email])
submitter_params = params.permit(:name, :email, :phone).tap do |attrs|
attrs[:email] = Submissions.normalize_email(attrs[:email])
end
required_fields = @template.preferences.fetch('link_form_fields', ['email'])
required_params = required_fields.index_with { |key| submitter_params[key] }
raise ActionController::RoutingError, I18n.t('not_found') if required_params.any? { |_, v| v.blank? } ||
required_params.except('name').compact_blank.blank?
@submitter = Submitter.where(submission: @template.submissions)
.where.not(completed_at: nil)
.find_by!(required_params.except('name'))
end end
private private
def enqueue_new_submitter_jobs(submitter)
WebhookUrls.enqueue_events(submitter.submission, 'submission.created')
SearchEntries.enqueue_reindex(submitter)
return unless submitter.submission.expire_at?
ProcessSubmissionExpiredJob.perform_at(submitter.submission.expire_at, 'submission_id' => submitter.submission_id)
end
def load_resubmit_submitter
@resubmit_submitter =
if params[:resubmit].present? && !params[:resubmit].in?([true, 'true'])
Submitter.find_by(slug: params[:resubmit])
end
end
def authorize_start!
return redirect_to start_form_path(@template.slug) if @template.archived_at?
return if @resubmit_submitter
return if @template.shared_link? || (current_user && current_ability.can?(:read, @template))
Rollbar.warning("Not shared template: #{@template.id}") if defined?(Rollbar)
redirect_to start_form_path(@template.slug)
end
def find_or_initialize_submitter(template, submitter_params)
required_fields = template.preferences.fetch('link_form_fields', ['email'])
required_params = required_fields.index_with { |key| submitter_params[key] }
find_params = required_params.except('name')
submitter = Submitter.new if find_params.compact_blank.blank?
submitter ||=
Submitter
.where(submission: template.submissions.where(expire_at: Time.current..)
.or(template.submissions.where(expire_at: nil)).where(archived_at: nil))
.order(id: :desc)
.where(declined_at: nil)
.where(external_id: nil)
.where(template.preferences['shared_link_2fa'] == true ? {} : { ip: [nil, request.remote_ip] })
.then { |rel| params[:resubmit].present? || params[:selfsign].present? ? rel.where(completed_at: nil) : rel }
.find_or_initialize_by(find_params)
submitter.name = required_params['name'] if submitter.new_record?
unless @resubmit_submitter
required_params.each do |key, value|
submitter.errors.add(key.to_sym, :blank) if value.blank?
end
end
submitter
end
def assign_submission_attributes(submitter, template)
submitter.assign_attributes(
uuid: (filter_undefined_submitters(template).first || @template.submitters.first)['uuid'],
ip: request.remote_ip,
ua: request.user_agent,
values: @resubmit_submitter&.preferences&.fetch('default_values', nil) || {},
preferences: @resubmit_submitter&.preferences.presence || { 'send_email' => true },
metadata: @resubmit_submitter&.metadata.presence || {}
)
submitter.assign_attributes(@resubmit_submitter.slice(:name, :email, :phone)) if @resubmit_submitter
if submitter.values.present?
@resubmit_submitter.attachments.each do |attachment|
submitter.attachments << attachment.dup if submitter.values.value?(attachment.uuid)
end
end
submitter.submission ||= Submission.new(template:,
account_id: template.account_id,
template_submitters: template.submitters,
expire_at: Templates.build_default_expire_at(template),
submitters: [submitter],
source: :link)
Submissions::CreateFromSubmitters.maybe_set_dynamic_documents(submitter.submission)
submitter.account_id = submitter.submission.account_id
submitter
end
def filter_undefined_submitters(template)
Templates.filter_undefined_submitters(template.submitters)
end
def submitter_params def submitter_params
return { 'email' => current_user.email, 'name' => current_user.full_name } if params[:selfsign] params.require(:submitter).permit(:email)
return @resubmit_submitter.slice(:name, :phone, :email) if @resubmit_submitter.present?
params.require(:submitter).permit(:email, :phone, :name).tap do |attrs|
attrs[:email] = Submissions.normalize_email(attrs[:email])
end
end end
def load_template def load_template
@template = slug = params[:slug] || params[:start_form_slug]
if @resubmit_submitter
@resubmit_submitter.template
else
Template.find_by!(slug: params[:slug] || params[:start_form_slug])
end
end
def multiple_submitters_error_message @template = Template.find_by!(slug:)
if current_user&.account_id == @template.account_id
helpers.t('this_submission_has_multiple_signers_which_prevents_the_use_of_a_sharing_link_html')
else
I18n.t('not_found')
end
end
def handle_require_2fa(submitter, is_new_record:)
return render :show, status: :unprocessable_content if submitter.errors.present?
is_otp_verified = Submitters.verify_link_otp!(params[:one_time_code], submitter)
if cookies.encrypted[:email_2fa_slug] == submitter.slug || is_otp_verified
if submitter.save
enqueue_new_submitter_jobs(submitter) if is_new_record
if is_otp_verified
SubmissionEvents.create_with_tracking_data(submitter, 'email_verified', request)
cookies.encrypted[:email_2fa_slug] =
{ value: submitter.slug, expires: COOKIES_TTL.from_now, **COOKIES_DEFAULTS }
end
redirect_to submit_form_path(submitter.slug)
else
render :show, status: :unprocessable_content
end
else
Submitters.send_shared_link_email_verification_code(submitter, request:)
render :email_verification
end
rescue Submitters::UnableToSendCode, Submitters::InvalidOtp => e
redirect_to start_form_path(submitter.submission.template.slug,
params: submitter_params.merge(email_verification: true)),
alert: e.message
rescue RateLimit::LimitApproached
redirect_to start_form_path(submitter.submission.template.slug,
params: submitter_params.merge(email_verification: true)),
alert: I18n.t(:too_many_attempts)
end end
end end
@@ -1,31 +0,0 @@
# frozen_string_literal: true
class StartFormEmail2faSendController < ApplicationController
around_action :with_browser_locale
skip_before_action :authenticate_user!
skip_authorization_check
def create
@template = Template.find_by!(slug: params[:slug])
@submitter = @template.submissions.new(account_id: @template.account_id)
.submitters.new(**submitter_params, account_id: @template.account_id)
Submitters.send_shared_link_email_verification_code(@submitter, request:)
redir_params = { notice: I18n.t(:code_has_been_resent) } if params[:resend]
redirect_to start_form_path(@template.slug, params: submitter_params.merge(email_verification: true)),
**redir_params
rescue Submitters::UnableToSendCode => e
redirect_to start_form_path(@template.slug, params: submitter_params.merge(email_verification: true)),
alert: e.message
end
private
def submitter_params
params.require(:submitter).permit(:name, :email, :phone)
end
end
@@ -2,8 +2,6 @@
class StorageSettingsController < ApplicationController class StorageSettingsController < ApplicationController
before_action :load_encrypted_config before_action :load_encrypted_config
authorize_resource :encrypted_config, only: :index
authorize_resource :encrypted_config, parent: false, only: :create
def index; end def index; end
@@ -11,9 +9,9 @@ class StorageSettingsController < ApplicationController
if @encrypted_config.update(storage_configs) if @encrypted_config.update(storage_configs)
LoadActiveStorageConfigs.reload LoadActiveStorageConfigs.reload
redirect_to settings_storage_index_path, notice: I18n.t('changes_have_been_saved') redirect_to settings_storage_index_path, notice: 'Changes have been saved'
else else
render :index, status: :unprocessable_content render :index, status: :unprocessable_entity
end end
end end
@@ -1,27 +0,0 @@
# frozen_string_literal: true
class SubmissionEventsController < ApplicationController
SUBMISSION_EVENT_ICONS = {
'view_form' => 'eye',
'start_form' => 'player_play',
'complete_form' => 'check',
'send_email' => 'mail_forward',
'click_email' => 'hand_click',
'api_complete_form' => 'check',
'send_reminder_email' => 'mail_forward',
'send_2fa_sms' => '2fa',
'send_2fa_email' => '2fa',
'send_sms' => 'send',
'phone_verified' => 'phone_check',
'email_verified' => 'email_check',
'click_sms' => 'hand_click',
'decline_form' => 'x',
'start_verification' => 'player_play',
'complete_verification' => 'check',
'invite_party' => 'user_plus'
}.freeze
load_and_authorize_resource :submission
def index; end
end
@@ -1,23 +0,0 @@
# frozen_string_literal: true
class SubmissionsArchivedController < ApplicationController
load_and_authorize_resource :submission, parent: false
def index
@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)
@submissions = Submissions.search(current_user, @submissions, params[:q], search_template: true)
@submissions = Submissions::Filter.call(@submissions, current_user, params)
@submissions = if params[:completed_at_from].present? || params[:completed_at_to].present?
@submissions.order(Submitter.arel_table[:completed_at].maximum.desc)
else
@submissions.order(id: :desc)
end
@pagy, @submissions = pagy_auto(@submissions.preload(submitters: :start_form_submission_events))
end
end
+37 -78
View File
@@ -2,117 +2,76 @@
class SubmissionsController < ApplicationController class SubmissionsController < ApplicationController
before_action :load_template, only: %i[new create] before_action :load_template, only: %i[new create]
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]
before_action only: :create do
authorize!(:create, Submission)
end
FIELD_ICONS = {
'text' => 'text_size', 'signature' => 'writing_sign', 'date' => 'calendar_event',
'number' => 'square_number_1', 'image' => 'photo', 'initials' => 'letter_case_upper',
'file' => 'paperclip', 'select' => 'select', 'checkbox' => 'checkbox', 'radio' => 'circle_dot',
'stamp' => 'rubber_stamp', 'cells' => 'columns_3', 'multiple' => 'checks', 'phone' => 'phone_check',
'payment' => 'credit_card', 'verification' => 'id', 'kba' => 'user_scan'
}.freeze
def show def show
@submission = Submissions.preload_with_pages(@submission) @submission =
Submission.joins(:template).where(template: { account_id: current_account.id })
unless @submission.submitters.all?(&:completed_at?) .preload(template: { documents_attachments: { preview_images_attachments: :blob } })
ActiveRecord::Associations::Preloader.new( .find(params[:id])
records: [@submission],
associations: [submitters: :start_form_submission_events]
).call
end
render :show, layout: 'plain' render :show, layout: 'plain'
end end
def new def new; end
authorize!(:new, Submission)
end
def create def create
save_template_message(@template, params) if params[:save_message] == '1'
[params.delete(:subject), params.delete(:body)] if params[:is_custom_message] != '1'
submissions = submissions =
if params[:emails].present? if params[:emails].present?
Submissions.create_from_emails(template: @template, create_submissions_from_emails
user: current_user,
source: :invite,
mark_as_sent: params[:send_email] == '1',
emails: params[:emails],
params: params.merge('send_completed_email' => true))
else else
create_submissions(@template, submissions_params, params) create_submissions_from_submitters
end end
WebhookUrls.enqueue_events(submissions, 'submission.created') if params[:send_email] == '1'
submissions.flat_map(&:submitters).each do |submitter|
SubmitterMailer.invitation_email(submitter, message: params[:message]).deliver_later!
end
end
Submissions.send_signature_requests(submissions) redirect_to template_path(@template),
notice: "#{submissions.size} #{'recipient'.pluralize(submissions.size)} added"
SearchEntries.enqueue_reindex(submissions)
redirect_to template_path(@template), notice: I18n.t('new_recipients_have_been_added')
rescue Submissions::CreateFromSubmitters::BaseError => e
render turbo_stream: turbo_stream.replace(:submitters_error, partial: 'submissions/error',
locals: { error: e.message }),
status: :unprocessable_content
end end
def destroy def destroy
notice = submission = Submission.joins(:template).where(template: { account_id: current_account.id })
if params[:permanently].in?(['true', true]) .find(params[:id])
@submission.destroy!
I18n.t('submission_has_been_removed') submission.update!(deleted_at: Time.current)
else
@submission.update!(archived_at: Time.current)
WebhookUrls.enqueue_events(@submission, 'submission.archived') redirect_back(fallback_location: template_path(submission.template), notice: 'Submission has been archived')
I18n.t('submission_has_been_archived')
end
redirect_back(fallback_location: @submission.template_id ? template_path(@submission.template) : root_path, notice:)
end end
private private
def create_submissions(template, submissions_params, params) def create_submissions_from_emails
submissions_attrs = submissions_params[:submission].to_h.values emails = params[:emails].to_s.scan(User::EMAIL_REGEXP)
submissions_attrs, _, new_fields = emails.map do |email|
Submissions::NormalizeParamUtils.normalize_submissions_params!(submissions_attrs, template, add_fields: true) submission = @template.submissions.new(created_by_user: current_user)
submission.submitters.new(email:, uuid: @template.submitters.first['uuid'],
sent_at: params[:send_email] == '1' ? Time.current : nil)
Submissions.create_from_submitters(template: template, submission.tap(&:save!)
user: current_user, end
source: :invite,
submitters_order: params[:preserve_order] == '1' ? 'preserved' : 'random',
submissions_attrs:,
new_fields:,
params: params.merge('send_completed_email' => true))
end end
def save_template_message(template, params) def create_submissions_from_submitters
template.preferences['request_email_subject'] = params[:subject] if params[:subject].present? submissions_params[:submission].to_h.map do |_, attrs|
template.preferences['request_email_body'] = params[:body] if params[:body].present? submission = @template.submissions.new(created_by_user: current_user)
template.save! attrs[:submitters].each do |submitter_attrs|
submission.submitters.new(**submitter_attrs, sent_at: params[:send_email] == '1' ? Time.current : nil)
end
submission.tap(&:save!)
end
end end
def submissions_params def submissions_params
params.permit(submission: { submitters: [:uuid, :email, :phone, :name, { values: {} }] }) params.permit(submission: { submitters: [%i[uuid email]] })
end end
def load_template def load_template
@template = Template.accessible_by(current_ability).find(params[:template_id]) @template = current_account.templates.find(params[:template_id])
end end
end end
@@ -1,24 +0,0 @@
# frozen_string_literal: true
class SubmissionsDashboardController < ApplicationController
load_and_authorize_resource :submission, parent: false
def index
@submissions = @submissions.left_joins(:template)
@submissions = @submissions.where(archived_at: nil)
.where(templates: { archived_at: nil })
.preload(:template_accesses, :created_by_user, template: :author)
@submissions = Submissions.search(current_user, @submissions, params[:q], search_template: true)
@submissions = Submissions::Filter.call(@submissions, current_user, params)
@submissions = if params[:completed_at_from].present? || params[:completed_at_to].present?
@submissions.order(Submitter.arel_table[:completed_at].maximum.desc)
else
@submissions.order(id: :desc)
end
@pagy, @submissions = pagy_auto(@submissions.preload(submitters: :start_form_submission_events))
end
end
@@ -4,7 +4,6 @@ class SubmissionsDebugController < ApplicationController
layout 'plain' layout 'plain'
skip_before_action :authenticate_user! skip_before_action :authenticate_user!
skip_authorization_check
def index def index
@submitter = Submitter.preload({ attachments_attachments: :blob }, @submitter = Submitter.preload({ attachments_attachments: :blob },
@@ -16,16 +15,9 @@ class SubmissionsDebugController < ApplicationController
render 'submit_form/show' render 'submit_form/show'
end end
f.pdf do f.pdf do
result = Submissions::GenerateResultAttachments.call(@submitter)
if params[:audit]
Submissions::GenerateAuditTrail.call(@submitter.submission)
elsif params[:combined]
Submissions::GenerateCombinedAttachment.call(@submitter)
else
Submissions::GenerateResultAttachments.call(@submitter)
end
send_data Array.wrap(result).first.download, send_data ActiveStorage::Attachment.where(name: :documents).last.download,
filename: 'debug.pdf', filename: 'debug.pdf',
disposition: 'inline', disposition: 'inline',
type: 'application/pdf' type: 'application/pdf'
@@ -2,91 +2,17 @@
class SubmissionsDownloadController < ApplicationController class SubmissionsDownloadController < ApplicationController
skip_before_action :authenticate_user! skip_before_action :authenticate_user!
skip_authorization_check
TTL = 40.minutes
FILES_TTL = 5.minutes
def index def index
@submitter = Submitter.find_signed(params[:sig], purpose: :download_completed) if params[:sig].present? submitter = Submitter.find_by(slug: params[:submitter_slug])
signature_valid = Submissions::EnsureResultGenerated.call(submitter)
if @submitter&.slug == params[:submitter_slug]
true urls =
else Submitters.select_attachments_for_download(submitter).map do |attachment|
@submitter = nil helpers.rails_blob_url(attachment)
end end
@submitter ||= Submitter.find_by!(slug: params[:submitter_slug]) render json: urls
Submissions::EnsureResultGenerated.call(@submitter)
last_submitter = @submitter.submission.submitters.where.not(completed_at: nil).order(:completed_at).last
return head :not_found unless last_submitter
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)
if last_submitter.completed_at < TTL.ago
Rollbar.info("TTL: #{last_submitter.id}") if defined?(Rollbar)
return head :not_found
end
end
if params[:combined] == 'true'
respond_with_combined(last_submitter)
else
render json: build_urls(last_submitter)
end
end
private
def respond_with_combined(submitter)
url = build_combined_url(submitter)
if url
render json: [url]
else
head :not_found
end
end
def current_user_submitter?(submitter)
current_user && current_ability.can?(:read, submitter)
end
def build_urls(submitter)
filename_format = AccountConfig.find_or_initialize_by(account_id: submitter.account_id,
key: AccountConfig::DOCUMENT_FILENAME_FORMAT_KEY)&.value
Submitters.select_attachments_for_download(submitter).map do |attachment|
ActiveStorage::Blob.proxy_path(
attachment.blob,
expires_at: FILES_TTL.from_now.to_i,
filename: Submitters.build_document_filename(submitter, attachment.blob, filename_format)
)
end
end
def build_combined_url(submitter)
return if submitter.submission.submitters.exists?(completed_at: nil)
return if submitter.submission.submitters.order(:completed_at).last != submitter
attachment = submitter.submission.combined_document_attachment
attachment ||= Submissions::EnsureCombinedGenerated.call(submitter)
filename_format = AccountConfig.find_or_initialize_by(account_id: submitter.account_id,
key: AccountConfig::DOCUMENT_FILENAME_FORMAT_KEY)&.value
ActiveStorage::Blob.proxy_path(
attachment.blob,
expires_at: FILES_TTL.from_now.to_i,
filename: Submitters.build_document_filename(submitter, attachment.blob, filename_format)
)
end end
end end
@@ -1,29 +0,0 @@
# frozen_string_literal: true
class SubmissionsExportController < ApplicationController
load_and_authorize_resource :template
load_and_authorize_resource :submission, through: :template, parent: false, only: :index
def index
submissions = params[:archived] == 'true' ? @submissions.archived : @submissions.active
submissions = submissions.preload(submitters: { documents_attachments: :blob,
attachments_attachments: :blob })
.order(id: :asc)
submissions = Submissions.search(current_user, submissions, params[:q], search_values: true)
submissions = Submissions::Filter.call(submissions, current_user, params)
expires_at = Accounts.link_expires_at(current_account)
if params[:format] == 'csv'
send_data Submissions::GenerateExportFiles.call(submissions, format: params[:format], expires_at:),
filename: "#{@template.name}.csv"
elsif params[:format] == 'xlsx'
send_data Submissions::GenerateExportFiles.call(submissions, format: params[:format], expires_at:),
filename: "#{@template.name}.xlsx"
end
end
def new; end
end
@@ -1,19 +0,0 @@
# frozen_string_literal: true
class SubmissionsFiltersController < ApplicationController
ALLOWED_NAMES = %w[
author
folder
completed_at
status
created_at
].freeze
skip_authorization_check
def show
return head :not_found unless ALLOWED_NAMES.include?(params[:name])
render params[:name]
end
end
@@ -1,70 +0,0 @@
# frozen_string_literal: true
class SubmissionsPreviewController < ApplicationController
around_action :with_browser_locale
skip_before_action :authenticate_user!
skip_authorization_check
prepend_before_action :maybe_redirect_com, only: %i[show completed]
TTL = 40.minutes
def show
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])
raise ActionController::RoutingError, I18n.t('not_found') if @submission.account.archived_at?
if !@submission.submitters.all?(&:completed_at?) && !signature_valid &&
(!current_user || !current_ability.can?(:read, @submission))
raise ActionController::RoutingError, I18n.t('not_found')
end
if use_signature?(@submission) && !signature_valid
Rollbar.info("TTL: #{@submission.id}") if defined?(Rollbar)
return redirect_to submissions_preview_completed_path(@submission.slug)
end
@submission = Submissions.preload_with_pages(@submission)
render 'submissions/show', layout: 'plain'
end
def completed
@submission = Submission.find_by!(slug: params[:submissions_preview_slug])
raise ActionController::RoutingError, I18n.t('not_found') if @submission.account.archived_at?
@template = @submission.template
render :completed, layout: 'form'
end
private
def use_signature?(submission)
return false if current_user && can?(:read, submission)
return true if submission.submitters.any? do |e|
e.preferences['require_phone_2fa'] || e.preferences['require_email_2fa']
end
return true if submission.template&.preferences&.dig('require_phone_2fa')
return true if submission.template&.preferences&.dig('require_email_2fa')
!submission_valid_ttl?(submission)
end
def submission_valid_ttl?(submission)
last_submitter = submission.submitters.select(&:completed_at?).max_by(&:completed_at)
last_submitter && last_submitter.completed_at > TTL.ago
end
end
@@ -1,11 +0,0 @@
# frozen_string_literal: true
class SubmissionsUnarchiveController < ApplicationController
load_and_authorize_resource :submission
def create
@submission.update!(archived_at: nil)
redirect_to submission_path(@submission), notice: I18n.t('submission_has_been_unarchived')
end
end
+23 -90
View File
@@ -3,117 +3,50 @@
class SubmitFormController < ApplicationController class SubmitFormController < ApplicationController
layout 'form' layout 'form'
around_action :with_browser_locale, only: %i[show completed success]
skip_before_action :authenticate_user! skip_before_action :authenticate_user!
skip_authorization_check
before_action :load_submitter, only: %i[show update completed]
before_action :maybe_render_locked_page, only: :show
before_action :maybe_require_link_2fa, only: %i[show]
CONFIG_KEYS = [].freeze
def show def show
submission = @submitter.submission @submitter =
Submitter.preload(submission: { template: { documents_attachments: { preview_images_attachments: :blob } } })
.find_by!(slug: params[:slug])
return render :email_2fa unless Submitters::AuthorizedForForm.pass_email_2fa?(@submitter, request)
return redirect_to submit_form_completed_path(@submitter.slug) if @submitter.completed_at? return redirect_to submit_form_completed_path(@submitter.slug) if @submitter.completed_at?
@form_configs = Submitters::FormConfigs.call(@submitter, CONFIG_KEYS) cookies[:submitter_sid] = @submitter.signed_id
return render :awaiting if (@form_configs[:enforce_signing_order] ||
submission.template&.preferences&.dig('submitters_order') == 'preserved') &&
!Submitters.current_submitter_order?(@submitter)
Submissions.preload_with_pages(submission)
Submitters::MaybeUpdateDefaultValues.call(@submitter, current_user)
@attachments_index = build_attachments_index(submission)
return unless @form_configs[:prefill_signature]
if (user_signature = UserConfigs.load_signature(current_user))
@signature_attachment = ActiveStorage::Attachment.find_or_create_by!(
blob_id: user_signature.blob_id,
name: 'attachments',
record: @submitter
)
end
@signature_attachment ||=
Submitters::MaybeAssignDefaultBrowserSignature.call(@submitter, params, cookies, @attachments_index.values)
@attachments_index[@signature_attachment.uuid] = @signature_attachment if @signature_attachment
end end
def update def update
unless Submitters::AuthorizedForForm.call(@submitter, current_user, request) submitter = Submitter.find_by!(slug: params[:slug])
return render json: { error: I18n.t('verification_required_refresh_the_page_and_pass_2fa') }, submitter.values.merge!(normalized_values)
status: :unprocessable_content submitter.completed_at = Time.current if params[:completed] == 'true'
end submitter.opened_at ||= Time.current
if @submitter.completed_at? submitter.save!
return render json: { error: I18n.t('form_has_been_completed_already') }, status: :unprocessable_content
end
if @submitter.submission.template&.archived_at? || @submitter.submission.archived_at? if submitter.completed_at?
return render json: { error: I18n.t('form_has_been_archived') }, status: :unprocessable_content GenerateSubmitterResultAttachmentsJob.perform_later(submitter)
end
if @submitter.submission.expired? submitter.submission.template.account.users.active.each do |user|
return render json: { error: I18n.t('form_has_been_expired') }, status: :unprocessable_content SubmitterMailer.completed_email(submitter, user).deliver_later!
end
end end
if @submitter.declined_at?
return render json: { error: I18n.t('form_has_been_declined') },
status: :unprocessable_content
end
Submitters::SubmitValues.call(@submitter, params, request)
head :ok head :ok
rescue Submitters::SubmitValues::RequiredFieldError => e
Rollbar.warning("Required field #{@submitter.id}: #{e.message}") if defined?(Rollbar)
render json: { field_uuid: e.message }, status: :unprocessable_content
rescue Submitters::SubmitValues::ValidationError => e
render json: { error: e.message }, status: :unprocessable_content
end end
def completed def completed
raise ActionController::RoutingError, I18n.t('not_found') if @submitter.account.archived_at? @submitter = Submitter.find_by!(slug: params[:submit_form_slug])
return if Submitters::AuthorizedForForm.call(@submitter, current_user, request)
redirect_to submit_form_path(params[:submit_form_slug])
end end
def success; end
private private
def maybe_require_link_2fa def normalized_values
return if Submitters::AuthorizedForForm.pass_link_2fa?(@submitter, current_user, request) params.fetch(:values, {}).to_unsafe_h.transform_values do |v|
if params[:cast_boolean] == 'true'
redirect_to start_form_path(@submitter.submission.template.slug) v == 'true'
end else
v.is_a?(Array) ? v.compact_blank : v
def maybe_render_locked_page end
return render :archived if @submitter.submission.template&.archived_at? || end
@submitter.submission.archived_at? ||
@submitter.account.archived_at?
return render :expired if @submitter.submission.expired?
render :declined if @submitter.declined_at?
end
def load_submitter
@submitter = Submitter.find_by!(slug: params[:slug] || params[:submit_form_slug])
end
def build_attachments_index(submission)
ActiveStorage::Attachment.where(record: submission.submitters, name: :attachments)
.preload(:blob).index_by(&:uuid)
end end
end end
@@ -1,34 +0,0 @@
# frozen_string_literal: true
class SubmitFormDeclineController < ApplicationController
skip_before_action :authenticate_user!
skip_authorization_check
def create
submitter = Submitter.find_by!(slug: params[:submit_form_slug])
return redirect_to submit_form_path(submitter.slug) if submitter.declined_at? ||
submitter.completed_at? ||
submitter.submission.archived_at? ||
submitter.submission.expired? ||
submitter.submission.template&.archived_at? ||
!Submitters::AuthorizedForForm.call(submitter, current_user,
request)
ApplicationRecord.transaction do
submitter.update!(declined_at: Time.current)
SubmissionEvents.create_with_tracking_data(submitter, 'decline_form', request, { reason: params[:reason] })
end
user = submitter.submission.created_by_user || submitter.template.author
if user.user_configs.find_by(key: UserConfig::RECEIVE_DECLINED_EMAIL)&.value != false
SubmitterMailer.declined_email(submitter, user).deliver_later!
end
WebhookUrls.enqueue_events(submitter, 'form.declined')
redirect_to submit_form_path(submitter.slug)
end
end
@@ -1,41 +0,0 @@
# frozen_string_literal: true
class SubmitFormDownloadController < ApplicationController
skip_before_action :authenticate_user!
skip_authorization_check
FILES_TTL = 5.minutes
def index
@submitter = Submitter.find_by!(slug: params[:submit_form_slug])
return redirect_to submitter_download_index_path(@submitter.slug) if @submitter.completed_at?
return head :unprocessable_content if @submitter.declined_at? ||
@submitter.submission.archived_at? ||
@submitter.submission.expired? ||
@submitter.submission.template&.archived_at? ||
AccountConfig.exists?(account_id: @submitter.account_id,
key: AccountConfig::ALLOW_TO_PARTIAL_DOWNLOAD_KEY,
value: false) ||
!Submitters::AuthorizedForForm.call(@submitter, current_user, request)
last_completed_submitter = @submitter.submission.submitters
.where.not(id: @submitter.id)
.where.not(completed_at: nil)
.max_by(&:completed_at)
attachments =
if last_completed_submitter
Submitters.select_attachments_for_download(last_completed_submitter)
else
@submitter.submission.schema_documents.preload(:blob)
end
urls = attachments.map do |attachment|
ActiveStorage::Blob.proxy_path(attachment.blob, expires_at: FILES_TTL.from_now.to_i)
end
render json: urls
end
end
@@ -1,22 +0,0 @@
# frozen_string_literal: true
class SubmitFormDrawSignatureController < ApplicationController
layout false
around_action :with_browser_locale, only: %i[show]
skip_before_action :authenticate_user!
skip_authorization_check
def show
@submitter = Submitter.find_by!(slug: params[:slug])
return redirect_to submit_form_completed_path(@submitter.slug) if @submitter.completed_at?
if @submitter.submission.template&.archived_at? || @submitter.submission.archived_at? ||
!Submitters::AuthorizedForForm.call(@submitter, current_user, request)
return redirect_to submit_form_path(@submitter.slug)
end
render :show
end
end
@@ -1,52 +0,0 @@
# frozen_string_literal: true
class SubmitFormEmail2fasController < ApplicationController
around_action :with_browser_locale
skip_before_action :authenticate_user!
skip_authorization_check
before_action :load_submitter
COOKIES_TTL = 12.hours
COOKIES_DEFAULTS = { httponly: true, secure: Rails.env.production? }.freeze
def create
RateLimit.call("verify-2fa-code-#{@submitter.id}", limit: 2, ttl: 45.seconds, enabled: true)
value = [@submitter.email.downcase.strip, @submitter.slug].join(':')
if EmailVerificationCodes.verify(params[:one_time_code].to_s.gsub(/\D/, ''), value)
SubmissionEvents.create_with_tracking_data(@submitter, 'email_verified', request, { email: @submitter.email })
cookies.encrypted[:email_2fa_slug] =
{ value: @submitter.slug, expires: COOKIES_TTL.from_now, **COOKIES_DEFAULTS }
redirect_to submit_form_path(@submitter.slug)
else
redirect_to submit_form_path(@submitter.slug, status: :error), alert: I18n.t(:invalid_code)
end
rescue RateLimit::LimitApproached
redirect_to submit_form_path(@submitter.slug, status: :error), alert: I18n.t(:too_many_attempts)
end
def update
if @submitter.submission_events.where(event_type: 'send_2fa_email').exists?(created_at: 15.seconds.ago..)
return redirect_to submit_form_path(@submitter.slug, status: :error), alert: I18n.t(:rate_limit_exceeded)
end
RateLimit.call("send-email-code-#{@submitter.id}", limit: 2, ttl: 45.seconds, enabled: true)
SendSubmitterVerificationEmailJob.perform_async('submitter_id' => @submitter.id, 'locale' => I18n.locale.to_s)
redir_params = params[:resend] ? { alert: I18n.t(:code_has_been_resent) } : {}
redirect_to submit_form_path(@submitter.slug, status: :sent), **redir_params
rescue RateLimit::LimitApproached
redirect_to submit_form_path(@submitter.slug, status: :error), alert: I18n.t(:too_many_attempts)
end
def load_submitter
@submitter = Submitter.find_by!(slug: params[:submitter_slug])
end
end
@@ -1,63 +0,0 @@
# frozen_string_literal: true
class SubmitFormInviteController < ApplicationController
skip_before_action :authenticate_user!
skip_authorization_check
def create
submitter = Submitter.find_by!(slug: params[:submit_form_slug])
return head :unprocessable_content unless can_invite?(submitter)
invite_submitters = filter_invite_submitters(submitter, 'invite_by_uuid')
optional_invite_submitters = filter_invite_submitters(submitter, 'optional_invite_by_uuid')
ApplicationRecord.transaction do
(invite_submitters + optional_invite_submitters).each do |item|
attrs = submitters_attributes.find { |e| e[:uuid] == item['uuid'] }
next unless attrs
next if attrs[:email].blank?
email = Submissions.normalize_email(attrs[:email])
submitter.submission.submitters.create!(uuid: attrs[:uuid], email:, account_id: submitter.account_id)
SubmissionEvents.create_with_tracking_data(submitter, 'invite_party', request, { uuid: submitter.uuid })
end
submitter.submission.update!(submitters_order: :preserved)
end
submitter.submission.submitters.reload
if invite_submitters.all? { |s| submitter.submission.submitters.any? { |e| e.uuid == s['uuid'] } }
Submitters::SubmitValues.call(submitter, ActionController::Parameters.new(completed: 'true'), request)
head :ok
else
head :unprocessable_content
end
end
private
def can_invite?(submitter)
!submitter.declined_at? &&
!submitter.completed_at? &&
!submitter.submission.archived_at? &&
!submitter.submission.expired? &&
!submitter.submission.template&.archived_at? &&
Submitters::AuthorizedForForm.call(submitter, current_user, request)
end
def filter_invite_submitters(submitter, key = 'invite_by_uuid')
(submitter.submission.template_submitters || submitter.submission.template.submitters).select do |s|
s[key] == submitter.uuid && submitter.submission.submitters.none? { |e| e.uuid == s['uuid'] }
end
end
def submitters_attributes
params.require(:submission).permit(submitters: [%i[uuid email]]).fetch(:submitters, [])
end
end
@@ -1,25 +0,0 @@
# frozen_string_literal: true
class SubmitFormValuesController < ApplicationController
skip_before_action :authenticate_user!
skip_authorization_check
def index
submitter = Submitter.find_by!(slug: params[:submit_form_slug])
return render json: {} if submitter.completed_at? ||
submitter.declined_at? ||
submitter.submission.template&.archived_at? ||
submitter.submission.archived_at? ||
submitter.submission.expired? ||
!Submitters::AuthorizedForForm.call(submitter, current_user, request)
value = submitter.values[params['field_uuid']]
attachment = submitter.attachments.where(created_at: params[:after]..).find_by(uuid: value) if value.present?
render json: {
value:,
attachment: attachment&.as_json(only: %i[uuid created_at], methods: %i[url filename content_type])
}, head: :ok
end
end
@@ -1,47 +0,0 @@
# frozen_string_literal: true
class SubmittersAutocompleteController < ApplicationController
load_and_authorize_resource :submitter, parent: false
SELECT_COLUMNS = %w[email phone name].freeze
LIMIT = 100
def index
field = SELECT_COLUMNS.find { |c| c == params[:field] }
submitters = search_submitters(@submitters, field)
arel_columns = SELECT_COLUMNS.map { |col| Submitter.arel_table[col] }
values =
if field
max_ids = submitters.group(field).limit(LIMIT).select(Submitter.arel_table[:id].maximum)
submitters.where(id: max_ids).order(id: :desc).pluck(arel_columns)
else
submitters.limit(LIMIT).group(arel_columns).pluck(arel_columns)
end
attrs = values.map { |row| SELECT_COLUMNS.zip(row).to_h }
render json: attrs
end
private
def search_submitters(submitters, field)
if field
if Docuseal.fulltext_search?
Submitters.fulltext_search_field(current_user, submitters, params[:q], field)
else
column = Submitter.arel_table[field.to_sym]
term = "#{params[:q].downcase}%"
submitters.where(column.matches(term))
end
else
Submitters.search(current_user, submitters, params[:q])
end
end
end
-82
View File
@@ -1,82 +0,0 @@
# frozen_string_literal: true
class SubmittersController < ApplicationController
load_and_authorize_resource :submitter, only: %i[edit update]
def edit
@submitter_email_message =
if @submitter.preferences['email_message_uuid'].present?
@submitter.account.email_messages.find_by(uuid: @submitter.preferences['email_message_uuid'])
end
end
def update
submission = @submitter.submission
if @submitter.submission_events.exists?(event_type: 'start_form') || submission.archived_at? || submission.expired?
return redirect_back fallback_location: submission_path(submission), alert: I18n.t('submitter_cannot_be_updated')
end
if submitter_params.values.all?(&:blank?)
return redirect_back fallback_location: submission_path(submission),
alert: I18n.t('at_least_one_field_must_be_filled')
end
if params[:is_custom_message] != '1'
params.delete(:subject)
params.delete(:body)
end
submitter_preferences = Submitters.normalize_preferences(@submitter.account, current_user, params)
if submitter_preferences.key?('email_message_uuid')
@submitter.preferences['email_message_uuid'] = submitter_preferences['email_message_uuid']
end
assign_submitter_attrs(@submitter, submitter_params)
if @submitter.save
maybe_resend_email_sms(@submitter, params)
SearchEntries.enqueue_reindex(@submitter)
redirect_back fallback_location: submission_path(submission), notice: I18n.t('changes_have_been_saved')
else
redirect_back fallback_location: submission_path(submission), alert: I18n.t('unable_to_save')
end
end
private
def maybe_resend_email_sms(submitter, params)
if params[:send_email] == '1' && submitter.email.present?
is_sent_recently = Docuseal.multitenant? &&
EmailEvent.exists?(email: submitter.email,
tag: 'submitter_invitation',
emailable: submitter,
event_type: 'send',
created_at: 4.hours.ago..Time.current)
SendSubmitterInvitationEmailJob.perform_async('submitter_id' => submitter.id) unless is_sent_recently
end
return if submitter.phone.blank?
return unless params[:send_sms] == '1'
SendSubmitterInvitationSmsJob.perform_async('submitter_id' => submitter.id)
end
def assign_submitter_attrs(submitter, attrs)
submitter.phone = attrs[:phone].to_s.gsub(/[^0-9+]/, '') if attrs.key?(:phone)
submitter.email = Submissions.normalize_email(attrs[:email]) if attrs.key?(:email)
submitter.name = attrs[:name] if attrs.key?(:name)
submitter
end
def submitter_params
params.require(:submitter).permit(:email, :name, :phone).transform_values(&:strip)
end
end
@@ -1,64 +0,0 @@
# frozen_string_literal: true
class SubmittersResubmitController < ApplicationController
load_and_authorize_resource :submitter, parent: false
def update
return redirect_to submit_form_path(slug: @submitter.slug) if @submitter.email != current_user.email
submission = @submitter.account.submissions.new(created_by_user: current_user,
submitters_order: :preserved,
**@submitter.submission.slice(:template_fields,
:account_id,
:name,
:template_id,
:template_schema,
:template_submitters,
:preferences))
@submitter.submission.submitters.each do |submitter|
new_submitter = submission.submitters.new(submitter.slice(:uuid, :email, :phone, :name,
:preferences, :metadata, :account_id))
next unless submitter.uuid == @submitter.uuid
assign_submitter_values(new_submitter, submitter)
@new_submitter ||= new_submitter
end
submission.save!
@submitter.submission.documents_attachments.each do |attachment|
submission.documents_attachments.create!(uuid: attachment.uuid, blob_id: attachment.blob_id)
end
redirect_to submit_form_path(slug: @new_submitter.slug)
end
private
def assign_submitter_values(new_submitter, submitter)
attachments_index = submitter.attachments.index_by(&:uuid)
submitter.submission.template_fields.each do |field|
next if field['submitter_uuid'] != submitter.uuid
next if field['default_value'] == '{{date}}'
next if field['type'] == 'stamp'
next if field['type'] == 'signature'
next if field.dig('preferences', 'formula').present?
value = submitter.values[field['uuid']]
next if value.blank?
if field['type'].in?(%w[image file initials])
Array.wrap(value).each do |attachment_uuid|
new_submitter.attachments << attachments_index[attachment_uuid].dup
end
end
new_submitter.values[field['uuid']] = value
end
end
end
@@ -1,23 +0,0 @@
# frozen_string_literal: true
class SubmittersSendEmailController < ApplicationController
load_and_authorize_resource :submitter, id_param: :submitter_slug, find_by: :slug
def create
if Docuseal.multitenant? && SubmissionEvent.exists?(submitter: @submitter,
event_type: 'send_email',
created_at: 10.hours.ago..Time.current)
Rollbar.warning("Already sent: #{@submitter.id}") if defined?(Rollbar)
return redirect_back(fallback_location: submission_path(@submitter.submission),
alert: I18n.t('email_has_been_sent_already'))
end
SendSubmitterInvitationEmailJob.perform_async('submitter_id' => @submitter.id)
@submitter.sent_at ||= Time.current
@submitter.save!
redirect_back(fallback_location: submission_path(@submitter.submission), notice: I18n.t('email_has_been_sent'))
end
end
@@ -1,39 +0,0 @@
# frozen_string_literal: true
class TemplateDocumentsController < ApplicationController
load_and_authorize_resource :template
FILES_TTL = 5.minutes
def index
render json: @template.schema_documents.map { |d| ActiveStorage::Blob.proxy_path(d.blob, expires_at: FILES_TTL.from_now.to_i) }
end
def create
if params[:blobs].blank? && params[:files].blank?
return render json: { error: I18n.t('file_is_missing') }, status: :unprocessable_content
end
old_fields_hash = @template.fields.hash
documents, = Templates::CreateAttachments.call(@template, params, extract_fields: true)
schema = documents.map do |doc|
{ attachment_uuid: doc.uuid, name: doc.filename.base }
end
render json: {
schema:,
fields: old_fields_hash == @template.fields.hash ? nil : @template.fields,
submitters: old_fields_hash == @template.fields.hash ? nil : @template.submitters,
documents: documents.as_json(
methods: %i[metadata signed_key],
include: {
preview_images: { methods: %i[url metadata filename] }
}
)
}
rescue Templates::CreateAttachments::PdfEncrypted
render json: { error: 'PDF encrypted', status: 'pdf_encrypted' }, status: :unprocessable_content
end
end
@@ -1,33 +0,0 @@
# frozen_string_literal: true
class TemplateFoldersAutocompleteController < ApplicationController
load_and_authorize_resource :template_folder, parent: false
LIMIT = 30
def index
parent_name, name =
if params[:parent_name].present?
[params[:parent_name], params[:q]]
else
params[:q].to_s.split(' /', 2).map(&:squish)
end
if name
parent_folder = @template_folders.find_by(name: parent_name, parent_folder_id: nil)
else
name = parent_name
end
template_folders = TemplateFolders.filter_active_folders(@template_folders.where(parent_folder:),
Template.accessible_by(current_ability))
name = name.to_s.downcase
template_folders = TemplateFolders.search(template_folders, name).order(id: :desc).limit(LIMIT)
render json: template_folders.preload(:parent_folder)
.sort_by { |e| e.name.downcase.index(name) || Float::MAX }
.as_json(only: %i[name archived_at], methods: %i[full_name])
end
end
@@ -1,86 +0,0 @@
# frozen_string_literal: true
class TemplateFoldersController < ApplicationController
load_and_authorize_resource :template_folder
helper_method :selected_order
TEMPLATES_PER_PAGE = 12
FOLDERS_PER_PAGE = 18
def show
@templates = Template.active.accessible_by(current_ability)
.where(folder: [@template_folder, *(params[:q].present? ? @template_folder.subfolders : [])])
.preload(:author, :template_accesses)
@template_folders =
@template_folder.subfolders.where(id: Template.accessible_by(current_ability).active.select(:folder_id))
@template_folders = TemplateFolders.search(@template_folders, params[:q])
@template_folders = TemplateFolders.sort(@template_folders, current_user, selected_order)
if @templates.exists?
@templates = Templates.search(current_user, @templates, params[:q])
@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
@pagy, @templates = pagy_auto(@templates, limit:)
load_related_submissions if params[:q].present? && @templates.blank?
else
@pagy, @template_folders = pagy(@template_folders, limit: FOLDERS_PER_PAGE)
@templates = @templates.none
end
end
def edit; end
def update
if @template_folder != current_account.default_template_folder &&
@template_folder.update(template_folder_params)
redirect_to folder_path(@template_folder), notice: I18n.t('folder_name_has_been_updated')
else
redirect_to folder_path(@template_folder), alert: I18n.t('unable_to_rename_folder')
end
end
private
def selected_order
@selected_order ||=
if cookies.permanent[:dashboard_templates_order].blank? ||
(cookies.permanent[:dashboard_templates_order] == 'used_at' && can?(:manage, :countless))
'created_at'
else
cookies.permanent[:dashboard_templates_order]
end
end
def template_folder_params
params.require(:template_folder).permit(:name)
end
def load_related_submissions
@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])
.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_pagy, @related_submissions = pagy_auto(@related_submissions, limit: 5)
end
end
@@ -1,21 +0,0 @@
# frozen_string_literal: true
class TemplateSharingsTestingController < ApplicationController
load_and_authorize_resource :template, parent: true
before_action do
authorize!(:manage, TemplateSharing.new(template: @template))
end
def create
testing_account = Accounts.find_or_create_testing_user(true_user.account).account
if params[:value] == '1'
TemplateSharing.create!(ability: :manage, account: testing_account, template: @template)
else
TemplateSharing.find_by(template: @template, account: testing_account)&.destroy!
end
head :ok
end
end
@@ -1,30 +0,0 @@
# frozen_string_literal: true
class TemplatesArchivedController < ApplicationController
load_and_authorize_resource :template, parent: false
def index
@templates = @templates.where.not(archived_at: nil)
.preload(:author, :template_accesses, folder: :parent_folder)
.order(id: :desc)
@templates = Templates.search(current_user, @templates, params[:q])
@pagy, @templates = pagy_auto(@templates, limit: 12)
return unless params[:q].present? && @templates.blank?
@related_submissions =
Submission.accessible_by(current_ability)
.joins(:template)
.where.not(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)
end
end
@@ -1,22 +0,0 @@
# frozen_string_literal: true
class TemplatesArchivedSubmissionsController < ApplicationController
load_and_authorize_resource :template
load_and_authorize_resource :submission, through: :template, parent: false
def index
@submissions = @submissions.where.not(archived_at: nil)
@submissions = Submissions.search(current_user, @submissions, params[:q], search_values: true)
@submissions = Submissions::Filter.call(@submissions, current_user, params)
@submissions = if params[:completed_at_from].present? || params[:completed_at_to].present?
@submissions.order(Submitter.arel_table[:completed_at].maximum.desc)
else
@submissions.order(id: :desc)
end
@pagy, @submissions = pagy_auto(@submissions.preload(submitters: :start_form_submission_events))
rescue ActiveRecord::RecordNotFound
redirect_to root_path
end
end
@@ -1,39 +0,0 @@
# frozen_string_literal: true
class TemplatesCloneAndReplaceController < ApplicationController
load_and_authorize_resource :template
def create
return head :unprocessable_content if params[:files].blank?
ActiveRecord::Associations::Preloader.new(
records: [@template],
associations: [schema_documents: :preview_images_attachments]
).call
cloned_template = Templates::Clone.call(@template, author: current_user)
cloned_template.name = File.basename(params[:files].first.original_filename, '.*')
cloned_template.save!
documents = Templates::ReplaceAttachments.call(cloned_template, params, extract_fields: true)
Templates.maybe_assign_access(cloned_template)
cloned_template.save!
Templates::CloneAttachments.call(template: cloned_template, original_template: @template,
excluded_attachment_uuids: documents.map(&:uuid))
SearchEntries.enqueue_reindex(cloned_template)
respond_to do |f|
f.html { redirect_to edit_template_path(cloned_template) }
f.json { render json: { id: cloned_template.id } }
end
rescue Templates::CreateAttachments::PdfEncrypted
respond_to do |f|
f.html { render turbo_stream: turbo_stream.append(params[:form_id], html: helpers.tag.prompt_password) }
f.json { render json: { error: 'PDF encrypted', status: 'pdf_encrypted' }, status: :unprocessable_content }
end
end
end
@@ -1,56 +0,0 @@
# frozen_string_literal: true
class TemplatesCloneController < ApplicationController
load_and_authorize_resource :template, instance_name: :base_template
def new
authorize!(:create, Template)
@template = Template.new(name: "#{@base_template.name} (#{I18n.t('clone')})")
end
def create
ActiveRecord::Associations::Preloader.new(
records: [@base_template],
associations: [schema_documents: :preview_images_attachments]
).call
@template = Templates::Clone.call(@base_template, author: current_user,
name: params.dig(:template, :name),
folder_name: params[:folder_name])
authorize!(:create, @template)
if params[:account_id].present? && true_ability.can?(:manage, Account.find(params[:account_id]))
@template.account_id = params[:account_id]
@template.author = true_user if true_user.account_id == @template.account_id
@template.folder = @template.account.default_template_folder if @template.account_id != current_account.id
else
@template.account = current_account
end
Templates.maybe_assign_access(@template)
if @template.save
Templates::CloneAttachments.call(template: @template, original_template: @base_template)
SearchEntries.enqueue_reindex(@template)
WebhookUrls.enqueue_events(@template, 'template.created')
maybe_redirect_to_template(@template)
else
render turbo_stream: turbo_stream.replace(:modal, partial: 'templates_clone/form'), status: :unprocessable_content
end
end
private
def maybe_redirect_to_template(template)
if template.account == current_account
redirect_to(edit_template_path(template))
else
redirect_back(fallback_location: root_path, notice: I18n.t('template_has_been_cloned'))
end
end
end
@@ -1,7 +0,0 @@
# frozen_string_literal: true
class TemplatesCodeModalController < ApplicationController
load_and_authorize_resource :template
def show; end
end
+26 -80
View File
@@ -1,114 +1,60 @@
# frozen_string_literal: true # frozen_string_literal: true
class TemplatesController < ApplicationController class TemplatesController < ApplicationController
load_and_authorize_resource :template before_action :load_base_template, only: %i[new create]
def show def show
submissions = @template.submissions.accessible_by(current_ability) @template = current_account.templates.active.find(params[:id])
submissions = submissions.active if @template.archived_at.blank?
submissions = Submissions.search(current_user, submissions, params[:q], search_values: true)
submissions = Submissions::Filter.call(submissions, current_user, params.except(:status))
@base_submissions = submissions @pagy, @submissions = pagy(@template.submissions.active.preload(:submitters).order(id: :desc))
submissions = Submissions::Filter.filter_by_status(submissions, params)
submissions = if params[:completed_at_from].present? || params[:completed_at_to].present?
submissions.order(Submitter.arel_table[:completed_at].maximum.desc)
else
submissions.order(id: :desc)
end
@pagy, @submissions = pagy_auto(submissions.preload(:template_accesses, submitters: :start_form_submission_events))
rescue ActiveRecord::RecordNotFound rescue ActiveRecord::RecordNotFound
redirect_to root_path redirect_to root_path
end end
def new; end def new
@template = current_account.templates.new
@template.name = "#{@base_template.name} (Clone)" if @base_template
end
def edit def edit
ActiveRecord::Associations::Preloader.new( @template = current_account.templates.preload(documents_attachments: { preview_images_attachments: :blob })
records: [@template], .find(params[:id])
associations: [schema_documents: [:blob, { 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
render :edit, layout: 'plain' render :edit, layout: 'plain'
end end
def create def create
@template = current_account.templates.new(template_params)
@template.author = current_user @template.author = current_user
@template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:folder_name]) @template.assign_attributes(@base_template.slice(:fields, :schema, :submitters)) if @base_template
@template.account = current_account
Templates.maybe_assign_access(@template)
if @template.save if @template.save
SearchEntries.enqueue_reindex(@template) Templates::CloneAttachments.call(template: @template, original_template: @base_template) if @base_template
WebhookUrls.enqueue_events(@template, 'template.created') redirect_to edit_template_path(@template)
redirect_to(edit_template_path(@template))
else else
render turbo_stream: turbo_stream.replace(:modal, template: 'templates/new'), status: :unprocessable_content render turbo_stream: turbo_stream.replace(:modal, template: 'templates/new'), status: :unprocessable_entity
end end
end end
def update
@template.assign_attributes(template_params)
is_name_changed = @template.name_changed?
@template.save!
SearchEntries.enqueue_reindex(@template) if is_name_changed
WebhookUrls.enqueue_events(@template, 'template.updated')
head :ok
end
def destroy def destroy
notice = @template = current_account.templates.find(params[:id])
if params[:permanently].in?(['true', true]) @template.update!(deleted_at: Time.current)
@template.destroy!
I18n.t('template_has_been_removed') redirect_back(fallback_location: root_path, notice: 'Template has been archived.')
else
@template.update!(archived_at: Time.current)
I18n.t('template_has_been_archived')
end
redirect_back(fallback_location: root_path, notice:)
end end
private private
def template_params def template_params
params.require(:template).permit( params.require(:template).permit(:name)
:name, end
{ schema: [[:attachment_uuid, :google_drive_file_id, :name, :dynamic,
{ conditions: [%i[field_uuid value action operation]] }]], def load_base_template
submitters: [%i[name uuid is_requester linked_to_uuid invite_via_field_uuid return if params[:base_template_id].blank?
invite_by_uuid optional_invite_by_uuid email order]],
variables_schema: {}, @base_template = current_account.templates
fields: [[:uuid, :submitter_uuid, :name, :type, .preload(documents_attachments: :preview_images_attachments)
:required, :readonly, :default_value, .find_by(id: params[:base_template_id])
:title, :description, :prefillable,
{ preferences: {},
default_value: [],
conditions: [%i[field_uuid value action operation]],
options: [%i[value uuid]],
validation: %i[message pattern min max step],
areas: [%i[uuid x y w h cell_w attachment_uuid option_uuid page]] }]] }
)
end end
end end
Loaded 100 of 943 files, more files were not shown because too many files have changed in this diff. Show more