diff --git a/app/controllers/api/v1/base_controller.rb b/app/controllers/api/v1/base_controller.rb index 2d58187..3a63826 100644 --- a/app/controllers/api/v1/base_controller.rb +++ b/app/controllers/api/v1/base_controller.rb @@ -11,20 +11,58 @@ module Api private - # Realiza la inversa de Site#hostname + # Por retrocompatibilidad con la forma en que estábamos + # gestionando los hostnames históricamente, necesitamos poder + # encontrar el sitio a partir de cualquiera de sus hostnames. # - # TODO: El sitio sutty.nl no aplica a ninguno de estos y le - # tuvimos que poner 'sutty.nl..sutty.nl' para pasar el test. + # Aunque en realidad con el hostname a partir del Origin nos + # bastaría. + # + # TODO: Generar API v2 que use solo el hostname y no haya que + # pasar site_id como parámetro redundante. 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 + @site_id ||= Deploy.site_name_from_hostname(params[:site_id]) end + # @return [Site] + def site + @site ||= Site.find_by_name(site_id) + end + + # Obtiene el hostname desde el Origin, con el hostname local como + # fallback. + # + # @return [String] + def origin_hostname + URI.parse(origin || origin_from_referer).host + rescue StandardError + "#{site_id}.#{Site.domain}" + end + + # Referer + # + # @see {https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referer} + # @return [String,Nil] + def referer + request.referer + end + alias referrer referer + + # Origin + # + # @see {https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Origin} + # @return [String,Nil] def origin - request.headers['Origin'] + request.origin + end + + # Genera un header Origin a partir del Referer si existe. + # + # @return [String,Nil] + def origin_from_referer + return if referer.blank? + + referer.split('/', 4).tap { |u| u.pop if u.size > 3 }.join('/') end # Los navegadores antiguos no envían Origin diff --git a/app/controllers/api/v1/contact_controller.rb b/app/controllers/api/v1/contact_controller.rb index deacf4a..1132ac0 100644 --- a/app/controllers/api/v1/contact_controller.rb +++ b/app/controllers/api/v1/contact_controller.rb @@ -23,7 +23,7 @@ module Api contact_params.to_h.symbolize_keys, params[:redirect] - redirect_to params[:redirect] || origin.to_s + redirect_to params[:redirect] || referer || site.url end private diff --git a/app/controllers/api/v1/invitades_controller.rb b/app/controllers/api/v1/invitades_controller.rb index eb2a4f2..09d2031 100644 --- a/app/controllers/api/v1/invitades_controller.rb +++ b/app/controllers/api/v1/invitades_controller.rb @@ -44,11 +44,10 @@ module Api # Genera el Origin correcto a partir de la URL del sitio. # - # En desarrollo devuelve el Origin enviado. - # + # @see {https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin} # @return [String] def return_origin - Rails.env.production? ? Site.find_by(name: site_id).url : origin + site&.deploys&.find_by_hostname(origin_hostname)&.url end # La cookie no es accesible a través de JS y todo su contenido @@ -59,6 +58,8 @@ module Api # TODO: Volver configurable por sitio expires = ENV.fetch('COOKIE_DURATION', '30').to_i.minutes + # TODO: ¿Son necesarios estos headers en la descarga de una + # imagen? ¿No será mejor moverlos al envío de datos? headers['Access-Control-Allow-Origin'] = return_origin headers['Access-Control-Allow-Credentials'] = true headers['Vary'] = 'Origin' diff --git a/app/controllers/api/v1/posts_controller.rb b/app/controllers/api/v1/posts_controller.rb index e2a839d..42c82ea 100644 --- a/app/controllers/api/v1/posts_controller.rb +++ b/app/controllers/api/v1/posts_controller.rb @@ -17,7 +17,7 @@ module Api site.touch if service.create_anonymous.persisted? # Redirigir a la URL de agradecimiento - redirect_to params[:redirect_to] || origin.to_s + redirect_to params[:redirect_to] || referer || site.url end private diff --git a/app/controllers/api/v1/protected_controller.rb b/app/controllers/api/v1/protected_controller.rb index 4e49f3a..b3b4dfc 100644 --- a/app/controllers/api/v1/protected_controller.rb +++ b/app/controllers/api/v1/protected_controller.rb @@ -85,7 +85,9 @@ module Api # XXX: Este header se puede falsificar de todas formas pero al # menos es una trampa. def site_is_origin? - return if origin? && site.urls(slash: false).any? { |u| origin.to_s.start_with? u } + return if site.urls(slash: false).any? do |u| + (origin || origin_from_referer).to_s.start_with? u + end @reason = 'site_is_not_origin' render plain: Rails.env.production? ? nil : @reason, status: :precondition_required @@ -116,11 +118,6 @@ module Api raise NotImplementedError end - # Encuentra el sitio o devuelve nulo - def site - @site ||= Site.find_by(name: site_id) - end - # Genera un registro con información básica para debug, quizás no # quede asociado a ningún sitio. # diff --git a/app/controllers/api/v1/sites_controller.rb b/app/controllers/api/v1/sites_controller.rb index 6abff70..596e253 100644 --- a/app/controllers/api/v1/sites_controller.rb +++ b/app/controllers/api/v1/sites_controller.rb @@ -9,14 +9,14 @@ module Api # Lista de nombres de dominios a emitir certificados def index - render json: sites_names + alternative_names + api_names + render json: Deploy.all.pluck(:hostname) end # Sitios con hidden service de Tor # # @return [Array] lista de nombres de sitios sin onion aun def hidden_services - render json: DeployHiddenService.where(values: nil).includes(:site).pluck(:name) + render json: DeployHiddenService.temporary.includes(:site).pluck(:name) end # Tor va a enviar el onion junto con el nombre del sitio y tenemos @@ -25,10 +25,8 @@ module Api # @params [String] name # @params [String] onion def add_onion - site = Site.find_by(name: params[:name]) - - if site - usuarie = GitAuthor.new email: 'tor@' + Site.domain, name: 'Tor' + if (site = Site.find_by_name(params[:name])) + usuarie = GitAuthor.new email: "tor@#{Site.domain}", name: 'Tor' service = SiteService.new site: site, usuarie: usuarie, params: params service.add_onion @@ -36,28 +34,6 @@ module Api head :ok end - - private - - # Nombres de los sitios - def sites_names - Site.all.order(:name).pluck(:name) - end - - # Dominios alternativos - def alternative_names - DeployAlternativeDomain.all.map(&:hostname) - end - - # Obtener todos los sitios con API habilitada, es decir formulario - # de contacto y/o colaboración anónima. - # - # TODO: Optimizar - def api_names - Site.where(contact: true) - .or(Site.where(colaboracion_anonima: true)) - .select("'api.' || name as name").map(&:name) - end end end end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index d849821..7fcd587 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -9,6 +9,7 @@ class ApplicationController < ActionController::Base before_action :prepare_exception_notifier before_action :configure_permitted_parameters, if: :devise_controller? + before_action :redirect_to_site_name!, only: %i[index show edit new], if: :site_id_contains_hostname? around_action :set_locale rescue_from Pundit::NilPolicyError, with: :page_not_found @@ -16,7 +17,7 @@ class ApplicationController < ActionController::Base rescue_from ActionController::ParameterMissing, with: :page_not_found before_action do - Rack::MiniProfiler.authorize_request if current_usuarie&.email&.ends_with?('@' + ENV.fetch('SUTTY', 'sutty.nl')) + Rack::MiniProfiler.authorize_request if Rails.env.development? end # No tenemos índice de sutty, vamos directamente a ver el listado de @@ -31,15 +32,11 @@ class ApplicationController < ActionController::Base /[a-f0-9]{8}-([a-f0-9]{4}-){3}[a-f0-9]{12}/ =~ string end - # Encontrar un sitio por su nombre + # Encontrar un sitio por su nombre. def find_site - id = params[:site_id] || params[:id] - - unless (site = current_usuarie&.sites&.find_by_name(id)) - raise SiteNotFound + current_usuarie&.sites&.find_by_name(site_id).tap do |site| + raise SiteNotFound unless site end - - site end # Devuelve el idioma actual y si no lo encuentra obtiene uno por @@ -79,6 +76,39 @@ class ApplicationController < ActionController::Base breadcrumb 'stats.index', root_path, match: :exact end + # Retrocompatibilidad con sitios cuyo nombre era su hostname. + # + # @see Deploy + def site_id_contains_hostname? + site_id&.end_with? '.' + end + + # Redirigir a la misma URL con el site_id cambiado. + # + # TODO: Eliminar cuando detectemos que no hay más redirecciones. + def redirect_to_site_name! + params.permit! + params[:site_id] = Deploy.site_name_from_hostname(site_id[0..-2]) + + redirect_to params, status: :moved_permanently + end + + # Los controladores dentro de SitesController van a usar site_id + # mientras que SiteController va a usar ID. + # + # @see SitesController + # @return [String,Nil] + def site_id + @site_id ||= params[:site_id] + end + + # El sitio actual + # + # @return [Site] + def site + @site ||= find_site + end + protected def configure_permitted_parameters diff --git a/app/controllers/collaborations_controller.rb b/app/controllers/collaborations_controller.rb index 2caa127..ab13ef5 100644 --- a/app/controllers/collaborations_controller.rb +++ b/app/controllers/collaborations_controller.rb @@ -7,7 +7,7 @@ class CollaborationsController < ApplicationController include Pundit def collaborate - @site = Site.find_by_name(params[:site_id]) + @site = find_site authorize Collaboration.new(@site) @invitade = current_usuarie || @site.usuaries.build @@ -21,7 +21,7 @@ class CollaborationsController < ApplicationController # # * Si le usuarie existe y no está logueade, pedirle la contraseña def accept_collaboration - @site = Site.find_by_name(params[:site_id]) + @site = find_site authorize Collaboration.new(@site) @invitade = current_usuarie diff --git a/app/controllers/sites_controller.rb b/app/controllers/sites_controller.rb index b482622..0b469a1 100644 --- a/app/controllers/sites_controller.rb +++ b/app/controllers/sites_controller.rb @@ -136,8 +136,10 @@ class SitesController < ApplicationController private - def site - @site ||= find_site + # En los controladores dentro de este controlador vamos a usar :id + # para obtener el nombre. + def site_id + @site_id ||= params[:site_id] || params[:id] end def site_params diff --git a/app/jobs/deploy_job.rb b/app/jobs/deploy_job.rb index 70997ce..31803ae 100644 --- a/app/jobs/deploy_job.rb +++ b/app/jobs/deploy_job.rb @@ -4,70 +4,63 @@ class DeployJob < ApplicationJob class DeployException < StandardError; end + attr_reader :site, :deployed + # rubocop:disable Metrics/MethodLength - def perform(site, notify = true, time = Time.now) + def perform(site_id, notify = true, time = Time.now) ActiveRecord::Base.connection_pool.with_connection do - @site = Site.find(site) + @site = Site.find(site_id) + @deployed = {} # Si ya hay una tarea corriendo, aplazar esta. Si estuvo # esperando más de 10 minutos, recuperar el estado anterior. # # Como el trabajo actual se aplaza al siguiente, arrastrar la # hora original para poder ir haciendo timeouts. - if @site.building? + if site.building? if 10.minutes.ago >= time - @site.update status: 'waiting' + site.update status: 'waiting' raise DeployException, - "#{@site.name} la tarea estuvo más de 10 minutos esperando, volviendo al estado original" + "#{site.name} la tarea estuvo más de 10 minutos esperando, volviendo al estado original" end - DeployJob.perform_in(60, site, notify, time) + DeployJob.perform_in(60, site_id, notify, time) return end - @site.update status: 'building' + site.update status: 'building' # Asegurarse que DeployLocal sea el primero! - @deployed = { deploy_local: deploy_locally } + deployed[:deploy_local] = site.deploy_local.deploy - # No es opcional - unless @deployed[:deploy_local] - @site.update status: 'waiting' - notify_usuaries if notify - - # Hacer fallar la tarea - raise DeployException, deploy_local.build_stats.last.log - end - - deploy_others + deploy_others if deployed[:deploy_local] # Volver a la espera - @site.update status: 'waiting' + site.update status: 'waiting' notify_usuaries if notify + + # Hacer fallar la tarea para enterarnos. + raise DeployException, site.deploy_local.build_stats.last.log unless deployed[:deploy_local] end end # rubocop:enable Metrics/MethodLength private - def deploy_local - @deploy_local ||= @site.deploys.find_by(type: 'DeployLocal') - end - - def deploy_locally - deploy_local.deploy - end - + # Correr todas las tareas que no sean el deploy local. def deploy_others - @site.deploys.where.not(type: 'DeployLocal').find_each do |d| - @deployed[d.type.underscore.to_sym] = d.deploy + site.deploys.where.not(type: 'DeployLocal').find_each do |d| + deployed[d.type.underscore.to_sym] = d.deploy end end + # Notificar a todes les usuaries no temporales. + # + # TODO: Poder configurar quiénes quieren recibir notificaciones. def notify_usuaries - @site.roles.where(rol: 'usuarie', temporal: false).pluck(:usuarie_id).each do |usuarie| - DeployMailer.with(usuarie: usuarie, site: @site.id) - .deployed(@deployed) + site.roles.where(rol: 'usuarie', temporal: false).pluck(:usuarie_id).each do |usuarie| + DeployMailer.with(usuarie: usuarie, site: site.id) + .deployed(deployed) .deliver_now end end diff --git a/app/mailers/deploy_mailer.rb b/app/mailers/deploy_mailer.rb index 1d0c730..03d72d4 100644 --- a/app/mailers/deploy_mailer.rb +++ b/app/mailers/deploy_mailer.rb @@ -13,7 +13,7 @@ class DeployMailer < ApplicationMailer @usuarie = Usuarie.find(params[:usuarie]) @site = @usuarie.sites.find(params[:site]) @deploys = which_ones - @deploy_local = @site.deploys.find_by(type: 'DeployLocal') + @deploy_local = @site.deploy_local # Informamos a cada quien en su idioma y damos una dirección de # respuesta porque a veces les usuaries nos escriben diff --git a/app/models/deploy.rb b/app/models/deploy.rb index 3f034ad..ac95a5f 100644 --- a/app/models/deploy.rb +++ b/app/models/deploy.rb @@ -1,44 +1,138 @@ # frozen_string_literal: true require 'open3' + # Este modelo implementa los distintos tipos de alojamiento que provee # Sutty. # -# Los datos se guardan en la tabla `deploys`. Para guardar los -# atributos, cada modelo tiene que definir su propio `store -# :attributes`. +# Cuando cambia el hostname de un Deploy, generamos un +# DeployAlternativeDomain en su lugar. Esto permite que no se rompan +# links preexistentes y que el nombre no pueda ser tomado por alguien +# más. +# +# TODO: Cambiar el nombre a algo que no sea industrial/militar. class Deploy < ApplicationRecord + # Un sitio puede tener muchas formas de publicarse. belongs_to :site + # Puede tener muchos access logs a través del hostname + has_many :access_logs, primary_key: 'hostname', foreign_key: 'host' + # Registro de las tareas ejecutadas has_many :build_stats, dependent: :destroy + # Siempre generar el hostname + after_initialize :default_hostname! + # Eliminar los archivos generados por el deploy. + before_destroy :remove_destination! + # Cambiar el lugar del destino antes de guardar los cambios, para que + # el hostname anterior siga estando disponible. + before_update :rename_destination!, if: :destination_changed? + # Los hostnames alternativos se crean después de actualizar, cuando ya + # se modificó el hostname. + around_update :create_alternative_domain!, if: :destination_changed? + + # Siempre tienen que pertenecer a un sitio + validates :site, presence: true + # El hostname tiene que ser único en toda la plataforma + validates :hostname, uniqueness: true + # Cada deploy puede implementar su propia validación + validates :hostname, hostname: true, unless: :implements_hostname_validation? + # Verificar que se puede cambiar de lugar el destino y no hay nada + # preexistente. + validate :destination_can_change?, if: :destination_changed? + + # Retrocompatibilidad: Encuentra el site_name a partir del hostname. + # + # @return [String,Nil] + def self.site_name_from_hostname(hostname) + where(hostname: hostname).includes(:site).pluck(:name).first + end + + # Detecta si el destino existe y si no es un symlink roto. + def exist? + File.exist? destination + end + + # Detecta si el link está roto + def broken? + File.symlink?(destination) && !File.exist?(File.readlink(destination)) + end + + # Ubicación del deploy + # + # @return [String] Una ruta en el sistema de archivos + def destination + File.join(Rails.root, '_deploy', hostname) + end + + # Ubicación anterior del deploy + # + # @return [String] Una ruta en el sistema de archivos + def destination_was + return destination unless will_save_change_to_hostname? + + File.join(Rails.root, '_deploy', hostname_was) + end + + # Determina si la ubicación cambió + def destination_changed? + persisted? && will_save_change_to_hostname? + end + + # Genera el hostname + # + # @return [String] + def default_hostname + raise NotImplementedError + end + + # Devolver la URL + # + # @return [String] + def url + "https://#{hostname}" + end + + # Ejecutar la tarea + # + # @return [Boolean] def deploy raise NotImplementedError end - def limit - raise NotImplementedError - end - + # El espacio ocupado por este deploy. + # + # @return [Integer] def size raise NotImplementedError end + # Empezar a contar el tiempo + # + # @return [Time] def time_start @start = Time.now end + # Detener el contador + # + # @return [Time] def time_stop @stop = Time.now end + # Obtener la demora de la tarea + # + # @return [Float] def time_spent_in_seconds (@stop - @start).round(3) end - def home_dir - site.path - end - + # El directorio donde se almacenan las gemas. + # + # TODO: En un momento podíamos tenerlas todas compartidas y ahorrar + # espacio, pero bundler empezó a mezclar cosas. + # + # @return [String] def gems_dir @gems_dir ||= Rails.root.join('_storage', 'gems', site.name) end @@ -77,9 +171,67 @@ class Deploy < ApplicationRecord private + # Genera el hostname pero permitir la inicialización del valor. Luego + # validamos que sea el formato correcto. + # + # @return [Boolean] + def default_hostname! + self.hostname ||= default_hostname + end + + # Cambia la ubicación de destino cuando cambia el hostname. + def rename_destination! + return unless File.exist? destination_was + + FileUtils.mv destination_was, destination + end + + # Elimina los archivos generados por el deploy + # + # @return [Boolean] + def remove_destination! + raise NotImplementedError + end + + # Cuando el deploy cambia de hostname, generamos un dominio + # alternativo para no romper links hacia este sitio. + def create_alternative_domain! + hw = hostname_was + + # Aplicar la actualización + yield + + # Crear el deploy alternativo con el nombre anterior una vez que + # lo cambiamos en la base de datos. + ad = site.deploys.create(type: 'DeployAlternativeDomain', hostname: hw) + ad.deploy if ad.persisted? + end + + # Devuelve un error si el destino ya existe. No debería fallar si ya + # pasamos la validación de cambio de nombres, pero siempre puede haber + # directorios y links sueltos. + def destination_can_change? + return true unless persisted? + + remove_destination! if broken? + + return true unless exist? + + errors.add :hostname, I18n.t('activerecord.errors.models.deploy.attributes.hostname.destination_exist') + end + + # Convierte el comando en una versión resumida. + # # @param [String] # @return [String] def readable_cmd(cmd) cmd.split(' -', 2).first.tr(' ', '_') end + + # Cada deploy puede decidir su propia validación + # + # @return [Boolean] + def implements_hostname_validation? + false + end end diff --git a/app/models/deploy_alternative_domain.rb b/app/models/deploy_alternative_domain.rb index e4960e6..7dbd35e 100644 --- a/app/models/deploy_alternative_domain.rb +++ b/app/models/deploy_alternative_domain.rb @@ -1,23 +1,21 @@ # frozen_string_literal: true -# Soportar dominios alternativos -class DeployAlternativeDomain < Deploy - store :values, accessors: %i[hostname], coder: JSON +# Soportar dominios alternativos. +class DeployAlternativeDomain < DeployWww + validates :hostname, domainname: true - # Generar un link simbólico del sitio principal al alternativo - def deploy - File.symlink?(destination) || - File.symlink(site.hostname, destination).zero? + # No hay un hostname por defecto + # + # @return [Nil] + def default_hostname; end + + private + + def implements_hostname_validation? + true end - # No hay límite para los dominios alternativos - def limit; end - - def size - File.size destination - end - - def destination - File.join(Rails.root, '_deploy', hostname.gsub(/\.\z/, '')) - end + # No hay un hostname por defecto. Debe ser informado por les + # usuaries. + def default_hostname!; end end diff --git a/app/models/deploy_hidden_service.rb b/app/models/deploy_hidden_service.rb index d4d2b82..3f687bd 100644 --- a/app/models/deploy_hidden_service.rb +++ b/app/models/deploy_hidden_service.rb @@ -1,18 +1,61 @@ # frozen_string_literal: true -# Genera una versión onion +# Alojar el sitio como un servicio oculto de Tor, que en realidad es un +# link simbólico al DeployLocal. class DeployHiddenService < DeployWww - def deploy - return true if fqdn.blank? + validates :hostname, format: { with: /\A[a-z2-7]{56}.onion\z/ } - super - end + # Sufijo para todos los dominios temporales. + TEMPORARY_SUFFIX = 'temporary' - def fqdn - values[:onion] - end + # Traer todos los servicios ocultos temporales. + scope :temporary, -> { where("hostname not like '#{TEMPORARY_SUFFIX}%'") } + # Los servicios ocultos son su propio transporte cifrado y + # autenticado. + # + # @return [String] def url - 'http://' + fqdn + "http://#{hostname}" + end + + # Los onions no son creados por Sutty sino por Tor y enviados luego a + # través de la API. El hostname por defecto es un nombre temporal que + # se parece a una dirección OnionV3. + # + # @return [String] + def default_hostname + "#{TEMPORARY_SUFFIX}#{random_base32}.onion" + end + + # Detecta si es una dirección temporal. + # + # @return [Boolean] + def temporary? + hostname.start_with? TEMPORARY_SUFFIX + end + + private + + # No soportamos cambiar de onion + def destination_changed? + false + end + + def implements_hostname_validation? + true + end + + # Adaptado de base32 + # + # @see {https://github.com/stesla/base32/blob/master/lib/base32.rb} + # @see {https://github.com/stesla/base32/blob/master/LICENSE} + def random_base32(length = nil) + table = 'abcdefghijklmnopqrstuvwxyz234567' + length ||= 56 - TEMPORARY_SUFFIX.length + + OpenSSL::Random.random_bytes(length).each_byte.map do |b| + table[b % 32] + end.join end end diff --git a/app/models/deploy_local.rb b/app/models/deploy_local.rb index c063b7b..5a6a87c 100644 --- a/app/models/deploy_local.rb +++ b/app/models/deploy_local.rb @@ -1,11 +1,11 @@ # frozen_string_literal: true -# Alojamiento local, solo genera el sitio, con lo que no necesita hacer -# nada más +# Alojamiento local, genera el sitio como si corriéramos `jekyll build`. class DeployLocal < Deploy - store :values, accessors: %i[], coder: JSON - - before_destroy :remove_destination! + # Asegurarse que el hostname es el permitido. + before_validation :reset_hostname!, :default_hostname! + # Actualiza el hostname con www si cambiamos el hostname + before_update :update_deploy_www!, if: :hostname_changed? # Realizamos la construcción del sitio usando Jekyll y un entorno # limpio para no pasarle secretos @@ -20,42 +20,52 @@ class DeployLocal < Deploy jekyll_build end - # Sólo permitimos un deploy local - def limit - 1 - end - # Obtener el tamaño de todos los archivos y directorios (los # directorios son archivos :) + # + # @return [Integer] def size paths = [destination, File.join(destination, '**', '**')] Dir.glob(paths).map do |file| - if File.symlink? file - 0 - else - File.size(file) - end + File.symlink?(file) ? 0 : File.size(file) end.inject(:+) end - def destination - File.join(Rails.root, '_deploy', site.hostname) + # El hostname es el nombre del sitio más el dominio principal. + # + # @return [String] + def default_hostname + "#{site.name}.#{Site.domain}" end private + def reset_hostname! + self.hostname = nil + end + + # XXX: En realidad el DeployWww debería regenerar su propio hostname. + def update_deploy_www! + site.deploys.where(type: 'DeployWww').map do |www| + www.update hostname: www.default_hostname + end + end + + # Crea el directorio destino si no existe. def mkdir FileUtils.mkdir_p destination end # Un entorno que solo tiene lo que necesitamos + # + # @return [Hash] def env # XXX: This doesn't support Windows paths :B paths = [File.dirname(`which bundle`), '/usr/bin', '/bin'] { - 'HOME' => home_dir, + 'HOME' => site.path, 'PATH' => paths.join(':'), 'SPREE_API_KEY' => site.tienda_api_key, 'SPREE_URL' => site.tienda_url, @@ -66,10 +76,15 @@ class DeployLocal < Deploy } end + # @return [String] def yarn_lock File.join(site.path, 'yarn.lock') end + # Determina si este proyecto se gestiona con Yarn, buscando si el + # archivo yarn.lock existe. + # + # @return [Boolean] def yarn_lock? File.exist? yarn_lock end @@ -79,12 +94,17 @@ class DeployLocal < Deploy end # Corre yarn dentro del repositorio + # + # @return [Boolean,Nil] def yarn return true unless yarn_lock? run 'yarn' end + # Instala las dependencias. + # + # @return [Boolean] def bundle if Rails.env.production? run %(bundle install --no-cache --path="#{gems_dir}") @@ -93,6 +113,9 @@ class DeployLocal < Deploy end end + # Genera el sitio. + # + # @return [Boolean] def jekyll_build run %(bundle exec jekyll build --trace --profile --destination "#{escaped_destination}") end diff --git a/app/models/deploy_private.rb b/app/models/deploy_private.rb index 3a6595f..30aca8b 100644 --- a/app/models/deploy_private.rb +++ b/app/models/deploy_private.rb @@ -11,12 +11,31 @@ class DeployPrivate < DeployLocal jekyll_build end - # Hacer el deploy a un directorio privado + # La URL del sitio dentro del panel. + # + # @return [String] + def url + Rails.application.routes.url_for(controller: :private, action: :show, site_id: site) + end + + # Hacer el deploy a un directorio privado. + # + # @return [String] def destination File.join(Rails.root, '_private', site.name) end + # El hostname no se usa para nada, porque el sitio es solo accesible a + # través del panel de Sutty. + # + # @return [String] + def default_hostname + "#{site.name}.private.#{Site.domain}" + end + # No usar recursos en compresión y habilitar los datos privados + # + # @return [Hash] def env @env ||= super.merge({ 'JEKYLL_ENV' => 'development', diff --git a/app/models/deploy_www.rb b/app/models/deploy_www.rb index 5602b0f..2cfb136 100644 --- a/app/models/deploy_www.rb +++ b/app/models/deploy_www.rb @@ -2,34 +2,48 @@ # Vincula la versión del sitio con www a la versión sin class DeployWww < Deploy - store :values, accessors: %i[], coder: JSON - - before_destroy :remove_destination! - + # La forma de hacer este deploy es generar un link simbólico entre el + # directorio canónico y el actual. + # + # @return [Boolean] def deploy - File.symlink?(destination) || - File.symlink(site.hostname, destination).zero? - end - - def limit - 1 + # Eliminar los links rotos + remove_destination! if broken? + + # No hacer nada si ya existe. + return true if exist? + + # Generar un link simbólico con la ruta relativa al destino + File.symlink(relative_path, destination).zero? end + # Siempre devuelve el espacio ocupado por el link simbólico, no el + # destino. + # + # @return [Integer] def size - File.size destination + relative_path.size end - def destination - File.join(Rails.root, '_deploy', fqdn) - end - - def fqdn - "www.#{site.hostname}" + # El hostname por defecto incluye WWW + # + # @return [String] + def default_hostname + "www.#{site.deploy_local.hostname}" end private + # Elimina el link simbólico si se elimina este deploy. def remove_destination! FileUtils.rm_f destination end + + # Obtiene la ubicación relativa del deploy local hacia la ubicación de + # este deploy + # + # @return [String] + def relative_path + Pathname.new(site.deploy_local.destination).relative_path_from(File.dirname(destination)).to_s + end end diff --git a/app/models/deploy_zip.rb b/app/models/deploy_zip.rb index ec8973d..d09c755 100644 --- a/app/models/deploy_zip.rb +++ b/app/models/deploy_zip.rb @@ -2,22 +2,24 @@ require 'zip' -# Genera un ZIP a partir del sitio ya construido +# Genera un ZIP a partir del sitio ya generado y lo coloca para descarga +# dentro del sitio público. # # TODO: Firmar con minisign class DeployZip < Deploy - store :values, accessors: %i[], coder: JSON + # El hostname es el nombre del archivo. + validates :hostname, format: { with: /\.zip\z/ } # Una vez que el sitio está generado, tomar todos los archivos y - # y generar un zip accesible públicamente. + # y generar un ZIP accesible públicamente. # - # rubocop:disable Metrics/MethodLength + # @return [Boolean] def deploy - FileUtils.rm_f path + remove_destination! time_start Dir.chdir(destination) do - Zip::File.open(path, Zip::File::CREATE) do |z| + Zip::File.open(hostname, Zip::File::CREATE) do |z| Dir.glob('./**/**').each do |f| File.directory?(f) ? z.mkdir(f) : z.add(f, f) end @@ -31,25 +33,47 @@ class DeployZip < Deploy File.exist? path end - # rubocop:enable Metrics/MethodLength - def limit - 1 + # La URL de descarga del archivo. + # + # @return [String] + def url + "#{site.deploy_local.url}/#{hostname}" end + # Devuelve el tamaño del ZIP en bytes + # + # @return [Integer] def size File.size path end + # El archivo ZIP se guarda dentro del sitio local para poder + # descargarlo luego. + # + # @return [String] def destination - File.join(Rails.root, '_deploy', site.hostname) + site.deploy_local.destination end - def file - "#{site.hostname}.zip" + # El "hostname" es la ubicación del archivo. + # + # @return [String] + def default_hostname + "#{site.deploy_local.hostname}.zip" end def path - File.join(destination, file) + File.join(destination, hostname) + end + + private + + def remove_destination! + FileUtils.rm_f path + end + + def implements_hostname_validation? + true end end diff --git a/app/models/site.rb b/app/models/site.rb index 58f2074..7668cb6 100644 --- a/app/models/site.rb +++ b/app/models/site.rb @@ -7,6 +7,7 @@ class Site < ApplicationRecord include Site::Forms include Site::FindAndReplace include Site::Api + include Site::Deployment include Tienda # Cifrar la llave privada que cifra y decifra campos ocultos. Sutty @@ -15,19 +16,11 @@ class Site < ApplicationRecord # protege de acceso al panel de Sutty! encrypts :private_key - # TODO: Hacer que los diferentes tipos de deploy se auto registren - # @see app/services/site_service.rb - DEPLOYS = %i[local private www zip hidden_service].freeze - - validates :name, uniqueness: true, hostname: { - allow_root_label: true - } - validates :design_id, presence: true + validates_uniqueness_of :name validates_inclusion_of :status, in: %w[waiting enqueued building] validates_presence_of :title validates :description, length: { in: 50..160 } - validate :deploy_local_presence validate :compatible_layouts, on: :update attr_reader :incompatible_layouts @@ -38,8 +31,6 @@ class Site < ApplicationRecord belongs_to :licencia has_many :log_entries, dependent: :destroy - has_many :deploys, dependent: :destroy - has_many :build_stats, through: :deploys has_many :roles, dependent: :destroy has_many :usuaries, -> { where('roles.rol = ?', 'usuarie') }, through: :roles @@ -58,13 +49,11 @@ class Site < ApplicationRecord after_initialize :load_jekyll after_create :load_jekyll, :static_file_migration! # Cambiar el nombre del directorio - before_update :update_name! + before_update :update_name!, if: :name_changed? before_save :add_private_key_if_missing! # Guardar la configuración si hubo cambios after_save :sync_attributes_with_config! - accepts_nested_attributes_for :deploys, allow_destroy: true - # El sitio en Jekyll attr_reader :jekyll @@ -85,49 +74,6 @@ class Site < ApplicationRecord @repository ||= Site::Repository.new path end - def hostname - sub = name || I18n.t('deploys.deploy_local.ejemplo') - - if sub.ends_with? '.' - sub.gsub(/\.\Z/, '') - else - "#{sub}.#{Site.domain}" - end - end - - # 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) - "https://#{hostname}#{slash ? '/' : ''}" - end - - # Obtiene los dominios alternativos - # - # @return Array - def alternative_hostnames - deploys.where(type: 'DeployAlternativeDomain').map(&:hostname).map do |h| - h.end_with?('.') ? h[0..-2] : "#{h}.#{Site.domain}" - end - end - - # Obtiene todas las URLs alternativas para este sitio - # - # @return Array - def alternative_urls(slash: true) - alternative_hostnames.map do |h| - "https://#{h}#{slash ? '/' : ''}" - end - end - - # Todas las URLs posibles para este sitio - # - # @return Array - def urls(slash: true) - alternative_urls(slash: slash) << url(slash: slash) - end - def invitade?(usuarie) !invitades.find_by(id: usuarie.id).nil? end @@ -453,8 +399,6 @@ class Site < ApplicationRecord end def update_name! - return unless name_changed? - FileUtils.mv path_was, path reload_jekyll! end @@ -477,19 +421,6 @@ class Site < ApplicationRecord Site::StaticFileMigration.new(site: self).migrate! end - # Valida si el sitio tiene al menos una forma de alojamiento asociada - # y es la local - # - # TODO: Volver opcional el alojamiento local, pero ahora mismo está - # atado a la generación del sitio así que no puede faltar - def deploy_local_presence - # Usamos size porque queremos saber la cantidad de deploys sin - # guardar también - 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 - # Valida que al cambiar de plantilla no tengamos artículos en layouts # inexistentes. def compatible_layouts diff --git a/app/models/site/deployment.rb b/app/models/site/deployment.rb new file mode 100644 index 0000000..abd8249 --- /dev/null +++ b/app/models/site/deployment.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +class Site + # Abstrae todo el comportamiento de publicación del sitio en un + # módulo. + module Deployment + extend ActiveSupport::Concern + + included do + # TODO: Hacer que los diferentes tipos de deploy se auto registren + # @see app/services/site_service.rb + DEPLOYS = %i[local private www zip hidden_service].freeze + + validates :name, + format: { with: /\A[0-9a-z\-]+\z/, + message: I18n.t('activerecord.errors.models.site.attributes.name.no_subdomains') } + validates :name, hostname: true + validates_presence_of :canonical_deploy + validate :deploy_local_presence + validate :name_changed_is_unique_hostname, if: :name_changed? + + has_one :canonical_deploy, class_name: 'Deploy' + has_many :deploys, dependent: :destroy + has_many :access_logs, through: :deploys + has_many :build_stats, through: :deploys + + before_validation :deploy_local_is_default_canonical_deploy!, unless: :canonical_deploy_id? + before_update :update_deploy_local_hostname!, if: :name_changed? + + accepts_nested_attributes_for :deploys, allow_destroy: true + + # El primer deploy del sitio, si no existe en la base de datos es + # porque recién estamos creando el sitio y todavía no se guardó. + # + # @return [DeployLocal] + def deploy_local + @deploy_local ||= deploys.order(created_at: :asc).find_by(type: 'DeployLocal') || deploys.find do |d| + d.type == 'DeployLocal' + end + end + + # Obtiene la URL principal + # + # @param :slash [Boolean] + # @return [String] + def canonical_url(slash: true) + canonical_deploy.url.dup.tap do |url| + url << '/' if slash + end + end + alias_method :url, :canonical_url + + # Devuelve todas las URLs posibles + # + # @param :slash [Boolean] + # @return [Array] + def urls(slash: true) + deploys.map(&:url).map do |url| + slash ? "#{url}/" : url + end + end + + # Obtiene el hostname principal + # + # @return [String] + def hostname + canonical_deploy.hostname + end + + private + + # Validar que al cambiar el nombre no estemos utilizando un + # hostname reservado por otro sitio. + # + # Al cambiar el nombre del DeployLocal se va a validar que el + # hostname nuevo sea único. + def name_changed_is_unique_hostname + deploy_local.hostname = nil + + return if deploy_local.valid? + + errors.add :name, I18n.t('activerecord.errors.models.site.attributes.name.duplicated_hostname') + end + + # Si cambia el nombre queremos actualizarlo en el DeployLocal y + # recargar el deploy canónico para tomar el nombre que + # corresponda. + def update_deploy_local_hostname! + deploy_local.update(hostname: name) + canonical_deploy.reload if canonical_deploy == deploy_local + end + + # Si no asignamos un deploy canónico en el momento le asignamos el + # deploy local + def deploy_local_is_default_canonical_deploy! + self.canonical_deploy ||= deploy_local + end + + # Valida si el sitio tiene al menos una forma de alojamiento asociada + # y es la local + # + # TODO: Volver opcional el alojamiento local, pero ahora mismo está + # atado a la generación del sitio así que no puede faltar + def deploy_local_presence + # Usamos size porque queremos saber la cantidad de deploys sin + # guardar también + 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 + end + end +end diff --git a/app/services/site_service.rb b/app/services/site_service.rb index 389549c..73d50f9 100644 --- a/app/services/site_service.rb +++ b/app/services/site_service.rb @@ -13,11 +13,10 @@ SiteService = Struct.new(:site, :usuarie, :params, keyword_init: true) do I18n.with_locale(usuarie&.lang&.to_sym || I18n.default_locale) do site.save && site.config.write && - commit_config(action: :create) + commit_config(action: :create) && + add_licencias end - add_licencias - site end diff --git a/app/views/deploy_mailer/deployed.html.haml b/app/views/deploy_mailer/deployed.html.haml index e8b2e7a..40c6a98 100644 --- a/app/views/deploy_mailer/deployed.html.haml +++ b/app/views/deploy_mailer/deployed.html.haml @@ -1,6 +1,6 @@ %h1= t('.hi') -= sanitize_markdown t('.explanation', fqdn: @deploy_local.site.hostname), += sanitize_markdown t('.explanation', url: @site.deploy_local.url), tags: %w[p a strong em] %table diff --git a/app/views/deploy_mailer/deployed.text.haml b/app/views/deploy_mailer/deployed.text.haml index 53a9b00..83696b0 100644 --- a/app/views/deploy_mailer/deployed.text.haml +++ b/app/views/deploy_mailer/deployed.text.haml @@ -1,6 +1,6 @@ = '# ' + t('.hi') \ -= t('.explanation', fqdn: @deploy_local.site.hostname) += t('.explanation', url: @site.deploy_local.url) \ = Terminal::Table.new do |table| - table << [t('.th.type'), t('.th.status')] diff --git a/app/views/deploys/_deploy_hidden_service.haml b/app/views/deploys/_deploy_hidden_service.haml index d638812..7399260 100644 --- a/app/views/deploys/_deploy_hidden_service.haml +++ b/app/views/deploys/_deploy_hidden_service.haml @@ -14,10 +14,10 @@ '0', '1' = deploy.label :_destroy, class: 'custom-control-label' do %h3= t('.title') - = sanitize_markdown t('.help', public_url: deploy.object.site.url), + = sanitize_markdown t('.help', public_url: site.deploy_local.url), tags: %w[p strong em a] - - if deploy.object.fqdn + - unless deploy.object.temporary? = sanitize_markdown t('.help_2', url: deploy.object.url), tags: %w[p strong em a] %hr/ diff --git a/app/views/deploys/_deploy_local.haml b/app/views/deploys/_deploy_local.haml index 69a6a52..7409b47 100644 --- a/app/views/deploys/_deploy_local.haml +++ b/app/views/deploys/_deploy_local.haml @@ -6,7 +6,9 @@ .row .col %h3= t('.title') - = sanitize_markdown t('.help', fqdn: deploy.object.site.hostname), + = sanitize_markdown t('.help', url: deploy.object.url), tags: %w[p strong em a] - = deploy.hidden_field :type + -# No duplicarlos una vez que existen. + - unless deploy.object.persisted? + = deploy.hidden_field :type diff --git a/app/views/deploys/_deploy_www.haml b/app/views/deploys/_deploy_www.haml index 9cf186a..5e4a08e 100644 --- a/app/views/deploys/_deploy_www.haml +++ b/app/views/deploys/_deploy_www.haml @@ -15,6 +15,6 @@ '0', '1' = deploy.label :_destroy, class: 'custom-control-label' do %h3= t('.title') - = sanitize_markdown t('.help', fqdn: deploy.object.fqdn), + = sanitize_markdown t('.help', url: deploy.object.url), tags: %w[p strong em a] %hr/ diff --git a/app/views/deploys/_deploy_zip.haml b/app/views/deploys/_deploy_zip.haml index c158892..5071e3a 100644 --- a/app/views/deploys/_deploy_zip.haml +++ b/app/views/deploys/_deploy_zip.haml @@ -15,10 +15,5 @@ '0', '1' = deploy.label :_destroy, class: 'custom-control-label' do %h3= t('.title') - -# TODO: secar la generación de URLs - - name = site.name || t('.ejemplo') - = sanitize_markdown t('.help', - fqdn: deploy.object.site.hostname, - file: deploy.object.file || "#{name}.zip"), - tags: %w[p strong em a] + = sanitize_markdown t('.help', url: deploy.object.url), tags: %w[p strong em a] %hr/ diff --git a/config/initializers/cors.rb b/config/initializers/cors.rb index 812a11e..44484d1 100644 --- a/config/initializers/cors.rb +++ b/config/initializers/cors.rb @@ -13,29 +13,19 @@ Rails.application.config.middleware.insert_before 0, Rack::Cors do # El problema sería que otros sitios con JS malicioso hagan pedidos # a nuestra API desde otros sitios infectados. # - # XXX: La primera parte del dominio tiene que coincidir con el - # nombre del sitio. - # # XXX: Al terminar de entender esto nos pasó que el servidor recibe # la petición de todas maneras, con lo que no estamos previniendo # que nos hablen, sino que lean información. Solo va a funcionar si # el servidor no tiene el Preflight cacheado. # # TODO: Limitar el acceso desde Nginx también. - # - # TODO: Poder consultar por sitios por todas sus URLs posibles. origins do |source, _| # Cacheamos la respuesta para no tener que volver a procesarla # cada vez. Rails.cache.fetch(source, expires_in: 1.hour) do - uri = URI(source) - - if (name = uri&.host&.split('.', 2)&.first).present? - Site.where(name: [name, uri.host + '.']).pluck(:name).first.present? - else - false - end - rescue URI::Error + hostname = URI(source)&.host + hostname.present? && Deploy.find_by_hostname(hostname).present? + rescue StandardError false end end diff --git a/config/initializers/validates_hostname.rb b/config/initializers/validates_hostname.rb new file mode 100644 index 0000000..43c47a6 --- /dev/null +++ b/config/initializers/validates_hostname.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +# Agrega el subdominio .local a menos que estemos en producción. +# +# TODO: Permitir TLDs que no sean de ICANN aquí. +PAK::ValidatesHostname::ALLOWED_TLDS << 'local' unless Rails.env.production? diff --git a/config/locales/en.yml b/config/locales/en.yml index 18faa8b..004eee6 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -70,7 +70,7 @@ en: hi: "Hi!" explanation: | This e-mail is to notify you that Sutty has built your site, which is - available at . + available at <%{url}/>. You'll find details below. th: @@ -143,8 +143,15 @@ en: tienda_api_key: Store access key errors: models: + deploy: + attributes: + hostname: + destination_exist: 'There already is a file in the destination' site: attributes: + name: + no_subdomains: 'Name cannot contain dots' + duplicated_hostname: 'There already is a site with this address' deploys: deploy_local_presence: 'We need to be build the site!' design_id: @@ -195,7 +202,7 @@ en: deploy_local: title: 'Host at Sutty' help: | - The site will be available at . + The site will be available at <%{url}/>. We're working out the details to allow you to use your own site domains, you can [help us](https://sutty.nl/en/index.html#contact)! @@ -211,7 +218,7 @@ en: title: 'Add www to the address' help: | When you enable this option, your site will also be available - under . + under <%{url}/>. The www prefix has been a way of referring to computers that are available on the World Wide Web. Since @@ -222,7 +229,7 @@ en: help: | ZIP files contain and compress all your site's files. With this option you can download and also share your entire site - through the address, keep it as backup + through the <%{url}> address, keep it as backup or have a strategy of solidary hosting, where many people share a copy of your site. diff --git a/config/locales/es.yml b/config/locales/es.yml index 5229a59..f8e47c2 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -70,7 +70,7 @@ es: hi: "¡Hola!" explanation: | Este correo es para notificarte que Sutty ha generado tu sitio y - ya está disponible en la dirección . + ya está disponible en la dirección <%{url}>. A continuación encontrarás el detalle de lo que hicimos. th: @@ -143,8 +143,15 @@ es: tienda_api_key: Clave de acceso errors: models: + deploy: + attributes: + hostname: + destination_exist: 'Ya hay un archivo en esta ubicación' site: attributes: + name: + no_subdomains: 'El nombre no puede contener puntos' + duplicated_hostname: 'Ya existe un sitio con ese nombre' deploys: deploy_local_presence: '¡Necesitamos poder generar el sitio!' design_id: @@ -197,7 +204,7 @@ es: deploy_local: title: 'Alojar en Sutty' help: | - El sitio estará disponible en . + El sitio estará disponible en <%{url}/>. Estamos desarrollando la posibilidad de agregar tus propios dominios, ¡ayudanos! @@ -213,7 +220,7 @@ es: title: 'Agregar www a la dirección' help: | Cuando habilitas esta opción, tu sitio también estará disponible - como . + como <%{url}/>. El prefijo www para las direcciones web ha sido una forma de referirse a las computadoras que están disponibles en la _World @@ -226,7 +233,7 @@ es: help: | Los archivos ZIP contienen y comprimen todos los archivos de tu sitio. Con esta opción podrás descargar y compartir tu sitio - entero a través de la dirección y + entero a través de la dirección <%{url}> y guardarla como copia de seguridad o una estrategia de alojamiento solidario, donde muchas personas comparten una copia de tu sitio. diff --git a/db/migrate/20210801060844_add_hostname_to_deploys.rb b/db/migrate/20210801060844_add_hostname_to_deploys.rb new file mode 100644 index 0000000..8183564 --- /dev/null +++ b/db/migrate/20210801060844_add_hostname_to_deploys.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +# Recupera la funcionalidad que estamos deprecando. +module AddValuesToDeploy + extend ActiveSupport::Concern + + included do + store :values, accessors: %i[hostname onion], coder: JSON + end +end + +# Convertir todos los valores serializados de Deploy en una columna, +# porque al final el único uso que tuvo fue para guardar los hostnames +# alternativos. +# +# ¡El hostname es único para poder evitar que haya duplicados! +class AddHostnameToDeploys < ActiveRecord::Migration[6.1] + # Crea una columna temporal y guarda todos los valores. Los traspasa + # y luego elimina la columna. + def up + Deploy.include AddValuesToDeploy + # Ya que estamos hacer limpieza. + Deploy.where(site_id: nil).destroy_all + + add_column :deploys, :hostname_tmp, :string + + Site.find_each do |site| + site.deploys.find_each do |deploy| + deploy.hostname_tmp = deploy.values[:hostname] || deploy.values[:onion] || deploy.hostname + end + end + + rename_column :deploys, :hostname_tmp, :hostname + remove_column :deploys, :values + + add_index :deploys, :hostname, unique: true + # A esta altura todos los dominios deberían estar migrados. + change_column :deploys, :hostname, :string, null: false + end + + # Recupera los valores desde la columna creada. + def down + Deploy.include AddValuesToDeploy + + rename_column :deploys, :hostname, :hostname_tmp + add_column :deploys, :values, :text + + Site.find_each do |site| + site.deploys.find_each do |deploy| + deploy.values[(deploy.is_a? DeployHiddenService ? :onion : :hostname)] = deploy.hostname_tmp + end + end + + remove_column :deploys, :hostname_tmp + end +end diff --git a/db/migrate/20210809155434_add_canonical_deploy_to_sites.rb b/db/migrate/20210809155434_add_canonical_deploy_to_sites.rb new file mode 100644 index 0000000..f0b2405 --- /dev/null +++ b/db/migrate/20210809155434_add_canonical_deploy_to_sites.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +# Los sitios pueden tener muchos tipos de publicación pero solo uno es +# el principal. Al usar un campo específico, podemos validar mejor su +# presencia y modificación. +# +# El valor por defecto es 0 para poder crear la columna sin modificarla +# después, pero la idea es que nunca haya ceros. +class AddCanonicalDeployToSites < ActiveRecord::Migration[6.1] + def up + add_belongs_to :sites, :canonical_deploy, index: true, null: false, default: 0 + + # Si el sitio tenía un dominio alternativo, usar ese en lugar del + # local, asumiendo que es el primero de todos los posibles. + Site.find_each do |site| + deploy = site.deploys.order(created_at: :asc).find_by_type('DeployAlternativeDomain') + deploy ||= site.deploy_local + + site.update canonical_deploy_id: deploy.id + end + end + + def down + remove_belongs_to :sites, :canonical_deploy, index: true + end +end diff --git a/test/controllers/api/v1/contact_controller_test.rb b/test/controllers/api/v1/contact_controller_test.rb index f84e015..21159c7 100644 --- a/test/controllers/api/v1/contact_controller_test.rb +++ b/test/controllers/api/v1/contact_controller_test.rb @@ -21,13 +21,14 @@ module Api end test 'el sitio tiene que existir' do + hostname = @site.hostname @site.destroy - get v1_site_contact_cookie_url(@site.hostname, **@host) + get v1_site_contact_cookie_url(hostname, **@host) assert_not cookies[@site.name] - post v1_site_contact_url(site_id: @site.hostname, form: :contacto, **@host), + post v1_site_contact_url(site_id: hostname, form: :contacto, **@host), params: { name: SecureRandom.hex, pronouns: SecureRandom.hex, @@ -106,7 +107,7 @@ module Api test 'se puede enviar mensajes a dominios propios' do ActionMailer::Base.deliveries.clear - @site.update name: 'example.org.' + @site.update name: 'example' redirect = "#{@site.url}?thanks" @@ -130,6 +131,34 @@ module Api assert_equal redirect, response.headers['Location'] assert_equal 2, ActionMailer::Base.deliveries.size end + + test 'algunos navegadores no soportan Origin' do + ActionMailer::Base.deliveries.clear + + @site.update name: 'example' + + redirect = "#{@site.url}?thanks" + + 10.times do + create :rol, site: @site + end + + get v1_site_contact_cookie_url(@site.hostname, **@host) + post v1_site_contact_url(site_id: @site.hostname, form: :contacto, **@host), + headers: { referer: @site.url }, + params: { + name: SecureRandom.hex, + pronouns: SecureRandom.hex, + contact: SecureRandom.hex, + from: "#{SecureRandom.hex}@sutty.nl", + body: SecureRandom.hex, + consent: true, + redirect: redirect + } + + assert_equal redirect, response.headers['Location'] + assert_equal 2, ActionMailer::Base.deliveries.size + end end end end diff --git a/test/controllers/api/v1/sites_controller_test.rb b/test/controllers/api/v1/sites_controller_test.rb index 5623edc..5007e4f 100644 --- a/test/controllers/api/v1/sites_controller_test.rb +++ b/test/controllers/api/v1/sites_controller_test.rb @@ -23,7 +23,7 @@ module Api test 'se puede obtener un listado de todos' do get v1_sites_url(host: "api.#{Site.domain}"), headers: @authorization, as: :json - assert_equal Site.all.pluck(:name), JSON.parse(response.body) + assert_equal Deploy.all.pluck(:hostname), JSON.parse(response.body) end end end diff --git a/test/controllers/sites_controller_test.rb b/test/controllers/sites_controller_test.rb index a7e2f68..c9c98df 100644 --- a/test/controllers/sites_controller_test.rb +++ b/test/controllers/sites_controller_test.rb @@ -119,12 +119,7 @@ class SitesControllerTest < ActionDispatch::IntegrationTest title: name, description: name * 2, design_id: design.id, - licencia_id: Licencia.all.second.id, - deploys_attributes: { - '0' => { - type: 'DeployLocal' - } - } + licencia_id: Licencia.all.second.id } } diff --git a/test/factories/site.rb b/test/factories/site.rb index d9d8720..7d91f84 100644 --- a/test/factories/site.rb +++ b/test/factories/site.rb @@ -9,11 +9,11 @@ FactoryBot.define do licencia after :build do |site| - site.deploys << build(:deploy_local, site: site) - end - - after :create do |site| - site.deploys << create(:deploy_local, site: site) + # XXX: Generamos un DeployLocal normalmente y no a través de una + # Factory porque necesitamos que el sitio se genere solo. + # + # @see {https://github.com/thoughtbot/factory_bot/wiki/How-factory_bot-interacts-with-ActiveRecord} + site.deploys.build(type: 'DeployLocal') end end end diff --git a/test/jobs/deploy_job_test.rb b/test/jobs/deploy_job_test.rb index a0fabfc..d5ef694 100644 --- a/test/jobs/deploy_job_test.rb +++ b/test/jobs/deploy_job_test.rb @@ -2,11 +2,7 @@ class DeployJobTest < ActiveSupport::TestCase test 'se puede compilar' do - rol = create :rol - site = rol.site - site.deploys << create(:deploy_zip, site: site) - - site.save + site = create :site DeployJob.perform_async(site.id) diff --git a/test/models/deploy_alternative_domain_test.rb b/test/models/deploy_alternative_domain_test.rb new file mode 100644 index 0000000..b7bd6d0 --- /dev/null +++ b/test/models/deploy_alternative_domain_test.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require 'test_helper' + +class DeployAlternativeDomainTest < ActiveSupport::TestCase + setup do + @site = create :site + @deploy_alt = @site.deploys.build type: 'DeployAlternativeDomain' + end + + teardown do + @site&.destroy + end + + def random_tld + PAK::ValidatesHostname::ALLOWED_TLDS.sample + end + + test 'el hostname se ingresa manualmente' do + assert_nil @deploy_alt.hostname + end + + test 'el hostname es obligatorio' do + assert_not @deploy_alt.valid? + end + + test 'el hostname es válido' do + assert_not @deploy_alt.update(hostname: ' ') + assert_not @deploy_alt.update(hostname: 'custom.domain.root.') + assert_not @deploy_alt.update(hostname: 'custom.domain') + assert @deploy_alt.update(hostname: "custom.domain.#{random_tld}") + end + + test 'el hostname tiene que ser único' do + assert_not @deploy_alt.update(hostname: @site.hostname) + end + + test 'se puede deployear' do + assert @site.deploy_local.deploy + assert @deploy_alt.update(hostname: "#{SecureRandom.hex}.sutty.#{random_tld}") + assert @deploy_alt.deploy + assert File.symlink?(@deploy_alt.destination) + end +end diff --git a/test/models/deploy_hidden_service_test.rb b/test/models/deploy_hidden_service_test.rb new file mode 100644 index 0000000..cfa09b4 --- /dev/null +++ b/test/models/deploy_hidden_service_test.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require 'test_helper' + +class DeployHiddenServiceTest < ActiveSupport::TestCase + setup do + @site = create :site + @deploy_hidden = @site.deploys.build type: 'DeployHiddenService' + end + + teardown do + @site&.destroy + end + + test 'el hostname es válido' do + assert_not @deploy_hidden.update(hostname: ' ') + assert_not @deploy_hidden.update(hostname: 'custom.domain.root.') + assert_not @deploy_hidden.update(hostname: 'custom.domain') + assert @deploy_hidden.update(hostname: "#{@deploy_hidden.send(:random_base32, 56)}.onion") + end + + test 'los hostnames pueden ser temporales' do + assert @deploy_hidden.hostname.start_with? 'temporary' + end + + test 'el hostname tiene que ser único' do + assert @deploy_hidden.save + assert_not @site.deploys.create(type: 'DeployHiddenService', hostname: @deploy_hidden.hostname).valid? + end + + test 'se puede deployear' do + assert @site.deploy_local.deploy + assert @deploy_hidden.deploy + assert File.symlink?(@deploy_hidden.destination) + end +end diff --git a/test/models/deploy_local_test.rb b/test/models/deploy_local_test.rb index 7e8712d..25781ba 100644 --- a/test/models/deploy_local_test.rb +++ b/test/models/deploy_local_test.rb @@ -1,24 +1,44 @@ # frozen_string_literal: true +require 'test_helper' + class DeployLocalTest < ActiveSupport::TestCase + setup do + @site = create :site + end + + teardown do + @site&.destroy + end + + test 'se pueden crear' do + assert @site.deploy_local.valid? + assert_equal @site.hostname, @site.deploy_local.hostname + end + + test 'no se puede cambiar el hostname' do + hostname = @site.deploy_local.hostname + @site.deploy_local.hostname = SecureRandom.hex + + assert @site.deploy_local.save + assert_equal hostname, @site.deploy_local.hostname + end + test 'se puede deployear' do - site = create :site - local = create :deploy_local, site: site - deploy = create :deploy_zip, site: site + deploy_local = @site.deploy_local - # Primero tenemos que generar el sitio - local.deploy + assert deploy_local.deploy + assert File.directory?(deploy_local.destination) + assert File.exist?(File.join(deploy_local.destination, 'index.html')) + assert_equal 3, deploy_local.build_stats.count - escaped_path = Shellwords.escape(deploy.path) + assert deploy_local.build_stats.map(&:bytes).compact.inject(:+).positive? + assert deploy_local.build_stats.map(&:seconds).compact.inject(:+).positive? + end - assert deploy.deploy - assert File.file?(deploy.path) - assert_equal 'application/zip', - `file --mime-type "#{escaped_path}"`.split(' ').last - assert_equal 1, deploy.build_stats.count - assert deploy.build_stats.map(&:bytes).inject(:+).positive? - assert deploy.build_stats.map(&:seconds).inject(:+).positive? - - local.destroy + test 'al eliminarlos se elimina el directorio' do + deploy_local = @site.deploy_local + assert deploy_local.destroy + assert_not File.directory?(deploy_local.destination) end end diff --git a/test/models/deploy_www_test.rb b/test/models/deploy_www_test.rb index 8ae00eb..08ff8c3 100644 --- a/test/models/deploy_www_test.rb +++ b/test/models/deploy_www_test.rb @@ -3,17 +3,23 @@ require 'test_helper' class DeployWwwTest < ActiveSupport::TestCase + setup do + @site = create :site + @deploy_www = @site.deploys.create type: 'DeployWww' + end + + teardown do + @site&.destroy + end + + test 'el hostname empieza con www' do + assert @deploy_www.hostname.start_with?('www.') + end + test 'se puede deployear' do - site = create :site - local = create :deploy_local, site: site - deploy = create :deploy_www, site: site + assert @site.deploy_local.deploy - # Primero tenemos que generar el sitio - local.deploy - - assert deploy.deploy - assert File.symlink?(deploy.destination) - - local.destroy + assert @deploy_www.deploy + assert File.symlink?(@deploy_www.destination) end end diff --git a/test/models/deploy_zip_test.rb b/test/models/deploy_zip_test.rb index d5ffe51..2e4e55f 100644 --- a/test/models/deploy_zip_test.rb +++ b/test/models/deploy_zip_test.rb @@ -3,18 +3,29 @@ require 'test_helper' class DeployZipTest < ActiveSupport::TestCase + setup do + @site = create :site + @deploy_zip = @site.deploys.create(type: 'DeployZip') + end + + teardown do + @site&.destroy + end + + test 'el nombre es el hostname.zip' do + assert_equal "#{@site.hostname}.zip", @deploy_zip.hostname + end + test 'se puede deployear' do - deploy_local = create :deploy_local + # Primero tenemos que generar el sitio + assert @site.deploy_local.deploy - assert deploy_local.deploy - assert File.directory?(deploy_local.destination) - assert File.exist?(File.join(deploy_local.destination, 'index.html')) - assert_equal 3, deploy_local.build_stats.count - - assert deploy_local.build_stats.map(&:bytes).compact.inject(:+).positive? - assert deploy_local.build_stats.map(&:seconds).compact.inject(:+).positive? - - assert deploy_local.destroy - assert_not File.directory?(deploy_local.destination) + assert @deploy_zip.deploy + assert File.file?(@deploy_zip.path) + assert_equal 'application/zip', + `file --mime-type "#{@deploy_zip.path}"`.split.last + assert_equal 1, @deploy_zip.build_stats.count + assert @deploy_zip.build_stats.map(&:bytes).inject(:+).positive? + assert @deploy_zip.build_stats.map(&:seconds).inject(:+).positive? end end diff --git a/test/models/site/deployment_test.rb b/test/models/site/deployment_test.rb new file mode 100644 index 0000000..799a33e --- /dev/null +++ b/test/models/site/deployment_test.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +require 'test_helper' + +class Site::DeploymentTest < ActiveSupport::TestCase + def site + @site ||= create :site + end + + teardown do + @site&.destroy + end + + test 'al publicar el sitio se crea el directorio' do + assert site.deploy_local.deploy + assert site.deploy_local.exist? + end + + test 'al cambiar el nombre no puede pisar un dominio que ya existe' do + site_pre = create :site + dup_name = "test-#{SecureRandom.hex}" + assert site_pre.deploys.create(type: 'DeployAlternativeDomain', hostname: "#{dup_name}.#{Site.domain}") + assert_not site.update(name: dup_name) + end + + test 'al cambiar el nombre se crea un deploy alternativo' do + site_name = site.name + new_name = SecureRandom.hex + original_destination = site.deploy_local.destination + urls = [site.url] + + assert site.deploy_local.deploy + assert_not site.deploy_local.destination_changed? + assert site.update(name: new_name) + + urls << site.url + + assert_equal urls.sort, site.urls.sort + assert File.symlink?(original_destination) + assert File.exist?(site.deploy_local.destination) + assert_equal 2, site.deploys.count + end + + test 'al cambiar el nombre se renombra el directorio' do + site_name = site.name + new_name = "test-#{SecureRandom.hex}" + original_destination = site.deploy_local.destination + + assert site.deploy_local.deploy + assert_not site.deploy_local.destination_changed? + assert site.update(name: new_name) + assert site.deploy_local.hostname.start_with?(new_name) + assert File.symlink?(original_destination) + assert File.exist?(site.deploy_local.destination) + end + + test 'al cambiar el nombre se actualiza el www' do + site_name = site.name + new_name = "test-#{SecureRandom.hex}" + + assert (deploy_www = site.deploys.create(type: 'DeployWww')) + assert site.deploy_local.deploy + assert_not site.deploy_local.destination_changed? + assert site.update(name: new_name) + assert deploy_www.reload.hostname.include?(new_name) + assert_equal 4, site.deploys.count + end + + test 'al cambiar el nombre varias veces se crean varios links' do + assert site.deploy_local.deploy + + q = rand(3..10) + q.times do + assert site.update(name: "test-#{SecureRandom.hex}") + end + + assert_equal q, site.deploys.count + end + + test 'no se puede cambiar el nombre si ya existía un archivo en el mismo lugar' do + assert site.deploy_local.deploy + + new_name = "test-#{SecureRandom.hex}" + FileUtils.mkdir File.join(Rails.root, '_deploy', "#{new_name}.#{Site.domain}") + + assert_not site.update(name: new_name) + end +end diff --git a/test/models/site_test.rb b/test/models/site_test.rb index e53fff0..cfef44e 100644 --- a/test/models/site_test.rb +++ b/test/models/site_test.rb @@ -26,18 +26,18 @@ class SiteTest < ActiveSupport::TestCase assert_not site2.valid? end - test 'el nombre del sitio puede contener subdominios' do + test 'el nombre del sitio no puede contener subdominios' do @site = build :site, name: 'hola.chau' site.validate - assert_not site.errors.messages[:name].present? + assert site.errors.messages[:name].present? end - test 'el nombre del sitio puede terminar con punto' do + test 'el nombre del sitio no puede terminar con punto' do @site = build :site, name: 'hola.chau.' site.validate - assert_not site.errors.messages[:name].present? + assert site.errors.messages[:name].present? end test 'el nombre del sitio no puede contener wildcard' do @@ -93,9 +93,9 @@ class SiteTest < ActiveSupport::TestCase test 'tienen un hostname que puede cambiar' do assert_equal "#{site.name}.#{Site.domain}", site.hostname - site.name = name = SecureRandom.hex + site.update(name: (new_name = SecureRandom.hex)) - assert_equal "#{name}.#{Site.domain}", site.hostname + assert_equal "#{new_name}.#{Site.domain}", site.hostname end test 'se pueden traer los datos de una plantilla' do