diff --git a/.env.example b/.env.example index 43fb2b7..3bc42cc 100644 --- a/.env.example +++ b/.env.example @@ -4,3 +4,6 @@ SMTP_PORT=1025 SMTP_DOMAIN=localhost SMTP_ADDRESS=localhost DB_PASSWORD= +G_RECAPTCHA_SITE_KEY= +G_RECAPTCHA_SECRET_KEY= +G_RECAPTCHA_SCORE_THRESHOLD=0.8 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a0ad627..30ca3d9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,11 +65,11 @@ jobs: - 5432:5432 options: --health-cmd="pg_isready" --health-interval=10s --health-timeout=5s --health-retries=3 - # redis: - # image: redis - # ports: - # - 6379:6379 - # options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5 + redis: + image: redis + ports: + - 6379:6379 + options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5 steps: - name: Install packages @@ -88,12 +88,4 @@ jobs: env: RAILS_ENV: test RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }} - run: bin/rails db:test:prepare test test:system - - - name: Keep screenshots from failed system tests - uses: actions/upload-artifact@v4 - if: failure() - with: - name: screenshots - path: ${{ github.workspace }}/tmp/screenshots - if-no-files-found: ignore + run: bin/rails db:prepare && bin/bundle exec rspec diff --git a/.rubocop.yml b/.rubocop.yml index f9d86d4..2115d33 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,8 +1,13 @@ # Omakase Ruby styling for Rails inherit_gem: { rubocop-rails-omakase: rubocop.yml } -# Overwrite or add rules to create your own house style -# -# # Use `[a, [b, c]]` not `[ a, [ b, c ] ]` -# Layout/SpaceInsideArrayLiteralBrackets: -# Enabled: false +Style/StringLiterals: + Enabled: true + EnforcedStyle: double_quotes + Include: + - "app/**/*" + - "config/**/*" + - "lib/**/*" + - "test/**/*" + - "Gemfile" + - "spec/**/*" diff --git a/README.md b/README.md index f10546d..0d4c47e 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Now, let's get you set up: 1. Install [Docker Desktop](https://docs.docker.com/desktop/) on your local machine. This allows you to run the database, Redis store, and [Mailpit](https://mailpit.axllent.org/) containers locally. You can, of course, skip this step if Docker's not your thing. Oh whale 🐳! 1. Pull down this repository to your local machine. -1. At the root of the project, run `cp .env.example .env`. Then slide on over to your new `.env` file and fill out the empty environment variables. If you're using Docker, feel free to put whatever you want for `SMTP_USERNAME`, `SMTP_PASSWORD`, and `DB_PASSWORD`. +1. At the root of the project, run `cp .env.example .env`. Then slide on over to your new `.env` file and fill out the empty environment variables. If you're using Docker, feel free to put whatever you want for `SMTP_USERNAME`, `SMTP_PASSWORD`, and `DB_PASSWORD`. Make sure to use reCAPTCHA v3 for all `G_RECAPTCHA_**` values. You can generate your own reCAPTCHA credentials at [https://www.google.com/recaptcha/](https://www.google.com/recaptcha/). 1. Open the Docker Desktop application. Then, in your termnial, run `docker compose up`. This will create and start the containers for PostgreSQL, Redis, and Mailpit. 1. In your terminal, run `bin/setup` to install your dependcies and set up your database. 1. In your terminal, run `bin/rails s` to fire up the development server. diff --git a/app/controllers/pledges_controller.rb b/app/controllers/pledges_controller.rb index efa0816..62014d3 100644 --- a/app/controllers/pledges_controller.rb +++ b/app/controllers/pledges_controller.rb @@ -6,7 +6,7 @@ def new def create @pledge = Pledge.new(pledge_params) - if @pledge.save + if safe_submission? && @pledge.save flash[:success] = "Thanks for signing! 🥳" PledgeMailer.new_pledge( first_name: pledge_params[:first_name], @@ -24,4 +24,10 @@ def create def pledge_params params.require(:pledge).permit(:first_name, :last_name, :email) end + + def safe_submission? + ::Recaptcha.new( + token: params[:g_recaptcha_response] + ).call + end end diff --git a/app/javascript/controllers/recaptcha_controller.js b/app/javascript/controllers/recaptcha_controller.js new file mode 100644 index 0000000..ba02966 --- /dev/null +++ b/app/javascript/controllers/recaptcha_controller.js @@ -0,0 +1,26 @@ +import { Controller } from "@hotwired/stimulus"; + +export default class extends Controller { + validate(event) { + event.preventDefault(); + + const siteKey = document + .getElementById("recaptcha-initlializer") + .src.split("?render=") + .slice(-1)[0]; + const form = this.element; + const tokenInput = form.querySelector("input[name='g_recaptcha_response']"); + const submitButton = form.querySelector("button[type='submit']"); + + grecaptcha.ready(function () { + grecaptcha + .execute(siteKey, { + action: "submit", + }) + .then(function (token) { + tokenInput.value = token; + form.requestSubmit(submitButton); + }); + }); + } +} diff --git a/app/services/recaptcha.rb b/app/services/recaptcha.rb new file mode 100644 index 0000000..887cce5 --- /dev/null +++ b/app/services/recaptcha.rb @@ -0,0 +1,31 @@ +require "net/http" +require "uri" + +class Recaptcha + def initialize(token:) + @token = token + end + + def call + endpoint = "https://www.google.com/recaptcha/api/siteverify" + data = { + secret: ENV["G_RECAPTCHA_SECRET_KEY"], + response: @token + } + response = Net::HTTP.post_form URI(endpoint), data + payload = JSON.parse(response.body) + threshold = ENV.fetch("G_RECAPTCHA_SCORE_THRESHOLD", "0.5").to_f + + if payload["success"] && payload["score"] > threshold + Rails.logger.info "reCAPTCHA evaluated as safe: payload => #{payload}" + true + else + Rails.logger.warn "reCAPTCHA evaluated as unsafe: payload => #{payload}" + false + end + + rescue StandardError => exception + Rails.logger.error "Form response failed to validate with reCAPTCHA: exception => #{exception}, payload => #{payload}" + false + end +end diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 87d7056..3094e25 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -19,6 +19,7 @@ <%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %> <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> <%= javascript_importmap_tags %> + diff --git a/app/views/pledges/form/_index.html.erb b/app/views/pledges/form/_index.html.erb index 1233027..d8fd733 100644 --- a/app/views/pledges/form/_index.html.erb +++ b/app/views/pledges/form/_index.html.erb @@ -9,7 +9,7 @@
  • I will cultivate relationships.
  • - <%= form_for @pledge, html: { class: "flex flex-col justify-center gap-5 w-full max-w-content px-5 md:px-10 lg:px-0" } do |form| %> + <%= form_for @pledge, html: { class: "flex flex-col justify-center gap-5 w-full max-w-content px-5 md:px-10 lg:px-0", data: { controller: "recaptcha" } } do |form| %>
    <%= form.label :first_name, class: "bg-soil w-fit text-white font-medium italic px-3 uppercase ml-8 -mb-[10px] z-10 text-xs" %> @@ -24,8 +24,9 @@ <%= form.label :email, class: "bg-soil w-fit text-white font-medium italic px-3 uppercase ml-8 -mb-[10px] z-10 text-xs" %> <%= form.text_field :email, required: true, class: "bg-transparent border-2 border-mango rounded-none h-11 px-3 text-white focus:outline-0 focus:border-yellow-400" %>
    +
    - <%= form.submit "Sign", class: "flex justify-center items-center px-8 min-h-11 bg-mango font-medium italic uppercase text-white text-lg cursor-pointer rounded-none border-2 border-mango hover:bg-persimmon hover:border-persimmon focus:outline-0 focus:border-yellow-400" %> + <%= form.submit "Sign", class: "flex justify-center items-center px-8 min-h-11 bg-mango font-medium italic uppercase text-white text-lg cursor-pointer rounded-none border-2 border-mango hover:bg-persimmon hover:border-persimmon focus:outline-0 focus:border-yellow-400", data: { action: "recaptcha#validate" } %>
    <% end %> <%= render "pledges/form/swirly_arrow" %> diff --git a/config/initializers/filter_parameter_logging.rb b/config/initializers/filter_parameter_logging.rb index c010b83..aabf35f 100644 --- a/config/initializers/filter_parameter_logging.rb +++ b/config/initializers/filter_parameter_logging.rb @@ -4,5 +4,5 @@ # Use this to limit dissemination of sensitive information. # See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors. Rails.application.config.filter_parameters += [ - :passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn + :passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :g_recaptcha_response ] diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 20880be..ae4fd6c 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -1,14 +1,15 @@ # This file is copied to spec/ when you run 'rails generate rspec:install' -require 'spec_helper' -ENV['RAILS_ENV'] ||= 'test' -require_relative '../config/environment' +require "spec_helper" +ENV["RAILS_ENV"] ||= "test" +require_relative "../config/environment" # Prevent database truncation if the environment is production abort("The Rails environment is running in production mode!") if Rails.env.production? # Uncomment the line below in case you have `--require rails_helper` in the `.rspec` file # that will avoid rails generators crashing because migrations haven't been run yet # return unless Rails.env.test? -require 'rspec/rails' +require "rspec/rails" # Add additional requires below this line. Rails is not loaded until this point! +ActiveJob::Base.queue_adapter = :test # Requires supporting ruby files with custom matchers and macros, etc, in # spec/support/ and its subdirectories. Files matching `spec/**/*_spec.rb` are @@ -35,7 +36,7 @@ RSpec.configure do |config| # Remove this line if you're not using ActiveRecord or ActiveRecord fixtures config.fixture_paths = [ - Rails.root.join('spec/fixtures') + Rails.root.join("spec/fixtures") ] # If you're not using ActiveRecord, or you'd prefer not to run each of your diff --git a/spec/requests/pledges_spec.rb b/spec/requests/pledges_spec.rb new file mode 100644 index 0000000..cb353ce --- /dev/null +++ b/spec/requests/pledges_spec.rb @@ -0,0 +1,41 @@ +require "rails_helper" + +describe PledgesController do + include ActionMailer::TestHelper + include ActiveJob::TestHelper + + describe "#create" do + let(:first_name) { "Isaac" } + let(:last_name) { "Newton" } + let(:email) { "inewton@example.net" } + let(:token) { "motion_token" } + let(:params) do + { + pledge: { + first_name: first_name, + last_name: last_name, + email: email, + g_recaptcha_response: token + } + } + end + let(:recaptcha_stub) { double call: true } + + before do + allow(Recaptcha).to receive(:new).and_return recaptcha_stub + post "/pledges", params: params + end + + it "creates a new pledge" do + expect(Pledge.count).to eq 1 + end + + it "enqueues an email job" do + expect(enqueued_jobs.first[:job]).to eq ActionMailer::MailDeliveryJob + end + + it "redirects to the root" do + expect(response.code).to eq "302" + end + end +end diff --git a/spec/services/recaptcha_spec.rb b/spec/services/recaptcha_spec.rb new file mode 100644 index 0000000..d52d61a --- /dev/null +++ b/spec/services/recaptcha_spec.rb @@ -0,0 +1,50 @@ +require "rails_helper" + +describe Recaptcha do + subject { described_class.new(token: double) } + + describe "#call" do + let(:response) { double body: "{\n \"success\": true,\n \"challenge_ts\": \"2024-11-25T00:03:52Z\",\n \"hostname\": \"example.net\",\n \"score\": 0.9,\n \"action\": \"submit\"\n}" } + + before do + allow(Rails.logger).to receive_messages(info: nil, warn: nil, error: nil) + allow(Net::HTTP).to receive(:post_form).and_return response + end + + context "safe form submission" do + it "logs an info message and returns true" do + expect(subject.call).to eq true + expect(Rails.logger).to have_received(:info) + end + end + + context "unsafe form submission" do + let(:response) { double body: "{\n \"success\": false,\n \"challenge_ts\": \"2024-11-25T00:03:52Z\",\n \"hostname\": \"example.net\",\n \"score\": 0.1,\n \"action\": \"submit\"\n}" } + + it "logs a warning message and returns false" do + expect(subject.call).to eq false + expect(Rails.logger).to have_received(:warn) + end + end + + context "invalid reCAPTCHA call" do + let(:response) { double body: "{\n \"success\": false,\n \"error-codes\": [\n \"invalid-input-response\"\n ]\n}" } + + it "logs a warning message and returns false" do + expect(subject.call).to eq false + expect(Rails.logger).to have_received(:warn) + end + end + + context "StandardError" do + before do + allow(Net::HTTP).to receive(:post_form).and_raise(StandardError) + end + + it "logs an error message and returns false" do + expect(subject.call).to eq false + expect(Rails.logger).to have_received(:error) + end + end + end +end