mirror of
https://0xacab.org/sutty/sutty
synced 2024-11-28 16:46:21 +00:00
623 lines
16 KiB
Ruby
623 lines
16 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
# Un sitio es un directorio dentro del directorio de sitios de cada
|
|
# Usuaria
|
|
class Site < ApplicationRecord
|
|
include FriendlyId
|
|
include Site::Forms
|
|
include Site::FindAndReplace
|
|
include Site::Api
|
|
include Site::DeployDependencies
|
|
include Site::BuildStats
|
|
include Site::LayoutOrdering
|
|
include Site::SocialDistributedPress
|
|
include Tienda
|
|
|
|
# Cifrar la llave privada que cifra y decifra campos ocultos. Sutty
|
|
# tiene acceso pero los datos se guardan cifrados en el sitio. Esto
|
|
# protege información privada en repositorios públicos, pero no la
|
|
# protege de acceso al panel de Sutty!
|
|
encrypts :private_key
|
|
|
|
validates :name, uniqueness: true, hostname: {
|
|
allow_root_label: true
|
|
}
|
|
|
|
validates :design_id, presence: true
|
|
validates_inclusion_of :status, in: %w[waiting enqueued building]
|
|
validates_presence_of :title
|
|
validates :description, length: { in: 10..160 }
|
|
validate :deploy_local_presence
|
|
validate :compatible_layouts, on: :update
|
|
|
|
attr_reader :incompatible_layouts
|
|
|
|
friendly_id :name, use: %i[finders]
|
|
|
|
belongs_to :design
|
|
belongs_to :licencia
|
|
|
|
has_many :stats
|
|
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
|
|
has_many :invitades, -> { where('roles.rol = ?', 'invitade') },
|
|
through: :roles, source: :usuarie
|
|
|
|
# Mantenemos el nombre que les da Jekyll
|
|
has_many_attached :static_files
|
|
|
|
# Clonar el directorio de esqueleto antes de crear el sitio
|
|
before_create :clone_skel!
|
|
# Elimina el directorio al destruir un sitio
|
|
before_destroy :remove_directories!
|
|
# Cambiar el nombre del directorio
|
|
before_update :update_name!
|
|
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
|
|
|
|
# XXX: Es importante incluir luego de los callbacks de :load_jekyll
|
|
include Site::Index
|
|
|
|
# No permitir HTML en estos atributos
|
|
def title=(title)
|
|
super(title.strip_tags)
|
|
end
|
|
|
|
def description=(description)
|
|
super(description.strip_tags)
|
|
end
|
|
|
|
# El repositorio git para este sitio
|
|
def repository
|
|
@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
|
|
|
|
# TODO: Cambiar al mergear origin-referer
|
|
#
|
|
# @return [Array]
|
|
def hostnames
|
|
@hostnames ||= deploys.map do |deploy|
|
|
case deploy
|
|
when DeployLocal
|
|
hostname
|
|
when DeployWww
|
|
deploy.fqdn
|
|
when DeployAlternativeDomain
|
|
deploy.hostname.dup.tap do |h|
|
|
h.replace(h.end_with?('.') ? h[0..-2] : "#{h}.#{Site.domain}")
|
|
end
|
|
when DeployHiddenService
|
|
deploy.onion
|
|
end
|
|
end.compact
|
|
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)
|
|
@urls ||= hostnames.map do |h|
|
|
"https://#{h}#{slash ? '/' : ''}"
|
|
end
|
|
end
|
|
|
|
def invitade?(usuarie)
|
|
!invitades.find_by(id: usuarie.id).nil?
|
|
end
|
|
|
|
def usuarie?(usuarie)
|
|
!usuaries.find_by(id: usuarie.id).nil?
|
|
end
|
|
|
|
# Este sitio acepta invitades?
|
|
def invitades?
|
|
acepta_invitades || config.fetch('invitades', false)
|
|
end
|
|
|
|
# Traer la ruta del sitio
|
|
def path
|
|
::File.join(Site.site_path, name)
|
|
end
|
|
|
|
# La ruta anterior
|
|
def path_was
|
|
::File.join(Site.site_path, name_was)
|
|
end
|
|
|
|
# Limpiar la ruta y unirla con el separador de directorios del
|
|
# sistema operativo. Como si algún día fuera a cambiar o
|
|
# soportáramos Windows :P
|
|
def relative_path(suspicious_path)
|
|
::File.join(path, *suspicious_path.gsub('..', '/').gsub('./', '').squeeze('/').split('/'))
|
|
end
|
|
|
|
# Obtiene la lista de traducciones actuales
|
|
#
|
|
# Siempre tiene que tener algo porque las traducciones están
|
|
# incorporadas a los sitios de Sutty, aunque les usuaries no traduzcan
|
|
# sus sitios.
|
|
#
|
|
# @return [Array]
|
|
def locales
|
|
@locales ||= config.fetch('locales', I18n.available_locales).map(&:to_sym)
|
|
end
|
|
|
|
# Modificar los locales disponibles
|
|
#
|
|
# @param :new_locales [Array]
|
|
# @return [Array]
|
|
def locales=(new_locales)
|
|
@locales = new_locales.map(&:to_sym).uniq
|
|
end
|
|
|
|
# Similar a site.i18n en jekyll-locales
|
|
#
|
|
# @return [Hash]
|
|
def i18n
|
|
data[I18n.locale.to_s] || {}
|
|
end
|
|
|
|
# Devuelve el idioma por defecto del sitio, el primero de la lista.
|
|
def default_locale
|
|
locales.first
|
|
end
|
|
alias default_lang default_locale
|
|
|
|
# Trae los datos del directorio _data dentro del sitio
|
|
def data
|
|
unless jekyll.data.present?
|
|
jekyll.reader.read_data
|
|
jekyll.data['layouts'] ||= {}
|
|
end
|
|
|
|
jekyll.data
|
|
end
|
|
|
|
# Traer las colecciones. Todos los artículos van a estar dentro de
|
|
# colecciones.
|
|
def collections
|
|
unless @read
|
|
jekyll.reader.read_collections
|
|
|
|
@read = true
|
|
end
|
|
|
|
jekyll.collections
|
|
end
|
|
|
|
# Traer la configuración de forma modificable
|
|
def config
|
|
@config ||= Site::Config.new(self)
|
|
end
|
|
|
|
# Los posts en el idioma actual o en uno en particular
|
|
#
|
|
# @param lang: [String|Symbol] traer los artículos de este idioma
|
|
def posts(lang: nil)
|
|
# Traemos los posts del idioma actual por defecto o el que haya
|
|
lang ||= locales.include?(I18n.locale) ? I18n.locale : default_locale
|
|
lang = lang.to_sym
|
|
|
|
# Crea un Struct dinámico con los valores de los locales, si
|
|
# llegamos a pasar un idioma que no existe vamos a tener una
|
|
# excepción NoMethodError
|
|
@posts ||= Struct.new(*locales).new
|
|
|
|
return @posts[lang] unless @posts[lang].blank?
|
|
|
|
@posts[lang] = PostRelation.new site: self, lang: lang
|
|
|
|
# No fallar si no existe colección para este idioma
|
|
# XXX: queremos fallar silenciosamente?
|
|
(collections[lang.to_s]&.docs || []).each do |doc|
|
|
layout = layouts[Post.find_layout(doc.path)]
|
|
|
|
@posts[lang].build(document: doc, layout: layout, lang: lang)
|
|
rescue TypeError => e
|
|
ExceptionNotifier.notify_exception(e, data: { site: name, site_id: id, path: doc.path })
|
|
end
|
|
|
|
@posts[lang]
|
|
end
|
|
|
|
# Todos los Post del sitio para poder buscar en todos.
|
|
#
|
|
# @return PostRelation
|
|
def docs
|
|
@docs ||= PostRelation.new(site: self, lang: :docs).push(locales.flat_map do |locale|
|
|
posts(lang: locale)
|
|
end).flatten!
|
|
end
|
|
|
|
# Elimina un artículo de la colección
|
|
def delete_post(post)
|
|
lang = post.lang.value
|
|
|
|
collections[lang.to_s].docs.delete(post.document) &&
|
|
posts(lang: lang).delete(post)
|
|
|
|
post
|
|
end
|
|
|
|
# Obtiene todas las plantillas de artículos
|
|
#
|
|
# @return [Hash] { post: Layout }
|
|
def layouts
|
|
# Crea un Struct dinámico cuyas llaves son los nombres de todos los
|
|
# layouts. Si pasamos un layout que no existe, obtenemos un
|
|
# NoMethodError
|
|
@layouts_struct ||= Struct.new(*layout_keys, keyword_init: true)
|
|
@layouts ||= @layouts_struct.new(**data['layouts'].map do |name, metadata|
|
|
[name.to_sym,
|
|
Layout.new(site: self, name: name.to_sym, meta: metadata.delete('meta')&.with_indifferent_access,
|
|
metadata: metadata.with_indifferent_access)]
|
|
end.to_h)
|
|
end
|
|
|
|
# TODO: Si la estructura de datos no existe, vamos a producir una
|
|
# excepción.
|
|
def layout_keys
|
|
@layout_keys ||= data['layouts'].keys.map(&:to_sym)
|
|
end
|
|
|
|
# Consulta si el Layout existe
|
|
#
|
|
# @param [String,Symbol] El nombre del Layout
|
|
# @return [Boolean]
|
|
def layout?(layout)
|
|
layout_keys.include? layout.to_sym
|
|
end
|
|
|
|
# Lee los layouts en HTML desde el sitio
|
|
#
|
|
# @return [Hash]
|
|
def theme_layouts
|
|
jekyll.reader.read_layouts
|
|
end
|
|
|
|
# Trae todos los valores disponibles para un campo
|
|
#
|
|
# TODO: Traer recursivamente, si el campo contiene Hash
|
|
#
|
|
# TODO: Mover a PostRelation#pluck
|
|
#
|
|
# @param attr [Symbol|String] El atributo a buscar
|
|
# @return Array
|
|
def everything_of(attr, lang: nil)
|
|
Rails.cache.fetch("#{cache_key_with_version}/everything_of/#{lang}/#{attr}", expires_in: 1.hour) do
|
|
attr = attr.to_sym
|
|
|
|
posts(lang: lang).flat_map do |p|
|
|
p[attr].value if p.attribute? attr
|
|
end.uniq.compact
|
|
end
|
|
end
|
|
|
|
# Poner en la cola de compilación
|
|
def enqueue!
|
|
update(status: 'enqueued') if waiting?
|
|
end
|
|
|
|
# Está en la cola de compilación?
|
|
#
|
|
# TODO: definir todos estos métodos dinámicamente, aunque todavía no
|
|
# tenemos una máquina de estados propiamente dicha.
|
|
def enqueued?
|
|
status == 'enqueued'
|
|
end
|
|
|
|
def waiting?
|
|
status == 'waiting'
|
|
end
|
|
|
|
def building?
|
|
status == 'building'
|
|
end
|
|
|
|
def jekyll?
|
|
::File.directory? path
|
|
end
|
|
|
|
def jekyll
|
|
@jekyll ||=
|
|
begin
|
|
install_gems
|
|
|
|
Jekyll::Site.new(configuration)
|
|
end
|
|
end
|
|
|
|
# Cargar el sitio Jekyll
|
|
#
|
|
# TODO: En lugar de leer todo junto de una vez, extraer la carga de
|
|
# documentos de Jekyll hacia Sutty para que podamos leer los datos que
|
|
# necesitamos.
|
|
def load_jekyll
|
|
return unless name.present? && ::File.directory?(path)
|
|
|
|
reload_jekyll!
|
|
end
|
|
|
|
def reload_jekyll!
|
|
reset
|
|
jekyll
|
|
end
|
|
|
|
def reload
|
|
super
|
|
reload_jekyll!
|
|
end
|
|
|
|
def configuration
|
|
return @configuration if @configuration
|
|
|
|
# Pasamos destination porque configuration() toma el directorio
|
|
# actual
|
|
#
|
|
# Especificamos `safe` para no cargar los _plugins, que interfieren
|
|
# entre sitios incluso.
|
|
#
|
|
# excerpt_separator está vacío para no incorporar el Excerpt en los
|
|
# metadatos de Document
|
|
@configuration =
|
|
::Jekyll.configuration('source' => path,
|
|
'destination' => ::File.join(path, '_site'),
|
|
'safe' => true, 'watch' => false,
|
|
'quiet' => true, 'excerpt_separator' => '')
|
|
|
|
# No necesitamos cargar plugins en este momento
|
|
%w[plugins gems].each do |unneeded|
|
|
@configuration[unneeded] = [] if @configuration.key? unneeded
|
|
end
|
|
|
|
# Si estamos usando nuestro propio plugin de i18n, los posts están
|
|
# en "colecciones"
|
|
locales.map(&:to_s).each do |i|
|
|
@configuration['collections'][i] = {}
|
|
end
|
|
|
|
@configuration
|
|
end
|
|
|
|
# Devuelve el dominio actual
|
|
def self.domain
|
|
ENV.fetch('SUTTY', 'sutty.nl')
|
|
end
|
|
|
|
# El directorio donde se almacenan los sitios
|
|
def self.site_path
|
|
@site_path ||= ::File.realpath(ENV.fetch('SITE_PATH', Rails.root.join('_sites')))
|
|
end
|
|
|
|
def self.default
|
|
find_by(name: "#{Site.domain}.")
|
|
end
|
|
|
|
def self.one_at_a_time
|
|
@@one_at_a_time ||= Thread::Mutex.new
|
|
end
|
|
|
|
def reset
|
|
@read = false
|
|
@layouts = nil
|
|
@layouts_struct = nil
|
|
@layout_keys = nil
|
|
@configuration = nil
|
|
@repository = nil
|
|
@incompatible_layouts = nil
|
|
@jekyll = nil
|
|
@config = nil
|
|
@posts = nil
|
|
@docs = nil
|
|
end
|
|
|
|
# @return [Pathname]
|
|
def bundle_path
|
|
@bundle_path ||= Rails.root.join('_storage', 'gems', name)
|
|
end
|
|
|
|
def gemfile_lock_path?
|
|
::File.exist? gemfile_lock_path
|
|
end
|
|
|
|
private
|
|
|
|
# Asegurarse que el sitio tenga una llave privada
|
|
def add_private_key_if_missing!
|
|
self.private_key ||= Lockbox.generate_key
|
|
end
|
|
|
|
# Clona el esqueleto de Sutty para crear el sitio nuevo, no pasa nada
|
|
# si el sitio ya existe
|
|
def clone_skel!
|
|
return if jekyll?
|
|
|
|
Rugged::Repository.clone_at(ENV['SKEL_SUTTY'], path, checkout_branch: design.gem)
|
|
|
|
# Necesita un bloque
|
|
repository.rugged.remotes.rename('origin', 'upstream') {}
|
|
end
|
|
|
|
# Elimina el directorio del sitio
|
|
def remove_directories!
|
|
FileUtils.rm_rf path
|
|
end
|
|
|
|
def update_name!
|
|
return unless name_changed?
|
|
|
|
FileUtils.mv path_was, path
|
|
reload_jekyll!
|
|
end
|
|
|
|
# Sincroniza algunos atributos del sitio con su configuración y
|
|
# guarda los cambios
|
|
#
|
|
# TODO: Guardar la configuración también, quizás aprovechando algún
|
|
# método de ActiveRecord para que lance un salvado recursivo.
|
|
def sync_attributes_with_config!
|
|
config.theme = design.gem unless design.no_theme?
|
|
config.description = description
|
|
config.title = title
|
|
config.url ||= url(slash: false)
|
|
config.hostname ||= hostname
|
|
config.locales = locales.map(&:to_s)
|
|
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
|
|
return unless design_id_changed?
|
|
|
|
new_configuration = configuration.dup
|
|
|
|
if design.no_theme?
|
|
new_configuration.delete 'theme'
|
|
else
|
|
new_configuration['theme'] = design.gem
|
|
end
|
|
|
|
new_site = Jekyll::Site.new(new_configuration)
|
|
new_site.read
|
|
|
|
# No vale la pena seguir procesando si no hay artículos!
|
|
return if new_site.documents.empty?
|
|
|
|
new_site.documents.map(&:read!)
|
|
|
|
old_layouts = new_site.documents.map do |doc|
|
|
doc.data['layout']
|
|
end.uniq.compact
|
|
|
|
@incompatible_layouts = old_layouts - new_site.layouts.keys
|
|
|
|
return if @incompatible_layouts.empty?
|
|
|
|
@incompatible_layouts.map! do |layout|
|
|
i18n.dig('layouts', layout) || layout
|
|
end
|
|
|
|
errors.add(:design_id,
|
|
I18n.t('activerecord.errors.models.site.attributes.design_id.layout_incompatible.error'))
|
|
end
|
|
|
|
def run_in_path(&block)
|
|
Site.one_at_a_time.synchronize do
|
|
Dir.chdir path, &block
|
|
end
|
|
end
|
|
|
|
# Instala las gemas cuando es necesario:
|
|
#
|
|
# * El sitio existe
|
|
# * No están instaladas
|
|
# * El archivo Gemfile se modificó
|
|
# * El archivo Gemfile.lock se modificó
|
|
def install_gems
|
|
return unless persisted?
|
|
|
|
deploy_local = deploys.find_by_type('DeployLocal')
|
|
deploy_local.git_lfs
|
|
|
|
if !gems_installed? || gemfile_updated? || gemfile_lock_updated?
|
|
deploy_local.bundle
|
|
touch
|
|
::File.touch(gemfile_path)
|
|
end
|
|
end
|
|
|
|
def gem_path
|
|
@gem_path ||=
|
|
begin
|
|
ruby_version = Gem::Version.new(RUBY_VERSION)
|
|
ruby_version.canonical_segments[2] = 0
|
|
|
|
bundle_path.join('ruby', ruby_version.canonical_segments.join('.'))
|
|
end
|
|
end
|
|
|
|
# Detecta si el repositorio de gemas existe
|
|
def gems_installed?
|
|
gem_path.directory? && !gem_path.empty?
|
|
end
|
|
|
|
# Detecta si el Gemfile fue modificado
|
|
def gemfile_updated?
|
|
updated_at < ::File.mtime(gemfile_path)
|
|
end
|
|
|
|
def gemfile_path
|
|
@gemfile_path ||= ::File.join(path, 'Gemfile')
|
|
end
|
|
|
|
# @return [String]
|
|
def gemfile_lock_path
|
|
@gemfile_lock_path ||= ::File.join(path, 'Gemfile.lock')
|
|
end
|
|
|
|
# Detecta si el Gemfile.lock fue modificado con respecto al sitio o al
|
|
# Gemfile.
|
|
def gemfile_lock_updated?
|
|
return false unless gemfile_lock_path?
|
|
|
|
[updated_at, ::File.mtime(::File.join(path, 'Gemfile'))].any? do |compare|
|
|
compare < ::File.mtime(gemfile_lock_path)
|
|
end
|
|
end
|
|
end
|