5
0
Fork 0
mirror of https://0xacab.org/sutty/sutty synced 2024-11-17 02:26:23 +00:00
panel/app/models/site.rb
f edff238a36 optimizar la lectura de datos desde jekyll
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.
2021-05-07 19:02:15 -03:00

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