5
0
Fork 0
mirror of https://0xacab.org/sutty/sutty synced 2024-09-28 15:26:58 +00:00

Merge branch 'origin-referer' into staging

This commit is contained in:
f 2021-08-28 12:27:49 -03:00
commit d151fffdd2
45 changed files with 1017 additions and 350 deletions

View file

@ -11,20 +11,58 @@ module Api
private 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 # Aunque en realidad con el hostname a partir del Origin nos
# tuvimos que poner 'sutty.nl..sutty.nl' para pasar el test. # 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 def site_id
@site_id ||= if params[:site_id].end_with? Site.domain @site_id ||= Deploy.site_name_from_hostname(params[:site_id])
params[:site_id].sub(/\.#{Site.domain}\z/, '')
else
params[:site_id] + '.'
end
end 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 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 end
# Los navegadores antiguos no envían Origin # Los navegadores antiguos no envían Origin

View file

@ -23,7 +23,7 @@ module Api
contact_params.to_h.symbolize_keys, contact_params.to_h.symbolize_keys,
params[:redirect] params[:redirect]
redirect_to params[:redirect] || origin.to_s redirect_to params[:redirect] || referer || site.url
end end
private private

View file

@ -44,11 +44,10 @@ module Api
# Genera el Origin correcto a partir de la URL del sitio. # 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] # @return [String]
def return_origin def return_origin
Rails.env.production? ? Site.find_by(name: site_id).url : origin site&.deploys&.find_by_hostname(origin_hostname)&.url
end end
# La cookie no es accesible a través de JS y todo su contenido # 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 # TODO: Volver configurable por sitio
expires = ENV.fetch('COOKIE_DURATION', '30').to_i.minutes 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-Origin'] = return_origin
headers['Access-Control-Allow-Credentials'] = true headers['Access-Control-Allow-Credentials'] = true
headers['Vary'] = 'Origin' headers['Vary'] = 'Origin'

View file

@ -17,7 +17,7 @@ module Api
site.touch if service.create_anonymous.persisted? site.touch if service.create_anonymous.persisted?
# Redirigir a la URL de agradecimiento # Redirigir a la URL de agradecimiento
redirect_to params[:redirect_to] || origin.to_s redirect_to params[:redirect_to] || referer || site.url
end end
private private

View file

@ -85,7 +85,9 @@ module Api
# XXX: Este header se puede falsificar de todas formas pero al # XXX: Este header se puede falsificar de todas formas pero al
# menos es una trampa. # menos es una trampa.
def site_is_origin? 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' @reason = 'site_is_not_origin'
render plain: Rails.env.production? ? nil : @reason, status: :precondition_required render plain: Rails.env.production? ? nil : @reason, status: :precondition_required
@ -116,11 +118,6 @@ module Api
raise NotImplementedError raise NotImplementedError
end 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 # Genera un registro con información básica para debug, quizás no
# quede asociado a ningún sitio. # quede asociado a ningún sitio.
# #

View file

@ -9,14 +9,14 @@ module Api
# Lista de nombres de dominios a emitir certificados # Lista de nombres de dominios a emitir certificados
def index def index
render json: sites_names + alternative_names + api_names render json: Deploy.all.pluck(:hostname)
end end
# Sitios con hidden service de Tor # Sitios con hidden service de Tor
# #
# @return [Array] lista de nombres de sitios sin onion aun # @return [Array] lista de nombres de sitios sin onion aun
def hidden_services def hidden_services
render json: DeployHiddenService.where(values: nil).includes(:site).pluck(:name) render json: DeployHiddenService.temporary.includes(:site).pluck(:name)
end end
# Tor va a enviar el onion junto con el nombre del sitio y tenemos # 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] name
# @params [String] onion # @params [String] onion
def add_onion def add_onion
site = Site.find_by(name: params[:name]) if (site = Site.find_by_name(params[:name]))
usuarie = GitAuthor.new email: "tor@#{Site.domain}", name: 'Tor'
if site
usuarie = GitAuthor.new email: 'tor@' + Site.domain, name: 'Tor'
service = SiteService.new site: site, usuarie: usuarie, service = SiteService.new site: site, usuarie: usuarie,
params: params params: params
service.add_onion service.add_onion
@ -36,28 +34,6 @@ module Api
head :ok head :ok
end 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 end
end end

View file

@ -9,6 +9,7 @@ class ApplicationController < ActionController::Base
before_action :prepare_exception_notifier before_action :prepare_exception_notifier
before_action :configure_permitted_parameters, if: :devise_controller? 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 around_action :set_locale
rescue_from Pundit::NilPolicyError, with: :page_not_found rescue_from Pundit::NilPolicyError, with: :page_not_found
@ -16,7 +17,7 @@ class ApplicationController < ActionController::Base
rescue_from ActionController::ParameterMissing, with: :page_not_found rescue_from ActionController::ParameterMissing, with: :page_not_found
before_action do 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 end
# No tenemos índice de sutty, vamos directamente a ver el listado de # 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 /[a-f0-9]{8}-([a-f0-9]{4}-){3}[a-f0-9]{12}/ =~ string
end end
# Encontrar un sitio por su nombre # Encontrar un sitio por su nombre.
def find_site def find_site
id = params[:site_id] || params[:id] current_usuarie&.sites&.find_by_name(site_id).tap do |site|
raise SiteNotFound unless site
unless (site = current_usuarie&.sites&.find_by_name(id))
raise SiteNotFound
end end
site
end end
# Devuelve el idioma actual y si no lo encuentra obtiene uno por # 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 breadcrumb 'stats.index', root_path, match: :exact
end 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 protected
def configure_permitted_parameters def configure_permitted_parameters

View file

@ -7,7 +7,7 @@ class CollaborationsController < ApplicationController
include Pundit include Pundit
def collaborate def collaborate
@site = Site.find_by_name(params[:site_id]) @site = find_site
authorize Collaboration.new(@site) authorize Collaboration.new(@site)
@invitade = current_usuarie || @site.usuaries.build @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 # * Si le usuarie existe y no está logueade, pedirle la contraseña
def accept_collaboration def accept_collaboration
@site = Site.find_by_name(params[:site_id]) @site = find_site
authorize Collaboration.new(@site) authorize Collaboration.new(@site)
@invitade = current_usuarie @invitade = current_usuarie

View file

@ -136,8 +136,10 @@ class SitesController < ApplicationController
private private
def site # En los controladores dentro de este controlador vamos a usar :id
@site ||= find_site # para obtener el nombre.
def site_id
@site_id ||= params[:site_id] || params[:id]
end end
def site_params def site_params

View file

@ -4,70 +4,63 @@
class DeployJob < ApplicationJob class DeployJob < ApplicationJob
class DeployException < StandardError; end class DeployException < StandardError; end
attr_reader :site, :deployed
# rubocop:disable Metrics/MethodLength # 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 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 # Si ya hay una tarea corriendo, aplazar esta. Si estuvo
# esperando más de 10 minutos, recuperar el estado anterior. # esperando más de 10 minutos, recuperar el estado anterior.
# #
# Como el trabajo actual se aplaza al siguiente, arrastrar la # Como el trabajo actual se aplaza al siguiente, arrastrar la
# hora original para poder ir haciendo timeouts. # hora original para poder ir haciendo timeouts.
if @site.building? if site.building?
if 10.minutes.ago >= time if 10.minutes.ago >= time
@site.update status: 'waiting' site.update status: 'waiting'
raise DeployException, 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 end
DeployJob.perform_in(60, site, notify, time) DeployJob.perform_in(60, site_id, notify, time)
return return
end end
@site.update status: 'building' site.update status: 'building'
# Asegurarse que DeployLocal sea el primero! # Asegurarse que DeployLocal sea el primero!
@deployed = { deploy_local: deploy_locally } deployed[:deploy_local] = site.deploy_local.deploy
# No es opcional deploy_others if deployed[:deploy_local]
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
# Volver a la espera # Volver a la espera
@site.update status: 'waiting' site.update status: 'waiting'
notify_usuaries if notify 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
end end
# rubocop:enable Metrics/MethodLength # rubocop:enable Metrics/MethodLength
private private
def deploy_local # Correr todas las tareas que no sean el deploy local.
@deploy_local ||= @site.deploys.find_by(type: 'DeployLocal')
end
def deploy_locally
deploy_local.deploy
end
def deploy_others def deploy_others
@site.deploys.where.not(type: 'DeployLocal').find_each do |d| site.deploys.where.not(type: 'DeployLocal').find_each do |d|
@deployed[d.type.underscore.to_sym] = d.deploy deployed[d.type.underscore.to_sym] = d.deploy
end end
end end
# Notificar a todes les usuaries no temporales.
#
# TODO: Poder configurar quiénes quieren recibir notificaciones.
def notify_usuaries def notify_usuaries
@site.roles.where(rol: 'usuarie', temporal: false).pluck(:usuarie_id).each do |usuarie| site.roles.where(rol: 'usuarie', temporal: false).pluck(:usuarie_id).each do |usuarie|
DeployMailer.with(usuarie: usuarie, site: @site.id) DeployMailer.with(usuarie: usuarie, site: site.id)
.deployed(@deployed) .deployed(deployed)
.deliver_now .deliver_now
end end
end end

View file

@ -13,7 +13,7 @@ class DeployMailer < ApplicationMailer
@usuarie = Usuarie.find(params[:usuarie]) @usuarie = Usuarie.find(params[:usuarie])
@site = @usuarie.sites.find(params[:site]) @site = @usuarie.sites.find(params[:site])
@deploys = which_ones @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 # Informamos a cada quien en su idioma y damos una dirección de
# respuesta porque a veces les usuaries nos escriben # respuesta porque a veces les usuaries nos escriben

View file

@ -1,44 +1,138 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'open3' require 'open3'
# Este modelo implementa los distintos tipos de alojamiento que provee # Este modelo implementa los distintos tipos de alojamiento que provee
# Sutty. # Sutty.
# #
# Los datos se guardan en la tabla `deploys`. Para guardar los # Cuando cambia el hostname de un Deploy, generamos un
# atributos, cada modelo tiene que definir su propio `store # DeployAlternativeDomain en su lugar. Esto permite que no se rompan
# :attributes`. # 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 class Deploy < ApplicationRecord
# Un sitio puede tener muchas formas de publicarse.
belongs_to :site 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 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 def deploy
raise NotImplementedError raise NotImplementedError
end end
def limit # El espacio ocupado por este deploy.
raise NotImplementedError #
end # @return [Integer]
def size def size
raise NotImplementedError raise NotImplementedError
end end
# Empezar a contar el tiempo
#
# @return [Time]
def time_start def time_start
@start = Time.now @start = Time.now
end end
# Detener el contador
#
# @return [Time]
def time_stop def time_stop
@stop = Time.now @stop = Time.now
end end
# Obtener la demora de la tarea
#
# @return [Float]
def time_spent_in_seconds def time_spent_in_seconds
(@stop - @start).round(3) (@stop - @start).round(3)
end end
def home_dir # El directorio donde se almacenan las gemas.
site.path #
end # TODO: En un momento podíamos tenerlas todas compartidas y ahorrar
# espacio, pero bundler empezó a mezclar cosas.
#
# @return [String]
def gems_dir def gems_dir
@gems_dir ||= Rails.root.join('_storage', 'gems', site.name) @gems_dir ||= Rails.root.join('_storage', 'gems', site.name)
end end
@ -77,9 +171,67 @@ class Deploy < ApplicationRecord
private 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] # @param [String]
# @return [String] # @return [String]
def readable_cmd(cmd) def readable_cmd(cmd)
cmd.split(' -', 2).first.tr(' ', '_') cmd.split(' -', 2).first.tr(' ', '_')
end end
# Cada deploy puede decidir su propia validación
#
# @return [Boolean]
def implements_hostname_validation?
false
end
end end

View file

@ -1,23 +1,21 @@
# frozen_string_literal: true # frozen_string_literal: true
# Soportar dominios alternativos # Soportar dominios alternativos.
class DeployAlternativeDomain < Deploy class DeployAlternativeDomain < DeployWww
store :values, accessors: %i[hostname], coder: JSON validates :hostname, domainname: true
# Generar un link simbólico del sitio principal al alternativo # No hay un hostname por defecto
def deploy #
File.symlink?(destination) || # @return [Nil]
File.symlink(site.hostname, destination).zero? def default_hostname; end
private
def implements_hostname_validation?
true
end end
# No hay límite para los dominios alternativos # No hay un hostname por defecto. Debe ser informado por les
def limit; end # usuaries.
def default_hostname!; end
def size
File.size destination
end
def destination
File.join(Rails.root, '_deploy', hostname.gsub(/\.\z/, ''))
end
end end

View file

@ -1,18 +1,61 @@
# frozen_string_literal: true # 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 class DeployHiddenService < DeployWww
def deploy validates :hostname, format: { with: /\A[a-z2-7]{56}.onion\z/ }
return true if fqdn.blank?
super # Sufijo para todos los dominios temporales.
end TEMPORARY_SUFFIX = 'temporary'
def fqdn # Traer todos los servicios ocultos temporales.
values[:onion] scope :temporary, -> { where("hostname not like '#{TEMPORARY_SUFFIX}%'") }
end
# Los servicios ocultos son su propio transporte cifrado y
# autenticado.
#
# @return [String]
def url 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
end end

View file

@ -1,11 +1,11 @@
# frozen_string_literal: true # frozen_string_literal: true
# Alojamiento local, solo genera el sitio, con lo que no necesita hacer # Alojamiento local, genera el sitio como si corriéramos `jekyll build`.
# nada más
class DeployLocal < Deploy class DeployLocal < Deploy
store :values, accessors: %i[], coder: JSON # Asegurarse que el hostname es el permitido.
before_validation :reset_hostname!, :default_hostname!
before_destroy :remove_destination! # 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 # Realizamos la construcción del sitio usando Jekyll y un entorno
# limpio para no pasarle secretos # limpio para no pasarle secretos
@ -20,42 +20,52 @@ class DeployLocal < Deploy
jekyll_build jekyll_build
end end
# Sólo permitimos un deploy local
def limit
1
end
# Obtener el tamaño de todos los archivos y directorios (los # Obtener el tamaño de todos los archivos y directorios (los
# directorios son archivos :) # directorios son archivos :)
#
# @return [Integer]
def size def size
paths = [destination, File.join(destination, '**', '**')] paths = [destination, File.join(destination, '**', '**')]
Dir.glob(paths).map do |file| Dir.glob(paths).map do |file|
if File.symlink? file File.symlink?(file) ? 0 : File.size(file)
0
else
File.size(file)
end
end.inject(:+) end.inject(:+)
end end
def destination # El hostname es el nombre del sitio más el dominio principal.
File.join(Rails.root, '_deploy', site.hostname) #
# @return [String]
def default_hostname
"#{site.name}.#{Site.domain}"
end end
private 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 def mkdir
FileUtils.mkdir_p destination FileUtils.mkdir_p destination
end end
# Un entorno que solo tiene lo que necesitamos # Un entorno que solo tiene lo que necesitamos
#
# @return [Hash]
def env def env
# XXX: This doesn't support Windows paths :B # XXX: This doesn't support Windows paths :B
paths = [File.dirname(`which bundle`), '/usr/bin', '/bin'] paths = [File.dirname(`which bundle`), '/usr/bin', '/bin']
{ {
'HOME' => home_dir, 'HOME' => site.path,
'PATH' => paths.join(':'), 'PATH' => paths.join(':'),
'SPREE_API_KEY' => site.tienda_api_key, 'SPREE_API_KEY' => site.tienda_api_key,
'SPREE_URL' => site.tienda_url, 'SPREE_URL' => site.tienda_url,
@ -66,10 +76,15 @@ class DeployLocal < Deploy
} }
end end
# @return [String]
def yarn_lock def yarn_lock
File.join(site.path, 'yarn.lock') File.join(site.path, 'yarn.lock')
end end
# Determina si este proyecto se gestiona con Yarn, buscando si el
# archivo yarn.lock existe.
#
# @return [Boolean]
def yarn_lock? def yarn_lock?
File.exist? yarn_lock File.exist? yarn_lock
end end
@ -79,12 +94,17 @@ class DeployLocal < Deploy
end end
# Corre yarn dentro del repositorio # Corre yarn dentro del repositorio
#
# @return [Boolean,Nil]
def yarn def yarn
return true unless yarn_lock? return true unless yarn_lock?
run 'yarn' run 'yarn'
end end
# Instala las dependencias.
#
# @return [Boolean]
def bundle def bundle
if Rails.env.production? if Rails.env.production?
run %(bundle install --no-cache --path="#{gems_dir}") run %(bundle install --no-cache --path="#{gems_dir}")
@ -93,6 +113,9 @@ class DeployLocal < Deploy
end end
end end
# Genera el sitio.
#
# @return [Boolean]
def jekyll_build def jekyll_build
run %(bundle exec jekyll build --trace --profile --destination "#{escaped_destination}") run %(bundle exec jekyll build --trace --profile --destination "#{escaped_destination}")
end end

View file

@ -11,12 +11,31 @@ class DeployPrivate < DeployLocal
jekyll_build jekyll_build
end 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 def destination
File.join(Rails.root, '_private', site.name) File.join(Rails.root, '_private', site.name)
end 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 # No usar recursos en compresión y habilitar los datos privados
#
# @return [Hash]
def env def env
@env ||= super.merge({ @env ||= super.merge({
'JEKYLL_ENV' => 'development', 'JEKYLL_ENV' => 'development',

View file

@ -2,34 +2,48 @@
# Vincula la versión del sitio con www a la versión sin # Vincula la versión del sitio con www a la versión sin
class DeployWww < Deploy class DeployWww < Deploy
store :values, accessors: %i[], coder: JSON # La forma de hacer este deploy es generar un link simbólico entre el
# directorio canónico y el actual.
before_destroy :remove_destination! #
# @return [Boolean]
def deploy def deploy
File.symlink?(destination) || # Eliminar los links rotos
File.symlink(site.hostname, destination).zero? remove_destination! if broken?
end
# No hacer nada si ya existe.
def limit return true if exist?
1
# Generar un link simbólico con la ruta relativa al destino
File.symlink(relative_path, destination).zero?
end end
# Siempre devuelve el espacio ocupado por el link simbólico, no el
# destino.
#
# @return [Integer]
def size def size
File.size destination relative_path.size
end end
def destination # El hostname por defecto incluye WWW
File.join(Rails.root, '_deploy', fqdn) #
end # @return [String]
def default_hostname
def fqdn "www.#{site.deploy_local.hostname}"
"www.#{site.hostname}"
end end
private private
# Elimina el link simbólico si se elimina este deploy.
def remove_destination! def remove_destination!
FileUtils.rm_f destination FileUtils.rm_f destination
end 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 end

View file

@ -2,22 +2,24 @@
require 'zip' 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 # TODO: Firmar con minisign
class DeployZip < Deploy 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 # 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 def deploy
FileUtils.rm_f path remove_destination!
time_start time_start
Dir.chdir(destination) do 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| Dir.glob('./**/**').each do |f|
File.directory?(f) ? z.mkdir(f) : z.add(f, f) File.directory?(f) ? z.mkdir(f) : z.add(f, f)
end end
@ -31,25 +33,47 @@ class DeployZip < Deploy
File.exist? path File.exist? path
end end
# rubocop:enable Metrics/MethodLength
def limit # La URL de descarga del archivo.
1 #
# @return [String]
def url
"#{site.deploy_local.url}/#{hostname}"
end end
# Devuelve el tamaño del ZIP en bytes
#
# @return [Integer]
def size def size
File.size path File.size path
end end
# El archivo ZIP se guarda dentro del sitio local para poder
# descargarlo luego.
#
# @return [String]
def destination def destination
File.join(Rails.root, '_deploy', site.hostname) site.deploy_local.destination
end end
def file # El "hostname" es la ubicación del archivo.
"#{site.hostname}.zip" #
# @return [String]
def default_hostname
"#{site.deploy_local.hostname}.zip"
end end
def path 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
end end

View file

@ -7,6 +7,7 @@ class Site < ApplicationRecord
include Site::Forms include Site::Forms
include Site::FindAndReplace include Site::FindAndReplace
include Site::Api include Site::Api
include Site::Deployment
include Tienda include Tienda
# Cifrar la llave privada que cifra y decifra campos ocultos. Sutty # 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! # protege de acceso al panel de Sutty!
encrypts :private_key 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 :design_id, presence: true
validates_uniqueness_of :name
validates_inclusion_of :status, in: %w[waiting enqueued building] validates_inclusion_of :status, in: %w[waiting enqueued building]
validates_presence_of :title validates_presence_of :title
validates :description, length: { in: 50..160 } validates :description, length: { in: 50..160 }
validate :deploy_local_presence
validate :compatible_layouts, on: :update validate :compatible_layouts, on: :update
attr_reader :incompatible_layouts attr_reader :incompatible_layouts
@ -38,8 +31,6 @@ class Site < ApplicationRecord
belongs_to :licencia belongs_to :licencia
has_many :log_entries, dependent: :destroy has_many :log_entries, dependent: :destroy
has_many :deploys, dependent: :destroy
has_many :build_stats, through: :deploys
has_many :roles, dependent: :destroy has_many :roles, dependent: :destroy
has_many :usuaries, -> { where('roles.rol = ?', 'usuarie') }, has_many :usuaries, -> { where('roles.rol = ?', 'usuarie') },
through: :roles through: :roles
@ -58,13 +49,11 @@ class Site < ApplicationRecord
after_initialize :load_jekyll after_initialize :load_jekyll
after_create :load_jekyll, :static_file_migration! after_create :load_jekyll, :static_file_migration!
# Cambiar el nombre del directorio # Cambiar el nombre del directorio
before_update :update_name! before_update :update_name!, if: :name_changed?
before_save :add_private_key_if_missing! before_save :add_private_key_if_missing!
# Guardar la configuración si hubo cambios # Guardar la configuración si hubo cambios
after_save :sync_attributes_with_config! after_save :sync_attributes_with_config!
accepts_nested_attributes_for :deploys, allow_destroy: true
# El sitio en Jekyll # El sitio en Jekyll
attr_reader :jekyll attr_reader :jekyll
@ -85,49 +74,6 @@ class Site < ApplicationRecord
@repository ||= Site::Repository.new path @repository ||= Site::Repository.new path
end 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) def invitade?(usuarie)
!invitades.find_by(id: usuarie.id).nil? !invitades.find_by(id: usuarie.id).nil?
end end
@ -453,8 +399,6 @@ class Site < ApplicationRecord
end end
def update_name! def update_name!
return unless name_changed?
FileUtils.mv path_was, path FileUtils.mv path_was, path
reload_jekyll! reload_jekyll!
end end
@ -477,19 +421,6 @@ class Site < ApplicationRecord
Site::StaticFileMigration.new(site: self).migrate! Site::StaticFileMigration.new(site: self).migrate!
end 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 # Valida que al cambiar de plantilla no tengamos artículos en layouts
# inexistentes. # inexistentes.
def compatible_layouts def compatible_layouts

View file

@ -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

View file

@ -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 I18n.with_locale(usuarie&.lang&.to_sym || I18n.default_locale) do
site.save && site.save &&
site.config.write && site.config.write &&
commit_config(action: :create) commit_config(action: :create) &&
add_licencias
end end
add_licencias
site site
end end

View file

@ -1,6 +1,6 @@
%h1= t('.hi') %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] tags: %w[p a strong em]
%table %table

View file

@ -1,6 +1,6 @@
= '# ' + t('.hi') = '# ' + t('.hi')
\ \
= t('.explanation', fqdn: @deploy_local.site.hostname) = t('.explanation', url: @site.deploy_local.url)
\ \
= Terminal::Table.new do |table| = Terminal::Table.new do |table|
- table << [t('.th.type'), t('.th.status')] - table << [t('.th.type'), t('.th.status')]

View file

@ -14,10 +14,10 @@
'0', '1' '0', '1'
= deploy.label :_destroy, class: 'custom-control-label' do = deploy.label :_destroy, class: 'custom-control-label' do
%h3= t('.title') %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] tags: %w[p strong em a]
- if deploy.object.fqdn - unless deploy.object.temporary?
= sanitize_markdown t('.help_2', url: deploy.object.url), = sanitize_markdown t('.help_2', url: deploy.object.url),
tags: %w[p strong em a] tags: %w[p strong em a]
%hr/ %hr/

View file

@ -6,7 +6,9 @@
.row .row
.col .col
%h3= t('.title') %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] tags: %w[p strong em a]
= deploy.hidden_field :type -# No duplicarlos una vez que existen.
- unless deploy.object.persisted?
= deploy.hidden_field :type

View file

@ -15,6 +15,6 @@
'0', '1' '0', '1'
= deploy.label :_destroy, class: 'custom-control-label' do = deploy.label :_destroy, class: 'custom-control-label' do
%h3= t('.title') %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] tags: %w[p strong em a]
%hr/ %hr/

View file

@ -15,10 +15,5 @@
'0', '1' '0', '1'
= deploy.label :_destroy, class: 'custom-control-label' do = deploy.label :_destroy, class: 'custom-control-label' do
%h3= t('.title') %h3= t('.title')
-# TODO: secar la generación de URLs = sanitize_markdown t('.help', url: deploy.object.url), tags: %w[p strong em a]
- 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]
%hr/ %hr/

View file

@ -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 # El problema sería que otros sitios con JS malicioso hagan pedidos
# a nuestra API desde otros sitios infectados. # 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 # XXX: Al terminar de entender esto nos pasó que el servidor recibe
# la petición de todas maneras, con lo que no estamos previniendo # 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 # que nos hablen, sino que lean información. Solo va a funcionar si
# el servidor no tiene el Preflight cacheado. # el servidor no tiene el Preflight cacheado.
# #
# TODO: Limitar el acceso desde Nginx también. # TODO: Limitar el acceso desde Nginx también.
#
# TODO: Poder consultar por sitios por todas sus URLs posibles.
origins do |source, _| origins do |source, _|
# Cacheamos la respuesta para no tener que volver a procesarla # Cacheamos la respuesta para no tener que volver a procesarla
# cada vez. # cada vez.
Rails.cache.fetch(source, expires_in: 1.hour) do Rails.cache.fetch(source, expires_in: 1.hour) do
uri = URI(source) hostname = URI(source)&.host
hostname.present? && Deploy.find_by_hostname(hostname).present?
if (name = uri&.host&.split('.', 2)&.first).present? rescue StandardError
Site.where(name: [name, uri.host + '.']).pluck(:name).first.present?
else
false
end
rescue URI::Error
false false
end end
end end

View file

@ -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?

View file

@ -70,7 +70,7 @@ en:
hi: "Hi!" hi: "Hi!"
explanation: | explanation: |
This e-mail is to notify you that Sutty has built your site, which is This e-mail is to notify you that Sutty has built your site, which is
available at <https://%{fqdn}>. available at <%{url}/>.
You'll find details below. You'll find details below.
th: th:
@ -143,8 +143,15 @@ en:
tienda_api_key: Store access key tienda_api_key: Store access key
errors: errors:
models: models:
deploy:
attributes:
hostname:
destination_exist: 'There already is a file in the destination'
site: site:
attributes: attributes:
name:
no_subdomains: 'Name cannot contain dots'
duplicated_hostname: 'There already is a site with this address'
deploys: deploys:
deploy_local_presence: 'We need to be build the site!' deploy_local_presence: 'We need to be build the site!'
design_id: design_id:
@ -195,7 +202,7 @@ en:
deploy_local: deploy_local:
title: 'Host at Sutty' title: 'Host at Sutty'
help: | help: |
The site will be available at <https://%{fqdn}/>. The site will be available at <%{url}/>.
We're working out the details to allow you to use your own site 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)! domains, you can [help us](https://sutty.nl/en/index.html#contact)!
@ -211,7 +218,7 @@ en:
title: 'Add www to the address' title: 'Add www to the address'
help: | help: |
When you enable this option, your site will also be available When you enable this option, your site will also be available
under <https://%{fqdn}/>. under <%{url}/>.
The www prefix has been a way of referring to The www prefix has been a way of referring to
computers that are available on the World Wide Web. Since computers that are available on the World Wide Web. Since
@ -222,7 +229,7 @@ en:
help: | help: |
ZIP files contain and compress all your site's files. With ZIP files contain and compress all your site's files. With
this option you can download and also share your entire site this option you can download and also share your entire site
through the <https://%{fqdn}/%{file}> address, keep it as backup through the <%{url}> address, keep it as backup
or have a strategy of solidary hosting, where many people or have a strategy of solidary hosting, where many people
share a copy of your site. share a copy of your site.

View file

@ -70,7 +70,7 @@ es:
hi: "¡Hola!" hi: "¡Hola!"
explanation: | explanation: |
Este correo es para notificarte que Sutty ha generado tu sitio y Este correo es para notificarte que Sutty ha generado tu sitio y
ya está disponible en la dirección <https://%{fqdn}>. ya está disponible en la dirección <%{url}>.
A continuación encontrarás el detalle de lo que hicimos. A continuación encontrarás el detalle de lo que hicimos.
th: th:
@ -143,8 +143,15 @@ es:
tienda_api_key: Clave de acceso tienda_api_key: Clave de acceso
errors: errors:
models: models:
deploy:
attributes:
hostname:
destination_exist: 'Ya hay un archivo en esta ubicación'
site: site:
attributes: attributes:
name:
no_subdomains: 'El nombre no puede contener puntos'
duplicated_hostname: 'Ya existe un sitio con ese nombre'
deploys: deploys:
deploy_local_presence: '¡Necesitamos poder generar el sitio!' deploy_local_presence: '¡Necesitamos poder generar el sitio!'
design_id: design_id:
@ -197,7 +204,7 @@ es:
deploy_local: deploy_local:
title: 'Alojar en Sutty' title: 'Alojar en Sutty'
help: | help: |
El sitio estará disponible en <https://%{fqdn}/>. El sitio estará disponible en <%{url}/>.
Estamos desarrollando la posibilidad de agregar tus propios Estamos desarrollando la posibilidad de agregar tus propios
dominios, ¡ayudanos! dominios, ¡ayudanos!
@ -213,7 +220,7 @@ es:
title: 'Agregar www a la dirección' title: 'Agregar www a la dirección'
help: | help: |
Cuando habilitas esta opción, tu sitio también estará disponible Cuando habilitas esta opción, tu sitio también estará disponible
como <https://%{fqdn}/>. como <%{url}/>.
El prefijo www para las direcciones web ha sido una forma de El prefijo www para las direcciones web ha sido una forma de
referirse a las computadoras que están disponibles en la _World referirse a las computadoras que están disponibles en la _World
@ -226,7 +233,7 @@ es:
help: | help: |
Los archivos ZIP contienen y comprimen todos los archivos de tu Los archivos ZIP contienen y comprimen todos los archivos de tu
sitio. Con esta opción podrás descargar y compartir tu sitio sitio. Con esta opción podrás descargar y compartir tu sitio
entero a través de la dirección <https://%{fqdn}/%{file}> y entero a través de la dirección <%{url}> y
guardarla como copia de seguridad o una estrategia de guardarla como copia de seguridad o una estrategia de
alojamiento solidario, donde muchas personas comparten una copia alojamiento solidario, donde muchas personas comparten una copia
de tu sitio. de tu sitio.

View file

@ -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

View file

@ -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

View file

@ -21,13 +21,14 @@ module Api
end end
test 'el sitio tiene que existir' do test 'el sitio tiene que existir' do
hostname = @site.hostname
@site.destroy @site.destroy
get v1_site_contact_cookie_url(@site.hostname, **@host) get v1_site_contact_cookie_url(hostname, **@host)
assert_not cookies[@site.name] 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: { params: {
name: SecureRandom.hex, name: SecureRandom.hex,
pronouns: SecureRandom.hex, pronouns: SecureRandom.hex,
@ -106,7 +107,7 @@ module Api
test 'se puede enviar mensajes a dominios propios' do test 'se puede enviar mensajes a dominios propios' do
ActionMailer::Base.deliveries.clear ActionMailer::Base.deliveries.clear
@site.update name: 'example.org.' @site.update name: 'example'
redirect = "#{@site.url}?thanks" redirect = "#{@site.url}?thanks"
@ -130,6 +131,34 @@ module Api
assert_equal redirect, response.headers['Location'] assert_equal redirect, response.headers['Location']
assert_equal 2, ActionMailer::Base.deliveries.size assert_equal 2, ActionMailer::Base.deliveries.size
end 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 end
end end

View file

@ -23,7 +23,7 @@ module Api
test 'se puede obtener un listado de todos' do test 'se puede obtener un listado de todos' do
get v1_sites_url(host: "api.#{Site.domain}"), headers: @authorization, as: :json 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 end
end end

View file

@ -119,12 +119,7 @@ class SitesControllerTest < ActionDispatch::IntegrationTest
title: name, title: name,
description: name * 2, description: name * 2,
design_id: design.id, design_id: design.id,
licencia_id: Licencia.all.second.id, licencia_id: Licencia.all.second.id
deploys_attributes: {
'0' => {
type: 'DeployLocal'
}
}
} }
} }

View file

@ -9,11 +9,11 @@ FactoryBot.define do
licencia licencia
after :build do |site| after :build do |site|
site.deploys << build(:deploy_local, site: site) # XXX: Generamos un DeployLocal normalmente y no a través de una
end # Factory porque necesitamos que el sitio se genere solo.
#
after :create do |site| # @see {https://github.com/thoughtbot/factory_bot/wiki/How-factory_bot-interacts-with-ActiveRecord}
site.deploys << create(:deploy_local, site: site) site.deploys.build(type: 'DeployLocal')
end end
end end
end end

View file

@ -2,11 +2,7 @@
class DeployJobTest < ActiveSupport::TestCase class DeployJobTest < ActiveSupport::TestCase
test 'se puede compilar' do test 'se puede compilar' do
rol = create :rol site = create :site
site = rol.site
site.deploys << create(:deploy_zip, site: site)
site.save
DeployJob.perform_async(site.id) DeployJob.perform_async(site.id)

View file

@ -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

View file

@ -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

View file

@ -1,24 +1,44 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'test_helper'
class DeployLocalTest < ActiveSupport::TestCase 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 test 'se puede deployear' do
site = create :site deploy_local = @site.deploy_local
local = create :deploy_local, site: site
deploy = create :deploy_zip, site: site
# Primero tenemos que generar el sitio assert deploy_local.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 test 'al eliminarlos se elimina el directorio' do
assert File.file?(deploy.path) deploy_local = @site.deploy_local
assert_equal 'application/zip', assert deploy_local.destroy
`file --mime-type "#{escaped_path}"`.split(' ').last assert_not File.directory?(deploy_local.destination)
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
end end
end end

View file

@ -3,17 +3,23 @@
require 'test_helper' require 'test_helper'
class DeployWwwTest < ActiveSupport::TestCase 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 test 'se puede deployear' do
site = create :site assert @site.deploy_local.deploy
local = create :deploy_local, site: site
deploy = create :deploy_www, site: site
# Primero tenemos que generar el sitio assert @deploy_www.deploy
local.deploy assert File.symlink?(@deploy_www.destination)
assert deploy.deploy
assert File.symlink?(deploy.destination)
local.destroy
end end
end end

View file

@ -3,18 +3,29 @@
require 'test_helper' require 'test_helper'
class DeployZipTest < ActiveSupport::TestCase 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 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 @deploy_zip.deploy
assert File.directory?(deploy_local.destination) assert File.file?(@deploy_zip.path)
assert File.exist?(File.join(deploy_local.destination, 'index.html')) assert_equal 'application/zip',
assert_equal 3, deploy_local.build_stats.count `file --mime-type "#{@deploy_zip.path}"`.split.last
assert_equal 1, @deploy_zip.build_stats.count
assert deploy_local.build_stats.map(&:bytes).compact.inject(:+).positive? assert @deploy_zip.build_stats.map(&:bytes).inject(:+).positive?
assert deploy_local.build_stats.map(&:seconds).compact.inject(:+).positive? assert @deploy_zip.build_stats.map(&:seconds).inject(:+).positive?
assert deploy_local.destroy
assert_not File.directory?(deploy_local.destination)
end end
end end

View file

@ -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

View file

@ -26,18 +26,18 @@ class SiteTest < ActiveSupport::TestCase
assert_not site2.valid? assert_not site2.valid?
end 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 = build :site, name: 'hola.chau'
site.validate site.validate
assert_not site.errors.messages[:name].present? assert site.errors.messages[:name].present?
end 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 = build :site, name: 'hola.chau.'
site.validate site.validate
assert_not site.errors.messages[:name].present? assert site.errors.messages[:name].present?
end end
test 'el nombre del sitio no puede contener wildcard' do 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 test 'tienen un hostname que puede cambiar' do
assert_equal "#{site.name}.#{Site.domain}", site.hostname 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 end
test 'se pueden traer los datos de una plantilla' do test 'se pueden traer los datos de una plantilla' do