mirror of
https://0xacab.org/sutty/sutty
synced 2024-11-17 02:26:23 +00:00
edff238a36
durante el proceso de compilación de jekyll se cargan todos los datos en memoria, buscando e interpretando todos los archivos del sitio. en el caso de sutty, solo queremos leer alguna información por vez. trabajando en el buscador me dí cuenta que aunque el panel cargue los posts desde la base de datos, sutty seguía leyendo la información completa del sitio, porque respetaba el proceso de lectura de jekyll. con este cambio podemos leer los _data/ por separado de los _posts/ con lo que la carga del sitio es mucho más rápida.
508 lines
14 KiB
Ruby
508 lines
14 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 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
|
|
|
|
# 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_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
|
|
|
|
friendly_id :name, use: %i[finders]
|
|
|
|
belongs_to :design
|
|
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
|
|
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!
|
|
# Carga el sitio Jekyll una vez que se inicializa el modelo o después
|
|
# de crearlo
|
|
after_initialize :load_jekyll
|
|
after_create :load_jekyll, :static_file_migration!
|
|
# 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
|
|
|
|
# El sitio en Jekyll
|
|
attr_reader :jekyll
|
|
|
|
# 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
|
|
|
|
# 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
|
|
|
|
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.
|
|
def locales
|
|
@locales ||= config.fetch('locales', I18n.available_locales).map(&:to_sym)
|
|
end
|
|
|
|
# Similar a site.i18n en jekyll-locales
|
|
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
|
|
|
|
# Define los valores por defecto según la llave buscada
|
|
@jekyll.data.default_proc = proc do |data, key|
|
|
data[key] = case key
|
|
when 'layout' then {}
|
|
end
|
|
end
|
|
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)
|
|
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
|
|
|
|
# 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!
|
|
!enqueued? && update_attribute(:status, 'enqueued')
|
|
end
|
|
|
|
# Está en la cola de compilación?
|
|
def enqueued?
|
|
status == 'enqueued'
|
|
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
|
|
|
|
Dir.chdir(path) do
|
|
@jekyll = Jekyll::Site.new(configuration)
|
|
end
|
|
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
|
|
|
|
# Eliminar el theme si no es una gema válida
|
|
@configuration.delete('theme') unless theme_available?
|
|
|
|
# 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
|
|
|
|
# Lista los nombres de las plantillas disponibles como gemas,
|
|
# tomándolas dinámicamente de las que agreguemos en el grupo :themes
|
|
# del Gemfile.
|
|
def available_themes
|
|
@available_themes ||= Bundler.load.current_dependencies.select do |gem|
|
|
gem.groups.include? :themes
|
|
end.map(&:name)
|
|
end
|
|
|
|
# Detecta si el tema actual es una gema
|
|
def theme_available?
|
|
available_themes.include? design&.gem
|
|
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 ||= ENV.fetch('SITE_PATH', Rails.root.join('_sites'))
|
|
end
|
|
|
|
def self.default
|
|
find_by(name: "#{Site.domain}.")
|
|
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
|
|
|
|
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 File.directory? path
|
|
|
|
Rugged::Repository.clone_at ENV['SKEL_SUTTY'], path
|
|
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
|
|
config.hostname = hostname
|
|
end
|
|
|
|
# Migra los archivos a Sutty
|
|
def static_file_migration!
|
|
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
|
|
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
|
|
end
|