From 1834beb13d23db5ceabbfad2b0d2f1d190ea5b8c Mon Sep 17 00:00:00 2001 From: Petko Bordjukov Date: Thu, 18 Apr 2024 21:11:08 +0300 Subject: [PATCH] Anti-spam measures for volunteering --- .../volunteer_confirmations_controller.rb | 20 ++++++++++++++++ .../public/volunteers_controller.rb | 5 ++++ app/mailers/volunteer_mailer.rb | 13 ++++++++++- app/models/volunteer.rb | 17 ++++++++++---- .../volunteer_email_confirmation.bg.erb | 5 ++++ .../volunteer_email_confirmation.en.erb | 5 ++++ config/environments/production.rb | 7 ++++-- config/initializers/devise.rb | 2 +- config/locales/bg.yml | 5 ++++ config/locales/en.yml | 5 ++++ config/routes.rb | 6 ++++- ...confirmation_and_approval_to_volunteers.rb | 22 ++++++++++++++++++ .../stylesheets/initfest/_flash_messages.scss | 4 ++-- .../assets/stylesheets/initfest/_forms.scss | 11 +++++++++ .../views/public/shared/_flash_messages.slim | 2 +- .../views/public/volunteers/_form.slim | 3 +++ .../views/public/volunteers/edit.slim | 4 ++++ spec/features/volunteership_spec.rb | 23 +++++++++++++++++-- 18 files changed, 145 insertions(+), 14 deletions(-) create mode 100644 app/controllers/public/volunteer_confirmations_controller.rb create mode 100644 app/views/volunteer_mailer/volunteer_email_confirmation.bg.erb create mode 100644 app/views/volunteer_mailer/volunteer_email_confirmation.en.erb create mode 100644 db/migrate/20240418161417_add_confirmation_and_approval_to_volunteers.rb diff --git a/app/controllers/public/volunteer_confirmations_controller.rb b/app/controllers/public/volunteer_confirmations_controller.rb new file mode 100644 index 0000000..198ba42 --- /dev/null +++ b/app/controllers/public/volunteer_confirmations_controller.rb @@ -0,0 +1,20 @@ +module Public + class VolunteerConfirmationsController < Public::ApplicationController + def create + @volunteer = Volunteer.find_by!(unique_id: params[:id]) + + if ActiveSupport::SecurityUtils.secure_compare(@volunteer.confirmation_token, params[:confirmation_token]) + @volunteer.transaction do + @volunteer.touch(:confirmed_at) + @volunteer.update(confirmation_token: nil) + end + + @volunteer.send_notification_to_volunteer + + redirect_to edit_volunteer_path(@volunteer.unique_id), notice: I18n.t("views.volunteers.email_confirmed_successfully") + else + redirect_to root_path, alert: I18n.t("views.volunteers.email_confirmation_error") + end + end + end +end diff --git a/app/controllers/public/volunteers_controller.rb b/app/controllers/public/volunteers_controller.rb index 6e93db6..1fa2cdd 100644 --- a/app/controllers/public/volunteers_controller.rb +++ b/app/controllers/public/volunteers_controller.rb @@ -1,5 +1,6 @@ module Public class VolunteersController < Public::ApplicationController + before_action :check_honey_pot, only: [:create, :edit] def new @volunteer = current_conference.volunteers.build end @@ -30,6 +31,10 @@ module Public private + def check_honey_pot + head :unauthorized unless params.dig(:volunteer_ht, :full_name).blank? + end + def volunteer_params params.require(:volunteer).permit( :name, :picture, :email, :phone, :tshirt_size, :tshirt_cut, diff --git a/app/mailers/volunteer_mailer.rb b/app/mailers/volunteer_mailer.rb index 13bc132..d96b7a1 100644 --- a/app/mailers/volunteer_mailer.rb +++ b/app/mailers/volunteer_mailer.rb @@ -13,8 +13,19 @@ class VolunteerMailer < ActionMailer::Base I18n.locale = @volunteer.language mail(to: @volunteer.email, reply_to: @volunteer.conference.email, - from: "no-reply@openfest.org", + from: "cfp@openfest.org", subject: I18n.t("volunteer_mailer.success_notification.subject", conference_name: @volunteer.conference.title)) end + + def volunteer_email_confirmation(new_volunteer) + @volunteer = new_volunteer + I18n.locale = @volunteer.language + mail(to: @volunteer.email, + reply_to: @volunteer.conference.email, + from: "cfp@openfest.org", + subject: I18n.t("volunteer_mailer.email_confirmation.subject", + conference_name: @volunteer.conference.title)) + end + end diff --git a/app/models/volunteer.rb b/app/models/volunteer.rb index eded70f..cb1be3b 100644 --- a/app/models/volunteer.rb +++ b/app/models/volunteer.rb @@ -9,7 +9,7 @@ class Volunteer < ActiveRecord::Base validates :tshirt_size, inclusion: {in: TSHIRT_SIZES.map(&:to_s)} validates :tshirt_cut, inclusion: {in: TSHIRT_CUTS.map(&:to_s)} validates :food_preferences, inclusion: {in: FOOD_PREFERENCES.map(&:to_s)} - validates :email, format: {with: /\A[^@]+@[^@]+\z/}, presence: true + validates :email, format: {with: /\A[^@]+@[^@]+\z/}, presence: true, uniqueness: {scope: :conference_id} validates :phone, presence: true, format: {with: /\A[+\- \(\)0-9]+\z/} validates :volunteer_team, presence: true validate :volunteer_teams_belong_to_conference @@ -22,7 +22,12 @@ class Volunteer < ActiveRecord::Base before_create :ensure_main_volunteer_team_is_part_of_additional_volunteer_teams before_create :assign_unique_id - after_create :send_notification_to_volunteer + before_create :assign_confirmation_token + after_commit :send_email_confirmation_to_volunteer, on: [:create] + + def send_notification_to_volunteer + VolunteerMailer.volunteer_notification(self).deliver_later + end private @@ -34,8 +39,12 @@ class Volunteer < ActiveRecord::Base self.unique_id = Digest::SHA256.hexdigest(SecureRandom.uuid) end - def send_notification_to_volunteer - VolunteerMailer.volunteer_notification(self).deliver_later + def assign_confirmation_token + self.confirmation_token = Digest::SHA256.hexdigest(SecureRandom.uuid) + end + + def send_email_confirmation_to_volunteer + VolunteerMailer.volunteer_email_confirmation(self).deliver_later end def volunteer_teams_belong_to_conference diff --git a/app/views/volunteer_mailer/volunteer_email_confirmation.bg.erb b/app/views/volunteer_mailer/volunteer_email_confirmation.bg.erb new file mode 100644 index 0000000..8231ab3 --- /dev/null +++ b/app/views/volunteer_mailer/volunteer_email_confirmation.bg.erb @@ -0,0 +1,5 @@ +Здравейте, + +Моля, потвърдете e-mail адреса си от следния линк: + +<%= confirm_volunteer_url(@volunteer.unique_id, confirmation_token: @volunteer.confirmation_token, host: @volunteer.conference.host_name, protocol: 'https') %> diff --git a/app/views/volunteer_mailer/volunteer_email_confirmation.en.erb b/app/views/volunteer_mailer/volunteer_email_confirmation.en.erb new file mode 100644 index 0000000..6376fec --- /dev/null +++ b/app/views/volunteer_mailer/volunteer_email_confirmation.en.erb @@ -0,0 +1,5 @@ +Hello, + +Please confirm your e-mail address by clicking the following link: + +<%= confirm_volunteer_url(@volunteer.unique_id, confirmation_token: @volunteer.confirmation_token, host: @volunteer.conference.host_name, protocol: 'https') %> diff --git a/config/environments/production.rb b/config/environments/production.rb index 8375bc1..0efb41f 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -86,9 +86,12 @@ Rails.application.configure do # Set this to true and configure the email server for immediate delivery to raise delivery errors. # config.action_mailer.raise_delivery_errors = false - config.action_mailer.delivery_method = :sendmail - config.action_mailer.default_options = {from: "no-reply@openfest.org"} + config.action_mailer.delivery_method = :smtp + config.action_mailer.default_options = {from: "cfp@openfest.org"} config.action_mailer.default_url_options = {host: "cfp.openfest.org"} + config.action_mailer.smtp_settings = { + address: "mail.openfest.org" + } # Enable locale fallbacks for I18n (makes lookups for any locale fall back to # the I18n.default_locale when a translation cannot be found). diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index 1c7475b..cdcfe1c 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -12,7 +12,7 @@ Devise.setup do |config| # Configure the e-mail address which will be shown in Devise::Mailer, # note that it will be overwritten if you use your own mailer class # with default "from" parameter. - config.mailer_sender = "no-reply@openfest.org" + config.mailer_sender = "cfp@openfest.org" # Configure the class responsible to send e-mails. # config.mailer = 'Devise::Mailer' diff --git a/config/locales/bg.yml b/config/locales/bg.yml index 6b4c748..f17742e 100644 --- a/config/locales/bg.yml +++ b/config/locales/bg.yml @@ -242,6 +242,8 @@ bg: volunteer_mailer: success_notification: subject: "Кандидатурата Ви за доброволец за %{conference_name} беше получена" + email_confirmation: + subject: "Потвърдете e-mail адреса си, за да се включите в %{conference_name}" event_states: approved: one: "Одобрено" @@ -412,6 +414,9 @@ bg: welcome: submit_event: "Предложи %{event_type}" volunteers: + email_not_confirmed: Вашият e-mail адрес не е потвърден. Моля, проверете електронната си поща и кликнете на линка от полученото писмо за потвърждение. + email_confirmed_successfully: Успешно потвърдихте e-mail адреса си! + email_confirmation_error: Възникна грешка при опит за потвърждаване на e-mail адреса Ви. new_volunteer_title: Кандидатствай за доброволец edit_volunteer_title: "Кандидатура за доброволец на %{name}" apply: Кандидатствай за доброволец diff --git a/config/locales/en.yml b/config/locales/en.yml index 931f208..5f24b78 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -242,6 +242,8 @@ en: volunteer_mailer: success_notification: subject: "Your application for \"volunteership\" on %{conference_name} was received" + email_confirmation: + subject: "Confirm your e-mail address to participate in %{conference_name}" event_states: approved: one: "Approved" @@ -412,6 +414,9 @@ en: welcome: submit_event: "Submit %{event_type}" volunteers: + email_not_confirmed: Your e-mail address has not been confirmed yet. Please check your e-mail and click on the link from the confirmation message you received. + email_confirmed_successfully: You have successfully confirmed your e-mail address! + email_confirmation_error: There was an error during the attempt to confirm your e-mail address. new_volunteer_title: Apply for a volunteer edit_volunteer_title: "Volunteership application of %{name}" apply: Apply for a volunteer diff --git a/config/routes.rb b/config/routes.rb index d5740cc..df10dba 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -11,7 +11,11 @@ Rails.application.routes.draw do get :confirm end end - resources :volunteers + resources :volunteers do + member do + get :confirm, to: "volunteer_confirmations#create" + end + end resources :volunteer_teams, only: [:index] resources :feedback, as: "conference_feedbacks", controller: "conference_feedbacks", only: [:new, :create, :index] end diff --git a/db/migrate/20240418161417_add_confirmation_and_approval_to_volunteers.rb b/db/migrate/20240418161417_add_confirmation_and_approval_to_volunteers.rb new file mode 100644 index 0000000..b4a9296 --- /dev/null +++ b/db/migrate/20240418161417_add_confirmation_and_approval_to_volunteers.rb @@ -0,0 +1,22 @@ +class AddConfirmationAndApprovalToVolunteers < ActiveRecord::Migration[7.1] + class Volunteer < ActiveRecord::Base; end + + def change + add_column :volunteers, :confirmation_token, :string + add_index :volunteers, :confirmation_token + add_column :volunteers, :confirmed_at, :timestamptz + add_index :volunteers, :confirmed_at + add_column :volunteers, :approved_at, :timestamptz + add_index :volunteers, :approved_at + + reversible do |dir| + dir.up do + execute "UPDATE volunteers SET confirmed_at = created_at, approved_at = created_at;" + end + + dir.down do + # no-op + end + end + end +end diff --git a/lib/initfest/assets/stylesheets/initfest/_flash_messages.scss b/lib/initfest/assets/stylesheets/initfest/_flash_messages.scss index 53c344c..d8c0ef1 100644 --- a/lib/initfest/assets/stylesheets/initfest/_flash_messages.scss +++ b/lib/initfest/assets/stylesheets/initfest/_flash_messages.scss @@ -1,4 +1,4 @@ -#flash_messages { +.flash_messages { border: 1px solid #CCC; background-color: #F1F1F1; padding: 10px; @@ -20,4 +20,4 @@ .alert { color: orange; } -} \ No newline at end of file +} diff --git a/lib/initfest/assets/stylesheets/initfest/_forms.scss b/lib/initfest/assets/stylesheets/initfest/_forms.scss index 9b1be94..2bdb983 100644 --- a/lib/initfest/assets/stylesheets/initfest/_forms.scss +++ b/lib/initfest/assets/stylesheets/initfest/_forms.scss @@ -162,3 +162,14 @@ .btn-link-red:active { background: #D27A59; } + +.special-form-field { + transform: scale(0.00069420); + opacity: 0; + position: absolute; + top: 0; + left: 0; + height: 0; + width: 0; + z-index: -1; +} diff --git a/lib/initfest/views/public/shared/_flash_messages.slim b/lib/initfest/views/public/shared/_flash_messages.slim index c94abd1..d3f8572 100644 --- a/lib/initfest/views/public/shared/_flash_messages.slim +++ b/lib/initfest/views/public/shared/_flash_messages.slim @@ -1,3 +1,3 @@ -div#flash_messages +div#flash_messages.flash_messages - flash.each do |key, value| = content_tag :div, value, class: "flash #{key}" diff --git a/lib/initfest/views/public/volunteers/_form.slim b/lib/initfest/views/public/volunteers/_form.slim index 55194ea..20bee55 100644 --- a/lib/initfest/views/public/volunteers/_form.slim +++ b/lib/initfest/views/public/volunteers/_form.slim @@ -9,6 +9,9 @@ = f.hidden_field :picture, value: @volunteer.picture.signed_id if @volunteer.picture.attached? = f.input :picture, as: :file, required: false, direct: true, wrapper: false, input_html: {direct_upload: true} + = text_field :volunteer_ht, :full_name, class: 'special-form-field', tabindex: "-1", "aria-hidden": true + = label :volunteer_ht, :full_name, 'Full Name', class: 'special-form-field', "aria-hidden": true + = f.input :name, autofocus: true = f.input :email = f.input :phone, input_html: {value: @volunteer.phone.try(:phony_formatted, format: :international)} diff --git a/lib/initfest/views/public/volunteers/edit.slim b/lib/initfest/views/public/volunteers/edit.slim index c6701b0..f7e2d8d 100644 --- a/lib/initfest/views/public/volunteers/edit.slim +++ b/lib/initfest/views/public/volunteers/edit.slim @@ -1,5 +1,9 @@ - content_for(:title) { t 'views.volunteers.edit_volunteer_title', name: @volunteer.name } +- if !@volunteer.new_record? && !@volunteer.confirmed_at + div.flash_messages + div.flash.alert = t 'views.volunteers.email_not_confirmed' + h1 = t 'views.volunteers.edit_volunteer_title', name: @volunteer.name = render 'form' diff --git a/spec/features/volunteership_spec.rb b/spec/features/volunteership_spec.rb index 9094c24..8165a6c 100644 --- a/spec/features/volunteership_spec.rb +++ b/spec/features/volunteership_spec.rb @@ -1,6 +1,8 @@ require "rails_helper" feature "Volunteering" do + include ActiveJob::TestHelper + before do Rails.application.load_seed sign_in_as_admin @@ -12,12 +14,29 @@ feature "Volunteering" do visit root_path click_on I18n.t("views.volunteers.apply") - fill_in_volunteer_profile - expect(page).to have_content I18n.t("views.volunteers.successful_application") + perform_enqueued_jobs do + fill_in_volunteer_profile + expect(page).to have_content I18n.t("views.volunteers.successful_application") + expect(page).to have_content I18n.t("views.volunteers.email_not_confirmed") + end + + perform_enqueued_jobs do + visit link_from_last_email + end + + expect(page).not_to have_content I18n.t("views.volunteers.email_not_confirmed") + + expect(ActionMailer::Base.deliveries.last.subject).to eq(I18n.t("volunteer_mailer.success_notification.subject", conference_name: "FooConf #{1.year.from_now.year}")) sign_in_as_admin click_on_first_conference_in_management_root click_on I18n.t("activerecord.models.volunteership", count: 2).capitalize expect(page).to have_content "Volunteer Foo" end + + private + + def link_from_last_email + ActionMailer::Base.deliveries.last.body.to_s.strip[%r{(?<=https://www\.example\.com).*?(?=$)}] + end end