# 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 :access_logs, through: :deploys 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!, :update_deploy_local_hostname!, if: :name_changed? before_save :add_private_key_if_missing! # Guardar la configuración si hubo cambios after_save :sync_attributes_with_config! accepts_nested_attributes_for :deploys, allow_destroy: true # El sitio en Jekyll attr_reader :jekyll # 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 # El primer deploy del sitio # # @return [DeployLocal] def deploy_local @deploy_local ||= deploys.order(created_at: :asc).find_by(type: 'DeployLocal') end # Todas las URLs posibles para este sitio, ordenados según fecha de # creación. # # @param :slash [Boolean] Con o sin / al final, por defecto con # @return [Array] def urls(slash: true) deploys.order(created_at: :asc).map(&:url).map do |url| slash ? "#{url}/" : url end end # Todos los hostnames, ordenados según fecha de creación. # # @return [Array] def hostnames deploys.order(created_at: :asc).pluck(:hostname) end # Obtiene la URL principal # # @param :slash [Boolean] # @return [String] def url(slash: true) deploy_local.url.tap do |url| "#{url}/" if slash end end # TODO: Usar DeployCanonical def hostname deploy_local.hostname 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 # 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 # 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! 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 # Si cambia el nombre queremos actualizarlo en el DeployLocal def update_deploy_local_hostname! deploy_local&.update hostname: name 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