From 6aa50520e8ca65fb2906cc635b92810777c4c449 Mon Sep 17 00:00:00 2001 From: f Date: Mon, 23 Mar 2020 17:46:19 -0300 Subject: [PATCH] formulario de contacto, closes #120 --- app/controllers/api/v1/base_controller.rb | 6 ++ app/controllers/api/v1/contact_controller.rb | 93 +++++++++++++++++++ app/jobs/contact_job.rb | 8 ++ app/mailers/contact_mailer.rb | 33 +++++++ app/models/site.rb | 13 ++- .../contact_mailer/notify_usuaries.html.haml | 11 +++ .../contact_mailer/notify_usuaries.text.haml | 11 +++ config/environments/development.rb | 1 + config/environments/test.rb | 1 + config/locales/en.yml | 5 + config/locales/es.yml | 5 + config/routes.rb | 1 + .../api/v1/contact_controller_test.rb | 89 ++++++++++++++++++ 13 files changed, 272 insertions(+), 5 deletions(-) create mode 100644 app/controllers/api/v1/contact_controller.rb create mode 100644 app/jobs/contact_job.rb create mode 100644 app/mailers/contact_mailer.rb create mode 100644 app/views/contact_mailer/notify_usuaries.html.haml create mode 100644 app/views/contact_mailer/notify_usuaries.text.haml create mode 100644 test/controllers/api/v1/contact_controller_test.rb diff --git a/app/controllers/api/v1/base_controller.rb b/app/controllers/api/v1/base_controller.rb index 6f76e51a..25c10e65 100644 --- a/app/controllers/api/v1/base_controller.rb +++ b/app/controllers/api/v1/base_controller.rb @@ -6,6 +6,12 @@ module Api class BaseController < ActionController::Base protect_from_forgery with: :null_session respond_to :json + + private + + def origin + request.headers['Origin'] + end end end end diff --git a/app/controllers/api/v1/contact_controller.rb b/app/controllers/api/v1/contact_controller.rb new file mode 100644 index 00000000..429136fd --- /dev/null +++ b/app/controllers/api/v1/contact_controller.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +module Api + module V1 + # API para formulario de contacto + class ContactController < BaseController + # Aplicar algunos chequeos básicos. Deberíamos registrar de + # alguna forma los errores pero tampoco queremos que nos usen + # recursos. + # + # TODO: Agregar los mismos chequeos que en PostsController + before_action :site_exists?, unless: :performed? + before_action :site_is_origin?, unless: :performed? + before_action :from_is_address?, unless: :performed? + before_action :gave_consent?, unless: :performed? + + # Recibe un mensaje a través del formulario de contacto y lo envía + # a les usuaries del sitio. + # + # Tenemos que verificar que el sitio exista y que algunos campos + # estén llenos para detener spambots o DDOS. También nos vamos a + # estar apoyando en la limitación de peticiones en el servidor web. + def receive + # No hacer nada si no se pasaron los chequeos + return if performed? + + # Si todo salió bien, enviar los correos y redirigir al sitio. + # El sitio nos dice a dónde tenemos que ir. + ContactJob.perform_async site_id: site.id, + **contact_params.to_h.symbolize_keys + + redirect_to contact_params[:redirect] || site.url + end + + private + + # Comprueba que el sitio existe + # + # TODO: Responder con una zip bomb! + def site_exists? + render html: body(:site_exists), status: status unless site + end + + # Comprueba que el mensaje vino fue enviado desde el sitio + def site_is_origin? + return if origin.to_s == site.url + + render html: body(:site_is_origin), status: status + end + + # Detecta si la dirección de contacto es válida. Además es + # opcional. + def from_is_address? + return unless contact_params[:from] + return if EmailAddress.valid? contact_params[:from] + + render html: body(:from_is_address), status: status + end + + # No aceptar nada si no dió su consentimiento + def gave_consent? + return if contact_params[:gdpr].present? + + render html: body(:gave_consent), status: status + end + + # Encuentra el sitio + def site + @site ||= Site.find_by(name: params[:site_id]) + end + + # Parámetros limpios + def contact_params + @contact_params ||= params.permit(:gdpr, :name, :pronouns, :from, + :contact, :body, :redirect) + end + + # Para poder testear, enviamos un mensaje en el cuerpo de la + # respuesta + # + # @param [Any] el mensaje + def body(message) + return message.to_s if Rails.env.test? + end + + # No queremos informar nada a los spammers, pero en testeo + # queremos saber por qué. :no_content oculta el cuerpo. + def status + Rails.env.test? ? :unprocessable_entity : :no_content + end + end + end +end diff --git a/app/jobs/contact_job.rb b/app/jobs/contact_job.rb new file mode 100644 index 00000000..9425b512 --- /dev/null +++ b/app/jobs/contact_job.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +# Envía los mensajes de contacto +class ContactJob < ApplicationJob + def perform(**args) + ContactMailer.with(**args).notify_usuaries.deliver_now + end +end diff --git a/app/mailers/contact_mailer.rb b/app/mailers/contact_mailer.rb new file mode 100644 index 00000000..b372c531 --- /dev/null +++ b/app/mailers/contact_mailer.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +# Formulario de contacto +class ContactMailer < ApplicationMailer + # Enviar el formulario de contacto a todes les usuaries + def notify_usuaries + # Enviar de a 10 usuaries para minimizar el riesgo que nos + # consideren spammers. + # + # TODO: #i18n. Agrupar usuaries por su idioma + usuaries.each_slice(10) do |u| + mail to: u, + reply_to: params[:from], + subject: I18n.t('contact_mailer.subject', site: site.title) + end + end + + private + + def site + @site ||= Site.find params[:site_id] + end + + # Trae solo les usuaries definitives para eliminar un vector de ataque + # donde alguien crea un sitio, agrega a muches usuaries y les envía + # correos. + # + # TODO: Mover a Site#usuaries + def usuaries + site.roles.where(rol: 'usuarie', temporal: false).includes(:usuarie) + .pluck(:email) + end +end diff --git a/app/models/site.rb b/app/models/site.rb index ffdb4473..d6e87b39 100644 --- a/app/models/site.rb +++ b/app/models/site.rb @@ -68,12 +68,14 @@ class Site < ApplicationRecord end def hostname + return @hostname if @hostname + sub = name || I18n.t('deploys.deploy_local.ejemplo') - if sub.ends_with? '.' - sub.gsub(/\.\Z/, '') - else - "#{sub}.#{Site.domain}" - end + @hostname = if sub.ends_with? '.' + sub.gsub(/\.\Z/, '') + else + "#{sub}.#{Site.domain}" + end end def url @@ -340,6 +342,7 @@ class Site < ApplicationRecord config.description = description config.title = title config.url = url + config.hostname = hostname end # Migra los archivos a Sutty diff --git a/app/views/contact_mailer/notify_usuaries.html.haml b/app/views/contact_mailer/notify_usuaries.html.haml new file mode 100644 index 00000000..02a74624 --- /dev/null +++ b/app/views/contact_mailer/notify_usuaries.html.haml @@ -0,0 +1,11 @@ +-# + Solo enviamos versión de texto para no aceptar HTML en el formulario + de contacto + +%p + %strong= I18n.t('contact_mailer.name', name: sanitize(@params[:name]), + pronouns: sanitize(@params[:pronouns])) + %strong= I18n.t('contact_mailer.contact', contact: sanitize(@params[:contact])) + %strong= I18n.t('contact_mailer.gdpr', gdpr: sanitize(@params[:gdpr])) + +%div= sanitize @params[:body] diff --git a/app/views/contact_mailer/notify_usuaries.text.haml b/app/views/contact_mailer/notify_usuaries.text.haml new file mode 100644 index 00000000..393ddd62 --- /dev/null +++ b/app/views/contact_mailer/notify_usuaries.text.haml @@ -0,0 +1,11 @@ +-# + Solo enviamos versión de texto para no aceptar HTML en el formulario + de contacto + += I18n.t('contact_mailer.name', + name: sanitize(@params[:name]), + pronouns: sanitize(@params[:pronouns])) += I18n.t('contact_mailer.contact', contact: sanitize(@params[:contact])) += I18n.t('contact_mailer.gdpr', gdpr: sanitize(@params[:gdpr])) +\ += sanitize @params[:body] diff --git a/config/environments/development.rb b/config/environments/development.rb index 5a47ac0d..ac21523e 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -64,6 +64,7 @@ Rails.application.configure do # routes, locales, etc. This feature depends on the listen gem. config.file_watcher = ActiveSupport::EventedFileUpdateChecker + config.action_mailer.default_options = { from: ENV['DEFAULT_FROM'] } config.action_mailer.perform_caching = false config.action_mailer.delivery_method = :letter_opener config.action_mailer.perform_deliveries = true diff --git a/config/environments/test.rb b/config/environments/test.rb index f3334615..2f5a5f1f 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -44,6 +44,7 @@ Rails.application.configure do # The :test delivery method accumulates sent emails in the # ActionMailer::Base.deliveries array. config.action_mailer.delivery_method = :test + config.action_mailer.default_options = { from: ENV['DEFAULT_FROM'] } config.action_mailer.default_url_options = { host: 'localhost', port: 3000 } diff --git a/config/locales/en.yml b/config/locales/en.yml index 00e591b9..06b0fd0f 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -36,6 +36,11 @@ en: es: Castillian Spanish en: English seconds: '%{seconds} seconds' + contact_mailer: + subject: '[%site] Contact form' + name: 'Name: %{name} (%{pronouns})' + contact: 'Contact: %{contact}' + gdpr: 'Consent: %{gdpr}' deploy_mailer: deployed: subject: "[Sutty] The site %{site} has been built" diff --git a/config/locales/es.yml b/config/locales/es.yml index cd0764a8..4d310aeb 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -38,6 +38,11 @@ es: es: Castellano en: Inglés seconds: '%{seconds} segundos' + contact_mailer: + subject: '[%{site}] Formulario de contacto' + name: 'Nombre: %{name} (%{pronouns})' + contact: 'Contacto: %{contact}' + gdpr: 'Consentimiento: %{gdpr}' deploy_mailer: deployed: subject: "[Sutty] El sitio %{site} ha sido generado" diff --git a/config/routes.rb b/config/routes.rb index 9e6c0660..f89bc537 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -24,6 +24,7 @@ Rails.application.routes.draw do resources :sites, only: %i[index], constraints: { site_id: /[a-z0-9\-\.]+/, id: /[a-z0-9\-\.]+/ } do get 'invitades/cookie', to: 'invitades#cookie' resources :posts, only: %i[create] + post :contact, to: 'contact#receive' end end end diff --git a/test/controllers/api/v1/contact_controller_test.rb b/test/controllers/api/v1/contact_controller_test.rb new file mode 100644 index 00000000..815bb6c1 --- /dev/null +++ b/test/controllers/api/v1/contact_controller_test.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +require 'test_helper' + +module Api + module V1 + class ContactControllerTest < ActionDispatch::IntegrationTest + setup do + @rol = create :rol + @site = @rol.site + @usuarie = @rol.usuarie + end + + teardown do + @site&.destroy + end + + test 'el sitio tiene que existir' do + @site.destroy + + post v1_site_contact_url(@site), + params: { + name: SecureRandom.hex, + pronouns: SecureRandom.hex, + contact: SecureRandom.hex, + from: "#{SecureRandom.hex}@sutty.nl", + body: SecureRandom.hex, + gdpr: true + } + + assert_response :unprocessable_entity, response.status + assert_equal 'site_exists', response.body + end + + test 'hay que enviar desde el sitio principal' do + post v1_site_contact_url(@site), + params: { + name: SecureRandom.hex, + pronouns: SecureRandom.hex, + contact: SecureRandom.hex, + from: "#{SecureRandom.hex}@sutty.nl", + body: SecureRandom.hex, + gdpr: true + } + + assert_response :unprocessable_entity, response.status + assert_equal 'site_is_origin', response.body + end + + test 'hay que dar consentimiento' do + post v1_site_contact_url(@site), + headers: { + Origin: @site.url + }, + params: { + name: SecureRandom.hex, + pronouns: SecureRandom.hex, + contact: SecureRandom.hex, + from: "#{SecureRandom.hex}@sutty.nl", + body: SecureRandom.hex + } + + assert_response :unprocessable_entity, response.status + assert_equal 'gave_consent', response.body + end + + test 'enviar un mensaje genera correos' do + 10.times do + create :rol, site: @site + end + + post v1_site_contact_url(@site), + headers: { + Origin: @site.url + }, + params: { + name: SecureRandom.hex, + pronouns: SecureRandom.hex, + contact: SecureRandom.hex, + from: "#{SecureRandom.hex}@sutty.nl", + body: SecureRandom.hex, + gdpr: true + } + + assert_equal 2, ActionMailer::Base.deliveries.size + end + end + end +end