5
0
Fork 0
mirror of https://0xacab.org/sutty/sutty synced 2025-03-14 21:08:18 +00:00

Merge branch 'production.panel.sutty.nl' into 'rails'

Draft: Producción

Closes #17598, #17571, #17593, #17530, #17575, #17576, #17577, #17580, #17581, #17582, #17584, #17586, #17588, #17589, #17590, #17534, #17533, #16100, #13603, #12410, and #12753

See merge request sutty/sutty!265
This commit is contained in:
fauno 2025-01-27 23:19:22 +00:00
commit b268861123
271 changed files with 3597 additions and 674 deletions

View file

@ -22,8 +22,6 @@ RUN apk add npm && npm install -g pnpm@~7 && apk del npm
COPY ./monit.conf /etc/monit.d/sutty.conf COPY ./monit.conf /etc/monit.d/sutty.conf
RUN apk add npm && npm install -g pnpm && apk del npm
VOLUME "/srv" VOLUME "/srv"
EXPOSE 3000 EXPOSE 3000

View file

@ -79,8 +79,8 @@ gem 'webpacker'
gem 'yaml_db', git: 'https://0xacab.org/sutty/yaml_db.git' gem 'yaml_db', git: 'https://0xacab.org/sutty/yaml_db.git'
gem 'kaminari' gem 'kaminari'
gem 'device_detector' gem 'device_detector'
gem 'htmlbeautifier'
gem 'dry-schema' gem 'dry-schema'
gem 'htmlbeautifier'
gem 'rubanok' gem 'rubanok'
gem 'after_commit_everywhere', '~> 1.0' gem 'after_commit_everywhere', '~> 1.0'
@ -118,9 +118,8 @@ group :development, :test do
gem 'derailed_benchmarks' gem 'derailed_benchmarks'
gem 'dotenv-rails' gem 'dotenv-rails'
gem 'pry' gem 'pry'
# Adds support for Capybara system testing and selenium driver gem 'capybara'
gem 'capybara', '~> 2.13' gem 'selenium-webdriver'
gem 'selenium-webdriver', '~> 4.8.0'
gem 'sqlite3' gem 'sqlite3'
end end

View file

@ -118,13 +118,15 @@ GEM
bundler-audit (0.9.1) bundler-audit (0.9.1)
bundler (>= 1.2.0, < 3) bundler (>= 1.2.0, < 3)
thor (~> 1.0) thor (~> 1.0)
capybara (2.18.0) capybara (3.40.0)
addressable addressable
matrix
mini_mime (>= 0.1.3) mini_mime (>= 0.1.3)
nokogiri (>= 1.3.3) nokogiri (~> 1.11)
rack (>= 1.0.0) rack (>= 1.6.0)
rack-test (>= 0.5.4) rack-test (>= 0.6.3)
xpath (>= 2.0, < 4.0) regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2)
chartkick (5.0.2) chartkick (5.0.2)
climate_control (1.2.0) climate_control (1.2.0)
coderay (1.1.3) coderay (1.1.3)
@ -360,6 +362,7 @@ GEM
net-pop net-pop
net-smtp net-smtp
marcel (1.0.4) marcel (1.0.4)
matrix (0.4.2)
memory_profiler (1.0.1) memory_profiler (1.0.1)
mercenary (0.4.0) mercenary (0.4.0)
method_source (1.1.0) method_source (1.1.0)
@ -542,7 +545,7 @@ GEM
sprockets (> 3.0) sprockets (> 3.0)
sprockets-rails sprockets-rails
tilt tilt
selenium-webdriver (4.8.6) selenium-webdriver (4.9.1)
rexml (~> 3.2, >= 3.2.5) rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 3.0) rubyzip (>= 1.2.2, < 3.0)
websocket (~> 1.0) websocket (~> 1.0)
@ -632,7 +635,7 @@ DEPENDENCIES
bootstrap (~> 4) bootstrap (~> 4)
brakeman brakeman
bundler-audit bundler-audit
capybara (~> 2.13) capybara
chartkick chartkick
commonmarker commonmarker
concurrent-ruby-ext concurrent-ruby-ext
@ -707,7 +710,7 @@ DEPENDENCIES
safe_yaml safe_yaml
safely_block (~> 0.3.0) safely_block (~> 0.3.0)
sassc-rails sassc-rails
selenium-webdriver (~> 4.8.0) selenium-webdriver
sourcemap sourcemap
spring spring
spring-watcher-listen spring-watcher-listen

View file

@ -1,13 +1,6 @@
migrate: bundle exec rake db:prepare db:seed
sutty: bundle exec puma config.ru
blazer_5m: bundle exec rake blazer:run_checks SCHEDULE="5 minutes"
blazer_1h: bundle exec rake blazer:run_checks SCHEDULE="1 hour"
blazer_1d: bundle exec rake blazer:run_checks SCHEDULE="1 day"
blazer: bundle exec rake blazer:send_failing_checks
prometheus: bundle exec prometheus_exporter -b 0.0.0.0 --prefix "sutty_"
distributed_press_tokens_renew: bundle exec rake distributed_press:tokens:renew
cleanup: bundle exec rake cleanup:everything cleanup: bundle exec rake cleanup:everything
distributed_press_tokens_renew: bundle exec rake distributed_press:tokens:renew
emergency_cleanup: bundle exec rake cleanup:everything BEFORE=7 emergency_cleanup: bundle exec rake cleanup:everything BEFORE=7
stats: bundle exec rake stats:process_all
que: daemonize -c /srv/ -p /srv/tmp/que.pid -u rails /usr/local/bin/syslogize bundle exec que que: daemonize -c /srv/ -p /srv/tmp/que.pid -u rails /usr/local/bin/syslogize bundle exec que
stats: bundle exec rake stats:process_all
fediblock: bundle exec rails activity_pub:fediblocks fediblock: bundle exec rails activity_pub:fediblocks

View file

View file

@ -11,6 +11,21 @@ $colors: (
"magenta": $magenta "magenta": $magenta
); );
// TODO: Encontrar la forma de generar esto desde los locales de Rails
$custom-file-text: (
en: "Browse",
es: "Buscar archivo",
pt: "Buscar ficheiro",
pt-BR: "Buscar arquivo"
);
$custom-file-text-replace: (
en: "Replace file",
es: "Reemplazar archivo",
pt: "substituir ficheiro",
pt-BR: "substituir arquivo"
);
// Redefinir variables de Bootstrap // Redefinir variables de Bootstrap
$primary: $magenta; $primary: $magenta;
$secondary: $black; $secondary: $black;
@ -20,6 +35,19 @@ $form-feedback-valid-color: $black;
$form-feedback-invalid-color: $magenta; $form-feedback-invalid-color: $magenta;
$form-feedback-icon-valid-color: $black; $form-feedback-icon-valid-color: $black;
$component-active-bg: $magenta; $component-active-bg: $magenta;
$btn-white-space: nowrap;
$font-weight-bolder: 700;
$zindex-modal-backdrop: 0;
$modal-content-bg: var(--background);
$modal-content-border-color: var(--modal-content-border-color);
$card-bg: var(--background);
$card-border-color: var(--card-border-color);
$input-bg: var(--background);
$input-color: var(--foreground);
$btn-bg-color: var(--btn-bg-color);
$btn-color: var(--btn-color);
$input-group-addon-bg: var(--btn-bg-color);
$custom-file-color: var(--btn-color);
$spacers: ( $spacers: (
2-plus: 0.75rem 2-plus: 0.75rem
@ -32,6 +60,16 @@ $sizes: (
@import "bootstrap"; @import "bootstrap";
@import "editor"; @import "editor";
.custom-file-input {
&.replace-image {
@each $lang, $value in $custom-file-text-replace {
&:lang(#{$lang}) ~ .custom-file-label::after {
content: $value;
}
}
}
}
@each $color, $rgb in $theme-colors { @each $color, $rgb in $theme-colors {
.#{$color} { .#{$color} {
color: var(--#{$color}); color: var(--#{$color});
@ -60,6 +98,10 @@ $sizes: (
--foreground: #{$black}; --foreground: #{$black};
--background: #{$white}; --background: #{$white};
--color: #{$magenta}; --color: #{$magenta};
--card-border-color: #{rgba($black, .125)};
--btn-bg-color: #{$black};
--btn-color: #{$white};
--modal-content-border-color: rgba(#{$black}, .2);
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
@ -67,34 +109,28 @@ $sizes: (
--foreground: #{$white}; --foreground: #{$white};
--background: #{$black}; --background: #{$black};
--color: #{$cyan}; --color: #{$cyan};
--card-border-color: #{rgba($white, .250)};
--btn-bg-color: #{$white};
--btn-color: #{$black};
--modal-content-border-color: #{rgba($white, .2)};
} }
.btn-secondary { .btn-secondary {
background-color: $white;
color: $black;
border: none; border: none;
&:hover {
color: $black;
background-color: $cyan;
} }
&:active { @include form-validation-state("valid", $cyan, url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 8 8'><path fill='#{$white}' d='M2.3 6.73L.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/></svg>"));
background-color: $cyan;
}
&:focus { .custom-checkbox {
box-shadow: 0 0 0 0.2rem $cyan; .custom-control-input:checked ~ .custom-control-label {
&::after {
background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 8 8'><path fill='#{$black}' d='M6.564.75l-3.59 3.612-1.538-1.55L0 4.26l2.974 2.99L8 2.193z'/></svg>");
}
} }
} }
} }
// TODO: Encontrar la forma de generar esto desde los locales de Rails
$custom-file-text: (
en: 'Browse',
es: 'Buscar archivo'
);
@font-face { @font-face {
font-family: 'Saira'; font-family: 'Saira';
font-style: normal; font-style: normal;
@ -135,6 +171,10 @@ a {
color: var(--color); color: var(--color);
} }
&:focus {
outline: 1px solid var(--color);
}
&[target=_blank] { &[target=_blank] {
/* TODO: Convertir a base64 para no hacer peticiones extra */ /* TODO: Convertir a base64 para no hacer peticiones extra */
&:after { &:after {
@ -241,6 +281,8 @@ svg {
.btn { .btn {
margin-right: 0.3rem; margin-right: 0.3rem;
margin-bottom: 0.3rem; margin-bottom: 0.3rem;
background-color: $btn-bg-color;
color: $btn-color;
&:hover { &:hover {
color: var(--background); color: var(--background);
@ -256,6 +298,10 @@ svg {
} }
} }
.badge {
white-space: break-spaces;
}
.btn-sm { .btn-sm {
@extend .badge @extend .badge
} }
@ -318,10 +364,6 @@ svg {
} }
} }
.custom-control-label {
font-weight: bold;
}
.designs { .designs {
.design { .design {
margin-top: 1rem; margin-top: 1rem;
@ -621,3 +663,33 @@ $bezier: cubic-bezier(0.75, 0, 0.25, 1);
} }
} }
} }
// https://getbootstrap.com/docs/5.1/components/placeholders/
.placeholder {
display: inline-block;
min-height: $spacer;
cursor: wait;
vertical-align: middle;
opacity: .5;
background-color: $grey;
animation: placeholder-glow 2s ease-in-out infinite;
}
.placeholder-glow {
.placeholder {
-webkit-animation: placeholder-glow 2s ease-in-out infinite;
animation: placeholder-glow 2s ease-in-out infinite;
}
@-webkit-keyframes placeholder-glow {
50% {
opacity: .2;
}
}
@keyframes placeholder-glow {
50% {
opacity: .2;
}
}
}

View file

@ -6,29 +6,13 @@ $cyan: #13fefe;
--foreground: #{$white}; --foreground: #{$white};
--background: #{$black}; --background: #{$black};
--color: #{$cyan}; --color: #{$cyan};
} --card-border-color: #{rgba($white, .250)};
--btn-bg-color: #{$white};
.btn { --btn-color: #{$black};
background-color: $white;
} }
.btn-secondary { .btn-secondary {
background-color: $white;
color: $black;
border: none; border: none;
&:hover {
color: $black;
background-color: $cyan;
}
&:active {
background-color: $cyan;
}
&:focus {
box-shadow: 0 0 0 0.2rem $cyan;
}
} }

View file

@ -11,7 +11,7 @@ module ActiveStorage
# Permitir incrustar archivos subidos (especialmente PDFs) desde # Permitir incrustar archivos subidos (especialmente PDFs) desde
# otros sitios. # otros sitios.
def show def show
original_show.tap do |s| original_show.tap do |_s|
response.headers.delete 'X-Frame-Options' response.headers.delete 'X-Frame-Options'
end end
end end
@ -24,7 +24,7 @@ module ActiveStorage
if (token = decode_verified_token) if (token = decode_verified_token)
if acceptable_content?(token) if acceptable_content?(token)
blob = ActiveStorage::Blob.find_by_key! token[:key] blob = ActiveStorage::Blob.find_by_key! token[:key]
site = Site.find_by_name token[:service_name] site = Site.find_by_name! token[:service_name]
if remote_file?(token) if remote_file?(token)
begin begin
@ -32,18 +32,20 @@ module ActiveStorage
body = Down.download(url, max_size: 111.megabytes) body = Down.download(url, max_size: 111.megabytes)
checksum = Digest::MD5.file(body.path).base64digest checksum = Digest::MD5.file(body.path).base64digest
blob.metadata[:url] = url blob.metadata[:url] = url
blob.update_columns checksum: checksum, byte_size: body.size, metadata: blob.metadata blob.update_columns checksum:, byte_size: body.size, metadata: blob.metadata
rescue StandardError => e rescue StandardError => e
ExceptionNotifier.notify_exception(e, data: { key: token[:key], url: url, site: site.name }) ExceptionNotifier.notify_exception(e, data: { key: token[:key], url:, site: site.name })
head :content_too_large head :content_too_large
return
end end
else else
body = request.body body = request.body
checksum = token[:checksum] checksum = token[:checksum]
end end
named_disk_service(token[:service_name]).upload token[:key], body, checksum: checksum named_disk_service(site.name).upload(token[:key], body, checksum:)
site.static_files.attach(blob) site.static_files.attach(blob)
else else
@ -52,8 +54,14 @@ module ActiveStorage
else else
head :not_found head :not_found
end end
rescue ActiveStorage::IntegrityError rescue ActiveRecord::ActiveRecordError, ActiveStorage::Error => e
ExceptionNotifier.notify_exception(e, data: { token: })
head :unprocessable_entity head :unprocessable_entity
rescue Down::Error => e
ExceptionNotifier.notify_exception(e, data: { key: token[:key], url: url, site: site.name })
head :payload_too_large
end end
private private
@ -64,7 +72,10 @@ module ActiveStorage
def page_not_found(exception) def page_not_found(exception)
head :not_found head :not_found
ExceptionNotifier.notify_exception(exception, data: {params: params.to_hash})
params.permit!
ExceptionNotifier.notify_exception(exception, data: { params: params.to_hash })
end end
end end
end end

View file

@ -4,51 +4,88 @@ module Api
module V1 module V1
# API para sitios # API para sitios
class SitesController < BaseController class SitesController < BaseController
SUBDOMAIN = ".#{Site.domain}"
TESTING_SUBDOMAIN = ".testing.#{Site.domain}"
PARTS = Site.domain.split('.').count
if Rails.env.production?
http_basic_authenticate_with name: ENV['HTTP_BASIC_USER'], http_basic_authenticate_with name: ENV['HTTP_BASIC_USER'],
password: ENV['HTTP_BASIC_PASSWORD'] password: ENV['HTTP_BASIC_PASSWORD']
end
# Lista de nombres de dominios a emitir certificados # Lista de nombres de dominios a emitir certificados
def index def index
render json: alternative_names + api_names + www_names all_names = sites_names.concat(alternative_names).concat(www_names).concat(api_names).uniq.map do |name|
canonicalize name
end.reject do |name|
subdomain? name
end.reject do |name|
testing? name
end.uniq
render json: all_names
end end
private private
# @param query [ActiveRecord::Relation]
# @return [Array<String>]
def hostname_of(query)
query.pluck(Arel.sql("values->>'hostname'")).compact.uniq
end
def canonicalize(name) def canonicalize(name)
name.end_with?('.') ? name[0..-2] : "#{name}.#{Site.domain}" name.end_with?('.') ? name[0..-2] : "#{name}.#{Site.domain}"
end end
# Es un subdominio directo del dominio principal
#
# @param name [String]
# @return [Bool]
def subdomain?(name) def subdomain?(name)
name.end_with? ".#{Site.domain}" name.end_with?(SUBDOMAIN) && name.split('.').count == (PARTS + 1)
end end
# Dominios alternativos # Es un dominio de prueba
def alternative_names #
(DeployAlternativeDomain.all.map(&:hostname) + DeployLocalizedDomain.all.map(&:hostname)).map do |name| # @param name [String]
canonicalize name # @return [Bool]
end.reject do |name| def testing?(name)
subdomain? name name.end_with?(TESTING_SUBDOMAIN) && name.split('.').count == (PARTS + 2)
end end
# Nombres de los sitios
#
# @param name [String]
# @return [Array<String>]
def sites_names
Site.all.order(:name).pluck(:name)
end
# Dominios alternativos, incluyendo todas las clases derivadas de
# esta.
#
# @return [Array<String>]
def alternative_names
hostname_of(DeployAlternativeDomain.all)
end end
# Obtener todos los sitios con API habilitada, es decir formulario # Obtener todos los sitios con API habilitada, es decir formulario
# de contacto y/o colaboración anónima. # de contacto y/o colaboración anónima.
# #
# TODO: Optimizar # @return [Array<String>]
def api_names def api_names
Site.where(contact: true) Site.where(contact: true)
.or(Site.where(colaboracion_anonima: true)) .or(Site.where(colaboracion_anonima: true))
.select("'api.' || name as name").map(&:name).map do |name| .pluck(:name).map do |name|
canonicalize name "api.#{name}"
end.reject do |name|
subdomain? name
end end
end end
# Todos los dominios con WWW habilitado # Todos los dominios con WWW habilitado
def www_names def www_names
Site.where(id: DeployWww.all.pluck(:site_id)).select("'www.' || name as name").map(&:name).map do |name| Site.where(id: DeployWww.all.pluck(:site_id)).pluck(:name).map do |name|
canonicalize name "www.#{name}"
end end
end end
end end

View file

@ -2,7 +2,7 @@
# Forma de ingreso a Sutty # Forma de ingreso a Sutty
class ApplicationController < ActionController::Base class ApplicationController < ActionController::Base
include ExceptionHandler include ExceptionHandler if Rails.env.production?
include Pundit::Authorization include Pundit::Authorization
protect_from_forgery with: :null_session, prepend: true protect_from_forgery with: :null_session, prepend: true
@ -117,4 +117,9 @@ class ApplicationController < ActionController::Base
session[:usuarie_return_to] = request.fullpath session[:usuarie_return_to] = request.fullpath
end end
# Detecta si una petición fue hecha por HTMX
def htmx?
request.headers.key? 'HX-Request'
end
end end

View file

@ -5,9 +5,10 @@
# No necesitamos autenticación aun # No necesitamos autenticación aun
class CollaborationsController < ApplicationController class CollaborationsController < ApplicationController
include Pundit include Pundit
include StrongParamsHelper
def collaborate def collaborate
@site = Site.find_by_name(params[:site_id]) @site = Site.find_by_name(pluck_param(:site_id))
authorize Collaboration.new(@site) authorize Collaboration.new(@site)
@invitade = current_usuarie || @site.usuaries.build @invitade = current_usuarie || @site.usuaries.build
@ -21,7 +22,7 @@ class CollaborationsController < ApplicationController
# #
# * Si le usuarie existe y no está logueade, pedirle la contraseña # * Si le usuarie existe y no está logueade, pedirle la contraseña
def accept_collaboration def accept_collaboration
@site = Site.find_by_name(params[:site_id]) @site = Site.find_by_name(pluck_param(:site_id))
authorize Collaboration.new(@site) authorize Collaboration.new(@site)
@invitade = current_usuarie @invitade = current_usuarie

View file

@ -10,25 +10,41 @@ module ExceptionHandler
included do included do
rescue_from SiteNotFound, with: :site_not_found rescue_from SiteNotFound, with: :site_not_found
rescue_from PageNotFound, with: :page_not_found rescue_from PageNotFound, with: :page_not_found
rescue_from ActionController::RoutingError, with: :page_not_found rescue_from Pundit::Error, with: :page_not_found
rescue_from Pundit::NilPolicyError, with: :page_not_found rescue_from Pundit::NotAuthorizedError, with: :page_unauthorized
rescue_from Pundit::NilPolicyError, with: :page_not_found rescue_from Pundit::NilPolicyError, with: :page_not_found
rescue_from ActionController::RoutingError, with: :page_not_found rescue_from ActionController::RoutingError, with: :page_not_found
rescue_from ActionController::ParameterMissing, with: :page_not_found rescue_from ActionController::ParameterMissing, with: :page_not_found
end end
def site_not_found def site_not_found(exception)
reset_response! reset_response!
flash[:error] = I18n.t('errors.site_not_found') flash[:error] = I18n.t('errors.site_not_found')
ExceptionNotifier.notify_exception(exception, data: { usuarie: current_usuarie&.id, path: request.fullpath })
redirect_to sites_path redirect_to sites_path
end end
def page_not_found def page_unauthorized(exception)
reset_response! reset_response!
render 'application/page_not_found', status: :not_found flash[:error] = I18n.t('errors.page_unauthorized')
ExceptionNotifier.notify_exception(exception, data: { usuarie: current_usuarie&.id, path: request.fullpath })
redirect_to site_path(site)
end
def page_not_found(exception)
reset_response!
flash[:error] = I18n.t('errors.page_not_found')
ExceptionNotifier.notify_exception(exception)
redirect_to site_path(site)
end end
private private

View file

@ -2,6 +2,8 @@
# Controlador para artículos # Controlador para artículos
class PostsController < ApplicationController class PostsController < ApplicationController
include StrongParamsHelper
before_action :authenticate_usuarie! before_action :authenticate_usuarie!
before_action :service_for_direct_upload, only: %i[new edit] before_action :service_for_direct_upload, only: %i[new edit]
@ -13,6 +15,84 @@ class PostsController < ApplicationController
# Las URLs siempre llevan el idioma actual o el de le usuarie # Las URLs siempre llevan el idioma actual o el de le usuarie
def default_url_options def default_url_options
{ locale: locale } { locale: locale }
rescue SiteNotFound
{}
end
# @todo Mover a tu propio scope
def new_array
@value = pluck_param(:value)
@name = pluck_param(:name)
id = pluck_param(:id)
headers['HX-Trigger-After-Swap'] = 'htmx:resetForm'
render layout: false
end
def new_array_value
@value = pluck_param(:value)
render layout: false
end
def new_related_post
@uuid = pluck_param(:value)
@indexed_post = site.indexed_posts.find_by!(post_id: @uuid)
render layout: false
end
def new_has_one
@uuid = pluck_param(:value)
@indexed_post = site.indexed_posts.find_by!(post_id: @uuid)
render layout: false
end
# El formulario de un Post, si pasamos el UUID, estamos editando, sino
# estamos creando.
def form
uuid = pluck_param(:uuid, optional: true)
locale
@post =
if uuid.present?
site.indexed_posts.find_by!(post_id: uuid).post
else
# @todo Usar la base de datos
site.posts(lang: locale).build(layout: pluck_param(:layout))
end
swap_modals
render layout: false
end
# Genera un modal completo
#
# @todo recibir el atributo anterior
# @param :uuid [String] UUID del post (opcional)
# @param :layout [String] El layout a cargar (opcional)
def modal
uuid = pluck_param(:uuid, optional: true)
locale
# @todo hacer que si el uuid no existe se genera un post, para poder
# pasar el uuid sabiendolo
@post =
if uuid.present?
site.indexed_posts.find_by!(post_id: uuid).post
else
# @todo Usar la base de datos
site.posts(lang: locale).build(layout: pluck_param(:layout))
end
swap_modals
render layout: false
end end
def index def index
@ -55,7 +135,7 @@ class PostsController < ApplicationController
def new def new
authorize Post authorize Post
@post = site.posts(lang: locale).build(layout: params[:layout]) @post = site.posts(lang: locale).build(layout: pluck_param(:layout))
breadcrumb I18n.t('loaf.breadcrumbs.posts.new', layout: @post.layout.humanized_name.downcase), '' breadcrumb I18n.t('loaf.breadcrumbs.posts.new', layout: @post.layout.humanized_name.downcase), ''
end end
@ -65,13 +145,34 @@ class PostsController < ApplicationController
service = PostService.new(site: site, service = PostService.new(site: site,
usuarie: current_usuarie, usuarie: current_usuarie,
params: params) params: params)
@post = service.create @post = service.create_or_update
if @post.persisted? if post.persisted?
site.touch site.touch
forget_content forget_content
end
redirect_to site_post_path(@site, @post) # @todo Enviar la creación a otro endpoint para evitar tantas
# condiciones.
if htmx?
if post.persisted?
triggers = { 'notification:show' => { 'id' => pluck_param(:saved, optional: true) } }
swap_modals(triggers)
@value = post.title.value
@uuid = post.uuid.value
@name = pluck_param(:name)
render render_path_from_attribute, layout: false
else
headers['HX-Retarget'] = "##{pluck_param(:form)}"
headers['HX-Reswap'] = 'outerHTML'
render 'posts/form', layout: false, post: post, site: site, **params.permit(:form, :base, :dir, :locale)
end
elsif post.persisted?
redirect_to site_post_path(site, post)
else else
render 'posts/new' render 'posts/new'
end end
@ -83,6 +184,16 @@ class PostsController < ApplicationController
breadcrumb 'posts.edit', '' breadcrumb 'posts.edit', ''
end end
# Este endpoint se encarga de actualizar el post. Si el post se edita
# desde el formulario principal, re-renderizamos el formulario si hay
# errores o enviamos a otro lado al guardar.
#
# Si los datos llegaron por HTMX, hay que regenerar el formulario
# y reemplazarlo en su modal (?) o responder con su tarjeta para
# reemplazarla donde sea que esté.
#
# @todo la re-renderización del formulario no es necesaria si tenemos
# validación client-side.
def update def update
authorize post authorize post
@ -94,7 +205,37 @@ class PostsController < ApplicationController
if service.update.persisted? if service.update.persisted?
site.touch site.touch
forget_content forget_content
end
if htmx?
if post.persisted?
triggers = { 'notification:show' => pluck_param(:saved, optional: true) }
swap_modals(triggers)
@value = post.title.value
@uuid = post.uuid.value
if (result_id = pluck_param(:result_id, optional: true))
headers['HX-Retarget'] = "##{result_id}"
headers['HX-Reswap'] = 'outerHTML'
@indexed_post = site.indexed_posts.find_by_post_id(post.uuid.value)
render 'posts/new_related_post', layout: false
# @todo Confirmar que esta ruta no esté transitada
else
@name = pluck_param(:name)
render render_path_from_attribute, layout: false
end
else
headers['HX-Retarget'] = "##{params.require(:form)}"
headers['HX-Reswap'] = 'outerHTML'
render 'posts/form', layout: false, post: post, site: site, **params.permit(:form, :base, :dir, :locale)
end
elsif post.persisted?
redirect_to site_post_path(site, post) redirect_to site_post_path(site, post)
else else
render 'posts/edit' render 'posts/edit'
@ -168,4 +309,24 @@ class PostsController < ApplicationController
def service_for_direct_upload def service_for_direct_upload
session[:service_name] = site.name.to_sym session[:service_name] = site.name.to_sym
end end
# @param triggers [Hash] Otros disparadores
def swap_modals(triggers = {})
params.permit(:show, :hide).each_pair do |key, value|
triggers["modal:#{key}"] = { id: value } if value.present?
end
headers['HX-Trigger'] = triggers.to_json if triggers.present?
end
# @return [String]
def render_path_from_attribute
case pluck_param(:attribute)
when 'new_has_many' then 'posts/new_has_many_value'
when 'new_belongs_to' then 'posts/new_belongs_to_value'
when 'new_has_and_belongs_to_many' then 'posts/new_has_many_value'
when 'new_has_one' then 'posts/new_has_one_value'
else 'nothing'
end
end
end end

View file

@ -0,0 +1,77 @@
# frozen_string_literal: true
# Modificaciones locales al registro de usuaries
#
# @see {https://github.com/heartcombo/devise/wiki/How-To:-Use-Recaptcha-with-Devise}
class RegistrationsController < Devise::RegistrationsController
class SpambotError < StandardError; end
PRIVATE_HEADERS = /(cookie|secret|token)/i
prepend_before_action :anti_spambot_traps, only: %i[create]
prepend_after_action :lock_spambots, only: %i[create]
private
# Condiciones bajo las que consideramos que un registro viene de unx
# spambot
#
# @return [Bool]
def spambot?
@spambot ||= params.dig(:usuarie, :name).present?
end
# Bloquea las cuentas de spam dentro de un minuto, para hacerles creer
# que la cuenta se creó correctamente.
def lock_spambots
return unless spambot?
return unless current_usuarie
LockUsuarieJob.set(wait: 1.minute).perform_later(usuarie: current_usuarie)
end
# Detecta e informa spambots muy simples
#
# @return [nil]
def anti_spambot_traps
raise SpambotError if spambot?
rescue SpambotError => e
ExceptionNotifier.notify_exception(e, data: { params: anonymized_params, headers: anonymized_headers })
nil
end
# Devuelve parámetros anonimizados para prevenir filtrar la contraseña
# de falsos positivos.
#
# @return [Hash]
def anonymized_params
params.except(:authenticity_token).permit!.to_h.tap do |p|
p['usuarie'].delete 'password'
p['usuarie'].delete 'password_confirmation'
end
end
# Devuelve los encabezados de la petición sin información sensible de
# Rails
#
# @return [Hash]
def anonymized_headers
request.headers.to_h.select do |_, v|
v.is_a? String
end.reject do |k, _|
k =~ PRIVATE_HEADERS
end
end
# Si le usuarie es considerade spambot, no enviamos el correo de
# confirmación al crear la cuenta.
def sign_up_params
if spambot?
params[:usuarie][:confirmed_at] = Time.now.utc
devise_parameter_sanitizer.permit(:sign_up, keys: %i[confirmed_at])
end
super
end
end

View file

@ -29,7 +29,7 @@ class UsuariesController < ApplicationController
@usuarie = Usuarie.find(params[:id]) @usuarie = Usuarie.find(params[:id])
if @site.usuaries.count > 1 if @site.invitade?(@usuarie) || @site.usuaries.count > 1
# Mágicamente elimina el rol # Mágicamente elimina el rol
@usuarie.sites.delete(@site) @usuarie.sites.delete(@site)
else else

View file

@ -2,6 +2,19 @@
# Helpers # Helpers
module ApplicationHelper module ApplicationHelper
BRACKETS = /[\[\]]/.freeze
ALPHA_LARGE = [*'a'..'z', *'A'..'Z'].freeze
# Devuelve un indentificador aleatorio que puede usarse como atributo
# HTML. Reemplaza Nanoid. El primer caracter siempre es alfabético.
#
# @return [String]
def random_id
SecureRandom.urlsafe_base64.tap do |s|
s[0] = ALPHA_LARGE.sample
end
end
# Devuelve el atributo name de un campo anidado en el formato que # Devuelve el atributo name de un campo anidado en el formato que
# esperan los helpers *_field # esperan los helpers *_field
# #
@ -19,6 +32,14 @@ module ApplicationHelper
[root, name] [root, name]
end end
# Obtiene un ID
#
# @param base [String]
# @param attribute [String, Symbol]
def id_for(base, attribute)
"#{base.gsub(BRACKETS, '_')}_#{attribute}".squeeze('_')
end
def plain_field_name_for(*names) def plain_field_name_for(*names)
root, name = field_name_for(*names) root, name = field_name_for(*names)
@ -134,9 +155,17 @@ module ApplicationHelper
private private
# Obtiene la traducción desde el esquema en el idioma actual, o por
# defecto en el idioma del sitio. De lo contrario trae una traducción
# genérica.
#
# Si el idioma por defecto tiene un String vacía, se asume que no
# texto.
#
# @return [String,nil]
def post_t(*attribute, post:, type:) def post_t(*attribute, post:, type:)
post.layout.metadata.dig(*attribute, type.to_s, I18n.locale.to_s) || post.layout.metadata.dig(*attribute, type.to_s, I18n.locale.to_s).presence ||
post.layout.metadata.dig(*attribute, type.to_s, I18n.default_locale.to_s) || post.layout.metadata.dig(*attribute, type.to_s, post.site.default_locale.to_s) ||
I18n.t("posts.attributes.#{attribute.join('.')}.#{type}") I18n.t("posts.attributes.#{attribute.join('.')}.#{type}").presence
end end
end end

View file

@ -0,0 +1,19 @@
# frozen_string_literal: true
# Métodos reutilizables para trabajar con StrongParams
module StrongParamsHelper
# Obtiene el valor de un param
#
# @todo No hay una forma mejor de hacer esto?
# @param param [Symbol]
# @param :optional [Bool]
# @return [nil,String]
def pluck_param(param, optional: false)
if optional
params.permit(param).values.first.presence
else
params.require(param).presence
end
end
end

View file

@ -0,0 +1,138 @@
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static targets = ["item", "search", "current", "placeholder"];
connect() {
// TODO: Stimulus >1
this.newArrayValueURL = new URL(window.location.origin);
const [ pathname, search ] = this.element.dataset.arrayNewArrayValue.split("?");
this.newArrayValueURL.pathname = pathname;
this.newArrayValueURL.search = `?${search}`;
this.originalValue = JSON.parse(this.element.dataset.arrayOriginalValue);
}
/*
* Al eliminar el ítem, buscamos por su ID y lo eliminamos del
* documento.
*/
remove(event) {
// TODO: Stimulus >1
event.preventDefault();
this.itemTargets
.find((x) => x.id === event.target.dataset.removeTargetParam)
?.remove();
}
/*
* Al buscar, eliminamos las tildes y mayúsculas para no depender de
* cómo se escribió.
*
* Luego buscamos eso en el valor limpio, ignorando los items que ya
* están activados.
*
* Si el término de búsqueda está vacío, volvemos a la lista original.
*/
search(event) {
const needle = this.searchTarget.value
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.trim();
if (needle) {
for (const itemTarget of this.itemTargets) {
itemTarget.style.display =
itemTarget.querySelector("input")?.checked ||
itemTarget.dataset.searchableValue.includes(needle)
? ""
: "none";
}
} else {
for (const itemTarget of this.itemTargets) {
itemTarget.style.display = "";
}
}
}
/*
* Obtiene el input de un elemento
*
* @param [HTMLElement]
* @return [HTMLElement,nil]
*/
inputFrom(target) {
if (target.tagName === "INPUT") return target;
return target.querySelector("input");
}
/*
* Detecta si el item es o contiene un checkbox/radio activado.
*
* @param [HTMLElement]
* @return [Bool]
*/
isChecked(itemTarget) {
return this.inputFrom(itemTarget)?.checked || false;
}
cancelWithEscape(event) {
if (event?.key !== "Escape") return;
this.cancel();
}
/*
* Al cancelar, se vuelve al estado original de la lista
*/
cancel(event = undefined) {
for (const itemTarget of this.itemTargets) {
const input = this.inputFrom(itemTarget);
input.checked = this.originalValue.includes(itemTarget.dataset.value);
}
}
/*
* Al aceptar, se envía todo el listado de valores nuevos al _backend_
* para que devuelva la representación de cada ítem en HTML. Además,
* se guarda el nuevo valor como la lista original, para la próxima
* cancelación.
*/
accept(event) {
this.currentTarget.innerHTML = "";
this.originalValue = [];
const signal = window.abortController?.signal;
for (const itemTarget of this.itemTargets) {
if (!itemTarget.dataset.value) continue;
if (!this.isChecked(itemTarget)) continue;
this.originalValue.push(itemTarget.dataset.value);
this.newArrayValueURL.searchParams.set("value", itemTarget.dataset?.sendValue || itemTarget.dataset?.value);
const placeholder = this.placeholderTarget.content.firstElementChild.cloneNode(true);
this.currentTarget.appendChild(placeholder);
fetch(this.newArrayValueURL, { signal })
.then((response) => response.text())
.then((body) => {
const template = document.createElement("template");
template.innerHTML = body;
placeholder.replaceWith(template.content.firstElementChild);
});
}
// TODO: Stimulus >1
this.element.dataset.arrayOriginalValue = JSON.stringify(
this.originalValue,
);
}
}

View file

@ -1,4 +1,4 @@
import { Controller } from "stimulus"; import { Controller } from "@hotwired/stimulus";
export default class extends Controller { export default class extends Controller {
static targets = []; static targets = [];

View file

@ -1,4 +1,4 @@
import { Controller } from "stimulus"; import { Controller } from "@hotwired/stimulus";
// https://getbootstrap.com/docs/4.6/components/dropdowns/#single-button // https://getbootstrap.com/docs/4.6/components/dropdowns/#single-button
export default class extends Controller { export default class extends Controller {

View file

@ -0,0 +1,10 @@
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
/*
* Previene el envío de un formulario al presionar enter
*/
prevent(event) {
if (event.key == "Enter") event.preventDefault();
}
}

View file

@ -1,4 +1,4 @@
import { Controller } from 'stimulus' import { Controller } from '@hotwired/stimulus'
import bsCustomFileInput from "bs-custom-file-input"; import bsCustomFileInput from "bs-custom-file-input";
document.addEventListener("turbolinks:load", () => { document.addEventListener("turbolinks:load", () => {

View file

@ -0,0 +1,53 @@
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static targets = ["invalid", "submitting"];
// @todo Stimulus >1
get submittingIdValue() {
return this.element.dataset?.formValidationSubmittingIdValue;
}
// @todo Stimulus >1
get invalidIdValue() {
return this.element.dataset?.formValidationInvalidIdValue;
}
connect() {
this.element.setAttribute("novalidate", true);
for (const input of this.element.elements) {
if (input.type === "button" || input.type === "submit") continue;
if (input.dataset.action) {
input.dataset.action = `${input.dataset.action} htmx:validation:validate->form-validation#submit`;
} else {
input.dataset.action = "htmx:validation:validate->form-validation#submit";
}
}
}
submit(event = undefined) {
if (this.submitting) return;
this.submitting = true;
event?.preventDefault();
if (this.element.reportValidity()) {
this.element.classList.remove("was-validated");
if (!this.element.getAttributeNames().some(x => x.startsWith("hx-"))) this.element.submit();
window.dispatchEvent(new CustomEvent("notification:show", { detail: { value: this.submittingIdValue } }));
} else {
event?.stopPropagation();
this.element.classList.add("was-validated");
window.dispatchEvent(new CustomEvent("notification:show", { detail: { value: this.invalidIdValue } }));
}
this.submitting = false;
}
}

View file

@ -1,4 +1,4 @@
import { Controller } from 'stimulus' import { Controller } from '@hotwired/stimulus'
require("leaflet/dist/leaflet.css") require("leaflet/dist/leaflet.css")
import L from 'leaflet' import L from 'leaflet'

View file

@ -0,0 +1,107 @@
import { Controller } from "@hotwired/stimulus";
/*
* Un controlador que imita a HTMX
*/
export default class extends Controller {
connect() {
// @todo Convertir en <template>
this.placeholder = "<span class=\"placeholder w-100\" aria-hidden=\"true\"></span>";
}
/*
* Obtiene la URL y elimina la acción.
*
* @param event [Event]
*/
getUrlOnce(event) {
this.getUrl(event);
event.target.dataset.action = event.target.dataset.action.replace("htmx#getUrlOnce", "").trim();
}
/*
* Lanza el evento que va a descargar la URL y agregarse en algún
* lado.
*
* @param event [Event]
*/
getUrl(event) {
// @todo Stimulus >1
const value = event.target.dataset.htmxGetUrlParam;
if (value) {
window.dispatchEvent(new CustomEvent("htmx:getUrl", { detail: { value } }));
} else {
console.error("Missing data-htmx-get-url-param attribute on element", event.target);
}
}
/*
* Realiza una petición.
*
* @param url [String]
* @return [Promise<Response>]
*/
async request(url) {
const headers = new Headers();
const signal = window.abortController?.signal;
headers.set("HX-Request", "true");
return fetch(url, { headers, signal });
}
/*
* Obtiene la URL enviada por el evento y reemplaza el contenido del
* elemento.
*/
async swap(event) {
const response = await this.request(event.detail.value);
if (response.ok) {
this.element.innerHTML = this.placeholder;
this.element.innerHTML = await response.text();
this.triggerEvents(response.headers);
window.htmx.process(this.element);
} else {
console.error(response);
}
}
/*
* Agrega el resultado de la descarga al final del elemento.
*/
async beforeend(event) {
const response = await this.request(event.detail.value);
if (response.ok) {
this.element.insertAdjacentHTML("beforeend", this.placeholder);
this.element.lastElementChild.outerHTML = await response.text();
this.triggerEvents(response.headers);
// @todo Asume que cada endpoint solo devuelve un elemento por vez
window.htmx.process(this.element.lastElementChild);
} else {
console.error(response);
}
}
/*
* Lanza los eventos que vienen con la respuesta.
*/
triggerEvents(headers) {
if (!headers.has("HX-Trigger")) return;
const events = JSON.parse(headers.get("HX-Trigger"));
setTimeout(() => {
for (const event in events) {
const detail = events[event];
window.dispatchEvent(new CustomEvent(event, { detail }));
}
}, 1);
}
}

View file

@ -1,8 +1,8 @@
// Load all the controllers within this directory and all subdirectories. // Load all the controllers within this directory and all subdirectories.
// Controller files must be named *_controller.js. // Controller files must be named *_controller.js.
import { Application } from "stimulus" import { Application } from "@hotwired/stimulus"
import { definitionsFromContext } from "stimulus/webpack-helpers" import { definitionsFromContext } from "@hotwired/stimulus-webpack-helpers"
const application = Application.start() const application = Application.start()
const context = require.context("controllers", true, /_controller\.js$/) const context = require.context("controllers", true, /_controller\.js$/)

View file

@ -0,0 +1,95 @@
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static targets = ["modal", "backdrop"];
// TODO: Stimulus >1
connect() {
this.showEvent = this.show.bind(this);
this.hideEvent = this.hide.bind(this);
window.addEventListener("modal:show", this.showEvent);
window.addEventListener("modal:hide", this.hideEvent);
}
// TODO: Stimulus >1
disconnect() {
window.removeEventListener("modal:show", this.showEvent);
window.removeEventListener("modal:hide", this.hideEvent);
}
/*
* Abrir otro modal, enviando el ID a toda la ventana.
*/
showAnother(event = undefined) {
event?.preventDefault();
if (!event.target?.dataset?.modalShowValue) return;
window.dispatchEvent(new CustomEvent("modal:show", { detail: { id: event.target.dataset.modalShowValue, previousFocus: event.target.id } }));
}
/*
* Podemos enviar la orden de apertura como un click o como un
* CustomEvent incluyendo el id del modal como detail.
*
* El elemento clicleable puede tener un valor que se refiera a otro
* modal también.
*/
show(event = undefined) {
event?.preventDefault();
const modalId = event?.detail?.id;
if (modalId && this.element.id !== modalId) return;
this.modalTarget.style.display = "block";
this.backdropTarget.style.display = "block";
this.modalTarget.setAttribute("role", "dialog");
this.modalTarget.setAttribute("aria-modal", true);
this.modalTarget.removeAttribute("aria-hidden");
window.document.body.classList.add("modal-open");
if (event?.detail?.previousFocus) {
this.previousFocus = window.document.getElementById(event.detail.previousFocus);
} else {
this.previousFocus = event?.target;
}
setTimeout(() => {
this.modalTarget.classList.add("show");
this.backdropTarget.classList.add("show");
this.modalTarget.focus();
}, 1);
}
hideWithEscape(event) {
if (event?.key !== "Escape") return;
this.hide();
}
hide(event = undefined) {
event?.preventDefault();
const modalId = event?.detail?.id;
if (modalId && this.element.id !== modalId) return;
this.backdropTarget.classList.remove("show");
this.modalTarget.classList.remove("show");
this.modalTarget.setAttribute("aria-hidden", true);
this.modalTarget.removeAttribute("role");
this.modalTarget.removeAttribute("aria-modal");
this.previousFocus?.focus();
setTimeout(() => {
this.modalTarget.style.display = "";
this.backdropTarget.style.display = "";
}, 500);
window.document.body.classList.remove("modal-open");
}
}

View file

@ -0,0 +1,18 @@
import { Controller } from "@hotwired/stimulus";
import SuttyEditor from "@suttyweb/editor";
import "@suttyweb/editor/dist/style.css";
export default class extends Controller {
static targets = ["textarea"];
connect() {
this.editor =
new SuttyEditor({
target: this.element,
props: {
textareaEl: this.textareaTarget,
},
});
}
}

View file

@ -1,4 +1,4 @@
import { Controller } from 'stimulus' import { Controller } from '@hotwired/stimulus'
require("leaflet/dist/leaflet.css") require("leaflet/dist/leaflet.css")
import L from 'leaflet' import L from 'leaflet'

View file

@ -0,0 +1,43 @@
import { Controller } from "@hotwired/stimulus";
/*
* Solo se puede mostrar una notificación a la vez
*/
export default class extends Controller {
// @todo Stimulus >1
get showClasses() {
return (this.element.dataset?.notificationShowClass || "").split(" ").filter(x => x);
}
// @todo Stimulus >1
get hideClasses() {
return (this.element.dataset?.notificationHideClass || "").split(" ").filter(x => x);
}
/*
* Al recibir el evento de mostrar, si no está dirigido al elemento
* actual, se oculta.
*/
show(event = undefined) {
if (event?.detail?.value !== this.element.id) {
this.hide({ detail: { value: this.element.id } });
return;
}
this.element.classList.remove("d-none");
setTimeout(() => {
this.element.classList.remove(...this.hideClasses);
this.element.classList.add(...this.showClasses);
}, 1);
}
hide(event = undefined) {
if (event?.detail?.value !== this.element.id) return;
this.element.classList.remove(...this.showClasses);
this.element.classList.add(...this.hideClasses);
setTimeout(() => this.element.classList.add("d-none"), 150);
}
}

View file

@ -1,4 +1,4 @@
import { Controller } from 'stimulus' import { Controller } from '@hotwired/stimulus'
/* /*
* Permite reordenar las filas de una tabla. * Permite reordenar las filas de una tabla.

View file

@ -0,0 +1,53 @@
import { Controller } from "@hotwired/stimulus";
/*
* Para poder indicar que al menos uno del grupo de checkboxes es
* obligatorio, marcamos uno como `required` (que es el que mostraría el
* error) y se lo quitamos cuando detectamos que alguno cambió.
*/
export default class extends Controller {
static targets = ["required", "checkbox"];
connect() {
}
checkboxTargetConnected(checkboxTarget) {
if (checkboxTarget.checked) {
this.requiredTarget.required = false;
this.revalid();
}
}
/*
* El grupo deja de ser obligatorio cuando al menos uno está activo.
*/
change(event = undefined) {
if (event.target.checked) {
this.requiredTarget.required = false;
} else {
this.requiredTarget.required = !Array.from(this.checkboxTargets).some(x => x.checked);
}
for (const checkbox of this.checkboxTargets) {
if (checkbox === event.target) continue;
checkbox.required = !event.target.checked;
}
}
/*
* Si el checkbox es considerado inválido, transmitir todos los
* estados a los checkboxes.
*/
invalid(event = undefined) {
for (const checkbox of this.checkboxTargets) {
checkbox.required = true;
}
}
revalid(event = undefined) {
for (const checkbox of this.checkboxTargets) {
checkbox.required = false;
}
}
}

View file

@ -1,4 +1,4 @@
import { Controller } from "stimulus"; import { Controller } from "@hotwired/stimulus";
export default class extends Controller { export default class extends Controller {
static targets = ["toggle", "input"]; static targets = ["toggle", "input"];

View file

@ -0,0 +1,55 @@
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
connect() {
this.originalFormDataSerialized = this.serializeFormData(this.element);
this.submitting = false;
}
submit(event) {
this.submitting = true;
}
unsaved(event) {
if (this.submitting) return;
if (!this.hasChanged()) return;
this.submitting = false;
event.preventDefault();
event.returnValue = true;
}
unsavedTurbolinks(event) {
if (this.submitting) return;
if (!this.hasChanged()) return;
this.submitting = false;
if (window.confirm(this.element.dataset.unsavedChangesConfirmValue)) return;
event.preventDefault();
}
formData(form) {
const formData = new FormData(form);
formData.delete("authenticity_token");
return formData;
}
/*
* Elimina saltos de línea y espacios al serializar, para evitar
* detectar cambios cuando cambió el espaciado, por ejemplo cuando el
* editor con formato aplica espacios o elimina saltos de línea.
*/
serializeFormData(form) {
return (new URLSearchParams(this.formData(form))).toString().replaceAll("+", "").replaceAll("%0A", "");
}
hasChanged() {
return (this.originalFormDataSerialized !== this.serializeFormData(this.element));
}
}

View file

@ -5,3 +5,7 @@ document.addEventListener("turbolinks:click", () => {
window.htmx.trigger(hx, "htmx:abort"); window.htmx.trigger(hx, "htmx:abort");
} }
}); });
document.addEventListener("htmx:resetForm", (event) => {
event.target.reset();
});

View file

@ -4,6 +4,4 @@ import './input-tag'
import './prosemirror' import './prosemirror'
import './timezone' import './timezone'
import './turbolinks-anchors' import './turbolinks-anchors'
import './validation'
import './new_editor'
import './htmx_abort' import './htmx_abort'

View file

@ -1,14 +0,0 @@
import SuttyEditor from "@suttyweb/editor";
import "@suttyweb/editor/dist/style.css";
document.addEventListener("turbolinks:load", () => {
document.querySelectorAll(".new-editor").forEach((editorContainer) => {
new SuttyEditor({
target: editorContainer,
props: {
textareaEl: editorContainer.querySelector("textarea"),
},
});
});
});

View file

@ -1,34 +0,0 @@
document.addEventListener('turbolinks:load', () => {
// Al enviar el formulario del artículo, aplicar la validación
// localmente y actualizar los comentarios para lectores de pantalla.
document.querySelectorAll('form').forEach(form => {
form.addEventListener('submit', event => {
const invalid_help = form.querySelectorAll('.invalid-help')
const sending_help = form.querySelectorAll('.sending-help')
invalid_help.forEach(i => i.classList.add('d-none'))
sending_help.forEach(i => i.classList.add('d-none'))
form.querySelectorAll('[aria-invalid="true"]').forEach(aria => {
aria.setAttribute('aria-invalid', false)
aria.setAttribute('aria-describedby', aria.parentElement.querySelector('.feedback').id)
})
if (form.checkValidity() === false) {
event.preventDefault()
event.stopPropagation()
invalid_help.forEach(i => i.classList.remove('d-none'))
form.querySelectorAll(':invalid').forEach(invalid => {
invalid.setAttribute('aria-invalid', true)
invalid.setAttribute('aria-describedby', invalid.parentElement.querySelector('.invalid-feedback').id)
})
} else {
sending_help.forEach(i => i.classList.remove('d-none'))
}
form.classList.add('was-validated')
})
})
})

View file

@ -41,4 +41,5 @@ Rails.start()
Turbolinks.start() Turbolinks.start()
ActiveStorage.start() ActiveStorage.start()
window.htmx = require('htmx.org/dist/htmx.js') window.htmx = require("@suttyweb/htmx.org/dist/htmx.cjs.js");
window.htmx.config.selfRequestsOnly = true;

View file

@ -0,0 +1,23 @@
# frozen_string_literal: true
# Agrega un nodo nuevo en segundo plano y sincroniza todos los sitios
class AddFullRsyncJob < ApplicationJob
# Obtiene todos los sitios que estén sincronizando con un nodo de
# Sutty, agrega el nodo nuevo y empieza la sincronización.
#
# @param :hostname [String] El nombre del servidor remoto
# @param :destination [String] La ubicación de rsync
def perform(hostname:, destination:)
site_ids = DeployFullRsync.all.distinct.pluck(:site_id)
Site.where(id: site_ids).find_each do |site|
site
.deploys
.create(
type: 'DeployFullRsync',
destination: destination,
hostname: hostname
).deploy
end
end
end

View file

@ -4,6 +4,8 @@
class ApplicationJob < ActiveJob::Base class ApplicationJob < ActiveJob::Base
include Que::ActiveJob::JobExtensions include Que::ActiveJob::JobExtensions
attr_reader :site
# Esperar una cantidad random de segundos primos, para que no se # Esperar una cantidad random de segundos primos, para que no se
# superpongan tareas # superpongan tareas
# #
@ -15,8 +17,6 @@ class ApplicationJob < ActiveJob::Base
RANDOM_WAIT.sample.seconds RANDOM_WAIT.sample.seconds
end end
attr_reader :site
# Si falla por cualquier cosa informar y descartar # Si falla por cualquier cosa informar y descartar
discard_on(Exception) do |job, error| discard_on(Exception) do |job, error|
ExceptionNotifier.notify_exception(error, data: { job: job }) ExceptionNotifier.notify_exception(error, data: { job: job })

View file

@ -77,6 +77,8 @@ class DeployJob < ApplicationJob
t << ([type.to_s] + row.values) t << ([type.to_s] + row.values)
end end
end) end)
rescue DeployTimedOutException => e
notify_exception e
ensure ensure
if site.present? if site.present?
site.update status: 'waiting' site.update status: 'waiting'

View file

@ -1,18 +1,29 @@
# frozen_string_literal: true # frozen_string_literal: true
# Permite traer los cambios desde webhooks # Permite traer los cambios desde el repositorio remoto
class GitPullJob < ApplicationJob class GitPullJob < ApplicationJob
# @param :site [Site] # @param :site [Site]
# @param :usuarie [Usuarie] # @param :usuarie [Usuarie]
# @param :message [String]
# @return [nil] # @return [nil]
def perform(site, usuarie) def perform(site, usuarie, message)
@site = site @site = site
return unless site.repository.origin return unless site.repository.origin
return unless site.repository.fetch.positive?
site.repository.merge(usuarie) site.repository.fetch
return if site.repository.up_to_date?
if site.repository.fast_forward?
site.repository.fast_forward!
else
site.repository.merge(usuarie, message)
end
site.repository.git_lfs_checkout
site.reindex_changes! site.reindex_changes!
nil
end end
end end

View file

@ -0,0 +1,19 @@
# frozen_string_literal: true
# Bloquea el acceso a une usuarie
class LockUsuarieJob < ApplicationJob
# Cambiamos la contraseña, aplicamos un bloqueo y cerramos la sesión
# para que no pueda volver a entrar hasta que siga las instrucciones
# de desbloqueo.
#
# @param :usuarie [Usuarie]
# @return [nil]
def perform(usuarie:)
password = SecureRandom.base36
usuarie.skip_password_change_notification!
usuarie.update(password: password, password_confirmation: password, remember_created_at: nil, locked_at: Time.now.utc)
nil
end
end

View file

@ -0,0 +1,12 @@
# frozen_string_literal: true
module AutoPublishConcern
extend ActiveSupport::Concern
included do
def auto_publish!
DeployJob.perform_later(site) if site.auto_publish?
end
end
end

View file

@ -0,0 +1,12 @@
# frozen_string_literal: true
module CoreExtensions
module String
# Elimina tildes
module RemoveDiacritics
def remove_diacritics
unicode_normalize(:nfd).gsub(/[^\x00-\x7F]/, '')
end
end
end
end

View file

@ -0,0 +1,23 @@
# frozen_string_literal: true
class ActivityPub
# Notifica a les moderadores cuando un sitio fue reportado.
class ActorFlaggedMailer < ::ApplicationMailer
# Envía correo a cada moderadore en su idioma
#
# @param :site_id [String,Integer]
# @param :site_name [String]
# @param :content [String]
def notify_moderators
Usuarie.moderators.pluck(:lang, :email).group_by(&:first).transform_values do |value|
value.last
end.each_pair do |lang, emails|
I18n.with_locale(lang) do
emails.each do |email|
mail to: email, subject: I18n.t('activity_pub.actor_flagged_mailer.notify_moderators.subject')
end
end
end
end
end
end

View file

@ -2,6 +2,16 @@
class ActivityPub class ActivityPub
class Activity class Activity
class Flag < ActivityPub::Activity; end class Flag < ActivityPub::Activity
# Notifica a todes les moderadores
def update_activity_pub_state!
ActivityPub::ActorFlaggedMailer.with(
content: content['content'],
object: content['object'],
actor: content['actor'],
site_id: activity_pub.site_id
).notify_moderators.deliver_later
end
end
end end
end end

View file

@ -0,0 +1,11 @@
# frozen_string_literal: true
class ActivityPub
module ModeratorConcern
extend ActiveSupport::Concern
included do
scope :moderators, ->() { where(moderator: true) }
end
end
end

View file

@ -0,0 +1,33 @@
# frozen_string_literal: true
module Metadata
# Hasta ahora veníamos habilitando la opción de romper
# retroactivamente relaciones, sin informar que estaba sucediendo.
# Con este módulo, todas las relaciones que ya tienen una relación
# inversa son ignoradas.
module UnusedValuesConcern
extend ActiveSupport::Concern
included do
# Excluye el Post actual y todos los que ya tengan una relación
# inversa, para no romperla.
#
# @return [Array]
def values
@values ||= posts.map do |p|
next if p.uuid.value == post.uuid.value
disabled = false
# El campo está deshabilitado si está completo y no incluye el
# post actual.
if inverse?
disabled = p[inverse].present? && ![p[inverse].value].flatten.include?(post.uuid.value)
end
[title(p), p.uuid.value, disabled]
end.compact
end
end
end
end

View file

@ -10,7 +10,7 @@ require 'open3'
# :attributes`. # :attributes`.
class Deploy < ApplicationRecord class Deploy < ApplicationRecord
belongs_to :site belongs_to :site
belongs_to :rol belongs_to :rol, optional: true
has_many :build_stats, dependent: :destroy has_many :build_stats, dependent: :destroy

View file

@ -2,7 +2,7 @@
# Soportar dominios alternativos # Soportar dominios alternativos
class DeployAlternativeDomain < Deploy class DeployAlternativeDomain < Deploy
store :values, accessors: %i[hostname], coder: JSON store_accessor :values, :hostname
DEPENDENCIES = %i[deploy_local] DEPENDENCIES = %i[deploy_local]

View file

@ -12,7 +12,10 @@ require 'distributed_press/v1/client/site'
# Al ser publicado, envía los archivos en un tarball y actualiza la # Al ser publicado, envía los archivos en un tarball y actualiza la
# información. # información.
class DeployDistributedPress < Deploy class DeployDistributedPress < Deploy
store :values, accessors: %i[hostname remote_site_id remote_info distributed_press_publisher_id], coder: JSON store_accessor :values, :hostname
store_accessor :values, :remote_site_id
store_accessor :values, :remote_info
store_accessor :values, :distributed_press_publisher_id
before_create :create_remote_site! before_create :create_remote_site!
before_destroy :delete_remote_site! before_destroy :delete_remote_site!
@ -23,7 +26,7 @@ class DeployDistributedPress < Deploy
# #
# @param :output [Bool] # @param :output [Bool]
# @return [Bool] # @return [Bool]
def deploy def deploy(output: true)
status = false status = false
log = [] log = []

View file

@ -2,7 +2,7 @@
# Genera una versión onion # Genera una versión onion
class DeployHiddenService < DeployWww class DeployHiddenService < DeployWww
store :values, accessors: %i[onion], coder: JSON store_accessor :values, :onion
before_create :create_hidden_service! before_create :create_hidden_service!

View file

@ -3,14 +3,12 @@
# Alojamiento local, solo genera el sitio, con lo que no necesita hacer # Alojamiento local, solo genera el sitio, con lo que no necesita hacer
# nada más # nada más
class DeployLocal < Deploy class DeployLocal < Deploy
store :values, accessors: %i[], coder: JSON
before_destroy :remove_destination! before_destroy :remove_destination!
def bundle(output: false) def bundle(output: false)
run %(bundle config set --local clean 'true'), output: output run %(bundle config set --local clean 'true'), output: output
run(%(bundle config set --local deployment 'true'), output: output) if site.gemfile_lock_path? run %(bundle config set --local deployment 'true'), output: output if site.gemfile_lock_path?
run %(bundle config set --local path '#{gems_dir}'), output: output run %(bundle config set --local path '#{site.bundle_path}'), output: output
run %(bundle config set --local without 'test development'), output: output run %(bundle config set --local without 'test development'), output: output
run %(bundle config set --local cache_all 'false'), output: output run %(bundle config set --local cache_all 'false'), output: output
run %(bundle install), output: output run %(bundle install), output: output

View file

@ -2,7 +2,8 @@
# Soportar dominios localizados # Soportar dominios localizados
class DeployLocalizedDomain < DeployAlternativeDomain class DeployLocalizedDomain < DeployAlternativeDomain
store :values, accessors: %i[hostname locale], coder: JSON store_accessor :values, :hostname
store_accessor :values, :locale
# Generar un link simbólico del sitio principal al alternativo # Generar un link simbólico del sitio principal al alternativo
def deploy(**) def deploy(**)

View file

@ -2,7 +2,9 @@
# Reindexa los artículos al terminar la compilación # Reindexa los artículos al terminar la compilación
class DeployReindex < Deploy class DeployReindex < Deploy
def deploy(**) def deploy(output: true)
puts 'Reindex' if output
time_start time_start
site.reset site.reset

View file

@ -3,11 +3,15 @@
# Sincroniza sitios a servidores remotos usando Rsync. El servidor # Sincroniza sitios a servidores remotos usando Rsync. El servidor
# remoto tiene que tener rsync instalado. # remoto tiene que tener rsync instalado.
class DeployRsync < Deploy class DeployRsync < Deploy
store :values, accessors: %i[hostname destination host_keys], coder: JSON store_accessor :values, :hostname
store_accessor :values, :destination
store_accessor :values, :host_keys
DEPENDENCIES = %i[deploy_local deploy_zip] DEPENDENCIES = %i[deploy_local deploy_zip]
def deploy(output: false) def deploy(output: false)
raise(ArgumentError, 'destination no está configurado') if destination.blank?
ssh? && rsync(output: output) ssh? && rsync(output: output)
end end
@ -18,13 +22,6 @@ class DeployRsync < Deploy
deploy_local.size deploy_local.size
end end
# Devolver el destino o lanzar un error si no está configurado
def destination
values[:destination].tap do |d|
raise(ArgumentError, 'destination no está configurado') if d.blank?
end
end
# @return [String] # @return [String]
def url def url
"https://#{hostname}/" "https://#{hostname}/"
@ -43,9 +40,9 @@ class DeployRsync < Deploy
ssh_available = false ssh_available = false
Net::SSH.start(host, user, verify_host_key: tofu, timeout: 5) do |ssh| Net::SSH.start(host, user, verify_host_key: tofu, timeout: 5) do |ssh|
if values[:host_keys].blank? if self.host_keys.blank?
# Guardar las llaves que se encontraron en la primera conexión # Guardar las llaves que se encontraron en la primera conexión
values[:host_keys] = ssh.transport.host_keys.map do |host_key| self.host_keys = ssh.transport.host_keys.map do |host_key|
"#{host_key.ssh_type} #{host_key.fingerprint}" "#{host_key.ssh_type} #{host_key.fingerprint}"
end end
@ -74,14 +71,14 @@ class DeployRsync < Deploy
# #
# @return [Symbol] # @return [Symbol]
def tofu def tofu
values[:host_keys].present? ? :always : :accept_new self.host_keys.present? ? :always : :accept_new
end end
# Devuelve el par user host # Devuelve el par user host
# #
# @return [Array] # @return [Array]
def user_host def user_host
destination.split(':', 2).first.split('@', 2).tap do |d| @user_host ||= destination.split(':', 2).first.split('@', 2).tap do |d|
next unless d.size == 1 next unless d.size == 1
d.insert(0, nil) d.insert(0, nil)

View file

@ -2,8 +2,6 @@
# Vincula la versión del sitio con www a la versión sin # Vincula la versión del sitio con www a la versión sin
class DeployWww < Deploy class DeployWww < Deploy
store :values, accessors: %i[], coder: JSON
DEPENDENCIES = %i[deploy_local] DEPENDENCIES = %i[deploy_local]
before_destroy :remove_destination! before_destroy :remove_destination!

View file

@ -6,8 +6,6 @@ require 'zip'
# #
# TODO: Firmar con minisign # TODO: Firmar con minisign
class DeployZip < Deploy class DeployZip < Deploy
store :values, accessors: %i[], coder: JSON
DEPENDENCIES = %i[deploy_local] DEPENDENCIES = %i[deploy_local]
# Una vez que el sitio está generado, tomar todos los archivos y # Una vez que el sitio está generado, tomar todos los archivos y

View file

@ -19,8 +19,12 @@ class MetadataArray < MetadataTemplate
true && !private? true && !private?
end end
def titleize?
true
end
def to_s def to_s
value.join(', ') value.select(&:present?).join(', ')
end end
# Obtiene el valor desde el documento, convirtiéndolo a Array si no lo # Obtiene el valor desde el documento, convirtiéndolo a Array si no lo

View file

@ -13,6 +13,10 @@ class MetadataBelongsTo < MetadataRelatedPosts
'' ''
end end
def to_s
belongs_to.try(:title).try(:value).to_s
end
# Obtiene el valor desde el documento. # Obtiene el valor desde el documento.
# #
# @return [String] # @return [String]
@ -20,14 +24,6 @@ class MetadataBelongsTo < MetadataRelatedPosts
document.data[name.to_s] document.data[name.to_s]
end end
def validate
super
errors << I18n.t('metadata.belongs_to.missing_post') unless post_exists?
errors.empty?
end
# Guardar y guardar la relación inversa también, eliminando la # Guardar y guardar la relación inversa también, eliminando la
# relación anterior si existía. # relación anterior si existía.
def save def save
@ -40,13 +36,23 @@ class MetadataBelongsTo < MetadataRelatedPosts
# Si estamos cambiando la relación, tenemos que eliminar la relación # Si estamos cambiando la relación, tenemos que eliminar la relación
# anterior # anterior
if belonged_to.present? if belonged_to.present?
if belonged_to[inverse].respond_to? :has_one
belonged_to[inverse].value = ''
else
belonged_to[inverse].value = belonged_to[inverse].value.reject do |rej| belonged_to[inverse].value = belonged_to[inverse].value.reject do |rej|
rej == post.uuid.value rej == post.uuid.value
end end
end end
end
# No duplicar las relaciones # No duplicar las relaciones
if belongs_to.present?
if belongs_to[inverse].respond_to? :has_one
belongs_to[inverse].value = post.uuid.value
else
belongs_to[inverse].value = (belongs_to[inverse].value.dup << post.uuid.value) unless belongs_to.blank? || included? belongs_to[inverse].value = (belongs_to[inverse].value.dup << post.uuid.value) unless belongs_to.blank? || included?
end
end
true true
end end
@ -97,6 +103,6 @@ class MetadataBelongsTo < MetadataRelatedPosts
end end
def sanitize(uuid) def sanitize(uuid)
uuid.to_s.gsub(/[^a-f0-9\-]/i, '') uuid.to_s.gsub(/[^a-f0-9-]/i, '')
end end
end end

View file

@ -58,8 +58,13 @@ class MetadataContent < MetadataTemplate
uri = URI element['src'] uri = URI element['src']
# No permitimos recursos externos # No permitimos recursos externos, solo si sabemos cuales son
raise URI::Error unless Rails.application.config.hosts.include?(uri.hostname) # los recursos locales
if Rails.application.config.hosts.present?
unless Rails.application.config.hosts.include?(uri.hostname)
raise URI::Error
end
end
element['src'] = convert_src_to_internal_path uri element['src'] = convert_src_to_internal_path uri

View file

@ -1,8 +1,30 @@
# frozen_string_literal: true # frozen_string_literal: true
class MetadataDate < MetadataTemplate class MetadataDate < MetadataTemplate
# La fecha de hoy si no hay nada. Podemos traer un valor por defecto
# desde el esquema, siempre y cuando pueda considerarse una fecha
# válida.
#
# @return [Date,nil]
def default_value def default_value
Date.today if (dv = super.presence)
begin
Date.parse(dv)
# XXX: Notificar para que sepamos que el esquema no es válido.
# TODO: Validar el valor por defecto en sutty-schema-validator.
rescue Date::Error => e
ExceptionNotifier.notify_exception(e, data: { site: site.name, post: post.id, name:, type: })
nil
end
end
end
# Delegar el formato al valor, para uso dentro de date_field()
#
# @param format [String]
# @return [String,nil]
def strftime(format)
value&.strftime(format)
end end
# Devuelve una fecha, si no hay ninguna es la fecha de hoy. # Devuelve una fecha, si no hay ninguna es la fecha de hoy.

View file

@ -18,6 +18,18 @@ class MetadataFile < MetadataTemplate
# XXX: Esto ayuda a deserializar en {Site#everything_of} # XXX: Esto ayuda a deserializar en {Site#everything_of}
def values; end def values; end
# Usar la descripción
def titleize?
true
end
# Devolver la descripción
#
# @return [String]
def to_s
value['description'].to_s
end
def validate def validate
super super
@ -49,6 +61,8 @@ class MetadataFile < MetadataTemplate
value['path'] = relative_destination_path_with_filename.to_s if static_file value['path'] = relative_destination_path_with_filename.to_s if static_file
end end
self[:value] = self[:value].to_h
true true
end end

View file

@ -14,7 +14,7 @@ class MetadataGeo < MetadataTemplate
return true unless changed? return true unless changed?
return true if empty? return true if empty?
self[:value] = value.transform_values(&:to_f) self[:value] = value.transform_values(&:to_f).to_h
self[:value] = encrypt(value) if private? self[:value] = encrypt(value) if private?
true true

View file

@ -36,10 +36,15 @@ class MetadataHasMany < MetadataRelatedPosts
def save def save
super super
self[:value] = self[:value].uniq
return true unless changed? return true unless changed?
return true unless inverse? return true unless inverse?
(had_many - has_many).each do |remove| (had_many - has_many).each do |remove|
# No modificar nada si la relación ya estaba deshecha
next unless remove[inverse]&.value == post.uuid.value
remove[inverse]&.value = remove[inverse].default_value remove[inverse]&.value = remove[inverse].default_value
end end

View file

@ -0,0 +1,29 @@
# frozen_string_literal: true
class MetadataHasOne < MetadataBelongsTo
alias has_one belongs_to
alias had_one belonged_to
def save
# XXX: DRY
if !changed?
self[:value] = document_value
return true
end
self[:value] = sanitize value
return true unless changed?
return true unless inverse?
had_one[inverse]&.value = '' if had_one
has_one[inverse]&.value = post.uuid.value if has_one
true
end
def related_methods
@related_methods ||= %i[has_one had_one].freeze
end
end

View file

@ -0,0 +1,16 @@
# frozen_string_literal: true
class MetadataHasOneNested < MetadataHasOne
def nested
@nested ||= layout.metadata.dig(name, 'nested')
end
def nested?
true
end
# No tener conflictos con related
def related_methods
@related_methods ||= [].freeze
end
end

View file

@ -0,0 +1,5 @@
# frozen_string_literal: true
# Implementa la nueva interfaz de gestión de valores
class MetadataNewArray < MetadataArray
end

View file

@ -0,0 +1,6 @@
# frozen_string_literal: true
# Nueva interfaz
class MetadataNewBelongsTo < MetadataBelongsTo
include Metadata::UnusedValuesConcern
end

View file

@ -0,0 +1,4 @@
# frozen_string_literal: true
# Nueva interfaz para relaciones muchos a muchos
class MetadataNewHasAndBelongsToMany < MetadataHasAndBelongsToMany; end

View file

@ -0,0 +1,6 @@
# frozen_string_literal: true
# Interfaz nueva para uno a muchos
class MetadataNewHasMany < MetadataHasMany
include Metadata::UnusedValuesConcern
end

View file

@ -0,0 +1,4 @@
# frozen_string_literal: true
# Nueva interfaz para relaciones 1:1
class MetadataNewHasOne < MetadataHasOne; end

View file

@ -0,0 +1,4 @@
# frozen_string_literal: true
# Nueva interfaz para arrays predefinidos
class MetadataNewPredefinedArray < MetadataPredefinedArray; end

View file

@ -0,0 +1,8 @@
# frozen_string_literal: true
# Nueva interfaz
class MetadataNewPredefinedValue < MetadataPredefinedValue
def values
@values ||= (required ? {} : { I18n.t('posts.attributes.new_predefined_value.empty') => '' }).merge(super)
end
end

View file

@ -7,12 +7,25 @@ class MetadataPermalink < MetadataString
false false
end end
# Devuelve la URL actual del sitio
#
# @return [String, nil]
def default_value
if post.written?
super.presence || document.url
else
super.presence
end
end
private private
# Al hacer limpieza, validamos la ruta. Eliminamos / multiplicadas, # Al hacer limpieza, validamos la ruta. Eliminamos / multiplicadas,
# puntos suspensivos, la primera / para que siempre sea relativa y # puntos suspensivos, la primera / para que siempre sea relativa y
# agregamos una / al final si la ruta no tiene extensión. # agregamos una / al final si la ruta no tiene extensión.
def sanitize(value) def sanitize(value)
return value.strip if value.blank?
value = value.strip.unicode_normalize.gsub('..', '/').gsub('./', '').squeeze('/') value = value.strip.unicode_normalize.gsub('..', '/').gsub('./', '').squeeze('/')
value = value[1..-1] if value.start_with? '/' value = value[1..-1] if value.start_with? '/'
value += '/' if File.extname(value).blank? value += '/' if File.extname(value).blank?

View file

@ -0,0 +1,3 @@
# frozen_string_literal: true
class MetadataPlainText < MetadataContent; end

View file

@ -7,4 +7,13 @@ class MetadataPredefinedArray < MetadataArray
[v[I18n.locale.to_s], k] [v[I18n.locale.to_s], k]
end&.to_h end&.to_h
end end
# Devolver los valores legibles por humanes
#
# @todo Debería devolver los valores en el idioma del post, no de le
# usuarie
# @return [String]
def to_s
values.invert.select { |x, k| value.include?(x) }.values.join(', ')
end
end end

View file

@ -10,6 +10,10 @@ class MetadataPredefinedValue < MetadataString
@values ||= layout.dig(:metadata, name, 'values', I18n.locale.to_s)&.invert || {} @values ||= layout.dig(:metadata, name, 'values', I18n.locale.to_s)&.invert || {}
end end
def to_s
values.invert[value].to_s
end
private private
# Solo permite almacenar los valores predefinidos. # Solo permite almacenar los valores predefinidos.

View file

@ -22,10 +22,21 @@ class MetadataRelatedPosts < MetadataArray
false false
end end
def titleize?
false
end
def indexable_values def indexable_values
posts.where(uuid: value).map(&:title).map(&:value) posts.where(uuid: value).map(&:title).map(&:value)
end end
# Encuentra el filtro
#
# @return [Hash]
def filter
layout.metadata.dig(name, 'filter')&.to_h&.symbolize_keys || {}
end
private private
# Obtiene todos los posts y opcionalmente los filtra # Obtiene todos los posts y opcionalmente los filtra
@ -34,17 +45,12 @@ class MetadataRelatedPosts < MetadataArray
end end
def title(post) def title(post)
"#{post&.title&.value || post&.slug&.value} #{post&.date&.value.strftime('%F')} (#{post.layout.humanized_name})" "#{post&.title&.value || post&.slug&.value} #{post&.date&.value&.strftime('%F')} (#{post.layout.humanized_name})"
end
# Encuentra el filtro
def filter
layout.metadata.dig(name, 'filter')&.to_h&.symbolize_keys || {}
end end
def sanitize(uuid) def sanitize(uuid)
super(uuid.map do |u| super(uuid.map do |u|
u.to_s.gsub(/[^a-f0-9\-]/i, '') u.to_s.gsub(/[^a-f0-9-]/i, '')
end) end)
end end
end end

View file

@ -25,7 +25,7 @@ require 'jekyll/utils'
class MetadataSlug < MetadataTemplate class MetadataSlug < MetadataTemplate
# Trae el slug desde el título si existe o una string al azar # Trae el slug desde el título si existe o una string al azar
def default_value def default_value
title ? Jekyll::Utils.slugify(title, mode: site.slugify_mode) : SecureRandom.uuid Jekyll::Utils.slugify(title || SecureRandom.uuid, mode: site.slugify_mode)
end end
def value def value

View file

@ -11,6 +11,10 @@ class MetadataString < MetadataTemplate
true && !private? true && !private?
end end
def titleize?
true
end
private private
# No se permite HTML en las strings # No se permite HTML en las strings

View file

@ -12,6 +12,15 @@ MetadataTemplate = Struct.new(:site, :document, :name, :label, :type,
false false
end end
def nested?
false
end
# El valor puede ser parte de un título auto-generado
def titleize?
false
end
def inspect def inspect
"#<#{self.class} site=#{site.name.inspect} post=#{post.id.inspect} value=#{value.inspect}>" "#<#{self.class} site=#{site.name.inspect} post=#{post.id.inspect} value=#{value.inspect}>"
end end
@ -38,18 +47,10 @@ MetadataTemplate = Struct.new(:site, :document, :name, :label, :type,
"#{cache_key}-#{cache_version}" "#{cache_key}-#{cache_version}"
end end
# XXX: Deberíamos sanitizar durante la asignación?
def value=(new_value)
@value_was = value
self[:value] = new_value
end
# Siempre obtener el valor actual y solo obtenerlo del documento una # Siempre obtener el valor actual y solo obtenerlo del documento una
# vez. # vez.
def value_was def value_was
return @value_was if instance_variable_defined? '@value_was' @value_was ||= document_value.nil? ? default_value : document_value
@value_was = document_value
end end
def changed? def changed?
@ -169,7 +170,7 @@ MetadataTemplate = Struct.new(:site, :document, :name, :label, :type,
# once => el campo solo se puede modificar si estaba vacío # once => el campo solo se puede modificar si estaba vacío
def writable? def writable?
case layout.metadata.dig(name, 'writable') case layout.metadata.dig(name, 'writable')
when 'once' then value.blank? when 'once' then value_was.blank?
else true else true
end end
end end

View file

@ -1,4 +1,8 @@
# frozen_string_literal: true # frozen_string_literal: true
# Un campo de texto largo # Un campo de texto largo
class MetadataText < MetadataString; end class MetadataText < MetadataString
def titleize?
false
end
end

View file

@ -0,0 +1,26 @@
# frozen_string_literal: true
# El título es obligatorio para todos los Post, si el esquema no lo
# incluye, tenemos que poder generar un valor legible por humanes.
class MetadataTitle < MetadataString
def titleize?
false
end
# Siempre recalcular el título
def value
self[:value] = default_value
end
# Obtener todos los valores de texto del artículo y generar un título
# en base a eso.
#
# @return [String]
def default_value
post.attributes.select do |attr|
post[attr].titleize?
end.map do |attr|
post[attr].to_s
end.compact.join(' ').strip.squeeze(' ')
end
end

View file

@ -12,9 +12,24 @@ class Post
DEFAULT_ATTRIBUTES = %i[site document layout].freeze DEFAULT_ATTRIBUTES = %i[site document layout].freeze
# Otros atributos que no vienen en los metadatos # Otros atributos que no vienen en los metadatos
PRIVATE_ATTRIBUTES = %i[path slug attributes errors].freeze PRIVATE_ATTRIBUTES = %i[path slug attributes errors].freeze
PUBLIC_ATTRIBUTES = %i[lang date uuid created_at].freeze PUBLIC_ATTRIBUTES = %i[title lang date uuid created_at].freeze
ALIASED_ATTRIBUTES = %i[locale].freeze
ATTR_SUFFIXES = %w[? =].freeze ATTR_SUFFIXES = %w[? =].freeze
ATTRIBUTE_DEFINITIONS = {
'title' => { 'type' => 'title', 'required' => true },
'lang' => { 'type' => 'lang', 'required' => true },
'date' => { 'type' => 'document_date', 'required' => true },
'uuid' => { 'type' => 'uuid', 'required' => true },
'created_at' => { 'type' => 'created_at', 'required' => true },
'slug' => { 'type' => 'slug', 'required' => true },
'path' => { 'type' => 'path', 'required' => true },
'locale' => { 'alias' => 'lang' }
}.freeze
class PostError < StandardError; end
class UnknownAttributeError < PostError; end
attr_reader :attributes, :errors, :layout, :site, :document attr_reader :attributes, :errors, :layout, :site, :document
# TODO: Modificar el historial de Git con callbacks en lugar de # TODO: Modificar el historial de Git con callbacks en lugar de
@ -30,6 +45,10 @@ class Post
# a demanda? # a demanda?
def find_layout(path) def find_layout(path)
File.foreach(path).lazy.grep(/^layout: /).take(1).first&.split(' ')&.last&.tr('\'', '')&.tr('"', '')&.to_sym File.foreach(path).lazy.grep(/^layout: /).take(1).first&.split(' ')&.last&.tr('\'', '')&.tr('"', '')&.to_sym
rescue Errno::ENOENT => e
ExceptionNotifier.notify(e)
:post
end end
end end
@ -46,16 +65,39 @@ class Post
@layout = args[:layout] @layout = args[:layout]
@site = args[:site] @site = args[:site]
@document = args[:document] @document = args[:document]
@attributes = layout.attributes + PUBLIC_ATTRIBUTES @attributes = (layout.attributes + PUBLIC_ATTRIBUTES).uniq
@errors = {} @errors = {}
@metadata = {} @metadata = {}
# Inicializar valores layout.metadata = ATTRIBUTE_DEFINITIONS.merge(layout.metadata).with_indifferent_access
attributes.each do |attr|
public_send(attr)&.value = args[attr] if args.key?(attr) # Leer el documento si existe
# @todo Asignar todos los valores a self[:value] luego de leer
document&.read! unless new?
# Inicializar valores o modificar los que vengan del documento
assignable_attributes = args.slice(*attributes)
assign_attributes(assignable_attributes) if assignable_attributes.present?
end end
document.read! unless new? # Asignar atributos, ignorando atributos que no se pueden modificar
# o inexistentes
#
# @param attrs [Hash]
def assign_attributes(attrs)
attrs = attrs.transform_keys(&:to_sym)
attributes.each do |attr|
self[attr].value = attrs[attr] if attrs.key?(attr) && self[attr].writable?
end
unknown_attrs = attrs.keys.map(&:to_sym) - attributes
if unknown_attrs.present?
raise UnknownAttributeError, "Unknown attribute(s) #{unknown_attrs.map(&:to_s).join(', ')} for Post"
end
nil
end end
def inspect def inspect
@ -103,6 +145,7 @@ class Post
src = element.attributes['src'] src = element.attributes['src']
next unless src&.value&.start_with? 'public/' next unless src&.value&.start_with? 'public/'
file = MetadataFile.new(site: site, post: self, document: document, layout: layout) file = MetadataFile.new(site: site, post: self, document: document, layout: layout)
file.value['path'] = src.value file.value['path'] = src.value
@ -164,14 +207,13 @@ class Post
def method_missing(name, *_args) def method_missing(name, *_args)
# Limpiar el nombre del atributo, para que todos los ayudantes # Limpiar el nombre del atributo, para que todos los ayudantes
# reciban el método en limpio # reciban el método en limpio
unless attribute? name raise NoMethodError, I18n.t('exceptions.post.no_method', method: name) unless attribute? name
raise NoMethodError, I18n.t('exceptions.post.no_method',
method: name)
end
define_singleton_method(name) do define_singleton_method(name) do
template = layout.metadata[name.to_s] template = layout.metadata[name.to_s]
return public_send(template['alias'].to_sym) if template.key?('alias')
@metadata[name] ||= @metadata[name] ||=
MetadataFactory.build(document: document, MetadataFactory.build(document: document,
post: self, post: self,
@ -187,55 +229,6 @@ class Post
public_send name public_send name
end end
# TODO: Mover a method_missing
def slug
@metadata[:slug] ||= MetadataSlug.new(document: document, site: site, layout: layout, name: :slug, type: :slug,
post: self, required: true)
end
# TODO: Mover a method_missing
def date
@metadata[:date] ||= MetadataDocumentDate.new(document: document, site: site, layout: layout, name: :date,
type: :document_date, post: self, required: true)
end
# TODO: Mover a method_missing
def path
@metadata[:path] ||= MetadataPath.new(document: document, site: site, layout: layout, name: :path, type: :path,
post: self, required: true)
end
# TODO: Mover a method_missing
def lang
@metadata[:lang] ||= MetadataLang.new(document: document, site: site, layout: layout, name: :lang, type: :lang,
post: self, required: true)
end
alias locale lang
# TODO: Mover a method_missing
def uuid
@metadata[:uuid] ||= MetadataUuid.new(document: document, site: site, layout: layout, name: :uuid, type: :uuid,
post: self, required: true)
end
# La fecha de creación inmodificable del post
def created_at
@metadata[:created_at] ||= MetadataCreatedAt.new(document: document, site: site, layout: layout, name: :created_at, type: :created_at, post: self, required: true)
end
# Detecta si es un atributo válido o no, a partir de la tabla de la
# plantilla
def attribute?(mid)
included = DEFAULT_ATTRIBUTES.include?(mid) ||
PRIVATE_ATTRIBUTES.include?(mid) ||
PUBLIC_ATTRIBUTES.include?(mid)
included = attributes.include? mid if !included && singleton_class.method_defined?(:attributes)
included
end
# Devuelve los strong params para el layout. # Devuelve los strong params para el layout.
# #
# XXX: Nos gustaría no tener que instanciar Metadata acá, pero depende # XXX: Nos gustaría no tener que instanciar Metadata acá, pero depende
@ -386,11 +379,7 @@ class Post
end end
def update_attributes(hashable) def update_attributes(hashable)
hashable.to_hash.each do |attr, value| assign_attributes(hashable)
next unless self[attr].writable?
self[attr].value = value
end
save save
end end
@ -404,6 +393,38 @@ class Post
@usuaries ||= document_usuaries.empty? ? [] : Usuarie.where(id: document_usuaries).to_a @usuaries ||= document_usuaries.empty? ? [] : Usuarie.where(id: document_usuaries).to_a
end end
# Todos los atributos anidados
#
# @return [Array<Symbol>]
def nested_attributes
@nested_attributes ||= attributes.map { |a| self[a] }.select(&:nested?).map(&:name)
end
# Detecta si es un atributo válido o no, a partir de la tabla de la
# plantilla
def attribute?(mid)
included = DEFAULT_ATTRIBUTES.include?(mid) ||
PRIVATE_ATTRIBUTES.include?(mid) ||
PUBLIC_ATTRIBUTES.include?(mid) ||
ALIASED_ATTRIBUTES.include?(mid)
included = attributes.include? mid if !included && singleton_class.method_defined?(:attributes)
included
end
# Devuelve la URL absoluta
#
# @return [String, nil]
def absolute_url
return unless written?
@absolute_url ||=
URI.parse(site.url).tap do |uri|
uri.path = document.url
end.to_s
end
private private
# Levanta un error si al construir el artículo no pasamos un atributo. # Levanta un error si al construir el artículo no pasamos un atributo.

View file

@ -68,8 +68,7 @@ class Site < ApplicationRecord
before_create :clone_skel! before_create :clone_skel!
# Elimina el directorio al destruir un sitio # Elimina el directorio al destruir un sitio
before_destroy :remove_directories! before_destroy :remove_directories!
# Cambiar el nombre del directorio
before_update :update_name!
before_save :add_private_key_if_missing! before_save :add_private_key_if_missing!
# Guardar la configuración si hubo cambios # Guardar la configuración si hubo cambios
after_save :sync_attributes_with_config! after_save :sync_attributes_with_config!
@ -234,7 +233,9 @@ class Site < ApplicationRecord
# colecciones. # colecciones.
def collections def collections
unless @read unless @read
Site.one_at_a_time.synchronize do
jekyll.reader.read_collections jekyll.reader.read_collections
end
@read = true @read = true
end end
@ -434,7 +435,9 @@ class Site < ApplicationRecord
# Si estamos usando nuestro propio plugin de i18n, los posts están # Si estamos usando nuestro propio plugin de i18n, los posts están
# en "colecciones" # en "colecciones"
locales.map(&:to_s).each do |i| locales.map(&:to_s).each do |i|
@configuration['collections'][i] = {} @configuration['collections'][i] = {
'permalink' => configuration.send(:style_to_permalink, configuration['permalink'])
}
end end
@configuration @configuration
@ -504,12 +507,6 @@ class Site < ApplicationRecord
FileUtils.rm_rf path FileUtils.rm_rf path
end 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 # Sincroniza algunos atributos del sitio con su configuración y
# guarda los cambios # guarda los cambios
@ -589,17 +586,34 @@ class Site < ApplicationRecord
# * El archivo Gemfile.lock se modificó # * El archivo Gemfile.lock se modificó
def install_gems def install_gems
return unless persisted? return unless persisted?
return unless (!gems_installed? || theme_path.blank?) || gemfile_updated? || gemfile_lock_updated?
deploy_local = deploys.find_by_type('DeployLocal') deploys.find_by_type('DeployLocal').bundle
deploy_local.git_lfs
return unless !gems_installed? || gemfile_updated? || gemfile_lock_updated?
deploy_local.bundle
touch touch
FileUtils.touch(gemfile_path) FileUtils.touch(gemfile_path)
end end
# El sitio tiene una plantilla
#
# @return [Bool]
def theme?
config['theme'].present?
end
# El directorio donde se encuentran los archivos de la plantilla. Si
# es nil es que las dependencias todavía no se instalaron.
#
# @return [String,nil]
def theme_path
@theme_path ||=
if theme?
Dir[gem_path.join('gems', "#{config['theme']}-*").to_s].first
else
path
end
end
# @return [Pathname]
def gem_path def gem_path
@gem_path ||= @gem_path ||=
begin begin

View file

@ -46,11 +46,19 @@ class Site
private private
# Trae el último commit indexado desde el repositorio # Trae el último commit indexado desde el repositorio, o si no
# existe, trae el primer commit.
# #
# @return [Rugged::Commit] # @return [Rugged::Commit]
def indexed_commit def indexed_commit
@indexed_commit ||= repository.rugged.lookup(last_indexed_commit) @indexed_commit ||=
if repository.rugged.exists?(last_indexed_commit)
repository.rugged.lookup(last_indexed_commit)
else
repository.rugged.lookup(
repository.rugged.references[repository.rugged.head.canonical_name].log.first[:id_new]
)
end
end end
# Calcula la diferencia entre el último commit indexado y el # Calcula la diferencia entre el último commit indexado y el

View file

@ -13,11 +13,15 @@ class Site
# Por defecto, si el sitio no lo soporta, se obtienen los layouts # Por defecto, si el sitio no lo soporta, se obtienen los layouts
# ordenados alfabéticamente por traducción. # ordenados alfabéticamente por traducción.
# #
# @param [Usuarie,nil]
# @return [Hash] # @return [Hash]
def schema_organization def schema_organization(usuarie = nil)
@schema_organization ||= @schema_organization ||=
begin begin
schema_organization = data.dig('schema', 'organization') # XXX: retrocompatibilidad
key = (usuarie.blank? || usuarie?(usuarie)) ? 'organization' : 'organization_guest'
schema_organization = data.dig('schema', key)
schema_organization ||= data.dig('schema', 'organization')
schema_organization&.symbolize_keys! schema_organization&.symbolize_keys!
schema_organization&.transform_values! do |ary| schema_organization&.transform_values! do |ary|
ary.map(&:to_sym) ary.map(&:to_sym)

View file

@ -76,12 +76,17 @@ class Site
# escribir los cambios # escribir los cambios
rugged.checkout 'HEAD', strategy: :force rugged.checkout 'HEAD', strategy: :force
git_sh("git", "lfs", "fetch", "origin", default_branch)
# reemplaza los pointers por los archivos correspondientes
git_sh("git", "lfs", "checkout")
commit commit
end end
# Trae todos los archivos desde LFS
#
# @return [Boolean]
def git_lfs_checkout
git_sh('git', 'lfs', 'fetch', 'origin', default_branch)
git_sh('git', 'lfs', 'checkout')
end
# El último commit # El último commit
# #
# @return [Rugged::Commit] # @return [Rugged::Commit]
@ -111,10 +116,30 @@ class Site
walker.each.to_a walker.each.to_a
end end
# Hay commits sin aplicar? # Detecta si hay que hacer un pull o no
def needs_pull? #
fetch # @return [Boolean]
!commits.empty? def up_to_date?
rugged.merge_analysis(remote_head_commit).include?(:up_to_date)
end
# Detecta si es posible adelantar la historia local a la remota o
# necesitamos un merge
#
# @return [Boolean]
def fast_forward?
rugged.merge_analysis(remote_head_commit).include?(:fastforward)
end
# Mueve la historia local a la remota
#
# @see {https://stackoverflow.com/a/27077322}
# @return [nil]
def fast_forward!
rugged.checkout_tree(remote_head_commit)
rugged.references.update(rugged.head.resolve, remote_head_commit.oid)
nil
end end
# Guarda los cambios en git # Guarda los cambios en git

View file

@ -3,6 +3,7 @@
# Usuarie de la plataforma # Usuarie de la plataforma
class Usuarie < ApplicationRecord class Usuarie < ApplicationRecord
include Usuarie::Consent include Usuarie::Consent
include ActivityPub::ModeratorConcern
devise :invitable, :database_authenticatable, devise :invitable, :database_authenticatable,
:recoverable, :rememberable, :validatable, :recoverable, :rememberable, :validatable,

View file

@ -3,25 +3,51 @@
# Este servicio se encarga de crear artículos y guardarlos en git, # Este servicio se encarga de crear artículos y guardarlos en git,
# asignándoselos a une usuarie # asignándoselos a une usuarie
PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do
include AutoPublishConcern
# Si estamos pasando el UUID con los parámetros, el post quizás
# existe.
#
# @return [Post]
def create_or_update
uuid = params.require(base).permit(:uuid).values.first
if uuid.blank?
create
elsif (indexed_post = site.indexed_posts.find_by(post_id: uuid)).present?
self.post = indexed_post.post
update
else
create
end
end
# Crea un artículo nuevo # Crea un artículo nuevo
# #
# @return Post # @return Post
def create def create
self.post = site.posts(lang: locale) self.post ||= site.posts(lang: locale).build(layout: layout)
.build(layout: layout) params[base][:draft] = true if site.invitade? usuarie
post.usuaries << usuarie
params[:post][:draft] = true if site.invitade? usuarie
params.require(:post).permit(:slug).tap do |p| post.usuaries << usuarie
post.assign_attributes(post_params)
params.require(base).permit(:slug).tap do |p|
post.slug.value = p[:slug] if p[:slug].present? post.slug.value = p[:slug] if p[:slug].present?
end end
commit(action: :created, add: update_related_posts) if post.update(post_params) # Crea los posts anidados
create_nested_posts! post, params[base]
post.save
update_related_posts
commit(action: :created, add: files) if post.valid?
update_site_license! update_site_license!
# Devolver el post aunque no se haya salvado para poder rescatar los # Devolver el post aunque no se haya salvado para poder rescatar los
# errores # errores
auto_publish!
post post
end end
@ -34,28 +60,33 @@ PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do
# Los artículos anónimos siempre son borradores # Los artículos anónimos siempre son borradores
params[:draft] = true params[:draft] = true
commit(action: :created, add: [post.path.absolute]) if post.update(anon_post_params) commit(action: :created, add: files) if post.update(anon_post_params)
auto_publish!
post post
end end
def update def update
post.usuaries << usuarie post.usuaries << usuarie
params[:post][:draft] = true if site.invitade? usuarie params[base][:draft] = true if site.invitade? usuarie
# Eliminar ("mover") el archivo si cambió de ubicación. # Eliminar ("mover") el archivo si cambió de ubicación.
if post.update(post_params) if post.update(post_params)
rm = [] rm = []
rm << post.path.value_was if post.path.changed? rm << post.path.value_was if post.path.changed?
create_nested_posts! post, params[base]
update_related_posts
# Es importante que el artículo se guarde primero y luego los # Es importante que el artículo se guarde primero y luego los
# relacionados. # relacionados.
commit(action: :updated, add: update_related_posts, rm: rm) commit(action: :updated, add: files, rm: rm)
update_site_license! update_site_license!
end end
# Devolver el post aunque no se haya salvado para poder rescatar los # Devolver el post aunque no se haya salvado para poder rescatar los
# errores # errores
auto_publish!
post post
end end
@ -73,7 +104,7 @@ PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do
# #
# { uuid => 2, uuid => 1, uuid => 0 } # { uuid => 2, uuid => 1, uuid => 0 }
def reorder def reorder
reorder = params.require(:post).permit(reorder: {})&.dig(:reorder)&.transform_values(&:to_i) reorder = params.require(base).permit(reorder: {})&.dig(:reorder)&.transform_values(&:to_i)
posts = site.posts(lang: locale).where(uuid: reorder.keys) posts = site.posts(lang: locale).where(uuid: reorder.keys)
files = posts.map do |post| files = posts.map do |post|
@ -96,6 +127,22 @@ PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do
private private
# La base donde buscar los parámetros
#
# @return [Symbol]
def base
@base ||= params.permit(:base).try(:[], :base).try(:to_sym) || :post
end
# Una lista de archivos a modificar
#
# @return [Set]
def files
@files ||= Set.new.tap do |f|
f << post.path.absolute
end
end
def commit(action:, add: [], rm: []) def commit(action:, add: [], rm: [])
site.repository.commit(add: add, site.repository.commit(add: add,
rm: rm, rm: rm,
@ -108,7 +155,7 @@ PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do
# Solo permitir cambiar estos atributos de cada articulo # Solo permitir cambiar estos atributos de cada articulo
def post_params def post_params
params.require(:post).permit(post.params) @post_params ||= params.require(base).permit(post.params).to_h
end end
# Eliminar metadatos internos # Eliminar metadatos internos
@ -119,11 +166,11 @@ PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do
end end
def locale def locale
params.dig(:post, :lang)&.to_sym || I18n.locale params.dig(base, :lang)&.to_sym || I18n.locale
end end
def layout def layout
params.dig(:post, :layout) || params[:layout] params.dig(base, :layout) || params[:layout]
end end
# Actualiza los artículos relacionados según los métodos que los # Actualiza los artículos relacionados según los métodos que los
@ -146,15 +193,34 @@ PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do
end end
posts.map do |p| posts.map do |p|
p.path.absolute if p.save(validate: false) next unless p.save(validate: false)
end.compact << post.path.absolute
files << p.path.absolute
end
end end
# Si les usuaries modifican o crean una licencia, considerarla # Si les usuaries modifican o crean una licencia, considerarla
# personalizada en el panel. # personalizada en el panel.
def update_site_license! def update_site_license!
if site.usuarie?(usuarie) && post.layout.name == :license && !site.licencia.custom? return unless site.usuarie?(usuarie) && post.layout.name == :license && !site.licencia.custom?
site.update licencia: Licencia.find_by_icons('custom') site.update licencia: Licencia.find_by_icons('custom')
end end
# Encuentra todos los posts anidados y los crea o modifica
def create_nested_posts!(post, params)
post.nested_attributes.each do |nested_attribute|
nested_metadata = post[nested_attribute]
next unless params[nested_metadata].present?
# @todo find_or_initialize
nested_post = nested_metadata.has_one || site.posts(lang: post.lang.value).build(layout: nested_metadata.nested)
nested_params = params.require(nested_attribute).permit(nested_post.params).to_hash
# Completa la relación 1:1
nested_params[nested_metadata.inverse.to_s] = post.uuid.value
post[nested_attribute].value = nested_post.uuid.value
files << nested_post.path.absolute if nested_post.update(nested_params)
end
end end
end end

View file

@ -3,6 +3,8 @@
# Se encargar de guardar cambios en sitios # Se encargar de guardar cambios en sitios
# TODO: Implementar rollback en la configuración # TODO: Implementar rollback en la configuración
SiteService = Struct.new(:site, :usuarie, :params, keyword_init: true) do SiteService = Struct.new(:site, :usuarie, :params, keyword_init: true) do
include AutoPublishConcern
def deploy def deploy
site.enqueue! site.enqueue!
DeployJob.perform_later site DeployJob.perform_later site
@ -24,7 +26,9 @@ SiteService = Struct.new(:site, :usuarie, :params, keyword_init: true) do
# #
# TODO: hacer que el repositorio se cree cuando es necesario, para # TODO: hacer que el repositorio se cree cuando es necesario, para
# que no haya estados intermedios. # que no haya estados intermedios.
site.locales = [usuarie.lang] + I18n.available_locales site.locales = [usuarie.lang]
add_role_to_deploys! role
add_role_to_deploys! role add_role_to_deploys! role
@ -36,10 +40,14 @@ SiteService = Struct.new(:site, :usuarie, :params, keyword_init: true) do
add_licencias && add_licencias &&
add_code_of_conduct && add_code_of_conduct &&
add_privacy_policy && add_privacy_policy &&
site.index_posts! &&
deploy deploy
end end
if site.persisted?
site.index_posts!
auto_publish!
end
site site
end end
@ -94,6 +102,25 @@ SiteService = Struct.new(:site, :usuarie, :params, keyword_init: true) do
result.present? result.present?
end end
def rename(name)
return if name == site.name
moved = false
site.name = name
Site.transaction do
raise ActiveRecord::Rollback if File.exist?(site.path)
FileUtils.mv(site.path_was, site.path)
moved = true
ActiveStorage::Blob.where(service_name: site.name_was).update_all(service_name: site.name)
site.save
rescue StandardError
FileUtils.mv(site.path, site.path_was) if moved
raise
end
end
private private
# Guarda los cambios de la configuración en el repositorio git # Guarda los cambios de la configuración en el repositorio git

View file

@ -0,0 +1,18 @@
= t('.content', site_id: params[:site_id])
= Terminal::Table.new do |table|
- if (actor = params[:actor].presence).present?
- table << [ t('.actor') ]
- table.add_separator
- table << [ actor ]
- table.add_separator
- if (content = sanitize(params[:content])).present?
- table << [ word_wrap(content, line_width: 50) ]
- table.add_separator
- if (uris = [params[:object]].flatten).present?
- table << [ t('.uris') ]
- table.add_separator
- uris.each do |uri|
- table << [ uri ]

View file

@ -1,2 +1,2 @@
.alert.alert-primary.mx-auto.content.max-w-md-70ch{ role: 'alert', class: local_assigns[:class] } .alert.alert-primary.mx-auto.content.max-w-md-70ch{ role: 'alert', **local_assigns }
= yield = yield

View file

@ -0,0 +1,13 @@
-#
Un botón
@param :content [String] Contenido
@param :action [String] Acción de Stimulus
@param :target [String] Objetivo de Stimulus
@param [Hash] Atributos en bruto, con mayor prioridad que action y target
:ruby
attributes = local_assigns.to_h.except(:content)
attributes[:data] ||= {}
attributes[:data][:action] ||= local_assigns[:action]
attributes[:data][:target] ||= local_assigns[:target]
%button.btn.btn-secondary{ type: 'button', **attributes }= content

View file

@ -0,0 +1,8 @@
.card{ **local_assigns.except(:image, :description) }
- if local_assigns[:image]
= image_tag url_for(local_assigns[:image]), alt: local_assigns[:description], class: 'img-fluid'
.card-body
.card-title= title
= yield

View file

@ -1,6 +1,10 @@
- help_id = "#{id}_help" :ruby
help_id = "#{id}_help"
checkbox_attributes = local_assigns.slice(:id, :type, :name, :value, :required, :checked, :data, :disabled)
checkbox_attributes[:type] ||= 'checkbox'
.custom-control.custom-checkbox .custom-control{ class: "custom-#{checkbox_attributes[:type]}" }
%input.custom-control-input{ id: id, type: 'checkbox', name: name, value: value, required: required } %input.custom-control-input{ **checkbox_attributes }
%label.custom-control-label{ for: id, aria: { describedby: help_id } }= content %label.custom-control-label{ for: id, aria: { describedby: help_id } }= content
%small.form-text.text-muted{ id: help_id }= yield - if (block = yield).present?
%small.form-text.text-muted{ id: help_id }= block

View file

@ -0,0 +1,6 @@
- content = t("activerecord.attributes.#{field.object_name}.#{name}")
- id = "#{field.object_name}_#{name}"
- name = "#{field.object_name}[#{name}]"
= render 'bootstrap/custom_checkbox', id: id, name: name, content: content, required: local_assigns[:required], value: "1" do
= yield

Some files were not shown because too many files have changed in this diff Show more