5
0
Fork 0
mirror of https://0xacab.org/sutty/sutty synced 2024-11-25 04:56:23 +00:00
panel/app/models/site.rb

631 lines
16 KiB
Ruby
Raw Permalink Normal View History

2019-03-26 15:32:20 +00:00
# frozen_string_literal: true
2018-01-29 22:19:10 +00:00
# Un sitio es un directorio dentro del directorio de sitios de cada
# Usuaria
2019-07-03 22:04:50 +00:00
class Site < ApplicationRecord
2019-07-03 23:40:24 +00:00
include FriendlyId
2020-05-30 19:43:25 +00:00
include Site::Forms
2020-06-09 14:22:13 +00:00
include Site::FindAndReplace
include Site::Api
include Site::DeployDependencies
include Site::BuildStats
2023-04-17 21:30:21 +00:00
include Site::LayoutOrdering
2023-08-29 20:43:19 +00:00
include Site::SocialDistributedPress
include Site::DefaultOptions
2020-11-11 21:15:58 +00:00
include Tienda
2019-07-03 23:40:24 +00:00
self.filter_attributes += [/_key/, /_ciphertext\z/]
# 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!
2024-02-23 15:53:56 +00:00
has_encrypted :private_key
2019-11-18 17:08:30 +00:00
validates :name, uniqueness: true, hostname: {
allow_root_label: true
}
2019-07-17 22:18:48 +00:00
validates :design_id, presence: true
2019-07-26 01:11:01 +00:00
validates_inclusion_of :status, in: %w[waiting enqueued building]
2019-07-31 20:55:34 +00:00
validates_presence_of :title
2021-11-04 13:42:57 +00:00
validates :description, length: { in: 10..160 }
validate :deploy_local_presence
validate :compatible_layouts, on: :update
attr_reader :incompatible_layouts
2019-07-03 23:40:24 +00:00
friendly_id :name, use: %i[finders]
2019-07-17 22:18:48 +00:00
belongs_to :design
2019-07-19 22:37:53 +00:00
belongs_to :licencia
2019-07-17 22:18:48 +00:00
has_many :stats
has_many :log_entries, dependent: :destroy
has_many :deploys, dependent: :destroy
2019-08-02 00:20:42 +00:00
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
2019-07-03 22:04:50 +00:00
2019-08-22 01:09:29 +00:00
# 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!
2019-07-12 18:34:16 +00:00
# Elimina el directorio al destruir un sitio
before_destroy :remove_directories!
2019-07-13 00:20:36 +00:00
# Cambiar el nombre del directorio
before_update :update_name!
2020-08-20 23:38:31 +00:00
before_save :add_private_key_if_missing!
2019-07-31 20:55:34 +00:00
# Guardar la configuración si hubo cambios
after_save :sync_attributes_with_config!
2019-07-03 23:25:23 +00:00
accepts_nested_attributes_for :deploys, allow_destroy: true
2021-05-14 19:59:47 +00:00
# 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
2020-05-30 19:43:25 +00:00
# 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)
2021-05-06 20:54:49 +00:00
"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|
2021-05-06 20:54:49 +00:00
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|
2021-05-06 20:54:49 +00:00
"https://#{h}#{slash ? '/' : ''}"
2020-05-30 19:43:25 +00:00
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)
2020-06-25 19:33:36 +00:00
!invitades.find_by(id: usuarie.id).nil?
end
def usuarie?(usuarie)
2020-06-25 19:33:36 +00:00
!usuaries.find_by(id: usuarie.id).nil?
2019-07-03 23:25:23 +00:00
end
# Este sitio acepta invitades?
def invitades?
2020-08-22 20:56:37 +00:00
acepta_invitades || config.fetch('invitades', false)
end
2019-07-03 23:25:23 +00:00
# Traer la ruta del sitio
def path
2024-01-12 20:36:31 +00:00
::File.join(Site.site_path, name)
2019-07-13 00:20:36 +00:00
end
# La ruta anterior
def path_was
2024-01-12 20:36:31 +00:00
::File.join(Site.site_path, name_was)
2019-07-03 23:25:23 +00:00
end
2018-01-29 22:19:10 +00:00
# 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)
2024-01-12 20:36:31 +00:00
::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]
2019-09-20 13:19:04 +00:00
def locales
@locales ||= config.fetch('locales', I18n.available_locales).map(&:to_sym)
2018-05-11 16:24:30 +00:00
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.
2019-09-20 13:19:04 +00:00
def default_locale
locales.first
2018-02-22 19:01:11 +00:00
end
2019-09-20 13:19:04 +00:00
alias default_lang default_locale
2018-02-22 19:01:11 +00:00
# Trae los datos del directorio _data dentro del sitio
2018-02-09 21:28:27 +00:00
def data
unless jekyll.data.present?
jekyll.reader.read_data
jekyll.data['layouts'] ||= {}
end
jekyll.data
2018-02-09 21:28:27 +00:00
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
2018-02-22 19:01:11 +00:00
end
2018-01-29 22:19:10 +00:00
# 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
2018-02-22 19:01:11 +00:00
#
# @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?
2018-04-27 18:48:26 +00:00
@posts[lang] = PostRelation.new site: self, lang: lang
# No fallar si no existe colección para este idioma
# XXX: queremos fallar silenciosamente?
2020-10-04 00:32:32 +00:00
(collections[lang.to_s]&.docs || []).each do |doc|
layout = layouts[Post.find_layout(doc.path)]
2018-01-29 22:19:10 +00:00
@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 })
2018-02-23 19:20:51 +00:00
end
2018-01-29 22:19:10 +00:00
@posts[lang]
end
2020-05-23 15:38:03 +00:00
# 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|
2020-05-23 15:38:03 +00:00
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
return {} if data['layouts'].blank?
# Crea un Struct dinámico cuyas llaves son los nombres de todos los
# layouts. Si pasamos un layout que no existe, obtenemos un
# NoMethodError
2020-06-16 22:10:54 +00:00
@layouts_struct ||= Struct.new(*layout_keys, keyword_init: true)
2024-05-02 18:57:49 +00:00
@layouts ||= @layouts_struct.new(**data['layouts'].to_h do |name, metadata|
2021-05-06 20:54:49 +00:00
[name.to_sym,
Layout.new(site: self, name: name.to_sym, meta: metadata.delete('meta')&.with_indifferent_access,
metadata: metadata.with_indifferent_access)]
2024-05-02 18:57:49 +00:00
end)
2018-02-08 14:05:05 +00:00
end
# TODO: Si la estructura de datos no existe, vamos a producir una
# excepción.
2020-06-16 22:10:54 +00:00
def layout_keys
@layout_keys ||= data['layouts'].keys.map(&:to_sym)
2020-06-16 22:10:54 +00:00
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
#
2020-05-23 16:52:58 +00:00
# TODO: Mover a PostRelation#pluck
#
2020-05-23 15:38:03 +00:00
# @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
2020-05-23 15:38:03 +00:00
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
2019-07-26 01:11:01 +00:00
def enqueue!
update(status: 'enqueued') if waiting?
2018-02-20 17:47:11 +00:00
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.
2018-02-20 17:47:11 +00:00
def enqueued?
2019-07-26 01:11:01 +00:00
status == 'enqueued'
2018-02-20 17:47:11 +00:00
end
def waiting?
status == 'waiting'
end
def building?
status == 'building'
end
def jekyll?
2024-01-12 20:36:31 +00:00
::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
2024-01-12 20:36:31 +00:00
return unless name.present? && ::File.directory?(path)
reload_jekyll!
end
def reload_jekyll!
reset
jekyll
end
def reload
2024-05-02 18:57:49 +00:00
super.tap do |_s|
reload_jekyll!
end
self
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,
2024-01-12 20:36:31 +00:00
'destination' => ::File.join(path, '_site'),
'safe' => true, 'watch' => false,
'quiet' => true, 'excerpt_separator' => '')
2018-02-19 19:33:28 +00:00
# No necesitamos cargar plugins en este momento
%w[plugins gems].each do |unneeded|
@configuration[unneeded] = [] if @configuration.key? unneeded
2018-02-19 19:33:28 +00:00
end
2018-02-22 19:01:11 +00:00
# Si estamos usando nuestro propio plugin de i18n, los posts están
# en "colecciones"
locales.map(&:to_s).each do |i|
@configuration['collections'][i] = {}
2018-02-22 19:01:11 +00:00
end
@configuration
2018-02-19 19:33:28 +00:00
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
2024-01-12 20:36:31 +00:00
@site_path ||= ::File.realpath(ENV.fetch('SITE_PATH', Rails.root.join('_sites')))
end
2020-08-29 23:21:45 +00:00
def self.default
2021-05-06 20:54:49 +00:00
find_by(name: "#{Site.domain}.")
2020-08-29 23:21:45 +00:00
end
def self.one_at_a_time
@@one_at_a_time ||= Thread::Mutex.new
end
def reset
@read = false
@layouts = nil
@layouts_struct = nil
2020-09-29 21:22:28 +00:00
@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?
2024-01-12 20:36:31 +00:00
::File.exist? gemfile_lock_path
end
private
2020-08-20 23:38:31 +00:00
# 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?
2024-05-02 18:57:49 +00:00
Rugged::Repository.clone_at(ENV.fetch('SKEL_SUTTY', nil), path, checkout_branch: design.gem)
2023-09-21 15:25:23 +00:00
# Necesita un bloque
repository.rugged.remotes.rename('origin', 'upstream') {}
end
2019-07-12 19:11:07 +00:00
# Elimina el directorio del sitio
2019-07-12 18:34:16 +00:00
def remove_directories!
FileUtils.rm_rf path
end
2019-07-13 00:20:36 +00:00
def update_name!
return unless name_changed?
FileUtils.mv path_was, path
reload_jekyll!
2019-07-13 00:20:36 +00:00
end
2019-07-31 20:55:34 +00:00
# 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.
2019-07-31 20:55:34 +00:00
def sync_attributes_with_config!
config.theme = design.gem unless design.no_theme?
2019-08-09 19:49:25 +00:00
config.description = description
config.title = title
config.url ||= url(slash: false)
config.hostname ||= hostname
config.locales = locales.map(&:to_s)
2019-07-31 20:55:34 +00:00
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
2020-05-30 19:43:25 +00:00
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
2020-09-12 15:29:43 +00:00
if design.no_theme?
new_configuration.delete 'theme'
2020-09-25 00:16:15 +00:00
else
new_configuration['theme'] = design.gem
2020-09-12 15:29:43 +00:00
end
new_site = Jekyll::Site.new(new_configuration)
new_site.read
2020-09-28 22:28:57 +00:00
# No vale la pena seguir procesando si no hay artículos!
return if new_site.documents.empty?
new_site.documents.map(&:read!)
2020-09-28 22:28:57 +00:00
old_layouts = new_site.documents.map do |doc|
doc.data['layout']
end.uniq.compact
2020-09-28 22:28:57 +00:00
@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?
2023-05-13 20:42:14 +00:00
deploy_local = deploys.find_by_type('DeployLocal')
deploy_local.git_lfs
2024-05-02 18:57:49 +00:00
return unless !gems_installed? || gemfile_updated? || gemfile_lock_updated?
deploy_local.bundle
touch
FileUtils.touch(gemfile_path)
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?
2024-01-12 20:36:31 +00:00
updated_at < ::File.mtime(gemfile_path)
2023-09-26 17:58:24 +00:00
end
def gemfile_path
2024-01-12 20:36:31 +00:00
@gemfile_path ||= ::File.join(path, 'Gemfile')
end
# @return [String]
def gemfile_lock_path
2024-01-12 20:36:31 +00:00
@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?
2024-01-12 20:36:31 +00:00
[updated_at, ::File.mtime(::File.join(path, 'Gemfile'))].any? do |compare|
compare < ::File.mtime(gemfile_lock_path)
end
end
2018-01-29 22:19:10 +00:00
end