diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ae1b667c..754d06ad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,12 +6,13 @@ jobs: rubocop: name: Rubocop runs-on: ubuntu-latest + timeout-minutes: 10 steps: - uses: actions/checkout@v4 - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 3.4.2 - name: Cache gems uses: actions/cache@v4 with: @@ -30,12 +31,13 @@ jobs: erblint: name: Erblint runs-on: ubuntu-latest + timeout-minutes: 10 steps: - uses: actions/checkout@v4 - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 3.4.2 - name: Cache gems uses: actions/cache@v4 with: @@ -54,6 +56,7 @@ jobs: eslint: name: ESLint runs-on: ubuntu-latest + timeout-minutes: 10 steps: - uses: actions/checkout@v4 - name: Install Node.js @@ -80,12 +83,13 @@ jobs: 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: 3.4.1 + ruby-version: 3.4.2 - name: Cache gems uses: actions/cache@v4 with: @@ -107,6 +111,7 @@ jobs: rspec: name: RSpec runs-on: ubuntu-latest + timeout-minutes: 10 services: postgres: @@ -127,7 +132,7 @@ jobs: - name: Install Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: 3.4.2 - name: Set up Node uses: actions/setup-node@v1 with: @@ -157,7 +162,10 @@ jobs: bundle install --jobs 4 --retry 4 yarn install sudo apt-get update - sudo apt-get install libvips + sudo apt-get install -y libvips + wget -O pdfium-linux.tgz "https://github.com/docusealco/pdfium-binaries/releases/latest/download/pdfium-linux-$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/').tgz" + sudo tar -xzf pdfium-linux.tgz --strip-components=1 -C /usr/lib lib/libpdfium.so + rm -f pdfium-linux.tgz - name: Run env: RAILS_ENV: test diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 3250e475..0b6c25f9 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -1,13 +1,27 @@ name: Build Docker Images on: - push: - tags: - - "*.*.*" + workflow_dispatch: + inputs: + version: + description: Version + type: string + required: true + image: + description: QEMU image + type: string + required: false + default: tonistiigi/binfmt:latest + os: + description: OS + type: string + required: false + default: ubuntu-24.04-arm jobs: build: - runs-on: ubuntu-24.04-arm + runs-on: ${{ inputs.os }} + timeout-minutes: 20 steps: - name: Checkout code @@ -15,23 +29,23 @@ jobs: with: submodules: recursive - - - name: Docker meta + - name: Docker meta id: meta uses: docker/metadata-action@v4 with: - images: | - docuseal/docuseal - tags: | - type=semver,pattern={{version}} + images: docuseal/docuseal + tags: latest,${{ inputs.version }} + - name: Set up QEMU uses: docker/setup-qemu-action@v3 + with: + image: ${{ inputs.image }} - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Create .version file - run: echo ${{ github.ref_name }} > .version + run: echo ${{ inputs.version }} > .version - name: Login to Docker Hub uses: docker/login-action@v3 diff --git a/.gitignore b/.gitignore index 63ca72d8..d14f4595 100644 --- a/.gitignore +++ b/.gitignore @@ -37,8 +37,4 @@ yarn-debug.log* /docuseal /ee dump.rdb -/custom/ -/docuseal.iml -/.idea/misc.xml -/.idea/modules.xml -/.idea/vcs.xml +*.onnx diff --git a/.rspec b/.rspec new file mode 100644 index 00000000..a35c44f4 --- /dev/null +++ b/.rspec @@ -0,0 +1 @@ +--require rails_helper diff --git a/.rubocop.yml b/.rubocop.yml index a679c86a..294fc484 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -42,6 +42,15 @@ Metrics/CyclomaticComplexity: Metrics/PerceivedComplexity: Max: 15 +Style/MultipleComparison: + Enabled: false + +Style/NumericPredicate: + Enabled: false + +Naming/PredicateMethod: + Enabled: false + Layout/LineLength: AllowedPatterns: ['\A\s*#'] @@ -63,10 +72,13 @@ RSpec/MultipleExpectations: Max: 25 RSpec/ExampleLength: - Max: 50 + Max: 500 RSpec/MultipleMemoizedHelpers: - Max: 9 + Max: 15 + +RSpec/AnyInstance: + Enabled: false Metrics/BlockNesting: Max: 5 diff --git a/Dockerfile b/Dockerfile index 23fa1ad7..b0be901f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,22 @@ -FROM ruby:3.4.1-alpine AS fonts +FROM ruby:3.4.2-alpine AS download WORKDIR /fonts -RUN apk --no-cache add fontforge wget && wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Regular.ttf && wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Bold.ttf && wget https://github.com/impallari/DancingScript/raw/master/fonts/DancingScript-Regular.otf && wget https://cdn.jsdelivr.net/gh/notofonts/notofonts.github.io/fonts/NotoSansSymbols2/hinted/ttf/NotoSansSymbols2-Regular.ttf && wget https://github.com/Maxattax97/gnu-freefont/raw/master/ttf/FreeSans.ttf && wget https://github.com/impallari/DancingScript/raw/master/OFL.txt +RUN apk --no-cache add fontforge wget && \ + wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Regular.ttf && \ + wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Bold.ttf && \ + wget https://github.com/impallari/DancingScript/raw/master/fonts/DancingScript-Regular.otf && \ + wget https://cdn.jsdelivr.net/gh/notofonts/notofonts.github.io/fonts/NotoSansSymbols2/hinted/ttf/NotoSansSymbols2-Regular.ttf && \ + wget https://github.com/Maxattax97/gnu-freefont/raw/master/ttf/FreeSans.ttf && \ + wget https://github.com/impallari/DancingScript/raw/master/OFL.txt && \ + wget -O /model.onnx "https://github.com/docusealco/fields-detection/releases/download/1.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:3.4.1-alpine AS webpack +FROM ruby:3.4.2-alpine AS webpack ENV RAILS_ENV=production ENV NODE_ENV=production @@ -32,7 +42,7 @@ COPY ./app/views ./app/views RUN echo "gem 'shakapacker'" > Gemfile && ./bin/shakapacker -FROM ruby:3.4.1-alpine AS app +FROM ruby:3.4.2-alpine AS app ENV RAILS_ENV=production ENV BUNDLE_WITHOUT="development:test" @@ -41,7 +51,7 @@ ENV OPENSSL_CONF=/app/openssl_legacy.cnf WORKDIR /app -RUN echo '@edge https://dl-cdn.alpinelinux.org/alpine/edge/community' >> /etc/apk/repositories && apk add --no-cache sqlite-dev libpq-dev mariadb-dev vips-dev vips-poppler poppler-utils redis libheif@edge vips-heif gcompat ttf-freefont && mkdir /fonts && rm /usr/share/fonts/freefont/FreeSans.otf +RUN apk add --no-cache sqlite-dev libpq-dev mariadb-dev vips-dev yaml-dev redis libheif vips-heif gcompat ttf-freefont && mkdir /fonts && rm /usr/share/fonts/freefont/FreeSans.otf RUN echo $'.include = /etc/ssl/openssl.cnf\n\ \n\ @@ -57,7 +67,9 @@ activate = 1' >> /app/openssl_legacy.cnf COPY ./Gemfile ./Gemfile.lock ./ -RUN apk add --no-cache build-base && bundle install && apk del --no-cache build-base && rm -rf ~/.bundle /usr/local/bundle/cache && ruby -e "puts Dir['/usr/local/bundle/**/{spec,rdoc,resources/shared,resources/collation,resources/locales}']" | xargs rm -rf +RUN apk add --no-cache build-base && bundle install && apk del --no-cache build-base && rm -rf ~/.bundle /usr/local/bundle/cache && ruby -e "puts Dir['/usr/local/bundle/**/{spec,rdoc,resources/shared,resources/collation,resources/locales}']" | xargs rm -rf && ln -sf /usr/lib/libonnxruntime.so.1 $(ruby -e "print Dir[Gem::Specification.find_by_name('onnxruntime').gem_dir + '/vendor/*.so'].first") + +RUN echo 'https://dl-cdn.alpinelinux.org/alpine/edge/main' >> /etc/apk/repositories && echo 'https://dl-cdn.alpinelinux.org/alpine/edge/community' >> /etc/apk/repositories && apk add --no-cache onnxruntime COPY ./bin ./bin COPY ./app ./app @@ -70,8 +82,11 @@ COPY ./tmp ./tmp COPY LICENSE README.md Rakefile config.ru .version ./ COPY .version ./public/version -COPY --from=fonts /fonts/GoNotoKurrent-Regular.ttf /fonts/GoNotoKurrent-Bold.ttf /fonts/DancingScript-Regular.otf /fonts/OFL.txt /fonts -COPY --from=fonts /fonts/FreeSans.ttf /usr/share/fonts/freefont +COPY --from=download /fonts/GoNotoKurrent-Regular.ttf /fonts/GoNotoKurrent-Bold.ttf /fonts/DancingScript-Regular.otf /fonts/OFL.txt /fonts +COPY --from=download /fonts/FreeSans.ttf /usr/share/fonts/freefont +COPY --from=download /pdfium-linux/lib/libpdfium.so /usr/lib/libpdfium.so +COPY --from=download /pdfium-linux/licenses/pdfium.txt /usr/lib/libpdfium-LICENSE.txt +COPY --from=download /model.onnx /app/tmp/model.onnx COPY --from=webpack /app/public/packs ./public/packs RUN ln -s /fonts /app/public/fonts diff --git a/Gemfile b/Gemfile index 6961ac80..b0974208 100644 --- a/Gemfile +++ b/Gemfile @@ -2,7 +2,7 @@ source 'https://rubygems.org' -ruby '3.4.1' +ruby '3.4.2' gem 'arabic-letter-connector', require: 'arabic-letter-connector/logic' gem 'aws-sdk-s3', require: false @@ -11,6 +11,7 @@ gem 'azure-storage-blob', require: false gem 'bootsnap', require: false gem 'cancancan' gem 'csv' +gem 'csv-safe' gem 'devise' gem 'devise-two-factor' gem 'dotenv', require: false @@ -23,7 +24,9 @@ gem 'image_processing' gem 'jwt' gem 'lograge' gem 'mysql2', require: false +gem 'numo-narray' gem 'oj' +gem 'onnxruntime' gem 'pagy' gem 'pg', require: false gem 'premailer-rails' @@ -34,6 +37,7 @@ gem 'rails' gem 'rails_autolink' gem 'rails-i18n' gem 'rotp' +gem 'rouge', require: false gem 'rqrcode' gem 'ruby-vips' gem 'rubyXL' diff --git a/Gemfile.lock b/Gemfile.lock index bf6f2456..f8f8b1ee 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,29 +1,29 @@ GEM remote: https://rubygems.org/ specs: - actioncable (8.0.1) - actionpack (= 8.0.1) - activesupport (= 8.0.1) + actioncable (8.0.2.1) + actionpack (= 8.0.2.1) + activesupport (= 8.0.2.1) nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) - actionmailbox (8.0.1) - actionpack (= 8.0.1) - activejob (= 8.0.1) - activerecord (= 8.0.1) - activestorage (= 8.0.1) - activesupport (= 8.0.1) + actionmailbox (8.0.2.1) + actionpack (= 8.0.2.1) + activejob (= 8.0.2.1) + activerecord (= 8.0.2.1) + activestorage (= 8.0.2.1) + activesupport (= 8.0.2.1) mail (>= 2.8.0) - actionmailer (8.0.1) - actionpack (= 8.0.1) - actionview (= 8.0.1) - activejob (= 8.0.1) - activesupport (= 8.0.1) + actionmailer (8.0.2.1) + actionpack (= 8.0.2.1) + actionview (= 8.0.2.1) + activejob (= 8.0.2.1) + activesupport (= 8.0.2.1) mail (>= 2.8.0) rails-dom-testing (~> 2.2) - actionpack (8.0.1) - actionview (= 8.0.1) - activesupport (= 8.0.1) + actionpack (8.0.2.1) + actionview (= 8.0.2.1) + activesupport (= 8.0.2.1) nokogiri (>= 1.8.5) rack (>= 2.2.4) rack-session (>= 1.0.1) @@ -31,35 +31,35 @@ GEM rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) useragent (~> 0.16) - actiontext (8.0.1) - actionpack (= 8.0.1) - activerecord (= 8.0.1) - activestorage (= 8.0.1) - activesupport (= 8.0.1) + actiontext (8.0.2.1) + actionpack (= 8.0.2.1) + activerecord (= 8.0.2.1) + activestorage (= 8.0.2.1) + activesupport (= 8.0.2.1) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (8.0.1) - activesupport (= 8.0.1) + actionview (8.0.2.1) + activesupport (= 8.0.2.1) builder (~> 3.1) erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - activejob (8.0.1) - activesupport (= 8.0.1) + activejob (8.0.2.1) + activesupport (= 8.0.2.1) globalid (>= 0.3.6) - activemodel (8.0.1) - activesupport (= 8.0.1) - activerecord (8.0.1) - activemodel (= 8.0.1) - activesupport (= 8.0.1) + activemodel (8.0.2.1) + activesupport (= 8.0.2.1) + activerecord (8.0.2.1) + activemodel (= 8.0.2.1) + activesupport (= 8.0.2.1) timeout (>= 0.4.0) - activestorage (8.0.1) - actionpack (= 8.0.1) - activejob (= 8.0.1) - activerecord (= 8.0.1) - activesupport (= 8.0.1) + activestorage (8.0.2.1) + actionpack (= 8.0.2.1) + activejob (= 8.0.2.1) + activerecord (= 8.0.2.1) + activesupport (= 8.0.2.1) marcel (~> 1.0) - activesupport (8.0.1) + activesupport (8.0.2.1) base64 benchmark (>= 0.3) bigdecimal @@ -76,7 +76,7 @@ GEM public_suffix (>= 2.0.2, < 7.0) annotaterb (4.14.0) arabic-letter-connector (0.1.1) - ast (2.4.2) + ast (2.4.3) aws-eventstream (1.3.0) aws-partitions (1.1027.0) aws-sdk-core (3.214.0) @@ -104,9 +104,9 @@ GEM faraday_middleware (~> 1.0, >= 1.0.0.rc1) net-http-persistent (~> 4.0) nokogiri (~> 1, >= 1.10.8) - base64 (0.2.0) + base64 (0.3.0) bcrypt (3.1.20) - benchmark (0.4.0) + benchmark (0.4.1) better_html (2.1.1) actionview (>= 6.0) activesupport (>= 6.0) @@ -114,7 +114,7 @@ GEM erubi (~> 1.4) parser (>= 2.4) smart_properties - bigdecimal (3.1.8) + bigdecimal (3.2.2) bindex (0.8.1) bootsnap (1.18.4) msgpack (~> 1.2) @@ -141,8 +141,8 @@ GEM cldr-plurals-runtime-rb (1.1.0) cmdparse (3.0.7) coderay (1.1.3) - concurrent-ruby (1.3.4) - connection_pool (2.4.1) + concurrent-ruby (1.3.5) + connection_pool (2.5.3) crack (1.0.0) bigdecimal rexml @@ -150,6 +150,8 @@ GEM css_parser (1.21.0) addressable csv (3.3.2) + csv-safe (3.3.1) + csv (~> 3.0) cuprite (0.15.1) capybara (~> 3.0) ferrum (~> 0.15.0) @@ -174,8 +176,9 @@ GEM rake (>= 12.0.0, < 14.0.0) docile (1.4.1) dotenv (3.1.7) - drb (2.2.1) + drb (2.2.3) email_typo (0.2.3) + erb (5.0.2) erb_lint (0.7.0) activesupport better_html (>= 2.0.1) @@ -270,26 +273,28 @@ GEM os (>= 0.9, < 2.0) signet (>= 0.16, < 2.a) hashdiff (1.1.2) - hexapdf (1.0.3) + hexapdf (1.4.0) cmdparse (~> 3.0, >= 3.0.3) geom2d (~> 0.4, >= 0.4.1) openssl (>= 2.2.1) + strscan (>= 3.1.2) htmlentities (4.3.4) httpclient (2.8.3) - i18n (1.14.6) + i18n (1.14.7) concurrent-ruby (~> 1.0) image_processing (1.13.0) mini_magick (>= 4.9.5, < 5) ruby-vips (>= 2.0.17, < 3) - io-console (0.8.0) - irb (1.14.3) + io-console (0.8.1) + irb (1.15.2) + pp (>= 0.6.0) rdoc (>= 4.0.0) reline (>= 0.4.2) jmespath (1.6.2) - json (2.9.1) + json (2.15.0) jwt (2.9.3) base64 - language_server-protocol (3.17.0.3) + language_server-protocol (3.17.0.5) launchy (3.0.1) addressable (~> 2.8) childprocess (~> 5.0) @@ -300,13 +305,14 @@ GEM letter_opener (~> 1.9) railties (>= 6.1) rexml - logger (1.6.4) + lint_roller (1.1.0) + logger (1.7.0) lograge (0.14.0) actionpack (>= 4) activesupport (>= 4) railties (>= 4) request_store (~> 1.0) - loofah (2.23.1) + loofah (2.24.1) crass (~> 1.0.2) nokogiri (>= 1.12.0) mail (2.8.1) @@ -319,8 +325,8 @@ GEM method_source (1.1.0) mini_magick (4.13.2) mini_mime (1.1.5) - mini_portile2 (2.8.8) - minitest (5.25.4) + mini_portile2 (2.8.9) + minitest (5.25.5) msgpack (1.7.5) multi_json (1.15.0) multipart-post (2.4.1) @@ -328,43 +334,54 @@ GEM mysql2 (0.5.6) net-http-persistent (4.0.5) connection_pool (~> 2.2) - net-imap (0.5.6) + net-imap (0.5.9) date net-protocol net-pop (0.1.2) net-protocol net-protocol (0.2.2) timeout - net-smtp (0.5.0) + net-smtp (0.5.1) net-protocol nio4r (2.7.4) - nokogiri (1.18.3) + nokogiri (1.18.9) mini_portile2 (~> 2.8.2) racc (~> 1.4) - nokogiri (1.18.3-aarch64-linux-gnu) + nokogiri (1.18.9-aarch64-linux-gnu) racc (~> 1.4) - nokogiri (1.18.3-aarch64-linux-musl) + nokogiri (1.18.9-aarch64-linux-musl) racc (~> 1.4) - nokogiri (1.18.3-arm64-darwin) + nokogiri (1.18.9-arm64-darwin) racc (~> 1.4) - nokogiri (1.18.3-x86_64-linux-gnu) + nokogiri (1.18.9-x86_64-linux-gnu) racc (~> 1.4) - nokogiri (1.18.3-x86_64-linux-musl) + nokogiri (1.18.9-x86_64-linux-musl) racc (~> 1.4) - oj (3.16.8) + numo-narray (0.9.2.1) + oj (3.16.11) bigdecimal (>= 3.0) ostruct (>= 0.2) + onnxruntime (0.10.1) + ffi + onnxruntime (0.10.1-aarch64-linux) + ffi + onnxruntime (0.10.1-arm64-darwin) + ffi + onnxruntime (0.10.1-x86_64-linux) + ffi openssl (3.3.0) orm_adapter (0.5.0) os (1.1.4) - ostruct (0.6.1) + ostruct (0.6.3) package_json (0.1.0) pagy (9.3.3) - parallel (1.26.3) - parser (3.3.6.0) + parallel (1.27.0) + parser (3.3.9.0) ast (~> 2.4.1) racc pg (1.5.9) + pp (0.6.2) + prettyprint premailer (1.27.0) addressable css_parser (>= 1.19.0) @@ -375,42 +392,45 @@ GEM premailer (~> 1.7, >= 1.7.9) pretender (0.5.0) actionpack (>= 6.1) + prettyprint (0.2.0) + prism (1.5.1) pry (0.15.0) coderay (~> 1.1) method_source (~> 1.0) pry-rails (0.3.11) pry (>= 0.13.0) - psych (5.2.2) + psych (5.2.6) date stringio public_suffix (6.0.1) puma (6.5.0) nio4r (~> 2.0) racc (1.8.1) - rack (3.1.10) + rack (3.2.3) rack-proxy (0.7.7) rack - rack-session (2.0.0) + rack-session (2.1.1) + base64 (>= 0.1.0) rack (>= 3.0.0) - rack-test (2.1.0) + rack-test (2.2.0) rack (>= 1.3) rackup (2.2.1) rack (>= 3) - rails (8.0.1) - actioncable (= 8.0.1) - actionmailbox (= 8.0.1) - actionmailer (= 8.0.1) - actionpack (= 8.0.1) - actiontext (= 8.0.1) - actionview (= 8.0.1) - activejob (= 8.0.1) - activemodel (= 8.0.1) - activerecord (= 8.0.1) - activestorage (= 8.0.1) - activesupport (= 8.0.1) + rails (8.0.2.1) + actioncable (= 8.0.2.1) + actionmailbox (= 8.0.2.1) + actionmailer (= 8.0.2.1) + actionpack (= 8.0.2.1) + actiontext (= 8.0.2.1) + actionview (= 8.0.2.1) + activejob (= 8.0.2.1) + activemodel (= 8.0.2.1) + activerecord (= 8.0.2.1) + activestorage (= 8.0.2.1) + activesupport (= 8.0.2.1) bundler (>= 1.15.0) - railties (= 8.0.1) - rails-dom-testing (2.2.0) + railties (= 8.0.2.1) + rails-dom-testing (2.3.0) activesupport (>= 5.0.0) minitest nokogiri (>= 1.6) @@ -424,22 +444,23 @@ GEM actionview (> 3.1) activesupport (> 3.1) railties (> 3.1) - railties (8.0.1) - actionpack (= 8.0.1) - activesupport (= 8.0.1) + railties (8.0.2.1) + actionpack (= 8.0.2.1) + activesupport (= 8.0.2.1) irb (~> 1.13) rackup (>= 1.0.0) rake (>= 12.2) thor (~> 1.0, >= 1.2.2) zeitwerk (~> 2.6) rainbow (3.1.1) - rake (13.2.1) - rdoc (6.10.0) + rake (13.3.0) + rdoc (6.14.2) + erb psych (>= 4.0.0) redis-client (0.23.0) connection_pool - regexp_parser (2.9.3) - reline (0.6.0) + regexp_parser (2.11.3) + reline (0.6.2) io-console (~> 0.5) representable (3.2.0) declarative (< 0.1.0) @@ -451,8 +472,9 @@ GEM actionpack (>= 5.2) railties (>= 5.2) retriable (3.1.2) - rexml (3.4.0) + rexml (3.4.4) rotp (6.3.0) + rouge (4.5.2) rqrcode (2.2.0) chunky_png (~> 1.0) rqrcode_core (~> 1.0) @@ -474,18 +496,20 @@ GEM rspec-mocks (~> 3.13) rspec-support (~> 3.13) rspec-support (3.13.2) - rubocop (1.69.2) + rubocop (1.81.1) json (~> 2.3) - language_server-protocol (>= 3.17.0) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) parallel (~> 1.10) parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 2.9.3, < 3.0) - rubocop-ast (>= 1.36.2, < 2.0) + rubocop-ast (>= 1.47.1, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 4.0) - rubocop-ast (1.37.0) - parser (>= 3.3.1.0) + rubocop-ast (1.47.1) + parser (>= 3.3.7.2) + prism (~> 1.4) rubocop-performance (1.23.0) rubocop (>= 1.48.1, < 2.0) rubocop-ast (>= 1.31.1, < 2.0) @@ -537,10 +561,11 @@ GEM sqlite3 (2.5.0-arm64-darwin) sqlite3 (2.5.0-x86_64-linux-gnu) sqlite3 (2.5.0-x86_64-linux-musl) - stringio (3.1.2) + stringio (3.1.7) strip_attributes (1.14.1) activemodel (>= 3.0, < 9.0) - thor (1.3.2) + strscan (3.1.5) + thor (1.4.0) timeout (0.4.3) trailblazer-option (0.1.2) turbo-rails (2.0.11) @@ -555,9 +580,9 @@ GEM tzinfo-data (1.2024.2) tzinfo (>= 1.0.0) uber (0.1.0) - unicode-display_width (3.1.2) - unicode-emoji (~> 4.0, >= 4.0.4) - unicode-emoji (4.0.4) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.1.0) uniform_notifier (1.16.0) uri (1.0.3) useragent (0.16.11) @@ -573,12 +598,13 @@ GEM crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) webrick (1.9.1) - websocket-driver (0.7.6) + websocket-driver (0.8.0) + base64 websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) xpath (3.2.0) nokogiri (~> 1.8) - zeitwerk (2.7.1) + zeitwerk (2.7.3) PLATFORMS aarch64-linux @@ -601,6 +627,7 @@ DEPENDENCIES cancancan capybara csv + csv-safe cuprite debug devise @@ -620,7 +647,9 @@ DEPENDENCIES letter_opener_web lograge mysql2 + numo-narray oj + onnxruntime pagy pg premailer-rails @@ -632,6 +661,7 @@ DEPENDENCIES rails-i18n rails_autolink rotp + rouge rqrcode rspec-rails rubocop @@ -652,7 +682,7 @@ DEPENDENCIES webmock RUBY VERSION - ruby 3.4.1p0 + ruby 3.4.2p28 BUNDLED WITH 2.5.3 diff --git a/README.md b/README.md index 05d2acdf..9753da2a 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ DocuSeal is an open source platform that provides secure and efficient digital d - PDF signature verification - Users management - Mobile-optimized -- 6 UI languages with signing available in 13 languages +- 7 UI languages with signing available in 14 languages - API and Webhooks for integrations - Easy to deploy in minutes diff --git a/app/controllers/account_configs_controller.rb b/app/controllers/account_configs_controller.rb index 21c380a2..cf128ef9 100644 --- a/app/controllers/account_configs_controller.rb +++ b/app/controllers/account_configs_controller.rb @@ -1,8 +1,10 @@ # frozen_string_literal: true class AccountConfigsController < ApplicationController - before_action :load_account_config - authorize_resource :account_config + before_action :load_account_config, only: :create + authorize_resource :account_config, only: :create + + load_and_authorize_resource :account_config, only: :destroy ALLOWED_KEYS = [ AccountConfig::ALLOW_TYPED_SIGNATURE, @@ -13,8 +15,11 @@ class AccountConfigsController < ApplicationController 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, @@ -29,6 +34,14 @@ class AccountConfigsController < ApplicationController head :ok end + def destroy + raise InvalidKey unless ALLOWED_KEYS.include?(@account_config.key) + + @account_config.destroy! + + redirect_back(fallback_location: root_path) + end + private def load_account_config diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index daeb44cc..0cd12d52 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -8,7 +8,8 @@ class AccountsController < ApplicationController 'es-ES' => 'Español', 'pt-PT' => 'Português', 'de-DE' => 'Deutsch', - 'it-IT' => 'Italiano' + 'it-IT' => 'Italiano', + 'nl-NL' => 'Nederlands' }.freeze before_action :load_account @@ -27,7 +28,7 @@ class AccountsController < ApplicationController 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')) - return render :show, status: :unprocessable_entity + return render :show, status: :unprocessable_content end @encrypted_config.save! @@ -39,13 +40,14 @@ class AccountsController < ApplicationController redirect_to settings_account_path, notice: I18n.t('account_information_has_been_updated') end rescue ActiveRecord::RecordInvalid - render :show, status: :unprocessable_entity + render :show, status: :unprocessable_content end def destroy authorize!(:manage, current_account) - true_user.update!(locked_at: Time.current) + 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( diff --git a/app/controllers/api/active_storage_blobs_proxy_controller.rb b/app/controllers/api/active_storage_blobs_proxy_controller.rb index f023f021..a542c637 100644 --- a/app/controllers/api/active_storage_blobs_proxy_controller.rb +++ b/app/controllers/api/active_storage_blobs_proxy_controller.rb @@ -13,7 +13,7 @@ module Api def show blob_uuid, purp, exp = ApplicationRecord.signed_id_verifier.verified(params[:signed_uuid]) - if blob_uuid.blank? || (purp.present? && purp != 'blob') || (exp && exp < Time.current.to_i) + if blob_uuid.blank? || purp != 'blob' Rollbar.error('Blob not found') if defined?(Rollbar) return head :not_found @@ -21,7 +21,12 @@ module Api blob = ActiveStorage::Blob.find_by!(uuid: blob_uuid) - authorization_check!(blob) if exp.blank? + 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'] @@ -37,18 +42,22 @@ module Api private - def authorization_check!(blob) - attachment = blob.attachments.take + 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]) - is_authorized = attachment.name.in?(%w[logo preview_images]) || - (current_user && attachment.record.account.id == current_user.account_id) || - (current_user && !Docuseal.multitenant? && current_user.role == 'superadmin') || - !attachment.record.account.account_configs - .find_or_initialize_by(key: AccountConfig::DOWNLOAD_LINKS_AUTH_KEY).value + 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 is_authorized + return if !require_ttl && !require_auth + end - Rollbar.error('Blob aunauthorized') if defined?(Rollbar) + Rollbar.error('Blob unauthorized') if defined?(Rollbar) raise CanCan::AccessDenied end diff --git a/app/controllers/api/api_base_controller.rb b/app/controllers/api/api_base_controller.rb index b681aa67..70b992ed 100644 --- a/app/controllers/api/api_base_controller.rb +++ b/app/controllers/api/api_base_controller.rb @@ -16,7 +16,7 @@ module Api check_authorization rescue_from Params::BaseValidator::InvalidParameterError do |e| - render json: { error: e.message }, status: :unprocessable_entity + render json: { error: e.message }, status: :unprocessable_content end rescue_from RateLimit::LimitApproached do |e| @@ -33,7 +33,7 @@ module Api rescue_from JSON::ParserError do |e| Rollbar.warning(e) if defined?(Rollbar) - render json: { error: "JSON parse error: #{e.message}" }, status: :unprocessable_entity + render json: { error: "JSON parse error: #{e.message}" }, status: :unprocessable_content end end diff --git a/app/controllers/api/attachments_controller.rb b/app/controllers/api/attachments_controller.rb index eacabbd6..9d86b923 100644 --- a/app/controllers/api/attachments_controller.rb +++ b/app/controllers/api/attachments_controller.rb @@ -10,6 +10,23 @@ module Api def create submitter = Submitter.find_by!(slug: params[:submitter_slug]) + if params[:type].in?(%w[initials signature]) + image = Vips::Image.new_from_file(params[:file].path) + + if ImageUtils.blank?(image) + Rollbar.error("Empty signature: #{submitter.id}") if defined?(Rollbar) + + return render json: { error: "#{params[:type]} is empty" }, status: :unprocessable_content + end + + if ImageUtils.error?(image) + Rollbar.error("Error signature: #{submitter.id}") if defined?(Rollbar) + + return render json: { error: "#{params[:type]} error, try to sign on another device" }, + status: :unprocessable_content + end + end + attachment = Submitters.create_attachment!(submitter, params) if params[:remember_signature] == 'true' && submitter.email.present? @@ -17,6 +34,10 @@ module Api 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_entity end def build_new_cookie_signatures_json(submitter, attachment) diff --git a/app/controllers/api/form_events_controller.rb b/app/controllers/api/form_events_controller.rb index 3e8e2a38..4c881594 100644 --- a/app/controllers/api/form_events_controller.rb +++ b/app/controllers/api/form_events_controller.rb @@ -11,18 +11,21 @@ module Api params[:before] = Time.zone.at(params[:before].to_i) if params[:before].present? submitters = paginate( - submitters.preload(template: :folder, submission: [:submitters, { audit_trail_attachment: :blob, - combined_document_attachment: :blob }], + 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) + data: Submitters::SerializeForWebhook.call(s, expires_at:) } end, pagination: { diff --git a/app/controllers/api/submission_documents_controller.rb b/app/controllers/api/submission_documents_controller.rb index 283b55bf..148d499f 100644 --- a/app/controllers/api/submission_documents_controller.rb +++ b/app/controllers/api/submission_documents_controller.rb @@ -34,10 +34,12 @@ module Api 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) } + { name: attachment.filename.base, url: ActiveStorage::Blob.proxy_url(attachment.blob, expires_at:) } end } end diff --git a/app/controllers/api/submission_events_controller.rb b/app/controllers/api/submission_events_controller.rb index 1a32023b..54d01443 100644 --- a/app/controllers/api/submission_events_controller.rb +++ b/app/controllers/api/submission_events_controller.rb @@ -14,16 +14,19 @@ module Api :created_by_user, :submission_events, template: :folder, submitters: { documents_attachments: :blob, attachments_attachments: :blob }, - audit_trail_attachment: :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) + data: Submissions::SerializeForApi.call(s, s.submitters, expires_at:) } end, pagination: { diff --git a/app/controllers/api/submissions_controller.rb b/app/controllers/api/submissions_controller.rb index d1c3fdb8..15223276 100644 --- a/app/controllers/api/submissions_controller.rb +++ b/app/controllers/api/submissions_controller.rb @@ -10,24 +10,20 @@ module Api end def index - submissions = Submissions.search(@submissions, params[:q]) - submissions = submissions.where(template_id: params[:template_id]) if params[:template_id].present? - - if params[:template_folder].present? - submissions = submissions.joins(template: :folder).where(folder: { name: params[:template_folder] }) - end - - submissions = Submissions::Filter.call(submissions, current_user, params) + submissions = Submissions.search(current_user, @submissions, params[:q]) + submissions = filter_submissions(submissions, params) submissions = paginate(submissions.preload(:created_by_user, :submitters, - template: :folder, + 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) + with_events: false, with_documents: false, with_values: false, expires_at:) end, pagination: { count: submissions.size, @@ -47,7 +43,7 @@ module Api end if @submission.audit_trail_attachment.blank? && submitters.all?(&:completed_at?) - @submission.audit_trail_attachment = Submissions::GenerateAuditTrail.call(@submission) + @submission.audit_trail_attachment = Submissions::EnsureAuditGenerated.call(@submission) end render json: Submissions::SerializeForApi.call(@submission, submitters, params) @@ -56,12 +52,12 @@ module Api def create Params::SubmissionCreateValidator.call(params) - return render json: { error: 'Template not found' }, status: :unprocessable_entity if @template.nil? + 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_entity + return render json: { error: 'Template does not contain fields' }, status: :unprocessable_content end params[:send_email] = true unless params.key?(:send_email) @@ -69,12 +65,7 @@ module Api submissions = create_submissions(@template, params) - WebhookUrls.for_account_id(@template.account_id, 'submission.created').each do |webhook_url| - submissions.each do |submission| - SendSubmissionCreatedWebhookRequestJob.perform_async('submission_id' => submission.id, - 'webhook_url_id' => webhook_url.id) - end - end + WebhookUrls.enqueue_events(submissions, 'submission.created') Submissions.send_signature_requests(submissions) @@ -86,12 +77,14 @@ module Api 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_entity + render json: { error: e.message }, status: :unprocessable_content end def destroy @@ -100,10 +93,7 @@ module Api else @submission.update!(archived_at: Time.current) - WebhookUrls.for_account_id(@submission.account_id, 'submission.archived').each do |webhook_url| - SendSubmissionArchivedWebhookRequestJob.perform_async('submission_id' => @submission.id, - 'webhook_url_id' => webhook_url.id) - end + WebhookUrls.enqueue_events(@submission, 'submission.archived') end render json: @submission.as_json(only: %i[id archived_at]) @@ -111,6 +101,24 @@ module Api 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| @@ -139,7 +147,7 @@ module Api is_send_email = !params[:send_email].in?(['false', false]) if (emails = (params[:emails] || params[:email]).presence) && - (params[:submission].blank? && params[:submitters].blank?) + params[:submission].blank? && params[:submitters].blank? Submissions.create_from_emails(template:, user: current_user, source: :api, @@ -174,15 +182,17 @@ module Api def submissions_params permitted_attrs = [ :send_email, :send_sms, :bcc_completed, :completed_redirect_url, :reply_to, :go_to_last, - :expire_at, + :require_phone_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, - { metadata: {}, values: {}, readonly_fields: [], message: %i[subject body], + :require_phone_2fa, :order, + { metadata: {}, values: {}, roles: [], readonly_fields: [], message: %i[subject body], fields: [:name, :uuid, :default_value, :value, :title, :description, - :readonly, :validation_pattern, :invalid_message, - { default_value: [], value: [], preferences: {} }] }]] + :readonly, :required, :validation_pattern, :invalid_message, + { default_value: [], value: [], preferences: {}, validation: {} }] }]] } ] diff --git a/app/controllers/api/submitter_email_clicks_controller.rb b/app/controllers/api/submitter_email_clicks_controller.rb index 87221680..cef26542 100644 --- a/app/controllers/api/submitter_email_clicks_controller.rb +++ b/app/controllers/api/submitter_email_clicks_controller.rb @@ -6,10 +6,10 @@ module Api skip_authorization_check def create - submitter = Submitter.find_by!(slug: params[:submitter_slug]) + @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) + if params[:t] == SubmissionEvents.build_tracking_param(@submitter, 'click_email') + SubmissionEvents.create_with_tracking_data(@submitter, 'click_email', request) end render json: {} diff --git a/app/controllers/api/submitter_form_views_controller.rb b/app/controllers/api/submitter_form_views_controller.rb index 98d7f5b5..d9ac441e 100644 --- a/app/controllers/api/submitter_form_views_controller.rb +++ b/app/controllers/api/submitter_form_views_controller.rb @@ -6,17 +6,14 @@ module Api skip_authorization_check def create - submitter = Submitter.find_by!(slug: params[:submitter_slug]) + @submitter = Submitter.find_by!(slug: params[:submitter_slug]) - submitter.opened_at = Time.current - submitter.save + @submitter.opened_at = Time.current + @submitter.save - SubmissionEvents.create_with_tracking_data(submitter, 'view_form', request) + SubmissionEvents.create_with_tracking_data(@submitter, 'view_form', request) - WebhookUrls.for_account_id(submitter.account_id, 'form.viewed').each do |webhook_url| - SendFormViewedWebhookRequestJob.perform_async('submitter_id' => submitter.id, - 'webhook_url_id' => webhook_url.id) - end + WebhookUrls.enqueue_events(@submitter, 'form.viewed') render json: {} end diff --git a/app/controllers/api/submitters_controller.rb b/app/controllers/api/submitters_controller.rb index 1fd06d74..e7635591 100644 --- a/app/controllers/api/submitters_controller.rb +++ b/app/controllers/api/submitters_controller.rb @@ -5,26 +5,20 @@ module Api load_and_authorize_resource :submitter def index - submitters = Submitters.search(@submitters, params[:q]) + submitters = Submitters.search(current_user, @submitters, params[:q]) - submitters = submitters.where(external_id: params[:application_key]) if params[:application_key].present? - submitters = submitters.where(external_id: params[:external_id]) if params[:external_id].present? - submitters = submitters.where(submission_id: params[:submission_id]) if params[:submission_id].present? - - if params[:template_id].present? - submitters = submitters.joins(:submission).where(submission: { template_id: params[:template_id] }) - end - - submitters = maybe_filder_by_completed_at(submitters, params) + 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:) + Submitters::SerializeForApi.call(s, with_template: true, with_events: true, params:, expires_at:) end, pagination: { count: submitters.size, @@ -42,17 +36,19 @@ module Api def update if @submitter.completed_at? - return render json: { error: 'Submitter has already completed the submission.' }, status: :unprocessable_entity + return render json: { error: 'Submitter has already completed the submission.' }, status: :unprocessable_content end - role = @submitter.submission.template_submitters.find { |e| e['uuid'] == @submitter.uuid }['name'] + 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, - for_submitter: @submitter) + 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(@submitter.submission, - [normalized_params], + Submissions::CreateFromSubmitters.maybe_set_template_fields(submission, [normalized_params], default_submitter_uuid: @submitter.uuid) assign_submitter_attrs(@submitter, normalized_params) @@ -73,14 +69,14 @@ module Api Submitters.send_signature_requests([@submitter]) end - render json: Submitters::SerializeForApi.call(@submitter, with_template: false, - with_urls: true, - with_events: false, - params:) + 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_entity + render json: { error: e.message }, status: :unprocessable_content end def submitter_params @@ -88,9 +84,9 @@ module Api 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, + :completed, :phone, :application_key, :external_id, :go_to_last, :require_phone_2fa, { metadata: {}, values: {}, readonly_fields: [], message: %i[subject body], - fields: [[:name, :uuid, :default_value, :value, + fields: [[:name, :uuid, :default_value, :value, :required, :readonly, :validation_pattern, :invalid_message, { default_value: [], value: [], preferences: {} }]] } ) @@ -163,6 +159,19 @@ module Api 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) @@ -186,6 +195,10 @@ module Api 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 diff --git a/app/controllers/api/templates_clone_controller.rb b/app/controllers/api/templates_clone_controller.rb index e7e1db7e..d87a2eb1 100644 --- a/app/controllers/api/templates_clone_controller.rb +++ b/app/controllers/api/templates_clone_controller.rb @@ -5,7 +5,7 @@ module Api load_and_authorize_resource :template def create - authorize!(:manage, @template) + authorize!(:create, @template) ActiveRecord::Associations::Preloader.new( records: [@template], @@ -21,18 +21,20 @@ module Api ) cloned_template.source = :api - cloned_template.save! schema_documents = Templates::CloneAttachments.call(template: cloned_template, original_template: @template, documents: params[:documents]) - WebhookUrls.for_account_id(cloned_template.account_id, 'template.created').each do |webhook_url| - SendTemplateCreatedWebhookRequestJob.perform_async('template_id' => cloned_template.id, - 'webhook_url_id' => webhook_url.id) - end + 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) + render json: Templates::SerializeForApi.call(cloned_template, schema_documents:) end end end diff --git a/app/controllers/api/templates_controller.rb b/app/controllers/api/templates_controller.rb index a695e728..c8211b7f 100644 --- a/app/controllers/api/templates_controller.rb +++ b/app/controllers/api/templates_controller.rb @@ -7,7 +7,7 @@ module Api def index templates = filter_templates(@templates, params) - templates = paginate(templates.preload(:author, :folder)) + templates = paginate(templates.preload(:author, folder: :parent_folder)) schema_documents = ActiveStorage::Attachment.where(record_id: templates.map(&:id), @@ -24,13 +24,14 @@ module Api 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.select { |e| e.record_id == t.id }, - preview_image_attachments - ) + 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, @@ -65,10 +66,9 @@ module Api @template.update!(template_params) - WebhookUrls.for_account_id(@template.account_id, 'template.updated').each do |webhook_url| - SendTemplateUpdatedWebhookRequestJob.perform_async('template_id' => @template.id, - 'webhook_url_id' => webhook_url.id) - end + SearchEntries.enqueue_reindex(@template) + + WebhookUrls.enqueue_events(@template, 'template.updated') render json: @template.as_json(only: %i[id updated_at]) end @@ -86,11 +86,17 @@ module Api private def filter_templates(templates, params) - templates = Templates.search(templates, params[:q]) + 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.joins(:folder).where(folder: { name: params[:folder] }) if params[:folder].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 @@ -99,15 +105,17 @@ module Api permitted_params = [ :name, :external_id, + :shared_link, { - submitters: [%i[name uuid is_requester invite_by_uuid optional_invite_by_uuid linked_to_uuid email]], + submitters: [%i[name uuid is_requester invite_by_uuid optional_invite_by_uuid linked_to_uuid email order]], fields: [[:uuid, :submitter_uuid, :name, :type, :required, :readonly, :default_value, - :title, :description, + :title, :description, :prefillable, { preferences: {}, + default_value: [], conditions: [%i[field_uuid value action operation]], options: [%i[value uuid]], - validation: %i[message pattern], + validation: %i[message pattern min max step], areas: [%i[x y w h cell_w attachment_uuid option_uuid page]] }]] } ] diff --git a/app/controllers/api/tools_controller.rb b/app/controllers/api/tools_controller.rb index 63a50f89..09ea9235 100644 --- a/app/controllers/api/tools_controller.rb +++ b/app/controllers/api/tools_controller.rb @@ -7,8 +7,8 @@ module Api def merge files = params[:files] || [] - return render json: { error: 'Files are required' }, status: :unprocessable_entity if files.blank? - return render json: { error: 'At least 2 files are required' }, status: :unprocessable_entity if files.size < 2 + 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) @@ -35,7 +35,7 @@ module Api end } rescue HexaPDF::MalformedPDFError - render json: { error: 'Malformed PDF' }, status: :unprocessable_entity + render json: { error: 'Malformed PDF' }, status: :unprocessable_content end end end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index a3cb0242..cd8d228c 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -13,6 +13,8 @@ class ApplicationController < ActionController::Base before_action :maybe_redirect_to_setup, unless: :signed_in? before_action :authenticate_user!, unless: :devise_controller? + before_action :set_csp, if: -> { request.get? && !request.headers['HTTP_X_TURBO'] } + helper_method :button_title, :current_account, :form_link_host, @@ -55,6 +57,14 @@ class ApplicationController < ActionController::Base 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 def with_locale(&) @@ -115,4 +125,21 @@ class ApplicationController < ActionController::Base 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 diff --git a/app/controllers/console_redirect_controller.rb b/app/controllers/console_redirect_controller.rb index dd80e9fe..4b910263 100644 --- a/app/controllers/console_redirect_controller.rb +++ b/app/controllers/console_redirect_controller.rb @@ -17,8 +17,10 @@ class ConsoleRedirectController < ApplicationController scope: :console, exp: 1.minute.from_now.to_i) - path = Addressable::URI.parse(params[:redir]).path if params[:redir].to_s.starts_with?(Docuseal::CONSOLE_URL) + 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}?#{{ auth: }.to_query}", allow_other_host: true) + redirect_to "#{Docuseal::CONSOLE_URL}#{path}?#{{ **redir_uri&.query_values, 'auth' => auth }.to_query}", + allow_other_host: true end end diff --git a/app/controllers/email_smtp_settings_controller.rb b/app/controllers/email_smtp_settings_controller.rb index d41ca570..f918f172 100644 --- a/app/controllers/email_smtp_settings_controller.rb +++ b/app/controllers/email_smtp_settings_controller.rb @@ -9,16 +9,18 @@ class EmailSmtpSettingsController < ApplicationController def create if @encrypted_config.update(email_configs) - SettingsMailer.smtp_successful_setup(@encrypted_config.value['from_email'] || current_user.email).deliver_now! + 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_entity + render :index, status: :unprocessable_content end rescue StandardError => e flash[:alert] = e.message - render :index, status: :unprocessable_entity + render :index, status: :unprocessable_content end private diff --git a/app/controllers/errors_controller.rb b/app/controllers/errors_controller.rb index 16b5fc2d..0c9e3632 100644 --- a/app/controllers/errors_controller.rb +++ b/app/controllers/errors_controller.rb @@ -5,8 +5,12 @@ class ErrorsController < ActionController::Base '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', diff --git a/app/controllers/esign_settings_controller.rb b/app/controllers/esign_settings_controller.rb index 34feb84e..81c75fae 100644 --- a/app/controllers/esign_settings_controller.rb +++ b/app/controllers/esign_settings_controller.rb @@ -53,7 +53,7 @@ class EsignSettingsController < ApplicationController @cert_record.errors.add(:name, I18n.t('already_exists')) return render turbo_stream: turbo_stream.replace(:modal, template: 'esign_settings/new'), - status: :unprocessable_entity + status: :unprocessable_content end save_new_cert!(@encrypted_config, @cert_record) @@ -64,7 +64,7 @@ class EsignSettingsController < ApplicationController @cert_record.errors.add(:password, e.message) - render turbo_stream: turbo_stream.replace(:modal, template: 'esign_settings/new'), status: :unprocessable_entity + render turbo_stream: turbo_stream.replace(:modal, template: 'esign_settings/new'), status: :unprocessable_content end def update diff --git a/app/controllers/mfa_setup_controller.rb b/app/controllers/mfa_setup_controller.rb index 6ec74ccd..2ea90412 100644 --- a/app/controllers/mfa_setup_controller.rb +++ b/app/controllers/mfa_setup_controller.rb @@ -24,7 +24,7 @@ class MfaSetupController < ApplicationController @error_message = I18n.t('code_is_invalid') - render turbo_stream: turbo_stream.replace(:mfa_form, partial: 'mfa_setup/form'), status: :unprocessable_entity + render turbo_stream: turbo_stream.replace(:mfa_form, partial: 'mfa_setup/form'), status: :unprocessable_content end end @@ -36,7 +36,7 @@ class MfaSetupController < ApplicationController else @error_message = I18n.t('code_is_invalid') - render turbo_stream: turbo_stream.replace(:modal, template: 'mfa_setup/edit'), status: :unprocessable_entity + render turbo_stream: turbo_stream.replace(:modal, template: 'mfa_setup/edit'), status: :unprocessable_content end end diff --git a/app/controllers/passwords_controller.rb b/app/controllers/passwords_controller.rb index 7ca14716..60de5b12 100644 --- a/app/controllers/passwords_controller.rb +++ b/app/controllers/passwords_controller.rb @@ -1,6 +1,10 @@ # 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 + class Current < ActiveSupport::CurrentAttributes attribute :user end @@ -16,4 +20,10 @@ class PasswordsController < Devise::PasswordsController Current.user = resource end end + + private + + def after_resetting_password_path_for(_) + new_session_path(resource_name) + end end diff --git a/app/controllers/personalization_settings_controller.rb b/app/controllers/personalization_settings_controller.rb index 9812aaee..d9d33490 100644 --- a/app/controllers/personalization_settings_controller.rb +++ b/app/controllers/personalization_settings_controller.rb @@ -4,6 +4,7 @@ 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, diff --git a/app/controllers/preview_document_page_controller.rb b/app/controllers/preview_document_page_controller.rb index 5bf42138..5e59e74a 100644 --- a/app/controllers/preview_document_page_controller.rb +++ b/app/controllers/preview_document_page_controller.rb @@ -12,6 +12,8 @@ class PreviewDocumentPageController < ActionController::API 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"] }) diff --git a/app/controllers/profile_controller.rb b/app/controllers/profile_controller.rb index e0adb16e..a1ee71bf 100644 --- a/app/controllers/profile_controller.rb +++ b/app/controllers/profile_controller.rb @@ -11,16 +11,16 @@ class ProfileController < ApplicationController if current_user.update(contact_params) redirect_to settings_profile_index_path, notice: I18n.t('contact_information_has_been_update') else - render :index, status: :unprocessable_entity + render :index, status: :unprocessable_content end end def update_password - if current_user.update(password_params) + if current_user.update_with_password(password_params) bypass_sign_in(current_user) redirect_to settings_profile_index_path, notice: I18n.t('password_has_been_changed') else - render :index, status: :unprocessable_entity + render :index, status: :unprocessable_content end end @@ -31,6 +31,6 @@ class ProfileController < ApplicationController end def password_params - params.require(:user).permit(:password, :password_confirmation) + params.require(:user).permit(:password, :password_confirmation, :current_password) end end diff --git a/app/controllers/reveal_access_token_controller.rb b/app/controllers/reveal_access_token_controller.rb new file mode 100644 index 00000000..c8959afd --- /dev/null +++ b/app/controllers/reveal_access_token_controller.rb @@ -0,0 +1,21 @@ +# 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 diff --git a/app/controllers/search_entries_reindex_controller.rb b/app/controllers/search_entries_reindex_controller.rb new file mode 100644 index 00000000..afbe60b2 --- /dev/null +++ b/app/controllers/search_entries_reindex_controller.rb @@ -0,0 +1,17 @@ +# 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 diff --git a/app/controllers/send_submission_email_controller.rb b/app/controllers/send_submission_email_controller.rb index 06481b1a..c3a95158 100644 --- a/app/controllers/send_submission_email_controller.rb +++ b/app/controllers/send_submission_email_controller.rb @@ -10,27 +10,40 @@ class SendSubmissionEmailController < ApplicationController SEND_DURATION = 30.minutes def create - @submitter = - if params[:template_slug] - Submitter.joins(submission: :template).find_by!(email: params[:email].to_s.downcase, - template: { slug: params[:template_slug] }) - elsif params[:submission_slug] - Submitter.joins(:submission).find_by!(email: params[:email].to_s.downcase, - submission: { slug: params[:submission_slug] }) - else - Submitter.find_by!(slug: params[:submitter_slug]) + if params[:template_slug] + template = Template.find_by!(slug: params[:template_slug]) + + @submitter = + Submitter.completed.where(submission: template.submissions).find_by!(email: params[:email].to_s.downcase) + elsif params[:submission_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 + return redirect_to submissions_preview_completed_path(params[:submission_slug], status: :error) unless @submitter + else + @submitter = Submitter.completed.find_by!(slug: params[:submitter_slug]) + end + RateLimit.call("send-email-#{@submitter.id}", limit: 2, ttl: 5.minutes) - unless EmailEvent.exists?(tag: :submitter_documents_copy, email: @submitter.email, emailable: @submitter, - event_type: :send, created_at: SEND_DURATION.ago..Time.current) - SubmitterMailer.documents_copy_email(@submitter, sig: true).deliver_later! - end + SubmitterMailer.documents_copy_email(@submitter, sig: true).deliver_later! if can_send?(@submitter) respond_to do |f| f.html { render :success } f.json { head :ok } 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 diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 8a7a5e5f..2281d56f 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -16,7 +16,7 @@ class SessionsController < Devise::SessionsController 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_entity + return render :otp, locals: { resource: User.new(sign_in_params) }, status: :unprocessable_content end super diff --git a/app/controllers/setup_controller.rb b/app/controllers/setup_controller.rb index 9b49b1f8..734edbb5 100644 --- a/app/controllers/setup_controller.rb +++ b/app/controllers/setup_controller.rb @@ -23,10 +23,10 @@ class SetupController < ApplicationController 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')) - return render :index, status: :unprocessable_entity + return render :index, status: :unprocessable_content end - return render :index, status: :unprocessable_entity unless @account.valid? + return render :index, status: :unprocessable_content unless @account.valid? if @user.save encrypted_configs = [ @@ -34,6 +34,7 @@ class SetupController < ApplicationController { key: EncryptedConfig::ESIGN_CERTS_KEY, value: GenerateCertificate.call.transform_values(&:to_pem) } ] @account.encrypted_configs.create!(encrypted_configs) + @account.account_configs.create!(key: :fulltext_search, value: true) if SearchEntry.table_exists? Docuseal.refresh_default_url_options! @@ -41,7 +42,7 @@ class SetupController < ApplicationController redirect_to newsletter_path else - render :index, status: :unprocessable_entity + render :index, status: :unprocessable_content end end diff --git a/app/controllers/start_form_controller.rb b/app/controllers/start_form_controller.rb index ee05ff28..08778a3e 100644 --- a/app/controllers/start_form_controller.rb +++ b/app/controllers/start_form_controller.rb @@ -6,28 +6,43 @@ class StartFormController < ApplicationController skip_before_action :authenticate_user! skip_authorization_check - around_action :with_browser_locale, only: %i[show completed] + 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 :authorize_start!, only: :update + + COOKIES_TTL = 12.hours + COOKIES_DEFAULTS = { httponly: true, secure: Rails.env.production? }.freeze def show - @submitter = @template.submissions.new(account_id: @template.account_id) - .submitters.new(uuid: (filter_undefined_submitters(@template).first || - @template.submitters.first)['uuid']) + raise ActionController::RoutingError, I18n.t('not_found') if @template.preferences['require_phone_2fa'] + + 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 def update - return redirect_to start_form_path(@template.slug) if @template.archived_at? - @submitter = find_or_initialize_submitter(@template, submitter_params) if @submitter.completed_at? - redirect_to start_form_completed_path(@template.slug, email: submitter_params[:email]) + redirect_to start_form_completed_path(@template.slug, submitter_params.compact_blank) else if filter_undefined_submitters(@template).size > 1 && @submitter.new_record? - @error_message = I18n.t('not_found') + @error_message = multiple_submitters_error_message - return render :show + return render :show, status: :unprocessable_content end if (is_new_record = @submitter.new_record?) @@ -38,55 +53,112 @@ class StartFormController < ApplicationController @submitter.assign_attributes(ip: request.remote_ip, ua: request.user_agent) end - if @submitter.save - if is_new_record - WebhookUrls.for_account_id(@submitter.account_id, 'submission.created').each do |webhook_url| - SendSubmissionCreatedWebhookRequestJob.perform_async('submission_id' => @submitter.submission_id, - 'webhook_url_id' => webhook_url.id) - end - 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 redirect_to submit_form_path(@submitter.slug) else - render :show + render :show, status: :unprocessable_content end end end def completed + return redirect_to start_form_path(@template.slug) if !@template.shared_link? || @template.archived_at? + + 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!(email: params[:email]) + .find_by!(required_params) end 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) - 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(ip: [nil, request.remote_ip]) - .then { |rel| params[:resubmit].present? ? rel.where(completed_at: nil) : rel } - .find_or_initialize_by(email: submitter_params[:email], **submitter_params.compact_blank) + 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) - resubmit_submitter = - (Submitter.where(submission: template.submissions).find_by(slug: params[:resubmit]) if params[:resubmit].present?) - 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 || {} + 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| + @resubmit_submitter.attachments.each do |attachment| submitter.attachments << attachment.dup if submitter.values.value?(attachment.uuid) end end @@ -94,6 +166,7 @@ class StartFormController < ApplicationController 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) @@ -103,18 +176,67 @@ class StartFormController < ApplicationController end def filter_undefined_submitters(template) - Templates.filter_undefined_submitters(template) + Templates.filter_undefined_submitters(template.submitters) end def submitter_params + return { 'email' => current_user.email, 'name' => current_user.full_name } if params[:selfsign] + 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 def load_template - slug = params[:slug] || params[:start_form_slug] + @template = + if @resubmit_submitter + @resubmit_submitter.template + else + Template.find_by!(slug: params[:slug] || params[:start_form_slug]) + end + end - @template = Template.find_by!(slug:) + def multiple_submitters_error_message + 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 diff --git a/app/controllers/start_form_email_2fa_send_controller.rb b/app/controllers/start_form_email_2fa_send_controller.rb new file mode 100644 index 00000000..6359debd --- /dev/null +++ b/app/controllers/start_form_email_2fa_send_controller.rb @@ -0,0 +1,31 @@ +# 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 diff --git a/app/controllers/storage_settings_controller.rb b/app/controllers/storage_settings_controller.rb index 182e8ca8..4b3a8390 100644 --- a/app/controllers/storage_settings_controller.rb +++ b/app/controllers/storage_settings_controller.rb @@ -13,7 +13,7 @@ class StorageSettingsController < ApplicationController redirect_to settings_storage_index_path, notice: I18n.t('changes_have_been_saved') else - render :index, status: :unprocessable_entity + render :index, status: :unprocessable_content end end diff --git a/app/controllers/submission_events_controller.rb b/app/controllers/submission_events_controller.rb index 9dbd57ed..14cf5321 100644 --- a/app/controllers/submission_events_controller.rb +++ b/app/controllers/submission_events_controller.rb @@ -12,6 +12,7 @@ class SubmissionEventsController < ApplicationController 'send_2fa_sms' => '2fa', 'send_sms' => 'send', 'phone_verified' => 'phone_check', + 'email_verified' => 'email_check', 'click_sms' => 'hand_click', 'decline_form' => 'x', 'start_verification' => 'player_play', diff --git a/app/controllers/submissions_archived_controller.rb b/app/controllers/submissions_archived_controller.rb index 3c3f49b6..793da755 100644 --- a/app/controllers/submissions_archived_controller.rb +++ b/app/controllers/submissions_archived_controller.rb @@ -4,14 +4,12 @@ class SubmissionsArchivedController < ApplicationController load_and_authorize_resource :submission, parent: false def index - @submissions = @submissions.joins(:template) + @submissions = @submissions.left_joins(:template) @submissions = @submissions.where.not(archived_at: nil) .or(@submissions.where.not(templates: { archived_at: nil })) - .preload(:created_by_user, template: :author) + .preload(:template_accesses, :created_by_user, template: :author) - @submissions = @submissions.preload(:template_accesses) unless current_user.role.in?(%w[admin superadmin]) - - @submissions = Submissions.search(@submissions, params[:q], search_template: true) + @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? @@ -20,6 +18,6 @@ class SubmissionsArchivedController < ApplicationController @submissions.order(id: :desc) end - @pagy, @submissions = pagy(@submissions.preload(submitters: :start_form_submission_events)) + @pagy, @submissions = pagy_auto(@submissions.preload(submitters: :start_form_submission_events)) end end diff --git a/app/controllers/submissions_controller.rb b/app/controllers/submissions_controller.rb index 1dac63bd..7dc689e0 100644 --- a/app/controllers/submissions_controller.rb +++ b/app/controllers/submissions_controller.rb @@ -8,6 +8,10 @@ class SubmissionsController < ApplicationController prepend_before_action :maybe_redirect_com, only: %i[show] + before_action only: :create do + authorize!(:create, Submission) + end + def show @submission = Submissions.preload_with_pages(@submission) @@ -26,14 +30,9 @@ class SubmissionsController < ApplicationController end def create - authorize!(:create, Submission) - save_template_message(@template, params) if params[:save_message] == '1' - if params[:is_custom_message] != '1' - params.delete(:subject) - params.delete(:body) - end + [params.delete(:subject), params.delete(:body)] if params[:is_custom_message] != '1' submissions = if params[:emails].present? @@ -44,19 +43,30 @@ class SubmissionsController < ApplicationController emails: params[:emails], params: params.merge('send_completed_email' => true)) else + submissions_attrs = submissions_params[:submission].to_h.values + + submissions_attrs, = + Submissions::NormalizeParamUtils.normalize_submissions_params!(submissions_attrs, @template) + Submissions.create_from_submitters(template: @template, user: current_user, source: :invite, submitters_order: params[:preserve_order] == '1' ? 'preserved' : 'random', - submissions_attrs: submissions_params[:submission].to_h.values, + submissions_attrs:, params: params.merge('send_completed_email' => true)) end - enqueue_submission_created_webhooks(@template, submissions) + WebhookUrls.enqueue_events(submissions, 'submission.created') Submissions.send_signature_requests(submissions) + 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 def destroy @@ -68,15 +78,12 @@ class SubmissionsController < ApplicationController else @submission.update!(archived_at: Time.current) - WebhookUrls.for_account_id(@submission.account_id, 'submission.archived').each do |webhook_url| - SendSubmissionArchivedWebhookRequestJob.perform_async('submission_id' => @submission.id, - 'webhook_url_id' => webhook_url.id) - end + WebhookUrls.enqueue_events(@submission, 'submission.archived') I18n.t('submission_has_been_archived') end - redirect_back(fallback_location: template_path(@submission.template), notice:) + redirect_back(fallback_location: @submission.template_id ? template_path(@submission.template) : root_path, notice:) end private @@ -88,17 +95,8 @@ class SubmissionsController < ApplicationController template.save! end - def enqueue_submission_created_webhooks(template, submissions) - WebhookUrls.for_account_id(template.account_id, 'submission.created').each do |webhook_url| - submissions.each do |submission| - SendSubmissionCreatedWebhookRequestJob.perform_async('submission_id' => submission.id, - 'webhook_url_id' => webhook_url.id) - end - end - end - def submissions_params - params.permit(submission: { submitters: [%i[uuid email phone name]] }) + params.permit(submission: { submitters: [:uuid, :email, :phone, :name, { values: {} }] }) end def load_template diff --git a/app/controllers/submissions_dashboard_controller.rb b/app/controllers/submissions_dashboard_controller.rb index 3403d22c..f0851741 100644 --- a/app/controllers/submissions_dashboard_controller.rb +++ b/app/controllers/submissions_dashboard_controller.rb @@ -4,15 +4,13 @@ class SubmissionsDashboardController < ApplicationController load_and_authorize_resource :submission, parent: false def index - @submissions = @submissions.joins(:template) + @submissions = @submissions.left_joins(:template) @submissions = @submissions.where(archived_at: nil) .where(templates: { archived_at: nil }) - .preload(:created_by_user, template: :author) + .preload(:template_accesses, :created_by_user, template: :author) - @submissions = @submissions.preload(:template_accesses) unless current_user.role.in?(%w[admin superadmin]) - - @submissions = Submissions.search(@submissions, params[:q], search_template: true) + @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? @@ -21,6 +19,6 @@ class SubmissionsDashboardController < ApplicationController @submissions.order(id: :desc) end - @pagy, @submissions = pagy(@submissions.preload(submitters: :start_form_submission_events)) + @pagy, @submissions = pagy_auto(@submissions.preload(submitters: :start_form_submission_events)) end end diff --git a/app/controllers/submissions_download_controller.rb b/app/controllers/submissions_download_controller.rb index 9ba81b91..4bcd3237 100644 --- a/app/controllers/submissions_download_controller.rb +++ b/app/controllers/submissions_download_controller.rb @@ -8,24 +8,24 @@ class SubmissionsDownloadController < ApplicationController FILES_TTL = 5.minutes def index - submitter = Submitter.find_signed(params[:sig], purpose: :download_completed) if params[:sig].present? + @submitter = Submitter.find_signed(params[:sig], purpose: :download_completed) if params[:sig].present? signature_valid = - if submitter&.slug == params[:submitter_slug] + if @submitter&.slug == params[:submitter_slug] true else - submitter = nil + @submitter = nil end - submitter ||= Submitter.find_by!(slug: params[:submitter_slug]) + @submitter ||= Submitter.find_by!(slug: params[:submitter_slug]) - Submissions::EnsureResultGenerated.call(submitter) + Submissions::EnsureResultGenerated.call(@submitter) - last_submitter = submitter.submission.submitters.where.not(completed_at: nil).order(:completed_at).last + last_submitter = @submitter.submission.submitters.where.not(completed_at: nil).order(:completed_at).last - Submissions::EnsureResultGenerated.call(last_submitter) + return head :not_found unless last_submitter - return head :not_found unless last_submitter.completed_at? + Submissions::EnsureResultGenerated.call(last_submitter) if last_submitter.completed_at < TTL.ago && !signature_valid && !current_user_submitter?(last_submitter) Rollbar.info("TTL: #{last_submitter.id}") if defined?(Rollbar) @@ -34,7 +34,7 @@ class SubmissionsDownloadController < ApplicationController end if params[:combined] == 'true' - url = build_combined_url(submitter) + url = build_combined_url(@submitter) if url render json: [url] @@ -70,7 +70,7 @@ class SubmissionsDownloadController < ApplicationController return if submitter.submission.submitters.order(:completed_at).last != submitter attachment = submitter.submission.combined_document_attachment - attachment ||= Submissions::GenerateCombinedAttachment.call(submitter) + attachment ||= Submissions::EnsureCombinedGenerated.call(submitter) filename_format = AccountConfig.find_or_initialize_by(account_id: submitter.account_id, key: AccountConfig::DOCUMENT_FILENAME_FORMAT_KEY)&.value diff --git a/app/controllers/submissions_export_controller.rb b/app/controllers/submissions_export_controller.rb index e929c12d..bfbb7359 100644 --- a/app/controllers/submissions_export_controller.rb +++ b/app/controllers/submissions_export_controller.rb @@ -10,11 +10,16 @@ class SubmissionsExportController < ApplicationController 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]), + 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]), + send_data Submissions::GenerateExportFiles.call(submissions, format: params[:format], expires_at:), filename: "#{@template.name}.xlsx" end end diff --git a/app/controllers/submissions_filters_controller.rb b/app/controllers/submissions_filters_controller.rb index b0298d4c..8c08bb1d 100644 --- a/app/controllers/submissions_filters_controller.rb +++ b/app/controllers/submissions_filters_controller.rb @@ -3,6 +3,7 @@ class SubmissionsFiltersController < ApplicationController ALLOWED_NAMES = %w[ author + folder completed_at status created_at diff --git a/app/controllers/submissions_preview_controller.rb b/app/controllers/submissions_preview_controller.rb index 7986e799..2ac30745 100644 --- a/app/controllers/submissions_preview_controller.rb +++ b/app/controllers/submissions_preview_controller.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true class SubmissionsPreviewController < ApplicationController + around_action :with_browser_locale skip_before_action :authenticate_user! skip_authorization_check @@ -27,7 +28,7 @@ class SubmissionsPreviewController < ApplicationController raise ActionController::RoutingError, I18n.t('not_found') end - if !submission_valid_ttl?(@submission) && !signature_valid + if use_signature?(@submission) && !signature_valid Rollbar.info("TTL: #{@submission.id}") if defined?(Rollbar) return redirect_to submissions_preview_completed_path(@submission.slug) @@ -40,6 +41,9 @@ class SubmissionsPreviewController < ApplicationController 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' @@ -47,9 +51,15 @@ class SubmissionsPreviewController < ApplicationController private - def submission_valid_ttl?(submission) - return true if current_user && current_user.account.submissions.exists?(id: submission.id) + def use_signature?(submission) + return false if current_user && can?(:read, submission) + return true if submission.submitters.any? { |e| e.preferences['require_phone_2fa'] } + return true if submission.template&.preferences&.dig('require_phone_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 diff --git a/app/controllers/submit_form_controller.rb b/app/controllers/submit_form_controller.rb index 90343331..963594fa 100644 --- a/app/controllers/submit_form_controller.rb +++ b/app/controllers/submit_form_controller.rb @@ -7,30 +7,29 @@ class SubmitFormController < ApplicationController 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 update] + CONFIG_KEYS = [].freeze def show - @submitter = Submitter.find_by!(slug: params[:slug]) - submission = @submitter.submission return redirect_to submit_form_completed_path(@submitter.slug) if @submitter.completed_at? - return render :archived if submission.template.archived_at? || - submission.archived_at? || - @submitter.account.archived_at? - return render :expired if submission.expired? - return render :declined if @submitter.declined_at? - return render :awaiting if submission.template.preferences['submitters_order'] == 'preserved' && + + @form_configs = Submitters::FormConfigs.call(@submitter, CONFIG_KEYS) + + return render :awaiting if (@form_configs[:enforce_signing_order] || + submission.template&.preferences&.dig('submitters_order') == 'preserved') && !Submitters.current_submitter_order?(@submitter) - Submitters.preload_with_pages(@submitter) + Submissions.preload_with_pages(submission) Submitters::MaybeUpdateDefaultValues.call(@submitter, current_user) @attachments_index = build_attachments_index(submission) - @form_configs = Submitters::FormConfigs.call(@submitter, CONFIG_KEYS) - return unless @form_configs[:prefill_signature] if (user_signature = UserConfigs.load_signature(current_user)) @@ -48,40 +47,64 @@ class SubmitFormController < ApplicationController end def update - submitter = Submitter.find_by!(slug: params[:slug]) - - if submitter.completed_at? - return render json: { error: I18n.t('form_has_been_completed_already') }, status: :unprocessable_entity + if @submitter.completed_at? + return render json: { error: I18n.t('form_has_been_completed_already') }, status: :unprocessable_content end - if submitter.template.archived_at? || submitter.submission.archived_at? - return render json: { error: I18n.t('form_has_been_archived') }, status: :unprocessable_entity + if @submitter.submission.template&.archived_at? || @submitter.submission.archived_at? + return render json: { error: I18n.t('form_has_been_archived') }, status: :unprocessable_content end - if submitter.submission.expired? - return render json: { error: I18n.t('form_has_been_expired') }, status: :unprocessable_entity + if @submitter.submission.expired? + return render json: { error: I18n.t('form_has_been_expired') }, status: :unprocessable_content end - if submitter.declined_at? + if @submitter.declined_at? return render json: { error: I18n.t('form_has_been_declined') }, - status: :unprocessable_entity + status: :unprocessable_content end - Submitters::SubmitValues.call(submitter, params, request) + Submitters::SubmitValues.call(@submitter, params, request) 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_entity + render json: { error: e.message }, status: :unprocessable_content end def completed - @submitter = Submitter.completed.find_by!(slug: params[:submit_form_slug]) + raise ActionController::RoutingError, I18n.t('not_found') if @submitter.account.archived_at? end def success; end private + def maybe_require_link_2fa + return if @submitter.submission.source != 'link' + return unless @submitter.submission.template&.preferences&.dig('shared_link_2fa') == true + return if cookies.encrypted[:email_2fa_slug] == @submitter.slug + return if @submitter.email == current_user&.email && current_user&.account_id == @submitter.account_id + + redirect_to start_form_path(@submitter.submission.template.slug) + end + + def maybe_render_locked_page + return render :archived if @submitter.submission.template&.archived_at? || + @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) diff --git a/app/controllers/submit_form_decline_controller.rb b/app/controllers/submit_form_decline_controller.rb index 94099b0d..918903fe 100644 --- a/app/controllers/submit_form_decline_controller.rb +++ b/app/controllers/submit_form_decline_controller.rb @@ -11,7 +11,7 @@ class SubmitFormDeclineController < ApplicationController submitter.completed_at? || submitter.submission.archived_at? || submitter.submission.expired? || - submitter.submission.template.archived_at? + submitter.submission.template&.archived_at? ApplicationRecord.transaction do submitter.update!(declined_at: Time.current) @@ -25,10 +25,7 @@ class SubmitFormDeclineController < ApplicationController SubmitterMailer.declined_email(submitter, user).deliver_later! end - WebhookUrls.for_account_id(submitter.account_id, 'form.declined').each do |webhook_url| - SendFormDeclinedWebhookRequestJob.perform_async('submitter_id' => submitter.id, - 'webhook_url_id' => webhook_url.id) - end + WebhookUrls.enqueue_events(submitter, 'form.declined') redirect_to submit_form_path(submitter.slug) end diff --git a/app/controllers/submit_form_download_controller.rb b/app/controllers/submit_form_download_controller.rb index 9be5c0ac..d6e0b692 100644 --- a/app/controllers/submit_form_download_controller.rb +++ b/app/controllers/submit_form_download_controller.rb @@ -7,25 +7,28 @@ class SubmitFormDownloadController < ApplicationController FILES_TTL = 5.minutes def index - submitter = Submitter.find_by!(slug: params[:submit_form_slug]) + @submitter = Submitter.find_by!(slug: params[:submit_form_slug]) - return redirect_to submitter_download_index_path(submitter.slug) if submitter.completed_at? + return redirect_to submitter_download_index_path(@submitter.slug) if @submitter.completed_at? - return head :unprocessable_entity if submitter.declined_at? || - submitter.submission.archived_at? || - submitter.submission.expired? || - submitter.submission.template.archived_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) - last_completed_submitter = submitter.submission.submitters - .where.not(id: submitter.id) - .where.not(completed_at: nil) - .max_by(&:completed_at) + 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.template.schema_documents.preload(:blob) + @submitter.submission.schema_documents.preload(:blob) end urls = attachments.map do |attachment| diff --git a/app/controllers/submit_form_draw_signature_controller.rb b/app/controllers/submit_form_draw_signature_controller.rb index f8352ade..773eb9e7 100644 --- a/app/controllers/submit_form_draw_signature_controller.rb +++ b/app/controllers/submit_form_draw_signature_controller.rb @@ -12,7 +12,7 @@ class SubmitFormDrawSignatureController < ApplicationController return redirect_to submit_form_completed_path(@submitter.slug) if @submitter.completed_at? - if @submitter.submission.template.archived_at? || @submitter.submission.archived_at? + if @submitter.submission.template&.archived_at? || @submitter.submission.archived_at? return redirect_to submit_form_path(@submitter.slug) end diff --git a/app/controllers/submit_form_invite_controller.rb b/app/controllers/submit_form_invite_controller.rb index 1d42779c..ab1f26c3 100644 --- a/app/controllers/submit_form_invite_controller.rb +++ b/app/controllers/submit_form_invite_controller.rb @@ -7,7 +7,7 @@ class SubmitFormInviteController < ApplicationController def create submitter = Submitter.find_by!(slug: params[:submit_form_slug]) - return head :unprocessable_entity unless can_invite?(submitter) + 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') @@ -34,7 +34,7 @@ class SubmitFormInviteController < ApplicationController head :ok else - head :unprocessable_entity + head :unprocessable_content end end @@ -45,7 +45,7 @@ class SubmitFormInviteController < ApplicationController !submitter.completed_at? && !submitter.submission.archived_at? && !submitter.submission.expired? && - !submitter.submission.template.archived_at? + !submitter.submission.template&.archived_at? end def filter_invite_submitters(submitter, key = 'invite_by_uuid') diff --git a/app/controllers/submit_form_values_controller.rb b/app/controllers/submit_form_values_controller.rb index 2c4a2ad3..e1a6b9ab 100644 --- a/app/controllers/submit_form_values_controller.rb +++ b/app/controllers/submit_form_values_controller.rb @@ -8,7 +8,7 @@ class SubmitFormValuesController < ApplicationController submitter = Submitter.find_by!(slug: params[:submit_form_slug]) return render json: {} if submitter.completed_at? || submitter.declined_at? - return render json: {} if submitter.submission.template.archived_at? || + return render json: {} if submitter.submission.template&.archived_at? || submitter.submission.archived_at? || submitter.submission.expired? diff --git a/app/controllers/submitters_autocomplete_controller.rb b/app/controllers/submitters_autocomplete_controller.rb index e86e129e..efbb110a 100644 --- a/app/controllers/submitters_autocomplete_controller.rb +++ b/app/controllers/submitters_autocomplete_controller.rb @@ -22,13 +22,17 @@ class SubmittersAutocompleteController < ApplicationController def search_submitters(submitters) if SELECT_COLUMNS.include?(params[:field]) - column = Submitter.arel_table[params[:field].to_sym] + if Docuseal.fulltext_search? + Submitters.fulltext_search_field(current_user, submitters, params[:q], params[:field]) + else + column = Submitter.arel_table[params[:field].to_sym] - term = "#{params[:q].downcase}%" + term = "#{params[:q].downcase}%" - submitters.where(column.matches(term)) + submitters.where(column.matches(term)) + end else - Submitters.search(submitters, params[:q]) + Submitters.search(current_user, submitters, params[:q]) end end end diff --git a/app/controllers/submitters_controller.rb b/app/controllers/submitters_controller.rb index 3adb3c89..697b3dc1 100644 --- a/app/controllers/submitters_controller.rb +++ b/app/controllers/submitters_controller.rb @@ -38,6 +38,8 @@ class SubmittersController < ApplicationController 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') diff --git a/app/controllers/submitters_resubmit_controller.rb b/app/controllers/submitters_resubmit_controller.rb index fb893d98..6ab28731 100644 --- a/app/controllers/submitters_resubmit_controller.rb +++ b/app/controllers/submitters_resubmit_controller.rb @@ -6,13 +6,15 @@ class SubmittersResubmitController < ApplicationController def update return redirect_to submit_form_path(slug: @submitter.slug) if @submitter.email != current_user.email - submission = @submitter.template.submissions.new(created_by_user: current_user, - submitters_order: :preserved, - **@submitter.submission.slice(:template_fields, - :account_id, - :template_schema, - :template_submitters, - :preferences)) + 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, @@ -27,6 +29,10 @@ class SubmittersResubmitController < ApplicationController 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 diff --git a/app/controllers/template_documents_controller.rb b/app/controllers/template_documents_controller.rb index abd72010..b29a18f6 100644 --- a/app/controllers/template_documents_controller.rb +++ b/app/controllers/template_documents_controller.rb @@ -3,9 +3,15 @@ class TemplateDocumentsController < ApplicationController load_and_authorize_resource :template + FILES_TTL = 5.minutes + + def index + render json: @template.schema_documents.map { |d| ActiveStorage::Blob.proxy_url(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_entity + return render json: { error: I18n.t('file_is_missing') }, status: :unprocessable_content end old_fields_hash = @template.fields.hash @@ -28,6 +34,6 @@ class TemplateDocumentsController < ApplicationController ) } rescue Templates::CreateAttachments::PdfEncrypted - render json: { error: 'PDF encrypted', status: 'pdf_encrypted' }, status: :unprocessable_entity + render json: { error: 'PDF encrypted', status: 'pdf_encrypted' }, status: :unprocessable_content end end diff --git a/app/controllers/template_folders_autocomplete_controller.rb b/app/controllers/template_folders_autocomplete_controller.rb index c3b621bc..2bfdec99 100644 --- a/app/controllers/template_folders_autocomplete_controller.rb +++ b/app/controllers/template_folders_autocomplete_controller.rb @@ -3,12 +3,31 @@ class TemplateFoldersAutocompleteController < ApplicationController load_and_authorize_resource :template_folder, parent: false - LIMIT = 100 + LIMIT = 30 def index - template_folders = @template_folders.joins(:templates).where(templates: { archived_at: nil }).distinct - template_folders = TemplateFolders.search(template_folders, params[:q]).limit(LIMIT) + parent_name, name = + if params[:parent_name].present? + [params[:parent_name], params[:q]] + else + params[:q].to_s.split(' /', 2).map(&:squish) + end - render json: template_folders.as_json(only: %i[name archived_at]) + 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 diff --git a/app/controllers/template_folders_controller.rb b/app/controllers/template_folders_controller.rb index a3418015..25e489ce 100644 --- a/app/controllers/template_folders_controller.rb +++ b/app/controllers/template_folders_controller.rb @@ -3,12 +3,41 @@ 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_folder.templates.active.accessible_by(current_ability) - .preload(:author, :template_accesses).order(id: :desc) - @templates = Templates.search(@templates, params[:q]) + @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) - @pagy, @templates = pagy(@templates, limit: 12) + @templates = @templates.none + end end def edit; end @@ -24,7 +53,34 @@ class TemplateFoldersController < ApplicationController 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 diff --git a/app/controllers/templates_archived_controller.rb b/app/controllers/templates_archived_controller.rb index f75e83a1..69b16936 100644 --- a/app/controllers/templates_archived_controller.rb +++ b/app/controllers/templates_archived_controller.rb @@ -4,9 +4,27 @@ class TemplatesArchivedController < ApplicationController load_and_authorize_resource :template, parent: false def index - @templates = @templates.where.not(archived_at: nil).preload(:author, :folder, :template_accesses).order(id: :desc) - @templates = Templates.search(@templates, params[:q]) + @templates = @templates.where.not(archived_at: nil) + .preload(:author, :template_accesses, folder: :parent_folder) + .order(id: :desc) - @pagy, @templates = pagy(@templates, limit: 12) + @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 diff --git a/app/controllers/templates_archived_submissions_controller.rb b/app/controllers/templates_archived_submissions_controller.rb index bf023677..39aa0877 100644 --- a/app/controllers/templates_archived_submissions_controller.rb +++ b/app/controllers/templates_archived_submissions_controller.rb @@ -6,7 +6,7 @@ class TemplatesArchivedSubmissionsController < ApplicationController def index @submissions = @submissions.where.not(archived_at: nil) - @submissions = Submissions.search(@submissions, params[:q], search_values: true) + @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? @@ -15,7 +15,7 @@ class TemplatesArchivedSubmissionsController < ApplicationController @submissions.order(id: :desc) end - @pagy, @submissions = pagy(@submissions.preload(submitters: :start_form_submission_events)) + @pagy, @submissions = pagy_auto(@submissions.preload(submitters: :start_form_submission_events)) rescue ActiveRecord::RecordNotFound redirect_to root_path end diff --git a/app/controllers/templates_clone_and_replace_controller.rb b/app/controllers/templates_clone_and_replace_controller.rb new file mode 100644 index 00000000..2d7c7782 --- /dev/null +++ b/app/controllers/templates_clone_and_replace_controller.rb @@ -0,0 +1,39 @@ +# 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 diff --git a/app/controllers/templates_controller.rb b/app/controllers/templates_controller.rb index 3010d4ab..c96360d9 100644 --- a/app/controllers/templates_controller.rb +++ b/app/controllers/templates_controller.rb @@ -8,7 +8,7 @@ class TemplatesController < ApplicationController def show submissions = @template.submissions.accessible_by(current_ability) submissions = submissions.active if @template.archived_at.blank? - submissions = Submissions.search(submissions, params[:q], search_values: true) + submissions = Submissions.search(current_user, submissions, params[:q], search_values: true) submissions = Submissions::Filter.call(submissions, current_user, params.except(:status)) @base_submissions = submissions @@ -21,9 +21,7 @@ class TemplatesController < ApplicationController submissions.order(id: :desc) end - submissions = submissions.preload(:template_accesses) unless current_user.role.in?(%w[admin superadmin]) - - @pagy, @submissions = pagy(submissions.preload(submitters: :start_form_submission_events)) + @pagy, @submissions = pagy_auto(submissions.preload(:template_accesses, submitters: :start_form_submission_events)) rescue ActiveRecord::RecordNotFound redirect_to root_path end @@ -71,21 +69,31 @@ class TemplatesController < ApplicationController @template.account = current_account end + Templates.maybe_assign_access(@template) + if @template.save Templates::CloneAttachments.call(template: @template, original_template: @base_template) if @base_template - enqueue_template_created_webhooks(@template) + SearchEntries.enqueue_reindex(@template) + + WebhookUrls.enqueue_events(@template, 'template.created') maybe_redirect_to_template(@template) else - render turbo_stream: turbo_stream.replace(:modal, template: 'templates/new'), status: :unprocessable_entity + render turbo_stream: turbo_stream.replace(:modal, template: 'templates/new'), status: :unprocessable_content end end def update - @template.update!(template_params) + @template.assign_attributes(template_params) + + is_name_changed = @template.name_changed? + + @template.save! + + SearchEntries.enqueue_reindex(@template) if is_name_changed - enqueue_template_updated_webhooks(@template) + WebhookUrls.enqueue_events(@template, 'template.updated') head :ok end @@ -110,15 +118,17 @@ class TemplatesController < ApplicationController def template_params params.require(:template).permit( :name, - { schema: [[:attachment_uuid, :name, { conditions: [%i[field_uuid value action operation]] }]], - submitters: [%i[name uuid is_requester linked_to_uuid invite_by_uuid optional_invite_by_uuid email]], + { schema: [[:attachment_uuid, :google_drive_file_id, :name, + { conditions: [%i[field_uuid value action operation]] }]], + submitters: [%i[name uuid is_requester linked_to_uuid invite_by_uuid optional_invite_by_uuid email order]], fields: [[:uuid, :submitter_uuid, :name, :type, :required, :readonly, :default_value, - :title, :description, + :title, :description, :prefillable, { preferences: {}, + default_value: [], conditions: [%i[field_uuid value action operation]], options: [%i[value uuid]], - validation: %i[message pattern], + validation: %i[message pattern min max step], areas: [%i[x y w h cell_w attachment_uuid option_uuid page]] }]] } ) end @@ -136,20 +146,6 @@ class TemplatesController < ApplicationController end end - def enqueue_template_created_webhooks(template) - WebhookUrls.for_account_id(template.account_id, 'template.created').each do |webhook_url| - SendTemplateCreatedWebhookRequestJob.perform_async('template_id' => template.id, - 'webhook_url_id' => webhook_url.id) - end - end - - def enqueue_template_updated_webhooks(template) - WebhookUrls.for_account_id(template.account_id, 'template.updated').each do |webhook_url| - SendTemplateUpdatedWebhookRequestJob.perform_async('template_id' => template.id, - 'webhook_url_id' => webhook_url.id) - end - end - def load_base_template return if params[:base_template_id].blank? diff --git a/app/controllers/templates_dashboard_controller.rb b/app/controllers/templates_dashboard_controller.rb index 7c806fa9..4497038e 100644 --- a/app/controllers/templates_dashboard_controller.rb +++ b/app/controllers/templates_dashboard_controller.rb @@ -8,14 +8,18 @@ class TemplatesDashboardController < ApplicationController TEMPLATES_PER_PAGE = 12 FOLDERS_PER_PAGE = 18 + helper_method :selected_order + def index - @template_folders = @template_folders.where(id: @templates.active.select(:folder_id)).order(id: :desc) + @template_folders = + TemplateFolders.filter_active_folders(@template_folders.where(parent_folder_id: nil), @templates) @template_folders = TemplateFolders.search(@template_folders, params[:q]) + @template_folders = TemplateFolders.sort(@template_folders, current_user, selected_order) @pagy, @template_folders = pagy( @template_folders, - items: FOLDERS_PER_PAGE, + limit: FOLDERS_PER_PAGE, page: @template_folders.count > SHOW_TEMPLATES_FOLDERS_THRESHOLD ? params[:page] : 1 ) @@ -23,7 +27,8 @@ class TemplatesDashboardController < ApplicationController @templates = @templates.none else @template_folders = @template_folders.reject { |e| e.name == TemplateFolder::DEFAULT_NAME } - @templates = filter_templates(@templates) + @templates = filter_templates(@templates).preload(:author, :template_accesses) + @templates = Templates::Order.call(@templates, current_user, selected_order) limit = if @template_folders.size < 4 @@ -32,26 +37,60 @@ class TemplatesDashboardController < ApplicationController (@template_folders.size < 7 ? 9 : 6) end - @pagy, @templates = pagy(@templates, limit:) + @pagy, @templates = pagy_auto(@templates, limit:) + + load_related_submissions if params[:q].present? && @templates.blank? end end private def filter_templates(templates) - rel = templates.active.preload(:author, :template_accesses).order(id: :desc) + rel = templates.active if params[:q].blank? - if Docuseal.multitenant? && !current_account.testing? - rel = rel.where(folder_id: current_account.default_template_folder.id) - else - shared_template_ids = - TemplateSharing.where(account_id: [current_account.id, TemplateSharing::ALL_ID]).select(:template_id) + if Docuseal.multitenant? ? current_account.testing? : current_account.linked_account_account + shared_account_ids = [current_user.account_id] + shared_account_ids << TemplateSharing::ALL_ID if !Docuseal.multitenant? && !current_account.testing? - rel = rel.where(folder_id: current_account.default_template_folder.id).or(rel.where(id: shared_template_ids)) + shared_template_ids = TemplateSharing.where(account_id: shared_account_ids).select(:template_id) + + rel = Template.where( + Template.arel_table[:id].in( + rel.where(folder_id: current_account.default_template_folder.id).select(:id).arel + .union(:all, shared_template_ids.arel) + ) + ) + else + rel = rel.where(folder_id: current_account.default_template_folder.id) end end - Templates.search(rel, params[:q]) + Templates.search(current_user, rel, params[:q]) + end + + 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 load_related_submissions + @related_submissions = Submission.accessible_by(current_ability) + .left_joins(:template) + .where(archived_at: nil) + .where(templates: { archived_at: nil }) + .preload(:template_accesses, :created_by_user, + template: :author, + submitters: :start_form_submission_events) + + @related_submissions = Submissions.search(current_user, @related_submissions, params[:q]) + .order(id: :desc) + + @related_submissions_pagy, @related_submissions = pagy_auto(@related_submissions, limit: 5) end end diff --git a/app/controllers/templates_debug_controller.rb b/app/controllers/templates_debug_controller.rb index 02d3d8b0..4ff31835 100644 --- a/app/controllers/templates_debug_controller.rb +++ b/app/controllers/templates_debug_controller.rb @@ -3,18 +3,29 @@ class TemplatesDebugController < ApplicationController load_and_authorize_resource :template + DEBUG_FILE = '' + def show - attachment = @template.documents.first + schema_uuids = @template.schema.index_by { |e| e['attachment_uuid'] } + attachment = @template.documents.find { |a| schema_uuids[a.uuid] } + + data = attachment.download + + unless attachment.image? + pdf = HexaPDF::Document.new(io: StringIO.new(data)) - pdf = HexaPDF::Document.new(io: StringIO.new(attachment.download)) + fields = Templates::FindAcroFields.call(pdf, attachment, data) + end - fields = Templates::FindAcroFields.call(pdf, attachment) + fields, = Templates::DetectFields.call(StringIO.new(data), attachment:) if fields.blank? attachment.metadata['pdf'] ||= {} attachment.metadata['pdf']['fields'] = fields @template.update!(fields: Templates::ProcessDocument.normalize_attachment_fields(@template, [attachment])) + debug_file if DEBUG_FILE.present? + ActiveRecord::Associations::Preloader.new( records: [@template], associations: [schema_documents: { preview_images_attachments: :blob }] @@ -30,4 +41,27 @@ class TemplatesDebugController < ApplicationController render 'templates/edit', layout: 'plain' end + + def debug_file + tempfile = Tempfile.new + tempfile.binmode + tempfile.write(File.read(DEBUG_FILE)) + tempfile.rewind + + filename = File.basename(DEBUG_FILE) + + file = ActionDispatch::Http::UploadedFile.new( + tempfile:, + filename:, + type: Marcel::MimeType.for(tempfile) + ) + + params = { files: [file] } + + documents = Templates::CreateAttachments.call(@template, params) + + schema = documents.map { |doc| { attachment_uuid: doc.uuid, name: doc.filename.base } } + + @template.update!(schema:) + end end diff --git a/app/controllers/templates_detect_fields_controller.rb b/app/controllers/templates_detect_fields_controller.rb new file mode 100644 index 00000000..8355dcb2 --- /dev/null +++ b/app/controllers/templates_detect_fields_controller.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class TemplatesDetectFieldsController < ApplicationController + include ActionController::Live + + load_and_authorize_resource :template + + def create + response.headers['Content-Type'] = 'text/event-stream' + + sse = SSE.new(response.stream) + + documents = @template.schema_documents.preload(:blob) + + documents.each do |document| + io = StringIO.new(document.download) + + Templates::DetectFields.call(io, attachment: document) do |(attachment_uuid, page, fields)| + sse.write({ attachment_uuid:, page:, fields: }) + end + end + + sse.write({ completed: true }) + ensure + response.stream.close + end +end diff --git a/app/controllers/templates_folders_controller.rb b/app/controllers/templates_folders_controller.rb index 3fdcc873..3e83023a 100644 --- a/app/controllers/templates_folders_controller.rb +++ b/app/controllers/templates_folders_controller.rb @@ -6,7 +6,9 @@ class TemplatesFoldersController < ApplicationController def edit; end def update - @template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:name]) + name = [params[:parent_name], params[:name]].compact_blank.join(' / ') + + @template.folder = TemplateFolders.find_or_create_by_name(current_user, name) if @template.save redirect_back(fallback_location: template_path(@template), notice: I18n.t('document_template_has_been_moved')) diff --git a/app/controllers/templates_form_preview_controller.rb b/app/controllers/templates_form_preview_controller.rb index f7a1a396..5c4f23c6 100644 --- a/app/controllers/templates_form_preview_controller.rb +++ b/app/controllers/templates_form_preview_controller.rb @@ -13,7 +13,7 @@ class TemplatesFormPreviewController < ApplicationController @submitter.submission.submitters = @template.submitters.map { |item| Submitter.new(uuid: item['uuid']) } - Submitters.preload_with_pages(@submitter) + Submissions.preload_with_pages(@submitter.submission) @attachments_index = ActiveStorage::Attachment.where(record: @submitter.submission.submitters, name: :attachments) .preload(:blob).index_by(&:uuid) diff --git a/app/controllers/templates_preferences_controller.rb b/app/controllers/templates_preferences_controller.rb index a9221f7b..391b6714 100644 --- a/app/controllers/templates_preferences_controller.rb +++ b/app/controllers/templates_preferences_controller.rb @@ -3,6 +3,15 @@ class TemplatesPreferencesController < ApplicationController load_and_authorize_resource :template + RESETTABLE_PREFERENCE_KEYS = { + AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY => %w[request_email_subject request_email_body submitters], + AccountConfig::SUBMITTER_INVITATION_REMINDER_EMAIL_KEY => %w[invitation_reminder_email_subject + invitation_reminder_email_body], + AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY => %w[documents_copy_email_subject documents_copy_email_body], + AccountConfig::SUBMITTER_COMPLETED_EMAIL_KEY => %w[completed_notification_email_subject + completed_notification_email_body] + }.freeze + def show; end def create @@ -15,23 +24,50 @@ class TemplatesPreferencesController < ApplicationController head :ok end + def destroy + authorize!(:update, @template) + + config_key = params[:config_key] + preferences_to_delete = RESETTABLE_PREFERENCE_KEYS[config_key] + + return head :ok if preferences_to_delete.blank? + + preferences_to_delete.each do |key| + @template.preferences.delete(key) + end + + @template.save! + + render turbo_stream: turbo_stream.replace("#{config_key}_form", + partial: "templates_preferences/#{config_key}_form"), + status: :ok + end + private def template_params params.require(:template).permit( preferences: %i[bcc_completed request_email_subject request_email_body + invitation_reminder_email_subject invitation_reminder_email_body documents_copy_email_subject documents_copy_email_body documents_copy_email_enabled documents_copy_email_attach_audit documents_copy_email_attach_documents documents_copy_email_reply_to completed_notification_email_attach_documents - completed_redirect_url - submitters_order + completed_redirect_url validate_unique_submitters + require_all_submitters submitters_order require_phone_2fa + default_expire_at_duration shared_link_2fa default_expire_at request_email_enabled completed_notification_email_subject completed_notification_email_body completed_notification_email_enabled completed_notification_email_attach_audit] + [completed_message: %i[title body], - submitters: [%i[uuid request_email_subject request_email_body]]] + submitters: [%i[uuid request_email_subject request_email_body]], link_form_fields: []] ).tap do |attrs| attrs[:preferences].delete(:submitters) if params[:request_email_per_submitter] != '1' + + if (default_expire_at = attrs.dig(:preferences, :default_expire_at).presence) + attrs[:preferences][:default_expire_at] = + (ActiveSupport::TimeZone[current_account.timezone] || Time.zone).parse(default_expire_at).utc + end + attrs[:preferences] = attrs[:preferences].transform_values do |value| if %w[true false].include?(value) value == 'true' diff --git a/app/controllers/templates_prefillable_fields_controller.rb b/app/controllers/templates_prefillable_fields_controller.rb new file mode 100644 index 00000000..6202ca5c --- /dev/null +++ b/app/controllers/templates_prefillable_fields_controller.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +class TemplatesPrefillableFieldsController < ApplicationController + PREFILLABLE_FIELD_TYPES = %w[text number cells date checkbox select radio phone].freeze + + load_and_authorize_resource :template + + def create + authorize!(:update, @template) + + field = @template.fields.find { |f| f['uuid'] == params[:field_uuid] } + + if params[:prefillable] == 'false' + field.delete('prefillable') + field.delete('readonly') + elsif params[:prefillable] == 'true' + field['prefillable'] = true + field['readonly'] = true + end + + @template.save! + + render turbo_stream: turbo_stream.replace(:prefillable_fields_list, partial: 'list', + locals: { template: @template }) + end +end diff --git a/app/controllers/templates_recipients_controller.rb b/app/controllers/templates_recipients_controller.rb index a1298094..17a4bbb8 100644 --- a/app/controllers/templates_recipients_controller.rb +++ b/app/controllers/templates_recipients_controller.rb @@ -9,6 +9,10 @@ class TemplatesRecipientsController < ApplicationController @template.submitters = submitters_params.map { |s| s.reject { |_, v| v.is_a?(String) && v.blank? } } + if @template.submitters.each_with_index.all? { |s, index| s['order'] == index } + @template.submitters.each { |s| s.delete('order') } + end + @template.save! render json: { submitters: @template.submitters } @@ -18,7 +22,7 @@ class TemplatesRecipientsController < ApplicationController def submitters_params permit_params = { submitters: [%i[name uuid is_requester optional_invite_by_uuid - invite_by_uuid linked_to_uuid email option]] } + invite_by_uuid linked_to_uuid email option order]] } params.require(:template).permit(permit_params).fetch(:submitters, {}).values.filter_map do |s| next if s[:uuid].blank? @@ -29,6 +33,7 @@ class TemplatesRecipientsController < ApplicationController s.delete(:is_requester) end + s[:order] = s[:order].to_i if s[:order].present? s.delete(:invite_by_uuid) if s[:invite_by_uuid].blank? s.delete(:optional_invite_by_uuid) if s[:optional_invite_by_uuid].blank? diff --git a/app/controllers/templates_share_link_controller.rb b/app/controllers/templates_share_link_controller.rb new file mode 100644 index 00000000..5dfed111 --- /dev/null +++ b/app/controllers/templates_share_link_controller.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class TemplatesShareLinkController < ApplicationController + load_and_authorize_resource :template + + def show; end + + def create + authorize!(:update, @template) + + @template.update!(template_params) + + if params[:redir].present? + redirect_to params[:redir] + else + head :ok + end + end + + private + + def template_params + params.require(:template).permit(:shared_link) + end +end diff --git a/app/controllers/templates_uploads_controller.rb b/app/controllers/templates_uploads_controller.rb index 4cd0072f..e8c00aea 100644 --- a/app/controllers/templates_uploads_controller.rb +++ b/app/controllers/templates_uploads_controller.rb @@ -23,7 +23,9 @@ class TemplatesUploadsController < ApplicationController @template.update!(schema:) - enqueue_template_created_webhooks(@template) + WebhookUrls.enqueue_events(@template, 'template.created') + + SearchEntries.enqueue_reindex(@template) redirect_to edit_template_path(@template) rescue Templates::CreateAttachments::PdfEncrypted @@ -44,6 +46,8 @@ class TemplatesUploadsController < ApplicationController template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:folder_name]) template.name = File.basename((url_params || params)[:files].first.original_filename, '.*') + Templates.maybe_assign_access(template) + template.save! template @@ -66,11 +70,4 @@ class TemplatesUploadsController < ApplicationController { files: [file] } end - - def enqueue_template_created_webhooks(template) - WebhookUrls.for_account_id(template.account_id, 'template.created').each do |webhook_url| - SendTemplateCreatedWebhookRequestJob.perform_async('template_id' => template.id, - 'webhook_url_id' => webhook_url.id) - end - end end diff --git a/app/controllers/user_configs_controller.rb b/app/controllers/user_configs_controller.rb index 09b7e6c5..097951b4 100644 --- a/app/controllers/user_configs_controller.rb +++ b/app/controllers/user_configs_controller.rb @@ -5,7 +5,8 @@ class UserConfigsController < ApplicationController authorize_resource :user_config ALLOWED_KEYS = [ - UserConfig::RECEIVE_COMPLETED_EMAIL + UserConfig::RECEIVE_COMPLETED_EMAIL, + UserConfig::SHOW_APP_TOUR ].freeze InvalidKey = Class.new(StandardError) @@ -28,6 +29,7 @@ class UserConfigsController < ApplicationController def user_config_params params.required(:user_config).permit(:key, :value, { value: {} }, { value: [] }).tap do |attrs| attrs[:value] = attrs[:value] == '1' if attrs[:value].in?(%w[1 0]) + attrs[:value] = attrs[:value] == 'true' if attrs[:value].in?(%w[true false]) end end end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 47a88e05..39555b59 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -16,7 +16,7 @@ class UsersController < ApplicationController @users.active.where.not(role: 'integration') end - @pagy, @users = pagy(@users.where(account: current_account).order(id: :desc)) + @pagy, @users = pagy(@users.preload(account: :account_accesses).where(account: current_account).order(id: :desc)) end def new; end @@ -24,12 +24,22 @@ class UsersController < ApplicationController def edit; end def create - if User.accessible_by(current_ability).exists?(email: @user.email) - @user.errors.add(:email, I18n.t('already_exists')) + existing_user = User.accessible_by(current_ability).find_by(email: @user.email) + + if existing_user + if existing_user.archived_at? && + current_ability.can?(:manage, existing_user) && current_ability.can?(:manage, @user.account) + existing_user.assign_attributes(@user.slice(:first_name, :last_name, :role, :account_id)) + existing_user.archived_at = nil + @user = existing_user + else + @user.errors.add(:email, I18n.t('already_exists')) - return render turbo_stream: turbo_stream.replace(:modal, template: 'users/new'), status: :unprocessable_entity + return render turbo_stream: turbo_stream.replace(:modal, template: 'users/new'), status: :unprocessable_content + end end + @user.password = SecureRandom.hex if @user.password.blank? @user.role = User::ADMIN_ROLE unless role_valid?(@user.role) if @user.save @@ -37,7 +47,7 @@ class UsersController < ApplicationController redirect_back fallback_location: settings_users_path, notice: I18n.t('user_has_been_invited') else - render turbo_stream: turbo_stream.replace(:modal, template: 'users/new'), status: :unprocessable_entity + render turbo_stream: turbo_stream.replace(:modal, template: 'users/new'), status: :unprocessable_content end end @@ -54,10 +64,10 @@ class UsersController < ApplicationController @user.account = account end - if @user.update(attrs.except(current_user == @user ? :role : nil)) + if @user.update(attrs.except(*(current_user == @user ? %i[password otp_required_for_login role] : %i[password]))) redirect_back fallback_location: settings_users_path, notice: I18n.t('user_has_been_updated') else - render turbo_stream: turbo_stream.replace(:modal, template: 'users/edit'), status: :unprocessable_entity + render turbo_stream: turbo_stream.replace(:modal, template: 'users/edit'), status: :unprocessable_content end end @@ -83,7 +93,7 @@ class UsersController < ApplicationController def user_params if params.key?(:user) - permitted_params = %i[email first_name last_name password archived_at] + permitted_params = %i[email first_name last_name password archived_at otp_required_for_login] permitted_params << :role if role_valid?(params.dig(:user, :role)) diff --git a/app/controllers/users_send_reset_password_controller.rb b/app/controllers/users_send_reset_password_controller.rb new file mode 100644 index 00000000..f6a8af82 --- /dev/null +++ b/app/controllers/users_send_reset_password_controller.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class UsersSendResetPasswordController < ApplicationController + load_and_authorize_resource :user + + LIMIT_DURATION = 10.minutes + + def update + authorize!(:manage, @user) + + if @user.reset_password_sent_at && @user.reset_password_sent_at > LIMIT_DURATION.ago + redirect_back fallback_location: settings_users_path, notice: I18n.t('email_has_been_sent_already') + else + @user.send_reset_password_instructions + + redirect_back fallback_location: settings_users_path, + notice: I18n.t('an_email_with_password_reset_instructions_has_been_sent') + end + end +end diff --git a/app/controllers/webhook_events_controller.rb b/app/controllers/webhook_events_controller.rb new file mode 100644 index 00000000..55bcb4ea --- /dev/null +++ b/app/controllers/webhook_events_controller.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +class WebhookEventsController < ApplicationController + load_and_authorize_resource :webhook_url, parent: false, id_param: :webhook_id + before_action :load_webhook_event + + def show + return unless current_ability.can?(:read, @webhook_event.record) + + @data = + case @webhook_event.event_type + when 'form.started', 'form.completed', 'form.declined', 'form.viewed' + Submitters::SerializeForWebhook.call(@webhook_event.record) + when 'submission.created', 'submission.completed', 'submission.expired' + Submissions::SerializeForApi.call(@webhook_event.record) + when 'template.created', 'template.updated' + Templates::SerializeForApi.call(@webhook_event.record) + when 'submission.archived' + @webhook_event.record.as_json(only: %i[id archived_at]) + end + end + + def resend + id_key = WebhookUrls::EVENT_TYPE_ID_KEYS.fetch(@webhook_event.event_type.split('.').first) + + last_attempt_id = @webhook_event.webhook_attempts.maximum(:id) + + WebhookUrls::EVENT_TYPE_TO_JOB_CLASS[@webhook_event.event_type].perform_async( + id_key => @webhook_event.record_id, + 'webhook_url_id' => @webhook_event.webhook_url_id, + 'event_uuid' => @webhook_event.uuid, + 'attempt' => SendWebhookRequest::MANUAL_ATTEMPT, + 'last_status' => 0 + ) + + render turbo_stream: [ + turbo_stream.after( + params[:button_id], + helpers.tag.submit_form( + helpers.button_to('', refresh_settings_webhook_event_path(@webhook_url.id, @webhook_event.uuid), + params: { last_attempt_id: }), + class: 'hidden', data: { interval: 3_000 } + ) + ) + ] + end + + def refresh + return head :ok if @webhook_event.webhook_attempts.maximum(:id) == params[:last_attempt_id].to_i + + render turbo_stream: [ + turbo_stream.replace(helpers.dom_id(@webhook_event), + partial: 'event_row', + locals: { with_status: true, webhook_url: @webhook_url, webhook_event: @webhook_event }), + turbo_stream.replace(helpers.dom_id(@webhook_event, :drawer_events), + partial: 'drawer_events', + locals: { webhook_url: @webhook_url, webhook_event: @webhook_event }) + ] + end + + private + + def load_webhook_event + @webhook_event = @webhook_url.webhook_events.find_by!(uuid: params[:id]) + end +end diff --git a/app/controllers/webhook_settings_controller.rb b/app/controllers/webhook_settings_controller.rb index 5f023ad1..482116ef 100644 --- a/app/controllers/webhook_settings_controller.rb +++ b/app/controllers/webhook_settings_controller.rb @@ -8,10 +8,26 @@ class WebhookSettingsController < ApplicationController @webhook_urls = @webhook_urls.order(id: :desc) @webhook_url = @webhook_urls.first_or_initialize - render @webhook_urls.size > 1 ? 'index' : 'show' + if @webhook_urls.size > 1 + render :index + else + @webhook_events = @webhook_url.webhook_events + + @webhook_events = @webhook_events.where(status: params[:status]) if %w[success error].include?(params[:status]) + + @pagy, @webhook_events = pagy_countless(@webhook_events.order(id: :desc)) + + render :show + end end - def show; end + def show + @webhook_events = @webhook_url.webhook_events + + @webhook_events = @webhook_events.where(status: params[:status]) if %w[success error].include?(params[:status]) + + @pagy, @webhook_events = pagy_countless(@webhook_events.order(id: :desc)) + end def new; end @@ -37,13 +53,18 @@ class WebhookSettingsController < ApplicationController def resend submitter = current_account.submitters.where.not(completed_at: nil).order(:id).last + authorize!(:read, submitter) + if submitter.blank? || @webhook_url.blank? return redirect_back(fallback_location: settings_webhooks_path, alert: I18n.t('unable_to_resend_webhook_request')) end - SendFormCompletedWebhookRequestJob.perform_async('submitter_id' => submitter.id, - 'webhook_url_id' => @webhook_url.id) + SendTestWebhookRequestJob.perform_async( + 'submitter_id' => submitter.id, + 'event_uuid' => SecureRandom.uuid, + 'webhook_url_id' => @webhook_url.id + ) redirect_back(fallback_location: settings_webhooks_path, notice: I18n.t('webhook_request_has_been_sent')) end diff --git a/app/javascript/application.js b/app/javascript/application.js index d35641ad..b839039d 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -23,7 +23,9 @@ import SignatureForm from './elements/signature_form' import SubmitForm from './elements/submit_form' import PromptPassword from './elements/prompt_password' import EmailsTextarea from './elements/emails_textarea' +import ToggleSubmit from './elements/toggle_submit' import ToggleOnSubmit from './elements/toggle_on_submit' +import CheckOnClick from './elements/check_on_click' import PasswordInput from './elements/password_input' import SearchInput from './elements/search_input' import ToggleAttribute from './elements/toggle_attribute' @@ -32,11 +34,26 @@ import CheckboxGroup from './elements/checkbox_group' import MaskedInput from './elements/masked_input' import SetDateButton from './elements/set_date_button' import IndeterminateCheckbox from './elements/indeterminate_checkbox' +import AppTour from './elements/app_tour' +import AppTourStart from './elements/app_tour_start' +import DashboardDropzone from './elements/dashboard_dropzone' +import RequiredCheckboxGroup from './elements/required_checkbox_group' +import PageContainer from './elements/page_container' +import EmailEditor from './elements/email_editor' +import MountOnClick from './elements/mount_on_click' +import RemoveOnEvent from './elements/remove_on_event' +import ScrollTo from './elements/scroll_to' +import SetValue from './elements/set_value' +import ReviewForm from './elements/review_form' +import ShowOnValue from './elements/show_on_value' +import CustomValidation from './elements/custom_validation' +import ToggleClasses from './elements/toggle_classes' +import AutosizeField from './elements/autosize_field' +import GoogleDriveFilePicker from './elements/google_drive_file_picker' +import OpenModal from './elements/open_modal' import * as TurboInstantClick from './lib/turbo_instant_click' -import './images/preview.png' - TurboInstantClick.start() document.addEventListener('turbo:before-cache', () => { @@ -50,6 +67,9 @@ document.addEventListener('keyup', (e) => { }) document.addEventListener('turbo:before-fetch-request', encodeMethodIntoRequestBody) +document.addEventListener('turbo:before-fetch-request', (event) => { + event.detail.fetchOptions.headers['X-Turbo'] = 'true' +}) document.addEventListener('turbo:submit-end', async (event) => { const resp = event.detail?.formSubmission?.result?.fetchResponse?.response @@ -92,6 +112,7 @@ safeRegisterElement('submit-form', SubmitForm) safeRegisterElement('prompt-password', PromptPassword) safeRegisterElement('emails-textarea', EmailsTextarea) safeRegisterElement('toggle-cookies', ToggleCookies) +safeRegisterElement('toggle-submit', ToggleSubmit) safeRegisterElement('toggle-on-submit', ToggleOnSubmit) safeRegisterElement('password-input', PasswordInput) safeRegisterElement('search-input', SearchInput) @@ -101,6 +122,24 @@ safeRegisterElement('checkbox-group', CheckboxGroup) safeRegisterElement('masked-input', MaskedInput) safeRegisterElement('set-date-button', SetDateButton) safeRegisterElement('indeterminate-checkbox', IndeterminateCheckbox) +safeRegisterElement('app-tour', AppTour) +safeRegisterElement('app-tour-start', AppTourStart) +safeRegisterElement('dashboard-dropzone', DashboardDropzone) +safeRegisterElement('check-on-click', CheckOnClick) +safeRegisterElement('required-checkbox-group', RequiredCheckboxGroup) +safeRegisterElement('page-container', PageContainer) +safeRegisterElement('email-editor', EmailEditor) +safeRegisterElement('mount-on-click', MountOnClick) +safeRegisterElement('remove-on-event', RemoveOnEvent) +safeRegisterElement('scroll-to', ScrollTo) +safeRegisterElement('set-value', SetValue) +safeRegisterElement('review-form', ReviewForm) +safeRegisterElement('show-on-value', ShowOnValue) +safeRegisterElement('custom-validation', CustomValidation) +safeRegisterElement('toggle-classes', ToggleClasses) +safeRegisterElement('autosize-field', AutosizeField) +safeRegisterElement('google-drive-file-picker', GoogleDriveFilePicker) +safeRegisterElement('open-modal', OpenModal) safeRegisterElement('template-builder', class extends HTMLElement { connectedCallback () { @@ -117,6 +156,7 @@ safeRegisterElement('template-builder', class extends HTMLElement { withPhone: this.dataset.withPhone === 'true', withVerification: ['true', 'false'].includes(this.dataset.withVerification) ? this.dataset.withVerification === 'true' : null, withLogo: this.dataset.withLogo !== 'false', + withFieldsDetection: this.dataset.withFieldsDetection === 'true', editable: this.dataset.editable !== 'false', authenticityToken: document.querySelector('meta[name="csrf-token"]')?.content, withPayment: this.dataset.withPayment === 'true', @@ -125,8 +165,12 @@ safeRegisterElement('template-builder', class extends HTMLElement { withSendButton: this.dataset.withSendButton !== 'false', withSignYourselfButton: this.dataset.withSignYourselfButton !== 'false', withConditions: this.dataset.withConditions === 'true', + withGoogleDrive: this.dataset.withGoogleDrive === 'true', + withReplaceAndCloneUpload: true, + withDownload: true, currencies: (this.dataset.currencies || '').split(',').filter(Boolean), - acceptFileTypes: this.dataset.acceptFileTypes + acceptFileTypes: this.dataset.acceptFileTypes, + showTourStartForm: this.dataset.showTourStartForm === 'true' }) this.component = this.app.mount(this.appElem) diff --git a/app/javascript/application.scss b/app/javascript/application.scss index 61d3f645..47eeb4f6 100644 --- a/app/javascript/application.scss +++ b/app/javascript/application.scss @@ -19,7 +19,7 @@ button .disabled { display: none; } -button[disabled] .disabled { +button[disabled] .disabled, button.btn-disabled .disabled { display: initial; } @@ -27,7 +27,7 @@ button .enabled { display: initial; } -button[disabled] .enabled { +button[disabled] .enabled, button.btn-disabled .enabled { display: none; } @@ -147,3 +147,11 @@ button[disabled] .enabled { outline-offset: 3px; outline-color: hsl(var(--bc) / 0.2); } + +.font-times { + font-family: "Times New Roman", Times, ui-serif, serif, Cambria, Georgia; +} + +.font-courier { + font-family: "Courier New", Consolas, "Liberation Mono", monospace, ui-monospace, SFMono-Regular, Menlo, Monaco; +} diff --git a/app/javascript/draw.js b/app/javascript/draw.js index c42c3c72..a4936e8d 100644 --- a/app/javascript/draw.js +++ b/app/javascript/draw.js @@ -1,5 +1,6 @@ import SignaturePad from 'signature_pad' import { cropCanvasAndExportToPNG } from './submission_form/crop_canvas' +import { isValidSignatureCanvas } from './submission_form/validate_signature' window.customElements.define('draw-signature', class extends HTMLElement { connectedCallback () { @@ -43,6 +44,8 @@ window.customElements.define('draw-signature', class extends HTMLElement { return response }) + }).catch(error => { + console.log(error) }).finally(() => { this.submitButton.disabled = false }) @@ -65,26 +68,26 @@ window.customElements.define('draw-signature', class extends HTMLElement { } async submitImage () { - return new Promise((resolve, reject) => { - cropCanvasAndExportToPNG(this.canvas, { errorOnTooSmall: true }).then(async (blob) => { - const file = new File([blob], 'signature.png', { type: 'image/png' }) - - const formData = new FormData() - - formData.append('file', file) - formData.append('submitter_slug', this.dataset.slug) - formData.append('name', 'attachments') - formData.append('remember_signature', 'true') - - return fetch('/api/attachments', { - method: 'POST', - body: formData - }).then((resp) => resp.json()).then((attachment) => { - return resolve(attachment) - }) - }).catch((error) => { - return reject(error) - }) + if (!isValidSignatureCanvas(this.pad.toData())) { + alert('Signature is too small or simple. Please redraw.') + + return Promise.reject(new Error('Image too small or simple')) + } + + return cropCanvasAndExportToPNG(this.canvas).then(async (blob) => { + const file = new File([blob], 'signature.png', { type: 'image/png' }) + + const formData = new FormData() + + formData.append('file', file) + formData.append('submitter_slug', this.dataset.slug) + formData.append('name', 'attachments') + formData.append('remember_signature', 'true') + + return fetch('/api/attachments', { + method: 'POST', + body: formData + }).then(resp => resp.json()) }) } diff --git a/app/javascript/elements/app_tour.js b/app/javascript/elements/app_tour.js new file mode 100644 index 00000000..e98d4281 --- /dev/null +++ b/app/javascript/elements/app_tour.js @@ -0,0 +1,338 @@ +export default class extends HTMLElement { + async connectedCallback () { + this.tourType = this.dataset.type + this.nextPagePath = this.dataset.nextPagePath + this.I18n = JSON.parse(this.dataset.i18n || '{}') + + if (this.dataset.showTour === 'true') this.start() + } + + async start () { + if (window.innerWidth < 768) return + + const [{ driver }] = await Promise.all([ + import('driver.js'), + import('driver.js/dist/driver.css') + ]) + + this.driverObj = driver({ + showProgress: true, + nextBtnText: this.I18n.next, + prevBtnText: this.I18n.previous, + doneBtnText: this.I18n.done, + onDestroyStarted: () => { + this.disableAppGuide().finally(() => { this.destroy() }) + }, + onHighlightStarted: (element) => { + if (element) { + const clickHandler = () => { + this.disableAppGuide().finally(() => { this.destroy() }) + element.removeEventListener('click', clickHandler) + } + + element.addEventListener('click', clickHandler) + } + } + }) + + if (this.tourType === 'dashboard') { + this.showDashboardTour() + } else if (this.tourType === 'builder') { + this.showTemplateBuilderTour() + } else if (this.tourType === 'account') { + this.showAccountTour() + } else if (this.tourType === 'template') { + this.showTemplateTour() + } + } + + disconnectedCallback () { + if (this.driverObj) this.destroy() + } + + destroy () { + if (this.builderTemplate) this.builderTemplate.fields.shift() + if (this.driverObj) this.driverObj.destroy() + } + + showTemplateTour () { + const steps = [ + { + element: '#share_link_clipboard', + popover: { + title: this.I18n.copy_and_share_link, + description: this.I18n.copy_and_share_link_description, + side: 'bottom', + align: 'end' + } + }, + { + element: '#sign_yourself_button', + popover: { + title: this.I18n.sign_the_document, + description: this.I18n.sign_the_document_description, + side: 'top', + align: 'center' + } + }, + { + element: '#send_to_recipients_button', + popover: { + title: this.I18n.send_for_signing, + description: this.I18n.add_recipients_description, + side: 'top', + align: 'center' + } + }, + { + element: '#add_recipients_button', + popover: { + title: this.I18n.add_recipients, + description: this.I18n.add_recipients_description, + side: 'bottom', + align: 'end' + } + }, + { + element: '#account_settings_button', + popover: { + title: this.I18n.settings, + description: this.I18n.settings_template_description, + side: 'right', + align: 'start', + showButtons: this.nextPagePath ? ['next', 'previous', 'close'] : ['previous', 'close'], + onNextClick: () => { + if (this.nextPagePath) { + window.Turbo.visit(this.nextPagePath) + } + } + } + } + ].filter((step) => document.querySelector(step.element)) + + this.driverObj.setSteps(steps) + this.driverObj.drive() + } + + showDashboardTour () { + this.driverObj.setSteps([ + { + element: '#templates_submissions_toggle', + popover: { + title: this.I18n.template_and_submissions, + description: this.I18n.template_and_submissions_description, + side: 'right', + align: 'start' + } + }, + { + element: '#templates_upload_button', + popover: { + title: this.I18n.upload_a_pdf_file, + description: this.I18n.upload_a_pdf_file_description, + side: 'left', + align: 'start', + showButtons: this.nextPagePath ? ['next', 'previous', 'close'] : ['previous', 'close'], + onNextClick: () => { + if (this.nextPagePath) { + window.Turbo.visit(this.nextPagePath) + } + } + }, + onHighlightStarted: () => {} + } + ]) + + this.driverObj.drive() + } + + showAccountTour () { + this.driverObj.setSteps([ + { + element: '#account_settings_menu', + popover: { + title: this.I18n.settings, + description: this.I18n.settings_account_description, + side: 'right', + align: 'start' + } + }, + { + element: '#support_channels', + popover: { + title: this.I18n.support, + description: this.I18n.support_description, + side: 'left', + align: 'start' + } + } + ].filter((step) => document.querySelector(step.element))) + + this.driverObj.drive() + } + + showTemplateBuilderTour () { + const builderComponent = document.querySelector('template-builder')?.component + + this.builderTemplate = builderComponent?.template + + if (this.builderTemplate) { + this.builderTemplate.fields.unshift({ + uuid: 'b387399b-88dc-4345-9d37-743e97a9b2b3', + submitter_uuid: this.builderTemplate.submitters[0].uuid, + name: 'First Name', + type: 'text' + }) + + builderComponent.$nextTick(() => { + this.driverObj.setSteps([ + { + element: '.roles-dropdown', + popover: { + title: this.I18n.select_a_signer_party, + description: this.I18n.select_a_signer_party_description, + side: 'left', + align: 'start', + onPopoverRender: () => { + const rolesDropdown = document.querySelector('.roles-dropdown') + + rolesDropdown.dispatchEvent(new Event('mouseenter', { bubbles: true, cancelable: true })) + rolesDropdown.classList.add('dropdown-open') + } + } + }, + { + element: '.roles-dropdown .dropdown-content', + popover: { + title: this.I18n.available_parties, + description: this.I18n.available_parties_description, + side: 'left', + align: 'start', + onPopoverRender: () => { + document.querySelector('.roles-dropdown .dropdown-content').classList.remove('driver-active-element') + }, + onNextClick: () => { + document.querySelector('.roles-dropdown').classList.remove('dropdown-open') + this.driverObj.moveNext() + } + } + }, + { + element: '#field-types-grid', + popover: { + title: this.I18n.available_field_types, + description: this.I18n.available_field_types_description, + side: 'right', + align: 'start', + onPrevClick: () => { + document.querySelector('.roles-dropdown').classList.add('dropdown-open') + this.driverObj.movePrevious() + } + } + }, + { + element: '#text_type_field_button', + popover: { + title: this.I18n.text_input_field, + description: this.I18n.text_input_field_description, + side: 'left', + align: 'start' + } + }, + { + element: '#signature_type_field_button', + popover: { + title: this.I18n.signature_field, + description: this.I18n.signature_field_description, + side: 'left', + align: 'start' + } + }, + { + element: '.fields', + popover: { + title: this.I18n.added_fields, + description: this.I18n.added_fields_description, + side: 'right', + align: 'start' + } + }, + { + element: '.list-field label:has(svg.tabler-icon-settings)', + popover: { + title: this.I18n.open_field_settings, + description: this.I18n.open_field_settings_description, + side: 'bottom', + align: 'end', + onPopoverRender: () => { + const settingsDropdown = document.querySelector('.list-field div:first-child span:has(svg.tabler-icon-settings)') + + document.querySelectorAll('.list-field div:first-child .text-transparent').forEach((e) => e.classList.remove('text-transparent')) + settingsDropdown.dispatchEvent(new Event('mouseenter', { bubbles: true, cancelable: true })) + settingsDropdown.classList.add('dropdown-open') + } + } + }, + { + element: '.list-field div:first-child span:has(svg.tabler-icon-settings) .dropdown-content', + popover: { + title: this.I18n.field_settings, + description: this.I18n.field_settings_description, + side: 'left', + align: 'start', + onPopoverRender: () => { + document.querySelector('.list-field div:first-child span:has(svg.tabler-icon-settings) .dropdown-content').classList.remove('driver-active-element') + }, + onNextClick: () => { + document.querySelector('.list-field div:first-child span:has(svg.tabler-icon-settings)').classList.remove('dropdown-open') + this.driverObj.moveNext() + } + } + }, + { + element: '#send_button', + popover: { + title: this.I18n.send_document, + description: this.I18n.send_document_description, + side: 'bottom', + align: 'end', + onPrevClick: () => { + document.querySelector('.list-field div:first-child span:has(svg.tabler-icon-settings)').classList.add('dropdown-open') + this.driverObj.movePrevious() + } + } + }, + { + element: '#sign_yourself_button', + popover: { + title: this.I18n.sign_yourself, + description: this.I18n.sign_yourself_description, + side: 'bottom', + align: 'end', + onNextClick: () => { + if (this.nextPagePath) { + window.Turbo.visit(this.nextPagePath) + } else { + this.destroy() + } + } + } + } + ]) + + this.driverObj.drive() + }) + } + } + + async disableAppGuide () { + return fetch('/user_configs', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content + }, + body: JSON.stringify({ key: 'show_app_tour', value: false }) + }) + } +} diff --git a/app/javascript/elements/app_tour_start.js b/app/javascript/elements/app_tour_start.js new file mode 100644 index 00000000..1b06becb --- /dev/null +++ b/app/javascript/elements/app_tour_start.js @@ -0,0 +1,7 @@ +export default class extends HTMLElement { + connectedCallback () { + this.querySelector('form').addEventListener('submit', () => { + window.app_tour.start() + }) + } +} diff --git a/app/javascript/elements/autosize_field.js b/app/javascript/elements/autosize_field.js new file mode 100644 index 00000000..b6f56763 --- /dev/null +++ b/app/javascript/elements/autosize_field.js @@ -0,0 +1,21 @@ +export default class extends HTMLElement { + connectedCallback () { + const originalFontValue = this.field.style.fontSize + + if (this.field.scrollHeight > this.field.clientHeight) { + this.field.style.fontSize = `calc(${originalFontValue} / 1.5)` + this.field.style.lineHeight = `calc(${this.field.style.fontSize} * 1.3)` + + if (this.field.scrollHeight > this.field.clientHeight) { + this.field.style.fontSize = `calc(${originalFontValue} / 2.0)` + this.field.style.lineHeight = `calc(${this.field.style.fontSize} * 1.3)` + } + } + + this.field.classList.remove('hidden') + } + + get field () { + return this.closest('field-value') + } +} diff --git a/app/javascript/elements/check_on_click.js b/app/javascript/elements/check_on_click.js new file mode 100644 index 00000000..e940dff3 --- /dev/null +++ b/app/javascript/elements/check_on_click.js @@ -0,0 +1,14 @@ +export default class extends HTMLElement { + connectedCallback () { + this.addEventListener('click', () => { + if (this.element && !this.element.disabled && !this.element.checked) { + this.element.checked = true + this.element.dispatchEvent(new Event('change', { bubbles: true })) + } + }) + } + + get element () { + return document.getElementById(this.dataset.elementId) + } +} diff --git a/app/javascript/elements/clipboard_copy.js b/app/javascript/elements/clipboard_copy.js index 777727d1..4118b9e2 100644 --- a/app/javascript/elements/clipboard_copy.js +++ b/app/javascript/elements/clipboard_copy.js @@ -3,8 +3,6 @@ export default class extends HTMLElement { this.clearChecked() this.addEventListener('click', (e) => { - e.stopPropagation() - const text = this.dataset.text || this.innerText.trim() if (navigator.clipboard) { diff --git a/app/javascript/elements/custom_validation.js b/app/javascript/elements/custom_validation.js new file mode 100644 index 00000000..ac738679 --- /dev/null +++ b/app/javascript/elements/custom_validation.js @@ -0,0 +1,14 @@ +export default class extends HTMLElement { + connectedCallback () { + const input = this.querySelector('input') + const invalidMessage = this.dataset.invalidMessage || '' + + input.addEventListener('invalid', () => { + input.setCustomValidity(input.value ? invalidMessage : '') + }) + + input.addEventListener('input', () => { + input.setCustomValidity('') + }) + } +} diff --git a/app/javascript/elements/dashboard_dropzone.js b/app/javascript/elements/dashboard_dropzone.js new file mode 100644 index 00000000..886aae37 --- /dev/null +++ b/app/javascript/elements/dashboard_dropzone.js @@ -0,0 +1,248 @@ +import { target, targets, targetable } from '@github/catalyst/lib/targetable' + +const loadingIconHtml = ` + + +` + +export default targetable(class extends HTMLElement { + static [targets.static] = [ + 'hiddenOnDrag', + 'folderCards', + 'templateCards' + ] + + static [target.static] = [ + 'form', + 'fileDropzone', + 'folderDropzone', + 'fileDropzoneLoading' + ] + + connectedCallback () { + document.addEventListener('drop', this.onWindowDragdrop) + document.addEventListener('dragover', this.onWindowDropover) + + window.addEventListener('dragleave', this.onWindowDragleave) + + this.fileDropzone?.addEventListener('drop', this.onDropFile) + this.folderDropzone?.addEventListener('drop', this.onDropNewFolder) + + this.folderCards.forEach((el) => el.addEventListener('drop', (e) => this.onDropFolder(e, el))) + this.templateCards.forEach((el) => el.addEventListener('drop', this.onDropTemplate)) + this.templateCards.forEach((el) => el.addEventListener('dragstart', this.onTemplateDragStart)) + + return [this.fileDropzone, this.folderDropzone, ...this.folderCards, ...this.templateCards].forEach((el) => { + el?.addEventListener('dragover', this.onDragover) + el?.addEventListener('dragleave', this.onDragleave) + }) + } + + disconnectedCallback () { + document.removeEventListener('drop', this.onWindowDragdrop) + document.removeEventListener('dragover', this.onWindowDropover) + + window.removeEventListener('dragleave', this.onWindowDragleave) + } + + onTemplateDragStart = (e) => { + const id = e.target.href.split('/').pop() + + this.folderCards.forEach((el) => el.classList.remove('bg-base-200', 'before:hidden')) + this.folderDropzone?.classList?.remove('hidden') + window.flash?.remove() + + e.dataTransfer.effectAllowed = 'move' + + if (id) { + e.dataTransfer.setData('template_id', id) + + const dragPreview = e.target.cloneNode(true) + const rect = e.target.getBoundingClientRect() + + const height = e.target.children[0].getBoundingClientRect().height + 50 + + dragPreview.children[1].remove() + dragPreview.style.width = `${rect.width}px` + dragPreview.style.height = `${height}px` + dragPreview.style.position = 'absolute' + dragPreview.style.top = '-1000px' + dragPreview.style.pointerEvents = 'none' + dragPreview.style.opacity = '0.9' + + document.body.appendChild(dragPreview) + + e.dataTransfer.setDragImage(dragPreview, rect.width / 2, height / 2) + + setTimeout(() => document.body.removeChild(dragPreview), 0) + } + } + + onDropFile = (e) => { + e.preventDefault() + + this.fileDropzoneLoading.classList.remove('hidden') + this.fileDropzoneLoading.previousElementSibling.classList.add('hidden') + this.fileDropzoneLoading.classList.add('opacity-50') + + this.uploadFiles(e.dataTransfer.files, '/templates_upload') + } + + onDropFolder = (e, el) => { + e.preventDefault() + + const templateId = e.dataTransfer.getData('template_id') + + if (e.dataTransfer.files.length || templateId) { + const loading = document.createElement('div') + const svg = el.querySelector('svg') + + loading.innerHTML = loadingIconHtml + loading.children[0].classList.add(...svg.classList) + + el.replaceChild(loading.children[0], svg) + el.classList.add('opacity-50') + + if (e.dataTransfer.files.length) { + const params = new URLSearchParams({ folder_name: el.innerText.trim() }).toString() + + this.uploadFiles(e.dataTransfer.files, `/templates_upload?${params}`) + } else { + const formData = new FormData() + + formData.append('name', el.dataset.fullName) + + fetch(`/templates/${templateId}/folder`, { + method: 'PUT', + redirect: 'manual', + body: formData, + headers: { + 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content + } + }).finally(() => { + window.Turbo.cache.clear() + window.Turbo.visit(location.href) + }) + } + } + } + + onDropTemplate = (e) => { + e.preventDefault() + + if (e.dataTransfer.files.length) { + const loading = document.createElement('div') + loading.classList.add('bottom-5', 'left-0', 'flex', 'justify-center', 'w-full', 'absolute') + loading.innerHTML = loadingIconHtml + + e.target.appendChild(loading) + e.target.classList.add('opacity-50') + + const id = e.target.href.split('/').pop() + + this.uploadFiles(e.dataTransfer.files, `/templates/${id}/clone_and_replace`) + } + } + + onWindowDragdrop = (e) => { + e.preventDefault() + + if (!this.isLoading) this.hideDraghover() + } + + uploadFiles (files, url) { + this.isLoading = true + + this.form.action = url + + this.form.querySelector('[type="file"]').files = files + + this.form.querySelector('[type="submit"]').click() + } + + onWindowDropover = (e) => { + e.preventDefault() + + if (e.dataTransfer?.types?.includes('Files')) { + this.showDraghover() + } + } + + onDragover (e) { + if (e.dataTransfer?.types?.includes('Files') || this.dataset.targets !== 'dashboard-dropzone.templateCards') { + this.style.backgroundColor = '#F7F3F0' + + if (this.classList.contains('before:border-base-300')) { + this.classList.remove('before:border-base-300') + this.classList.add('before:border-base-content/30') + } else if (this.classList.contains('border-base-300')) { + this.classList.remove('border-base-300') + this.classList.add('border-base-content/30') + } + } + } + + onDropNewFolder (e) { + e.preventDefault() + + const templateId = e.dataTransfer.getData('template_id') + + const a = document.createElement('a') + + a.href = `/templates/${templateId}/folder/edit?autocomplete=false` + a.dataset.turboFrame = 'modal' + a.classList.add('hidden') + + document.body.append(a) + + a.click() + + a.remove() + } + + onDragleave () { + this.style.backgroundColor = null + + if (this.classList.contains('before:border-base-content/30')) { + this.classList.remove('before:border-base-content/30') + this.classList.add('before:border-base-300') + } else if (this.classList.contains('border-base-content/30')) { + this.classList.remove('border-base-content/30') + this.classList.add('border-base-300') + } + } + + onWindowDragleave = (e) => { + if (e.clientX <= 0 || e.clientY <= 0 || e.clientX >= window.innerWidth || e.clientY >= window.innerHeight) { + this.hideDraghover() + } + } + + showDraghover = () => { + if (this.isDrag) return + + this.isDrag = true + + window.flash?.remove() + this.fileDropzone?.classList?.remove('hidden') + + this.hiddenOnDrag.forEach((el) => { el.style.display = 'none' }) + + return [...this.folderCards, ...this.templateCards].forEach((el) => { + el.classList.remove('bg-base-200', 'before:hidden') + }) + } + + hideDraghover = () => { + this.isDrag = false + + this.fileDropzone?.classList?.add('hidden') + this.folderDropzone?.classList?.add('hidden') + + this.hiddenOnDrag.forEach((el) => { el.style.display = null }) + + return [...this.folderCards, ...this.templateCards].forEach((el) => { + el.classList.add('bg-base-200', 'before:hidden') + }) + } +}) diff --git a/app/javascript/elements/email_editor.js b/app/javascript/elements/email_editor.js new file mode 100644 index 00000000..38f0d3b4 --- /dev/null +++ b/app/javascript/elements/email_editor.js @@ -0,0 +1,131 @@ +import { target, targetable } from '@github/catalyst/lib/targetable' + +let loaderPromise = null + +function loadCodeMirror () { + if (!loaderPromise) { + loaderPromise = Promise.all([ + import(/* webpackChunkName: "email-editor" */ '@codemirror/view'), + import(/* webpackChunkName: "email-editor" */ '@codemirror/commands'), + import(/* webpackChunkName: "email-editor" */ '@codemirror/language'), + import(/* webpackChunkName: "email-editor" */ '@codemirror/lang-html'), + import(/* webpackChunkName: "email-editor" */ '@specious/htmlflow') + ]).then(([view, commands, language, html, htmlflow]) => { + return { + minimalSetup: [ + commands.history(), + language.syntaxHighlighting(language.defaultHighlightStyle, { fallback: true }), + view.keymap.of([...commands.defaultKeymap, ...commands.historyKeymap]) + ], + EditorView: view.EditorView, + html: html.html, + htmlflow: htmlflow.default || htmlflow + } + }) + } + + return loaderPromise +} + +export default targetable(class extends HTMLElement { + static [target.static] = [ + 'codeViewTab', + 'previewViewTab', + 'editorContainer', + 'previewIframe' + ] + + connectedCallback () { + this.mount() + + if (this.input.value) { + this.showPreviewView() + } else { + this.showCodeView() + } + + this.previewViewTab.addEventListener('click', this.showPreviewView) + this.codeViewTab.addEventListener('click', this.showCodeView) + } + + showCodeView = () => { + this.editorView.dispatch({ + changes: { from: 0, to: this.editorView.state.doc.length, insert: this.input.value } + }) + + this.previewViewTab.classList.remove('tab-active', 'tab-bordered') + this.previewViewTab.classList.add('pb-[3px]') + this.codeViewTab.classList.remove('pb-[3px]') + this.codeViewTab.classList.add('tab-active', 'tab-bordered') + this.editorContainer.classList.remove('hidden') + this.previewIframe.classList.add('hidden') + } + + showPreviewView = () => { + this.previewIframe.srcdoc = this.input.value + + this.codeViewTab.classList.remove('tab-active', 'tab-bordered') + this.codeViewTab.classList.add('pb-[3px]') + this.previewViewTab.classList.remove('pb-[3px]') + this.previewViewTab.classList.add('tab-active', 'tab-bordered') + this.editorContainer.classList.add('hidden') + this.previewIframe.classList.remove('hidden') + } + + async mount () { + this.input = this.querySelector('input[type="hidden"]') + this.input.style.display = 'none' + + const { EditorView, minimalSetup, html, htmlflow } = await loadCodeMirror() + + this.editorView = new EditorView({ + doc: this.input.value, + parent: this.editorContainer, + extensions: [ + html(), + minimalSetup, + EditorView.lineWrapping, + EditorView.updateListener.of(update => { + if (update.docChanged) this.input.value = update.state.doc.toString() + }), + EditorView.theme({ + '&': { + backgroundColor: 'white', + color: 'black', + fontSize: '14px', + fontFamily: 'monospace' + }, + '&.cm-focused': { + outline: 'none' + }, + '&.cm-editor': { + borderRadius: '0.375rem', + border: 'none' + }, + '.cm-gutters': { + display: 'none' + } + }) + ] + }) + + this.previewIframe.srcdoc = this.editorView.state.doc.toString() + + this.previewIframe.onload = () => { + const previewIframeDoc = this.previewIframe.contentDocument + + if (previewIframeDoc.body) { + previewIframeDoc.body.contentEditable = true + } + + const contentDocument = this.previewIframe.contentDocument || this.previewIframe.contentWindow.document + + contentDocument.body.addEventListener('input', async () => { + const html = contentDocument.documentElement.outerHTML.replace(' contenteditable="true"', '') + const prettifiedHtml = await htmlflow(html) + + this.input.value = prettifiedHtml + }) + } + } +}) diff --git a/app/javascript/elements/fetch_form.js b/app/javascript/elements/fetch_form.js new file mode 100644 index 00000000..b3378a63 --- /dev/null +++ b/app/javascript/elements/fetch_form.js @@ -0,0 +1,36 @@ +export default class extends HTMLElement { + connectedCallback () { + this.form.addEventListener('submit', (e) => { + e.preventDefault() + + this.submit() + }) + + if (this.dataset.onload === 'true') { + this.form.querySelector('button').click() + } + } + + submit () { + fetch(this.form.action, { + method: this.form.method, + body: new FormData(this.form) + }).then(async (resp) => { + if (!resp.ok) { + try { + const data = JSON.parse(await resp.text()) + + if (data.error) { + alert(data.error) + } + } catch (err) { + console.error(err) + } + } + }) + } + + get form () { + return this.querySelector('form') + } +} diff --git a/app/javascript/elements/file_dropzone.js b/app/javascript/elements/file_dropzone.js index d8fbb116..12ef253d 100644 --- a/app/javascript/elements/file_dropzone.js +++ b/app/javascript/elements/file_dropzone.js @@ -5,19 +5,37 @@ export default actionable(targetable(class extends HTMLElement { static [target.static] = [ 'loading', 'icon', - 'input' + 'input', + 'area' ] connectedCallback () { - this.addEventListener('drop', this.onDrop) - this.addEventListener('dragover', (e) => e.preventDefault()) - + this.addEventListener('drop', this.onDrop) document.addEventListener('turbo:submit-end', this.toggleLoading) + this.area?.addEventListener('dragover', this.onDragover) + this.area?.addEventListener('dragleave', this.onDragleave) } disconnectedCallback () { + this.removeEventListener('drop', this.onDrop) document.removeEventListener('turbo:submit-end', this.toggleLoading) + this.area?.removeEventListener('dragover', this.onDragover) + this.area?.removeEventListener('dragleave', this.onDragleave) + } + + onDragover (e) { + if (e.dataTransfer?.types?.includes('Files')) { + this.style.backgroundColor = '#F7F3F0' + this.classList.remove('border-base-300', 'hover:bg-base-200/30') + this.classList.add('border-base-content/30') + } + } + + onDragleave () { + this.style.backgroundColor = null + this.classList.remove('border-base-content/30') + this.classList.add('border-base-300', 'hover:bg-base-200/30') } onDrop (e) { @@ -35,7 +53,7 @@ export default actionable(targetable(class extends HTMLElement { } toggleLoading = (e) => { - if (e && e.target && !e.target.contains(this)) { + if (e && e.target && (!e.target.contains(this) || !e.detail?.formSubmission?.formElement?.contains(this))) { return } diff --git a/app/javascript/elements/folder_autocomplete.js b/app/javascript/elements/folder_autocomplete.js index 2ef977c5..de2a5069 100644 --- a/app/javascript/elements/folder_autocomplete.js +++ b/app/javascript/elements/folder_autocomplete.js @@ -2,6 +2,8 @@ import autocomplete from 'autocompleter' export default class extends HTMLElement { connectedCallback () { + if (this.dataset.enabled === 'false') return + autocomplete({ input: this.input, preventSubmit: this.dataset.submitOnSelect === 'true' ? 0 : 1, @@ -14,12 +16,16 @@ export default class extends HTMLElement { } onSelect = (item) => { - this.input.value = item.name + this.input.value = this.dataset.parentName ? item.name : item.full_name } fetch = (text, resolve) => { const queryParams = new URLSearchParams({ q: text }) + if (this.dataset.parentName) { + queryParams.append('parent_name', this.dataset.parentName) + } + fetch('/template_folders_autocomplete?' + queryParams).then(async (resp) => { const items = await resp.json() @@ -34,7 +40,7 @@ export default class extends HTMLElement { div.setAttribute('dir', 'auto') - div.textContent = item.name + div.textContent = this.dataset.parentName ? item.name : item.full_name return div } diff --git a/app/javascript/elements/google_drive_file_picker.js b/app/javascript/elements/google_drive_file_picker.js new file mode 100644 index 00000000..9aae712d --- /dev/null +++ b/app/javascript/elements/google_drive_file_picker.js @@ -0,0 +1,55 @@ +export default class extends HTMLElement { + connectedCallback () { + const iframeTemplate = this.querySelector('template') + + this.observer = new IntersectionObserver((entries) => { + if (entries.some(e => e.isIntersecting)) { + iframeTemplate.parentElement.prepend(iframeTemplate.content) + + this.observer.disconnect() + } + }) + + this.observer.observe(this) + + window.addEventListener('message', this.messageHandler) + } + + messageHandler = (event) => { + if (event.data.type === 'google-drive-files-picked') { + this.form.querySelectorAll('input[name="google_drive_file_ids[]"]').forEach(el => el.remove()) + + const files = event.data.files || [] + + files.forEach((file) => { + const input = document.createElement('input') + input.type = 'hidden' + input.name = 'google_drive_file_ids[]' + input.value = file.id + this.form.appendChild(input) + }) + + this.form.querySelector('button[type="submit"]').click() + this.loader.classList.remove('hidden') + } else if (event.data.type === 'google-drive-picker-loaded') { + this.loader.classList.add('hidden') + this.form.classList.remove('hidden') + } else if (event.data.type === 'google-drive-picker-request-oauth') { + document.getElementById(this.dataset.oauthButtonId).classList.remove('hidden') + this.classList.add('hidden') + } + } + + disconnectedCallback () { + this.observer?.unobserve(this) + window.removeEventListener('message', this.messageHandler) + } + + get form () { + return this.querySelector('form') + } + + get loader () { + return document.getElementById('google_drive_loader') + } +} diff --git a/app/javascript/elements/mount_on_click.js b/app/javascript/elements/mount_on_click.js new file mode 100644 index 00000000..3a71f8ff --- /dev/null +++ b/app/javascript/elements/mount_on_click.js @@ -0,0 +1,11 @@ +export default class extends HTMLElement { + connectedCallback () { + this.addEventListener('click', () => { + document.body.append(this.template.content) + }) + } + + get template () { + return document.getElementById(this.dataset.templateId) + } +} diff --git a/app/javascript/elements/open_modal.js b/app/javascript/elements/open_modal.js new file mode 100644 index 00000000..b7b30787 --- /dev/null +++ b/app/javascript/elements/open_modal.js @@ -0,0 +1,16 @@ +export default class extends HTMLElement { + connectedCallback () { + const src = this.getAttribute('src') + const link = document.createElement('a') + + link.href = src + link.setAttribute('data-turbo-frame', 'modal') + link.style.display = 'none' + + this.appendChild(link) + + link.click() + + window.history.replaceState({}, document.title, window.location.pathname) + } +} diff --git a/app/javascript/elements/page_container.js b/app/javascript/elements/page_container.js new file mode 100644 index 00000000..ca5221ba --- /dev/null +++ b/app/javascript/elements/page_container.js @@ -0,0 +1,12 @@ +export default class extends HTMLElement { + connectedCallback () { + const image = this.querySelector('img') + + image.addEventListener('load', (e) => { + image.setAttribute('width', e.target.naturalWidth) + image.setAttribute('height', e.target.naturalHeight) + + this.style.aspectRatio = `${e.target.naturalWidth} / ${e.target.naturalHeight}` + }) + } +} diff --git a/app/javascript/elements/remove_on_event.js b/app/javascript/elements/remove_on_event.js new file mode 100644 index 00000000..e912e4cd --- /dev/null +++ b/app/javascript/elements/remove_on_event.js @@ -0,0 +1,15 @@ +export default class extends HTMLElement { + connectedCallback () { + const eventType = this.dataset.on || 'click' + const selector = document.getElementById(this.dataset.selectorId) || this + const eventElement = eventType === 'submit' ? this.querySelector('form') : this + + eventElement.addEventListener(eventType, (event) => { + if (eventType === 'click') { + event.preventDefault() + } + + selector.remove() + }) + } +} diff --git a/app/javascript/elements/required_checkbox_group.js b/app/javascript/elements/required_checkbox_group.js new file mode 100644 index 00000000..e4dc13d7 --- /dev/null +++ b/app/javascript/elements/required_checkbox_group.js @@ -0,0 +1,17 @@ +export default class extends HTMLElement { + connectedCallback () { + this.querySelectorAll('input[type="checkbox"]').forEach(checkbox => { + checkbox.addEventListener('change', this.handleChange) + }) + } + + handleChange = () => { + if (this.checkedCount !== 0) { + this.closest('form')?.requestSubmit() + } + } + + get checkedCount () { + return this.querySelectorAll('input[type="checkbox"]:checked').length + } +} diff --git a/app/javascript/elements/review_form.js b/app/javascript/elements/review_form.js new file mode 100644 index 00000000..537fe602 --- /dev/null +++ b/app/javascript/elements/review_form.js @@ -0,0 +1,19 @@ +export default class extends HTMLElement { + connectedCallback () { + this.querySelectorAll('input[type="radio"]').forEach(radio => { + radio.addEventListener('change', (event) => { + const rating = parseInt(event.target.value) + + if (rating === 10) { + window.review_comment.value = '' + window.review_comment.classList.add('hidden') + window.review_submit.classList.add('hidden') + event.target.form.submit() + } else { + window.review_comment.classList.remove('hidden') + window.review_submit.classList.remove('hidden') + } + }) + }) + } +} diff --git a/app/javascript/elements/scroll_buttons.js b/app/javascript/elements/scroll_buttons.js new file mode 100644 index 00000000..6525adf9 --- /dev/null +++ b/app/javascript/elements/scroll_buttons.js @@ -0,0 +1,62 @@ +export default class extends HTMLElement { + connectedCallback () { + this.header = document.querySelector('#signing_form_header') + + window.addEventListener('scroll', this.onScroll) + window.addEventListener('resize', this.onResize) + + if (!this.isNarrow() && this.isHeaderNotVisible()) { + this.showButtons({ animate: false }) + } + } + + disconnectedCallback () { + window.removeEventListener('scroll', this.onScroll) + window.removeEventListener('resize', this.onResize) + } + + onResize = () => { + if (this.isNarrow()) { + this.hideButtons(true) + } else if (this.isHeaderNotVisible()) { + this.showButtons() + } + } + + isNarrow () { + return window.innerWidth < 1230 + } + + onScroll = () => { + if (this.isHeaderNotVisible() && !this.isNarrow()) { + this.showButtons() + } else { + this.hideButtons() + } + } + + isHeaderNotVisible () { + const rect = this.header.getBoundingClientRect() + return rect.bottom <= 0 || rect.top >= window.innerHeight + } + + showButtons ({ animate } = { animate: true }) { + if (animate) { + this.classList.add('transition-transform', 'duration-300') + } + + this.classList.remove('hidden', '-translate-y-10', 'opacity-0') + this.classList.add('translate-y-0', 'opacity-100') + } + + hideButtons () { + this.classList.remove('translate-y-0', 'opacity-100') + this.classList.add('-translate-y-10', 'opacity-0') + + setTimeout(() => { + if (this.classList.contains('-translate-y-10')) { + this.classList.add('hidden') + } + }, 300) + } +} diff --git a/app/javascript/elements/scroll_to.js b/app/javascript/elements/scroll_to.js new file mode 100644 index 00000000..ded724b5 --- /dev/null +++ b/app/javascript/elements/scroll_to.js @@ -0,0 +1,10 @@ +export default class extends HTMLElement { + connectedCallback () { + this.selector = document.getElementById(this.dataset.selectorId) + + this.addEventListener('click', () => { + this.selector.scrollIntoView({ behavior: 'smooth', block: 'start' }) + history.replaceState(null, null, `#${this.dataset.selectorId}`) + }) + } +} diff --git a/app/javascript/elements/search_input.js b/app/javascript/elements/search_input.js index 9b536ab3..585f7122 100644 --- a/app/javascript/elements/search_input.js +++ b/app/javascript/elements/search_input.js @@ -13,6 +13,14 @@ export default class extends HTMLElement { this.input.classList.remove('w-60') } }) + + this.button.addEventListener('click', (event) => { + if (!this.input.value && document.activeElement !== this.input) { + event.preventDefault() + + this.input.focus() + } + }) } get input () { @@ -22,4 +30,8 @@ export default class extends HTMLElement { get title () { return document.querySelector(this.dataset.title) } + + get button () { + return this.querySelector('button') + } } diff --git a/app/javascript/elements/set_value.js b/app/javascript/elements/set_value.js new file mode 100644 index 00000000..49e05c9c --- /dev/null +++ b/app/javascript/elements/set_value.js @@ -0,0 +1,11 @@ +export default class extends HTMLElement { + connectedCallback () { + const input = this.dataset.inputId ? document.getElementById(this.dataset.inputId) : this.querySelector('input') + + this.firstElementChild.addEventListener(this.dataset.on || 'click', () => { + if (this.dataset.emptyOnly !== 'true' || !input.value) { + input.value = this.dataset.value + } + }) + } +} diff --git a/app/javascript/elements/show_on_value.js b/app/javascript/elements/show_on_value.js new file mode 100644 index 00000000..c969d823 --- /dev/null +++ b/app/javascript/elements/show_on_value.js @@ -0,0 +1,17 @@ +export default class extends HTMLElement { + connectedCallback () { + this.addEventListener('change', (event) => { + const targetValue = this.dataset.value + const selectorId = this.dataset.selectorId + const targetElement = document.getElementById(selectorId) + + if (event.target.value === targetValue) { + targetElement.classList.remove('hidden') + } else { + targetElement.classList.add('hidden') + targetElement.value = '' + event.target.form.requestSubmit() + } + }) + } +} diff --git a/app/javascript/elements/submit_form.js b/app/javascript/elements/submit_form.js index 4b5969e5..e936c0bd 100644 --- a/app/javascript/elements/submit_form.js +++ b/app/javascript/elements/submit_form.js @@ -1,5 +1,33 @@ export default class extends HTMLElement { connectedCallback () { - this.querySelector('form').requestSubmit() + const form = this.querySelector('form') || (this.querySelector('input, button, select') || this.lastElementChild).form + + if (this.dataset.interval) { + this.interval = setInterval(() => { + form.requestSubmit() + }, parseInt(this.dataset.interval)) + } else if (this.dataset.on) { + this.lastElementChild.addEventListener(this.dataset.on, (event) => { + if (this.dataset.disable === 'true') { + form.querySelector('[type="submit"]')?.setAttribute('disabled', true) + } + + if (this.dataset.submitIfValue === 'true') { + if (event.target.value) { + form.requestSubmit() + } + } else { + form.requestSubmit() + } + }) + } else { + form.requestSubmit() + } + } + + disconnectedCallback () { + if (this.interval) { + clearInterval(this.interval) + } } } diff --git a/app/javascript/elements/submitter_autocomplete.js b/app/javascript/elements/submitter_autocomplete.js index 5fc539bf..bf24739a 100644 --- a/app/javascript/elements/submitter_autocomplete.js +++ b/app/javascript/elements/submitter_autocomplete.js @@ -36,7 +36,9 @@ export default class extends HTMLElement { } fetch = (text, resolve) => { - const q = text.split(/[;,\s]+/).pop().trim() + const q = this.dataset.field === 'email' + ? text.split(/[;,\s]+/).pop().trim() + : text if (q) { const queryParams = new URLSearchParams({ q, field: this.dataset.field }) diff --git a/app/javascript/elements/toggle_classes.js b/app/javascript/elements/toggle_classes.js new file mode 100644 index 00000000..ab96f293 --- /dev/null +++ b/app/javascript/elements/toggle_classes.js @@ -0,0 +1,11 @@ +export default class extends HTMLElement { + connectedCallback () { + const button = this.querySelector('a, button') + + button.addEventListener('click', () => { + this.dataset.classes.split(' ').forEach((cls) => { + button.classList.toggle(cls) + }) + }) + } +} diff --git a/app/javascript/elements/toggle_visible.js b/app/javascript/elements/toggle_visible.js index b7d29a9c..7fa968f2 100644 --- a/app/javascript/elements/toggle_visible.js +++ b/app/javascript/elements/toggle_visible.js @@ -4,8 +4,18 @@ export default actionable(class extends HTMLElement { trigger (event) { const elementIds = JSON.parse(this.dataset.elementIds) - elementIds.forEach((elementId) => { - document.getElementById(elementId).classList.toggle('hidden', event.target.value !== elementId) - }) + if (event.target.type === 'checkbox') { + elementIds.forEach((elementId) => { + document.getElementById(elementId)?.classList.toggle('hidden') + }) + } else { + elementIds.forEach((elementId) => { + document.getElementById(elementId).classList.toggle('hidden', (event.target.dataset.toggleId || event.target.value) !== elementId) + }) + } + + if (this.dataset.focusId) { + document.getElementById(this.dataset.focusId)?.focus() + } } }) diff --git a/app/javascript/form.js b/app/javascript/form.js index 2e07f033..b8253f86 100644 --- a/app/javascript/form.js +++ b/app/javascript/form.js @@ -3,11 +3,19 @@ import { createApp, reactive } from 'vue' import Form from './submission_form/form' import DownloadButton from './elements/download_button' import ToggleSubmit from './elements/toggle_submit' +import FetchForm from './elements/fetch_form' +import ScrollButtons from './elements/scroll_buttons' +import PageContainer from './elements/page_container' +import SubmitForm from './elements/submit_form' const safeRegisterElement = (name, element, options = {}) => !window.customElements.get(name) && window.customElements.define(name, element, options) safeRegisterElement('download-button', DownloadButton) safeRegisterElement('toggle-submit', ToggleSubmit) +safeRegisterElement('fetch-form', FetchForm) +safeRegisterElement('scroll-buttons', ScrollButtons) +safeRegisterElement('page-container', PageContainer) +safeRegisterElement('submit-form', SubmitForm) safeRegisterElement('submission-form', class extends HTMLElement { connectedCallback () { this.appElem = document.createElement('div') diff --git a/app/javascript/form.scss b/app/javascript/form.scss index fb651155..92fc6f6a 100644 --- a/app/javascript/form.scss +++ b/app/javascript/form.scss @@ -19,7 +19,7 @@ button .disabled { display: none; } -button[disabled] .disabled { +button[disabled] .disabled, button.btn-disabled .disabled { display: initial; } @@ -27,7 +27,7 @@ button .enabled { display: initial; } -button[disabled] .enabled { +button[disabled] .enabled, button.btn-disabled .enabled { display: none; } @@ -70,3 +70,11 @@ button[disabled] .enabled { .base-radio { @apply radio bg-white radio-sm; } + +.font-times { + font-family: "Times New Roman", Times, ui-serif, serif, Cambria, Georgia; +} + +.font-courier { + font-family: "Courier New", Consolas, "Liberation Mono", monospace, ui-monospace, SFMono-Regular, Menlo, Monaco; +} diff --git a/app/javascript/images/preview.png b/app/javascript/images/preview.png deleted file mode 100644 index 81acd3a9..00000000 Binary files a/app/javascript/images/preview.png and /dev/null differ diff --git a/app/javascript/lib/turbo_instant_click.js b/app/javascript/lib/turbo_instant_click.js index 01789e95..d72d4ce8 100644 --- a/app/javascript/lib/turbo_instant_click.js +++ b/app/javascript/lib/turbo_instant_click.js @@ -56,7 +56,7 @@ function mouseoverListener (event) { const requestOptions = { credentials: 'same-origin', - headers: { Accept: 'text/html, application/xhtml+xml', 'VND.PREFETCH': 'true' }, + headers: { Accept: 'text/html, application/xhtml+xml', 'VND.PREFETCH': 'true', 'X-Turbo': 'true' }, method: 'GET', redirect: 'follow' } diff --git a/app/javascript/submission_form/area.vue b/app/javascript/submission_form/area.vue index 9066684e..d3a4eaf6 100644 --- a/app/javascript/submission_form/area.vue +++ b/app/javascript/submission_form/area.vue @@ -1,9 +1,9 @@