formularios de contacto!
This commit is contained in:
parent
67b232c626
commit
477b417567
14 changed files with 232 additions and 99 deletions
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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'))
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
56
app/models/site/forms.rb
Normal 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
|
|
@ -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
|
||||
|
|
|
@ -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}"
|
||||
\
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue