diff --git a/app/controllers/api/v1/base_controller.rb b/app/controllers/api/v1/base_controller.rb index 25c10e6..7e673f2 100644 --- a/app/controllers/api/v1/base_controller.rb +++ b/app/controllers/api/v1/base_controller.rb @@ -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 diff --git a/app/controllers/api/v1/contact_controller.rb b/app/controllers/api/v1/contact_controller.rb index f5c55ff..419c20d 100644 --- a/app/controllers/api/v1/contact_controller.rb +++ b/app/controllers/api/v1/contact_controller.rb @@ -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 diff --git a/app/controllers/api/v1/invitades_controller.rb b/app/controllers/api/v1/invitades_controller.rb index d086798..f009440 100644 --- a/app/controllers/api/v1/invitades_controller.rb +++ b/app/controllers/api/v1/invitades_controller.rb @@ -2,43 +2,57 @@ module Api module V1 + # 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 - # 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. + # 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) - .pluck(:name, :colaboracion_anonima) - .first + @site, anon = Site.where(name: site_id, colaboracion_anonima: true) + .pluck(:name, :colaboracion_anonima) + .first - # 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' + set_cookie if anon - expires = 30.minutes - cookies.encrypted[site] = { - httponly: true, - secure: !Rails.env.test?, - expires: expires, - same_site: :none, - value: { - csrf: form_authenticity_token, - expires: (Time.now + expires).to_i - } + 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 + def set_cookie + expires = 30.minutes + cookies.encrypted[@site] = { + httponly: true, + secure: !Rails.env.test?, + expires: expires, + same_site: :none, + value: { + csrf: form_authenticity_token, + expires: (Time.now + expires).to_i } - end - - render html: nil, status: :no_content + } end end end diff --git a/app/jobs/application_job.rb b/app/jobs/application_job.rb index 2555d2a..f3673f0 100644 --- a/app/jobs/application_job.rb +++ b/app/jobs/application_job.rb @@ -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 diff --git a/app/jobs/contact_job.rb b/app/jobs/contact_job.rb index 849c507..afdcd33 100644 --- a/app/jobs/contact_job.rb +++ b/app/jobs/contact_job.rb @@ -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. diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb index fe17c30..8369550 100644 --- a/app/mailers/application_mailer.rb +++ b/app/mailers/application_mailer.rb @@ -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')) diff --git a/app/mailers/contact_mailer.rb b/app/mailers/contact_mailer.rb index 058608e..2b97c63 100644 --- a/app/mailers/contact_mailer.rb +++ b/app/mailers/contact_mailer.rb @@ -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 diff --git a/app/models/site.rb b/app/models/site.rb index a1f30c8..bdba0f7 100644 --- a/app/models/site.rb +++ b/app/models/site.rb @@ -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 diff --git a/app/models/site/forms.rb b/app/models/site/forms.rb new file mode 100644 index 0000000..9321dd6 --- /dev/null +++ b/app/models/site/forms.rb @@ -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 diff --git a/app/views/contact_mailer/notify_usuaries.html.haml b/app/views/contact_mailer/notify_usuaries.html.haml index 02a7462..792660b 100644 --- a/app/views/contact_mailer/notify_usuaries.html.haml +++ b/app/views/contact_mailer/notify_usuaries.html.haml @@ -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 diff --git a/app/views/contact_mailer/notify_usuaries.text.haml b/app/views/contact_mailer/notify_usuaries.text.haml index 393ddd6..1c440de 100644 --- a/app/views/contact_mailer/notify_usuaries.text.haml +++ b/app/views/contact_mailer/notify_usuaries.text.haml @@ -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}" + \ diff --git a/config/locales/en.yml b/config/locales/en.yml index 7441dd3..723190b 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -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" diff --git a/config/locales/es.yml b/config/locales/es.yml index c902f5b..45aa689 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -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" diff --git a/config/routes.rb b/config/routes.rb index 20ec70f..57a1fdf 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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