# 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 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 distributed_press].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: 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 Site.one_at_a_time.synchronize do jekyll.reader.read_collections end @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) 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 # 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 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(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 def gemfile_lock_updated? return false unless gemfile_lock_path? updated_at < File.mtime(gemfile_lock_path) end end