formularios de contacto!

This commit is contained in:
f 2020-05-30 16:43:25 -03:00
parent 67b232c626
commit 477b417567
14 changed files with 232 additions and 99 deletions

View file

@ -9,6 +9,18 @@ module Api
private
# Realiza la inversa de Site#hostname
#
# TODO: El sitio sutty.nl no aplica a ninguno de estos y le
# tuvimos que poner 'sutty.nl..sutty.nl' para pasar el test.
def site_id
@site_id ||= if params[:site_id].end_with? Site.domain
params[:site_id].sub(/\.#{Site.domain}\z/, '')
else
params[:site_id] + '.'
end
end
def origin
request.headers['Origin']
end

View file

@ -12,9 +12,18 @@ module Api
# alguna forma los errores pero tampoco queremos que nos usen
# recursos.
#
# TODO: Agregar los mismos chequeos que en PostsController
# Devolvemos un error 428: Precondition Required dando la
# oportunidad a les visitantes de reintentar en el caso de falsos
# positivos. No devolvemos contenido para que el servidor web
# capture y muestra la página de error específica de cada sitio o
# la genérica de Sutty.
#
# XXX: Ordenar en orden ascendiente según uso de recursos.
before_action :cookie_is_valid?, unless: :performed?
before_action :valid_authenticity_token_in_cookie?, unless: :performed?
before_action :site_exists?, unless: :performed?
before_action :site_is_origin?, unless: :performed?
before_action :form_exists?, unless: :performed?
before_action :from_is_address?, unless: :performed?
before_action :gave_consent?, unless: :performed?
@ -28,67 +37,91 @@ module Api
# No hacer nada si no se pasaron los chequeos
return if performed?
# TODO: Verificar que los campos obligatorios hayan llegado!
# 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
form_name: params[:form],
form: contact_params.to_h.symbolize_keys
redirect_to contact_params[:redirect] || site.url
redirect_to contact_params[:redirect] || origin.to_s
end
private
def site_cookie
@site_cookie ||= cookies.encrypted[site_id]
end
# Comprueba que no se haya reutilizado una cookie vencida
#
# XXX: Si el navegador envió una cookie vencida es porque la está
# reutilizando, probablemente de forma maliciosa? Pero también
# puede ser que haya tardado más de media hora en enviar el
# formulario.
def cookie_is_valid?
head :precondition_required unless (site_cookie.try(:[], 'expires') || 0) > Time.now.to_i
end
# Queremos comprobar que la cookie corresponda con la sesión. La
# cookie puede haber vencido, así que es uno de los chequeos más
# simples que hacemos.
#
# TODO: Pensar una forma de redirigir al origen sin vaciar el
# formulario para que le usuarie recargue la cookie.
def valid_authenticity_token_in_cookie?
return if valid_authenticity_token? session, site_cookie['csrf']
head :precondition_required
end
# Comprueba que el sitio existe
#
# TODO: Responder con una zip bomb!
def site_exists?
render html: body(:site_exists), status: status unless site
head :precondition_required if site.nil?
end
# Comprueba que el mensaje vino fue enviado desde el sitio
def site_is_origin?
return if site.url.start_with? origin.to_s
return if origin.to_s.start_with? site.url(slash: false)
render html: body(:site_is_origin), status: status
head :precondition_required
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 contact_params[:from].empty?
return if EmailAddress.valid? contact_params[:from]
render html: body(:from_is_address), status: status
head :precondition_required
end
# No aceptar nada si no dió su consentimiento
def gave_consent?
return if contact_params[:gdpr].present?
return if contact_params[:consent].present?
render html: body(:gave_consent), status: status
head :precondition_required
end
# Realiza la inversa de Site#hostname
#
# TODO: El sitio sutty.nl no aplica a ninguno de estos y le
# tuvimos que poner 'sutty.nl..sutty.nl' para pasar el test.
def site_id
@site_id ||= if params[:site_id].end_with? Site.domain
params[:site_id].sub(/\.#{Site.domain}\z/, '')
else
params[:site_id] + '.'
end
# Los campos que se envían tienen que corresponder con un
# formulario de contacto.
def form_exists?
return if site.form? params[:form]
head :precondition_required
end
# Encuentra el sitio
# Encuentra el sitio o devuelve nulo
def site
@site ||= Site.find_by(name: site_id)
end
# Parámetros limpios
def contact_params
@contact_params ||= params.permit(:gdpr, :name, :pronouns, :from,
:contact, :body, :redirect)
@contact_params ||= params.permit(site.form(params[:form]).params)
end
# Para poder testear, enviamos un mensaje en el cuerpo de la

View file

@ -2,31 +2,48 @@
module Api
module V1
class InvitadesController < BaseController
# Obtiene una cookie válida por el tiempo especificado por el
# sitio.
#
# Aunque visitemos el sitio varias veces enviando la cookie
# anterior, la cookie se renueva.
class InvitadesController < BaseController
# Cookie para el formulario de contacto
def contact_cookie
@site, contact = Site.where(name: site_id, contact: true)
.pluck(:name, :contact)
.first
set_cookie if contact
render file: Rails.root.join('public', '1x1.png'),
content_type: 'image/png',
layout: false
end
# Cookie para colaboraciones anónimas
def cookie
# XXX: Prestar atención a que estas acciones sean lo más rápidas
# y utilicen la menor cantidad posible de recursos, porque son
# un vector de DDOS.
site, anon = Site.where(name: params[:site_id], colaboracion_anonima: true)
@site, anon = Site.where(name: site_id, colaboracion_anonima: true)
.pluck(:name, :colaboracion_anonima)
.first
set_cookie if anon
render json: {}, status: :ok
end
private
# La cookie no es accesible a través de JS y todo su contenido
# está cifrado para que no lo modifiquen les visitantes
#
# Enviamos un token de protección CSRF
if anon
headers['Access-Control-Allow-Credentials'] = true
headers['Access-Control-Allow-Origin'] = "https://#{site}"
headers['Vary'] = 'Origin'
def set_cookie
expires = 30.minutes
cookies.encrypted[site] = {
cookies.encrypted[@site] = {
httponly: true,
secure: !Rails.env.test?,
expires: expires,
@ -37,9 +54,6 @@ module Api
}
}
end
render html: nil, status: :no_content
end
end
end
end

View file

@ -3,4 +3,10 @@
# Base para trabajos
class ApplicationJob < ActiveJob::Base
include SuckerPunch::Job
private
def site
@site ||= Site.find @params[:site_id]
end
end

View file

@ -5,23 +5,24 @@ class ContactJob < ApplicationJob
def perform(**args)
@params = args
# Sanitizar los valores
args[:form].keys.each do |key|
args[:form][key] = ActionController::Base.helpers.sanitize args[:form][key]
end
# 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|
ContactMailer.with(**args.merge(usuaries: u, title: site.title))
ContactMailer.with(**args.merge(usuaries_emails: u))
.notify_usuaries.deliver_now
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.

View file

@ -9,6 +9,10 @@ class ApplicationMailer < ActionMailer::Base
private
def site
@site ||= Site.find @params[:site_id]
end
def inline_logo!
attachments.inline['logo.png'] ||=
File.read(Rails.root.join('app', 'assets', 'images', 'logo.png'))

View file

@ -2,10 +2,35 @@
# Formulario de contacto
class ContactMailer < ApplicationMailer
attr_reader :form
# Enviar el formulario de contacto a les usuaries
def notify_usuaries
mail to: params[:usuaries],
reply_to: params[:from],
subject: I18n.t('contact_mailer.subject', site: params[:title])
subject = "[#{site.title}] #{params[:form_name].humanize}"
params[:form_definition] = site.form(params[:form_name])
attachments[params[:form_name] + '.csv'] = generate_csv
mail to: params[:usuaries_emails],
reply_to: params[:form][:from],
subject: subject
end
private
# El CSV es un archivo adjunto con dos filas, una con las etiquetas de
# los campos en la cabecera y otra con los valores.
def generate_csv
csv = ["\xEF\xBB\xBF"]
csv << params[:form].keys.map do |field|
params[:form_definition].t(field)
end.to_csv(col_sep: ';', force_quotes: true)
csv << params[:form].values.to_csv(col_sep: ';', force_quotes: true)
{
mime_type: 'text/csv; charset=utf-8',
content: csv.join
}
end
end

View file

@ -4,6 +4,7 @@
# Usuaria
class Site < ApplicationRecord
include FriendlyId
include Site::Forms
# TODO: Hacer que los diferentes tipos de deploy se auto registren
# @see app/services/site_service.rb
@ -77,8 +78,16 @@ class Site < ApplicationRecord
end
end
def url
"https://#{hostname}/"
# Devuelve la URL siempre actualizada a través del hostname
#
# @param slash Boolean Agregar / al final o no
# @return String La URL con o sin / al final
def url(slash: true)
if slash
'https://' + hostname + '/'
else
'https://' + hostname
end
end
def invitade?(usuarie)
@ -399,9 +408,7 @@ class Site < ApplicationRecord
def deploy_local_presence
# Usamos size porque queremos saber la cantidad de deploys sin
# guardar también
if deploys.size.positive? && deploys.map(&:type).include?('DeployLocal')
return
end
return if deploys.size.positive? && deploys.map(&:type).include?('DeployLocal')
errors.add(:deploys, I18n.t('activerecord.errors.models.site.attributes.deploys.deploy_local_presence'))
end

56
app/models/site/forms.rb Normal file
View file

@ -0,0 +1,56 @@
# frozen_string_literal: true
class Site
module Forms
# Esta clase es un Hash que es capaz de convertirse a strong params
# según la definición del formulario en el sitio.
class Form < Hash
# Convierte el formulario a strong params
#
# @return Array
def params
map do |field, definition|
next if EXCLUDED_FIELDS.include? definition['type']
if ARRAY_FIELDS.include? definition['type']
{ field.to_sym => [] }
else
field.to_sym
end
end.compact
end
def t(field)
self[field.to_s]['label'][I18n.locale.to_s]
end
end
# Campos del formulario que no son necesarios
EXCLUDED_FIELDS = %w[separator].freeze
# Campos que aceptan valores múltiples (checkboxes, input-map,
# input-tag)
ARRAY_FIELDS = %w[array].freeze
# Obtiene todos los formularios disponibles
#
# @return Array Formularios disponibles para este sitio
def forms
@forms ||= data.dig('forms').try(:keys) || []
end
# El nombre del formulario está disponible
#
# @param [String|Symbol] El nombre del formulario
def form?(name)
forms.include? name.to_s
end
# Obtiene un formulario
#
# @return Site::Forms::Form
def form(name)
@cached_forms ||= {}
@cached_forms[name] ||= Form[data.dig('forms', name)]
end
end
end

View file

@ -1,11 +1,4 @@
-#
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]
- @params[:form].each do |field, value|
%p
%strong= @params[:form_definition].t(field) + ':'
= value

View file

@ -1,11 +1,3 @@
-#
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]
- @params[:form].each do |field, value|
= "#{@params[:form_definition].t(field)}: #{value}"
\

View file

@ -44,11 +44,6 @@ en:
document_missing: 'Needs an instance of Jekyll::Document'
no_method: '%{method} not allowed'
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"

View file

@ -46,11 +46,6 @@ es:
document_missing: 'Necesita una instancia de Jekyll::Document'
no_method: '%{method} no está permitido'
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"

View file

@ -19,8 +19,8 @@ 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]
get :'contact/cookie', to: 'contact#cookie'
post :contact, to: 'contact#receive'
get :'contact/cookie', to: 'invitades#contact_cookie'
post 'contact/:form', to: 'contact#receive'
end
end
end