mirror of
https://0xacab.org/sutty/sutty
synced 2025-03-14 18:08:20 +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:
commit
b268861123
271 changed files with 3597 additions and 674 deletions
|
@ -22,8 +22,6 @@ RUN apk add npm && npm install -g pnpm@~7 && apk del npm
|
|||
|
||||
COPY ./monit.conf /etc/monit.d/sutty.conf
|
||||
|
||||
RUN apk add npm && npm install -g pnpm && apk del npm
|
||||
|
||||
VOLUME "/srv"
|
||||
|
||||
EXPOSE 3000
|
||||
|
|
7
Gemfile
7
Gemfile
|
@ -79,8 +79,8 @@ gem 'webpacker'
|
|||
gem 'yaml_db', git: 'https://0xacab.org/sutty/yaml_db.git'
|
||||
gem 'kaminari'
|
||||
gem 'device_detector'
|
||||
gem 'htmlbeautifier'
|
||||
gem 'dry-schema'
|
||||
gem 'htmlbeautifier'
|
||||
gem 'rubanok'
|
||||
|
||||
gem 'after_commit_everywhere', '~> 1.0'
|
||||
|
@ -118,9 +118,8 @@ group :development, :test do
|
|||
gem 'derailed_benchmarks'
|
||||
gem 'dotenv-rails'
|
||||
gem 'pry'
|
||||
# Adds support for Capybara system testing and selenium driver
|
||||
gem 'capybara', '~> 2.13'
|
||||
gem 'selenium-webdriver', '~> 4.8.0'
|
||||
gem 'capybara'
|
||||
gem 'selenium-webdriver'
|
||||
gem 'sqlite3'
|
||||
end
|
||||
|
||||
|
|
19
Gemfile.lock
19
Gemfile.lock
|
@ -118,13 +118,15 @@ GEM
|
|||
bundler-audit (0.9.1)
|
||||
bundler (>= 1.2.0, < 3)
|
||||
thor (~> 1.0)
|
||||
capybara (2.18.0)
|
||||
capybara (3.40.0)
|
||||
addressable
|
||||
matrix
|
||||
mini_mime (>= 0.1.3)
|
||||
nokogiri (>= 1.3.3)
|
||||
rack (>= 1.0.0)
|
||||
rack-test (>= 0.5.4)
|
||||
xpath (>= 2.0, < 4.0)
|
||||
nokogiri (~> 1.11)
|
||||
rack (>= 1.6.0)
|
||||
rack-test (>= 0.6.3)
|
||||
regexp_parser (>= 1.5, < 3.0)
|
||||
xpath (~> 3.2)
|
||||
chartkick (5.0.2)
|
||||
climate_control (1.2.0)
|
||||
coderay (1.1.3)
|
||||
|
@ -360,6 +362,7 @@ GEM
|
|||
net-pop
|
||||
net-smtp
|
||||
marcel (1.0.4)
|
||||
matrix (0.4.2)
|
||||
memory_profiler (1.0.1)
|
||||
mercenary (0.4.0)
|
||||
method_source (1.1.0)
|
||||
|
@ -542,7 +545,7 @@ GEM
|
|||
sprockets (> 3.0)
|
||||
sprockets-rails
|
||||
tilt
|
||||
selenium-webdriver (4.8.6)
|
||||
selenium-webdriver (4.9.1)
|
||||
rexml (~> 3.2, >= 3.2.5)
|
||||
rubyzip (>= 1.2.2, < 3.0)
|
||||
websocket (~> 1.0)
|
||||
|
@ -632,7 +635,7 @@ DEPENDENCIES
|
|||
bootstrap (~> 4)
|
||||
brakeman
|
||||
bundler-audit
|
||||
capybara (~> 2.13)
|
||||
capybara
|
||||
chartkick
|
||||
commonmarker
|
||||
concurrent-ruby-ext
|
||||
|
@ -707,7 +710,7 @@ DEPENDENCIES
|
|||
safe_yaml
|
||||
safely_block (~> 0.3.0)
|
||||
sassc-rails
|
||||
selenium-webdriver (~> 4.8.0)
|
||||
selenium-webdriver
|
||||
sourcemap
|
||||
spring
|
||||
spring-watcher-listen
|
||||
|
|
11
Procfile
11
Procfile
|
@ -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
|
||||
distributed_press_tokens_renew: bundle exec rake distributed_press:tokens:renew
|
||||
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
|
||||
stats: bundle exec rake stats:process_all
|
||||
fediblock: bundle exec rails activity_pub:fediblocks
|
||||
|
|
|
@ -11,6 +11,21 @@ $colors: (
|
|||
"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
|
||||
$primary: $magenta;
|
||||
$secondary: $black;
|
||||
|
@ -20,6 +35,19 @@ $form-feedback-valid-color: $black;
|
|||
$form-feedback-invalid-color: $magenta;
|
||||
$form-feedback-icon-valid-color: $black;
|
||||
$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: (
|
||||
2-plus: 0.75rem
|
||||
|
@ -32,6 +60,16 @@ $sizes: (
|
|||
@import "bootstrap";
|
||||
@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 {
|
||||
.#{$color} {
|
||||
color: var(--#{$color});
|
||||
|
@ -60,6 +98,10 @@ $sizes: (
|
|||
--foreground: #{$black};
|
||||
--background: #{$white};
|
||||
--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) {
|
||||
|
@ -67,34 +109,28 @@ $sizes: (
|
|||
--foreground: #{$white};
|
||||
--background: #{$black};
|
||||
--color: #{$cyan};
|
||||
--card-border-color: #{rgba($white, .250)};
|
||||
--btn-bg-color: #{$white};
|
||||
--btn-color: #{$black};
|
||||
--modal-content-border-color: #{rgba($white, .2)};
|
||||
}
|
||||
|
||||
|
||||
.btn-secondary {
|
||||
background-color: $white;
|
||||
color: $black;
|
||||
border: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: $black;
|
||||
background-color: $cyan;
|
||||
}
|
||||
@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>"));
|
||||
|
||||
&:active {
|
||||
background-color: $cyan;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
box-shadow: 0 0 0 0.2rem $cyan;
|
||||
.custom-checkbox {
|
||||
.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-family: 'Saira';
|
||||
font-style: normal;
|
||||
|
@ -135,6 +171,10 @@ a {
|
|||
color: var(--color);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: 1px solid var(--color);
|
||||
}
|
||||
|
||||
&[target=_blank] {
|
||||
/* TODO: Convertir a base64 para no hacer peticiones extra */
|
||||
&:after {
|
||||
|
@ -241,6 +281,8 @@ svg {
|
|||
.btn {
|
||||
margin-right: 0.3rem;
|
||||
margin-bottom: 0.3rem;
|
||||
background-color: $btn-bg-color;
|
||||
color: $btn-color;
|
||||
|
||||
&:hover {
|
||||
color: var(--background);
|
||||
|
@ -256,6 +298,10 @@ svg {
|
|||
}
|
||||
}
|
||||
|
||||
.badge {
|
||||
white-space: break-spaces;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
@extend .badge
|
||||
}
|
||||
|
@ -318,10 +364,6 @@ svg {
|
|||
}
|
||||
}
|
||||
|
||||
.custom-control-label {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.designs {
|
||||
.design {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,29 +6,13 @@ $cyan: #13fefe;
|
|||
--foreground: #{$white};
|
||||
--background: #{$black};
|
||||
--color: #{$cyan};
|
||||
}
|
||||
|
||||
.btn {
|
||||
background-color: $white;
|
||||
--card-border-color: #{rgba($white, .250)};
|
||||
--btn-bg-color: #{$white};
|
||||
--btn-color: #{$black};
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: $white;
|
||||
color: $black;
|
||||
border: none;
|
||||
|
||||
&:hover {
|
||||
color: $black;
|
||||
background-color: $cyan;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: $cyan;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
box-shadow: 0 0 0 0.2rem $cyan;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -11,7 +11,7 @@ module ActiveStorage
|
|||
# Permitir incrustar archivos subidos (especialmente PDFs) desde
|
||||
# otros sitios.
|
||||
def show
|
||||
original_show.tap do |s|
|
||||
original_show.tap do |_s|
|
||||
response.headers.delete 'X-Frame-Options'
|
||||
end
|
||||
end
|
||||
|
@ -24,7 +24,7 @@ module ActiveStorage
|
|||
if (token = decode_verified_token)
|
||||
if acceptable_content?(token)
|
||||
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)
|
||||
begin
|
||||
|
@ -32,18 +32,20 @@ module ActiveStorage
|
|||
body = Down.download(url, max_size: 111.megabytes)
|
||||
checksum = Digest::MD5.file(body.path).base64digest
|
||||
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
|
||||
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
|
||||
|
||||
return
|
||||
end
|
||||
else
|
||||
body = request.body
|
||||
checksum = token[:checksum]
|
||||
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)
|
||||
else
|
||||
|
@ -52,8 +54,14 @@ module ActiveStorage
|
|||
else
|
||||
head :not_found
|
||||
end
|
||||
rescue ActiveStorage::IntegrityError
|
||||
rescue ActiveRecord::ActiveRecordError, ActiveStorage::Error => e
|
||||
ExceptionNotifier.notify_exception(e, data: { token: })
|
||||
|
||||
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
|
||||
|
||||
private
|
||||
|
@ -64,7 +72,10 @@ module ActiveStorage
|
|||
|
||||
def page_not_found(exception)
|
||||
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
|
||||
|
|
|
@ -4,51 +4,88 @@ module Api
|
|||
module V1
|
||||
# API para sitios
|
||||
class SitesController < BaseController
|
||||
http_basic_authenticate_with name: ENV['HTTP_BASIC_USER'],
|
||||
password: ENV['HTTP_BASIC_PASSWORD']
|
||||
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'],
|
||||
password: ENV['HTTP_BASIC_PASSWORD']
|
||||
end
|
||||
|
||||
# Lista de nombres de dominios a emitir certificados
|
||||
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
|
||||
|
||||
private
|
||||
|
||||
# @param query [ActiveRecord::Relation]
|
||||
# @return [Array<String>]
|
||||
def hostname_of(query)
|
||||
query.pluck(Arel.sql("values->>'hostname'")).compact.uniq
|
||||
end
|
||||
|
||||
def canonicalize(name)
|
||||
name.end_with?('.') ? name[0..-2] : "#{name}.#{Site.domain}"
|
||||
end
|
||||
|
||||
# Es un subdominio directo del dominio principal
|
||||
#
|
||||
# @param name [String]
|
||||
# @return [Bool]
|
||||
def subdomain?(name)
|
||||
name.end_with? ".#{Site.domain}"
|
||||
name.end_with?(SUBDOMAIN) && name.split('.').count == (PARTS + 1)
|
||||
end
|
||||
|
||||
# Dominios alternativos
|
||||
# Es un dominio de prueba
|
||||
#
|
||||
# @param name [String]
|
||||
# @return [Bool]
|
||||
def testing?(name)
|
||||
name.end_with?(TESTING_SUBDOMAIN) && name.split('.').count == (PARTS + 2)
|
||||
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
|
||||
(DeployAlternativeDomain.all.map(&:hostname) + DeployLocalizedDomain.all.map(&:hostname)).map do |name|
|
||||
canonicalize name
|
||||
end.reject do |name|
|
||||
subdomain? name
|
||||
end
|
||||
hostname_of(DeployAlternativeDomain.all)
|
||||
end
|
||||
|
||||
# Obtener todos los sitios con API habilitada, es decir formulario
|
||||
# de contacto y/o colaboración anónima.
|
||||
#
|
||||
# TODO: Optimizar
|
||||
# @return [Array<String>]
|
||||
def api_names
|
||||
Site.where(contact: true)
|
||||
.or(Site.where(colaboracion_anonima: true))
|
||||
.select("'api.' || name as name").map(&:name).map do |name|
|
||||
canonicalize name
|
||||
end.reject do |name|
|
||||
subdomain? name
|
||||
.pluck(:name).map do |name|
|
||||
"api.#{name}"
|
||||
end
|
||||
end
|
||||
|
||||
# Todos los dominios con WWW habilitado
|
||||
def www_names
|
||||
Site.where(id: DeployWww.all.pluck(:site_id)).select("'www.' || name as name").map(&:name).map do |name|
|
||||
canonicalize name
|
||||
Site.where(id: DeployWww.all.pluck(:site_id)).pluck(:name).map do |name|
|
||||
"www.#{name}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
# Forma de ingreso a Sutty
|
||||
class ApplicationController < ActionController::Base
|
||||
include ExceptionHandler
|
||||
include ExceptionHandler if Rails.env.production?
|
||||
include Pundit::Authorization
|
||||
|
||||
protect_from_forgery with: :null_session, prepend: true
|
||||
|
@ -117,4 +117,9 @@ class ApplicationController < ActionController::Base
|
|||
|
||||
session[:usuarie_return_to] = request.fullpath
|
||||
end
|
||||
|
||||
# Detecta si una petición fue hecha por HTMX
|
||||
def htmx?
|
||||
request.headers.key? 'HX-Request'
|
||||
end
|
||||
end
|
||||
|
|
|
@ -5,9 +5,10 @@
|
|||
# No necesitamos autenticación aun
|
||||
class CollaborationsController < ApplicationController
|
||||
include Pundit
|
||||
include StrongParamsHelper
|
||||
|
||||
def collaborate
|
||||
@site = Site.find_by_name(params[:site_id])
|
||||
@site = Site.find_by_name(pluck_param(:site_id))
|
||||
authorize Collaboration.new(@site)
|
||||
|
||||
@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
|
||||
def accept_collaboration
|
||||
@site = Site.find_by_name(params[:site_id])
|
||||
@site = Site.find_by_name(pluck_param(:site_id))
|
||||
authorize Collaboration.new(@site)
|
||||
|
||||
@invitade = current_usuarie
|
||||
|
|
|
@ -10,25 +10,41 @@ module ExceptionHandler
|
|||
included do
|
||||
rescue_from SiteNotFound, with: :site_not_found
|
||||
rescue_from PageNotFound, with: :page_not_found
|
||||
rescue_from ActionController::RoutingError, with: :page_not_found
|
||||
rescue_from Pundit::NilPolicyError, with: :page_not_found
|
||||
rescue_from Pundit::Error, with: :page_not_found
|
||||
rescue_from Pundit::NotAuthorizedError, with: :page_unauthorized
|
||||
rescue_from Pundit::NilPolicyError, with: :page_not_found
|
||||
rescue_from ActionController::RoutingError, with: :page_not_found
|
||||
rescue_from ActionController::ParameterMissing, with: :page_not_found
|
||||
end
|
||||
|
||||
def site_not_found
|
||||
def site_not_found(exception)
|
||||
reset_response!
|
||||
|
||||
flash[:error] = I18n.t('errors.site_not_found')
|
||||
|
||||
ExceptionNotifier.notify_exception(exception, data: { usuarie: current_usuarie&.id, path: request.fullpath })
|
||||
|
||||
redirect_to sites_path
|
||||
end
|
||||
|
||||
def page_not_found
|
||||
def page_unauthorized(exception)
|
||||
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
|
||||
|
||||
private
|
||||
|
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
# Controlador para artículos
|
||||
class PostsController < ApplicationController
|
||||
include StrongParamsHelper
|
||||
|
||||
before_action :authenticate_usuarie!
|
||||
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
|
||||
def default_url_options
|
||||
{ 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
|
||||
|
||||
def index
|
||||
|
@ -55,7 +135,7 @@ class PostsController < ApplicationController
|
|||
|
||||
def new
|
||||
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), ''
|
||||
end
|
||||
|
@ -65,13 +145,34 @@ class PostsController < ApplicationController
|
|||
service = PostService.new(site: site,
|
||||
usuarie: current_usuarie,
|
||||
params: params)
|
||||
@post = service.create
|
||||
@post = service.create_or_update
|
||||
|
||||
if @post.persisted?
|
||||
if post.persisted?
|
||||
site.touch
|
||||
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
|
||||
render 'posts/new'
|
||||
end
|
||||
|
@ -83,6 +184,16 @@ class PostsController < ApplicationController
|
|||
breadcrumb 'posts.edit', ''
|
||||
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
|
||||
authorize post
|
||||
|
||||
|
@ -94,7 +205,37 @@ class PostsController < ApplicationController
|
|||
if service.update.persisted?
|
||||
site.touch
|
||||
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)
|
||||
else
|
||||
render 'posts/edit'
|
||||
|
@ -168,4 +309,24 @@ class PostsController < ApplicationController
|
|||
def service_for_direct_upload
|
||||
session[:service_name] = site.name.to_sym
|
||||
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
|
||||
|
|
77
app/controllers/registrations_controller.rb
Normal file
77
app/controllers/registrations_controller.rb
Normal 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
|
|
@ -29,7 +29,7 @@ class UsuariesController < ApplicationController
|
|||
|
||||
@usuarie = Usuarie.find(params[:id])
|
||||
|
||||
if @site.usuaries.count > 1
|
||||
if @site.invitade?(@usuarie) || @site.usuaries.count > 1
|
||||
# Mágicamente elimina el rol
|
||||
@usuarie.sites.delete(@site)
|
||||
else
|
||||
|
|
|
@ -2,6 +2,19 @@
|
|||
|
||||
# Helpers
|
||||
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
|
||||
# esperan los helpers *_field
|
||||
#
|
||||
|
@ -19,6 +32,14 @@ module ApplicationHelper
|
|||
[root, name]
|
||||
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)
|
||||
root, name = field_name_for(*names)
|
||||
|
||||
|
@ -134,9 +155,17 @@ module ApplicationHelper
|
|||
|
||||
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:)
|
||||
post.layout.metadata.dig(*attribute, type.to_s, I18n.locale.to_s) ||
|
||||
post.layout.metadata.dig(*attribute, type.to_s, I18n.default_locale.to_s) ||
|
||||
I18n.t("posts.attributes.#{attribute.join('.')}.#{type}")
|
||||
post.layout.metadata.dig(*attribute, type.to_s, I18n.locale.to_s).presence ||
|
||||
post.layout.metadata.dig(*attribute, type.to_s, post.site.default_locale.to_s) ||
|
||||
I18n.t("posts.attributes.#{attribute.join('.')}.#{type}").presence
|
||||
end
|
||||
end
|
||||
|
|
19
app/helpers/strong_params_helper.rb
Normal file
19
app/helpers/strong_params_helper.rb
Normal 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
|
138
app/javascript/controllers/array_controller.js
Normal file
138
app/javascript/controllers/array_controller.js
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
import { Controller } from "stimulus";
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = [];
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { Controller } from "stimulus";
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
// https://getbootstrap.com/docs/4.6/components/dropdowns/#single-button
|
||||
export default class extends Controller {
|
||||
|
|
10
app/javascript/controllers/enter_controller.js
Normal file
10
app/javascript/controllers/enter_controller.js
Normal 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();
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
import { Controller } from 'stimulus'
|
||||
import { Controller } from '@hotwired/stimulus'
|
||||
import bsCustomFileInput from "bs-custom-file-input";
|
||||
|
||||
document.addEventListener("turbolinks:load", () => {
|
||||
|
|
53
app/javascript/controllers/form_validation_controller.js
Normal file
53
app/javascript/controllers/form_validation_controller.js
Normal 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;
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
import { Controller } from 'stimulus'
|
||||
import { Controller } from '@hotwired/stimulus'
|
||||
|
||||
require("leaflet/dist/leaflet.css")
|
||||
import L from 'leaflet'
|
||||
|
|
107
app/javascript/controllers/htmx_controller.js
Normal file
107
app/javascript/controllers/htmx_controller.js
Normal 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);
|
||||
}
|
||||
}
|
|
@ -1,8 +1,8 @@
|
|||
// Load all the controllers within this directory and all subdirectories.
|
||||
// Controller files must be named *_controller.js.
|
||||
|
||||
import { Application } from "stimulus"
|
||||
import { definitionsFromContext } from "stimulus/webpack-helpers"
|
||||
import { Application } from "@hotwired/stimulus"
|
||||
import { definitionsFromContext } from "@hotwired/stimulus-webpack-helpers"
|
||||
|
||||
const application = Application.start()
|
||||
const context = require.context("controllers", true, /_controller\.js$/)
|
||||
|
|
95
app/javascript/controllers/modal_controller.js
Normal file
95
app/javascript/controllers/modal_controller.js
Normal 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");
|
||||
}
|
||||
}
|
18
app/javascript/controllers/new_editor_controller.js
Normal file
18
app/javascript/controllers/new_editor_controller.js
Normal 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
import { Controller } from 'stimulus'
|
||||
import { Controller } from '@hotwired/stimulus'
|
||||
|
||||
require("leaflet/dist/leaflet.css")
|
||||
import L from 'leaflet'
|
||||
|
|
43
app/javascript/controllers/notification_controller.js
Normal file
43
app/javascript/controllers/notification_controller.js
Normal 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);
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
import { Controller } from 'stimulus'
|
||||
import { Controller } from '@hotwired/stimulus'
|
||||
|
||||
/*
|
||||
* Permite reordenar las filas de una tabla.
|
||||
|
|
53
app/javascript/controllers/required_checkbox_controller.js
Normal file
53
app/javascript/controllers/required_checkbox_controller.js
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
import { Controller } from "stimulus";
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["toggle", "input"];
|
||||
|
|
55
app/javascript/controllers/unsaved_changes_controller.js
Normal file
55
app/javascript/controllers/unsaved_changes_controller.js
Normal 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));
|
||||
}
|
||||
}
|
|
@ -5,3 +5,7 @@ document.addEventListener("turbolinks:click", () => {
|
|||
window.htmx.trigger(hx, "htmx:abort");
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("htmx:resetForm", (event) => {
|
||||
event.target.reset();
|
||||
});
|
||||
|
|
|
@ -4,6 +4,4 @@ import './input-tag'
|
|||
import './prosemirror'
|
||||
import './timezone'
|
||||
import './turbolinks-anchors'
|
||||
import './validation'
|
||||
import './new_editor'
|
||||
import './htmx_abort'
|
||||
|
|
|
@ -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"),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
|
@ -41,4 +41,5 @@ Rails.start()
|
|||
Turbolinks.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;
|
||||
|
|
23
app/jobs/add_full_rsync_job.rb
Normal file
23
app/jobs/add_full_rsync_job.rb
Normal 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
|
|
@ -4,6 +4,8 @@
|
|||
class ApplicationJob < ActiveJob::Base
|
||||
include Que::ActiveJob::JobExtensions
|
||||
|
||||
attr_reader :site
|
||||
|
||||
# Esperar una cantidad random de segundos primos, para que no se
|
||||
# superpongan tareas
|
||||
#
|
||||
|
@ -15,8 +17,6 @@ class ApplicationJob < ActiveJob::Base
|
|||
RANDOM_WAIT.sample.seconds
|
||||
end
|
||||
|
||||
attr_reader :site
|
||||
|
||||
# Si falla por cualquier cosa informar y descartar
|
||||
discard_on(Exception) do |job, error|
|
||||
ExceptionNotifier.notify_exception(error, data: { job: job })
|
||||
|
|
|
@ -77,6 +77,8 @@ class DeployJob < ApplicationJob
|
|||
t << ([type.to_s] + row.values)
|
||||
end
|
||||
end)
|
||||
rescue DeployTimedOutException => e
|
||||
notify_exception e
|
||||
ensure
|
||||
if site.present?
|
||||
site.update status: 'waiting'
|
||||
|
|
|
@ -1,18 +1,29 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# Permite traer los cambios desde webhooks
|
||||
|
||||
# Permite traer los cambios desde el repositorio remoto
|
||||
class GitPullJob < ApplicationJob
|
||||
# @param :site [Site]
|
||||
# @param :usuarie [Usuarie]
|
||||
# @param :message [String]
|
||||
# @return [nil]
|
||||
def perform(site, usuarie)
|
||||
def perform(site, usuarie, message)
|
||||
@site = site
|
||||
|
||||
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!
|
||||
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
|
19
app/jobs/lock_usuarie_job.rb
Normal file
19
app/jobs/lock_usuarie_job.rb
Normal 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
|
12
app/lib/concerns/auto_publish_concern.rb
Normal file
12
app/lib/concerns/auto_publish_concern.rb
Normal 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
|
12
app/lib/core_extensions/string/remove_diacritics.rb
Normal file
12
app/lib/core_extensions/string/remove_diacritics.rb
Normal 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
|
23
app/mailers/activity_pub/actor_flagged_mailer.rb
Normal file
23
app/mailers/activity_pub/actor_flagged_mailer.rb
Normal 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
|
|
@ -2,6 +2,16 @@
|
|||
|
||||
class ActivityPub
|
||||
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
|
||||
|
|
11
app/models/concerns/activity_pub/moderator_concern.rb
Normal file
11
app/models/concerns/activity_pub/moderator_concern.rb
Normal 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
|
33
app/models/concerns/metadata/unused_values_concern.rb
Normal file
33
app/models/concerns/metadata/unused_values_concern.rb
Normal 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
|
|
@ -10,7 +10,7 @@ require 'open3'
|
|||
# :attributes`.
|
||||
class Deploy < ApplicationRecord
|
||||
belongs_to :site
|
||||
belongs_to :rol
|
||||
belongs_to :rol, optional: true
|
||||
|
||||
has_many :build_stats, dependent: :destroy
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
# Soportar dominios alternativos
|
||||
class DeployAlternativeDomain < Deploy
|
||||
store :values, accessors: %i[hostname], coder: JSON
|
||||
store_accessor :values, :hostname
|
||||
|
||||
DEPENDENCIES = %i[deploy_local]
|
||||
|
||||
|
|
|
@ -12,7 +12,10 @@ require 'distributed_press/v1/client/site'
|
|||
# Al ser publicado, envía los archivos en un tarball y actualiza la
|
||||
# información.
|
||||
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_destroy :delete_remote_site!
|
||||
|
@ -23,7 +26,7 @@ class DeployDistributedPress < Deploy
|
|||
#
|
||||
# @param :output [Bool]
|
||||
# @return [Bool]
|
||||
def deploy
|
||||
def deploy(output: true)
|
||||
status = false
|
||||
log = []
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
# Genera una versión onion
|
||||
class DeployHiddenService < DeployWww
|
||||
store :values, accessors: %i[onion], coder: JSON
|
||||
store_accessor :values, :onion
|
||||
|
||||
before_create :create_hidden_service!
|
||||
|
||||
|
|
|
@ -3,14 +3,12 @@
|
|||
# Alojamiento local, solo genera el sitio, con lo que no necesita hacer
|
||||
# nada más
|
||||
class DeployLocal < Deploy
|
||||
store :values, accessors: %i[], coder: JSON
|
||||
|
||||
before_destroy :remove_destination!
|
||||
|
||||
def bundle(output: false)
|
||||
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 path '#{gems_dir}'), output: output
|
||||
run %(bundle config set --local deployment 'true'), output: output if site.gemfile_lock_path?
|
||||
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 cache_all 'false'), output: output
|
||||
run %(bundle install), output: output
|
||||
|
|
|
@ -2,7 +2,8 @@
|
|||
|
||||
# Soportar dominios localizados
|
||||
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
|
||||
def deploy(**)
|
||||
|
|
|
@ -2,7 +2,9 @@
|
|||
|
||||
# Reindexa los artículos al terminar la compilación
|
||||
class DeployReindex < Deploy
|
||||
def deploy(**)
|
||||
def deploy(output: true)
|
||||
puts 'Reindex' if output
|
||||
|
||||
time_start
|
||||
|
||||
site.reset
|
||||
|
|
|
@ -3,11 +3,15 @@
|
|||
# Sincroniza sitios a servidores remotos usando Rsync. El servidor
|
||||
# remoto tiene que tener rsync instalado.
|
||||
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]
|
||||
|
||||
def deploy(output: false)
|
||||
raise(ArgumentError, 'destination no está configurado') if destination.blank?
|
||||
|
||||
ssh? && rsync(output: output)
|
||||
end
|
||||
|
||||
|
@ -18,13 +22,6 @@ class DeployRsync < Deploy
|
|||
deploy_local.size
|
||||
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]
|
||||
def url
|
||||
"https://#{hostname}/"
|
||||
|
@ -43,9 +40,9 @@ class DeployRsync < Deploy
|
|||
ssh_available = false
|
||||
|
||||
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
|
||||
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}"
|
||||
end
|
||||
|
||||
|
@ -74,14 +71,14 @@ class DeployRsync < Deploy
|
|||
#
|
||||
# @return [Symbol]
|
||||
def tofu
|
||||
values[:host_keys].present? ? :always : :accept_new
|
||||
self.host_keys.present? ? :always : :accept_new
|
||||
end
|
||||
|
||||
# Devuelve el par user host
|
||||
#
|
||||
# @return [Array]
|
||||
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
|
||||
|
||||
d.insert(0, nil)
|
||||
|
|
|
@ -2,8 +2,6 @@
|
|||
|
||||
# Vincula la versión del sitio con www a la versión sin
|
||||
class DeployWww < Deploy
|
||||
store :values, accessors: %i[], coder: JSON
|
||||
|
||||
DEPENDENCIES = %i[deploy_local]
|
||||
|
||||
before_destroy :remove_destination!
|
||||
|
|
|
@ -6,8 +6,6 @@ require 'zip'
|
|||
#
|
||||
# TODO: Firmar con minisign
|
||||
class DeployZip < Deploy
|
||||
store :values, accessors: %i[], coder: JSON
|
||||
|
||||
DEPENDENCIES = %i[deploy_local]
|
||||
|
||||
# Una vez que el sitio está generado, tomar todos los archivos y
|
||||
|
|
|
@ -19,8 +19,12 @@ class MetadataArray < MetadataTemplate
|
|||
true && !private?
|
||||
end
|
||||
|
||||
def titleize?
|
||||
true
|
||||
end
|
||||
|
||||
def to_s
|
||||
value.join(', ')
|
||||
value.select(&:present?).join(', ')
|
||||
end
|
||||
|
||||
# Obtiene el valor desde el documento, convirtiéndolo a Array si no lo
|
||||
|
|
|
@ -13,6 +13,10 @@ class MetadataBelongsTo < MetadataRelatedPosts
|
|||
''
|
||||
end
|
||||
|
||||
def to_s
|
||||
belongs_to.try(:title).try(:value).to_s
|
||||
end
|
||||
|
||||
# Obtiene el valor desde el documento.
|
||||
#
|
||||
# @return [String]
|
||||
|
@ -20,14 +24,6 @@ class MetadataBelongsTo < MetadataRelatedPosts
|
|||
document.data[name.to_s]
|
||||
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
|
||||
# relación anterior si existía.
|
||||
def save
|
||||
|
@ -40,13 +36,23 @@ class MetadataBelongsTo < MetadataRelatedPosts
|
|||
# Si estamos cambiando la relación, tenemos que eliminar la relación
|
||||
# anterior
|
||||
if belonged_to.present?
|
||||
belonged_to[inverse].value = belonged_to[inverse].value.reject do |rej|
|
||||
rej == post.uuid.value
|
||||
if belonged_to[inverse].respond_to? :has_one
|
||||
belonged_to[inverse].value = ''
|
||||
else
|
||||
belonged_to[inverse].value = belonged_to[inverse].value.reject do |rej|
|
||||
rej == post.uuid.value
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# No duplicar las relaciones
|
||||
belongs_to[inverse].value = (belongs_to[inverse].value.dup << post.uuid.value) unless belongs_to.blank? || included?
|
||||
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?
|
||||
end
|
||||
end
|
||||
|
||||
true
|
||||
end
|
||||
|
@ -97,6 +103,6 @@ class MetadataBelongsTo < MetadataRelatedPosts
|
|||
end
|
||||
|
||||
def sanitize(uuid)
|
||||
uuid.to_s.gsub(/[^a-f0-9\-]/i, '')
|
||||
uuid.to_s.gsub(/[^a-f0-9-]/i, '')
|
||||
end
|
||||
end
|
||||
|
|
|
@ -58,8 +58,13 @@ class MetadataContent < MetadataTemplate
|
|||
|
||||
uri = URI element['src']
|
||||
|
||||
# No permitimos recursos externos
|
||||
raise URI::Error unless Rails.application.config.hosts.include?(uri.hostname)
|
||||
# No permitimos recursos externos, solo si sabemos cuales son
|
||||
# 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
|
||||
|
||||
|
|
|
@ -1,8 +1,30 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
# Devuelve una fecha, si no hay ninguna es la fecha de hoy.
|
||||
|
|
|
@ -18,6 +18,18 @@ class MetadataFile < MetadataTemplate
|
|||
# XXX: Esto ayuda a deserializar en {Site#everything_of}
|
||||
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
|
||||
super
|
||||
|
||||
|
@ -49,6 +61,8 @@ class MetadataFile < MetadataTemplate
|
|||
value['path'] = relative_destination_path_with_filename.to_s if static_file
|
||||
end
|
||||
|
||||
self[:value] = self[:value].to_h
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
|
|
|
@ -14,7 +14,7 @@ class MetadataGeo < MetadataTemplate
|
|||
return true unless changed?
|
||||
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?
|
||||
|
||||
true
|
||||
|
|
|
@ -36,10 +36,15 @@ class MetadataHasMany < MetadataRelatedPosts
|
|||
def save
|
||||
super
|
||||
|
||||
self[:value] = self[:value].uniq
|
||||
|
||||
return true unless changed?
|
||||
return true unless inverse?
|
||||
|
||||
(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
|
||||
end
|
||||
|
||||
|
|
29
app/models/metadata_has_one.rb
Normal file
29
app/models/metadata_has_one.rb
Normal 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
|
16
app/models/metadata_has_one_nested.rb
Normal file
16
app/models/metadata_has_one_nested.rb
Normal 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
|
5
app/models/metadata_new_array.rb
Normal file
5
app/models/metadata_new_array.rb
Normal file
|
@ -0,0 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# Implementa la nueva interfaz de gestión de valores
|
||||
class MetadataNewArray < MetadataArray
|
||||
end
|
6
app/models/metadata_new_belongs_to.rb
Normal file
6
app/models/metadata_new_belongs_to.rb
Normal file
|
@ -0,0 +1,6 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# Nueva interfaz
|
||||
class MetadataNewBelongsTo < MetadataBelongsTo
|
||||
include Metadata::UnusedValuesConcern
|
||||
end
|
4
app/models/metadata_new_has_and_belongs_to_many.rb
Normal file
4
app/models/metadata_new_has_and_belongs_to_many.rb
Normal file
|
@ -0,0 +1,4 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# Nueva interfaz para relaciones muchos a muchos
|
||||
class MetadataNewHasAndBelongsToMany < MetadataHasAndBelongsToMany; end
|
6
app/models/metadata_new_has_many.rb
Normal file
6
app/models/metadata_new_has_many.rb
Normal file
|
@ -0,0 +1,6 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# Interfaz nueva para uno a muchos
|
||||
class MetadataNewHasMany < MetadataHasMany
|
||||
include Metadata::UnusedValuesConcern
|
||||
end
|
4
app/models/metadata_new_has_one.rb
Normal file
4
app/models/metadata_new_has_one.rb
Normal file
|
@ -0,0 +1,4 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# Nueva interfaz para relaciones 1:1
|
||||
class MetadataNewHasOne < MetadataHasOne; end
|
4
app/models/metadata_new_predefined_array.rb
Normal file
4
app/models/metadata_new_predefined_array.rb
Normal file
|
@ -0,0 +1,4 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# Nueva interfaz para arrays predefinidos
|
||||
class MetadataNewPredefinedArray < MetadataPredefinedArray; end
|
8
app/models/metadata_new_predefined_value.rb
Normal file
8
app/models/metadata_new_predefined_value.rb
Normal 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
|
|
@ -7,12 +7,25 @@ class MetadataPermalink < MetadataString
|
|||
false
|
||||
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
|
||||
|
||||
# Al hacer limpieza, validamos la ruta. Eliminamos / multiplicadas,
|
||||
# puntos suspensivos, la primera / para que siempre sea relativa y
|
||||
# agregamos una / al final si la ruta no tiene extensión.
|
||||
def sanitize(value)
|
||||
return value.strip if value.blank?
|
||||
|
||||
value = value.strip.unicode_normalize.gsub('..', '/').gsub('./', '').squeeze('/')
|
||||
value = value[1..-1] if value.start_with? '/'
|
||||
value += '/' if File.extname(value).blank?
|
||||
|
|
3
app/models/metadata_plain_text.rb
Normal file
3
app/models/metadata_plain_text.rb
Normal file
|
@ -0,0 +1,3 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class MetadataPlainText < MetadataContent; end
|
|
@ -7,4 +7,13 @@ class MetadataPredefinedArray < MetadataArray
|
|||
[v[I18n.locale.to_s], k]
|
||||
end&.to_h
|
||||
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
|
||||
|
|
|
@ -10,6 +10,10 @@ class MetadataPredefinedValue < MetadataString
|
|||
@values ||= layout.dig(:metadata, name, 'values', I18n.locale.to_s)&.invert || {}
|
||||
end
|
||||
|
||||
def to_s
|
||||
values.invert[value].to_s
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Solo permite almacenar los valores predefinidos.
|
||||
|
|
|
@ -22,10 +22,21 @@ class MetadataRelatedPosts < MetadataArray
|
|||
false
|
||||
end
|
||||
|
||||
def titleize?
|
||||
false
|
||||
end
|
||||
|
||||
def indexable_values
|
||||
posts.where(uuid: value).map(&:title).map(&:value)
|
||||
end
|
||||
|
||||
# Encuentra el filtro
|
||||
#
|
||||
# @return [Hash]
|
||||
def filter
|
||||
layout.metadata.dig(name, 'filter')&.to_h&.symbolize_keys || {}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Obtiene todos los posts y opcionalmente los filtra
|
||||
|
@ -34,17 +45,12 @@ class MetadataRelatedPosts < MetadataArray
|
|||
end
|
||||
|
||||
def title(post)
|
||||
"#{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 || {}
|
||||
"#{post&.title&.value || post&.slug&.value} #{post&.date&.value&.strftime('%F')} (#{post.layout.humanized_name})"
|
||||
end
|
||||
|
||||
def sanitize(uuid)
|
||||
super(uuid.map do |u|
|
||||
u.to_s.gsub(/[^a-f0-9\-]/i, '')
|
||||
u.to_s.gsub(/[^a-f0-9-]/i, '')
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -25,7 +25,7 @@ require 'jekyll/utils'
|
|||
class MetadataSlug < MetadataTemplate
|
||||
# Trae el slug desde el título si existe o una string al azar
|
||||
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
|
||||
|
||||
def value
|
||||
|
|
|
@ -11,6 +11,10 @@ class MetadataString < MetadataTemplate
|
|||
true && !private?
|
||||
end
|
||||
|
||||
def titleize?
|
||||
true
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# No se permite HTML en las strings
|
||||
|
|
|
@ -12,6 +12,15 @@ MetadataTemplate = Struct.new(:site, :document, :name, :label, :type,
|
|||
false
|
||||
end
|
||||
|
||||
def nested?
|
||||
false
|
||||
end
|
||||
|
||||
# El valor puede ser parte de un título auto-generado
|
||||
def titleize?
|
||||
false
|
||||
end
|
||||
|
||||
def inspect
|
||||
"#<#{self.class} site=#{site.name.inspect} post=#{post.id.inspect} value=#{value.inspect}>"
|
||||
end
|
||||
|
@ -38,18 +47,10 @@ MetadataTemplate = Struct.new(:site, :document, :name, :label, :type,
|
|||
"#{cache_key}-#{cache_version}"
|
||||
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
|
||||
# vez.
|
||||
def value_was
|
||||
return @value_was if instance_variable_defined? '@value_was'
|
||||
|
||||
@value_was = document_value
|
||||
@value_was ||= document_value.nil? ? default_value : document_value
|
||||
end
|
||||
|
||||
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
|
||||
def writable?
|
||||
case layout.metadata.dig(name, 'writable')
|
||||
when 'once' then value.blank?
|
||||
when 'once' then value_was.blank?
|
||||
else true
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,4 +1,8 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# Un campo de texto largo
|
||||
class MetadataText < MetadataString; end
|
||||
class MetadataText < MetadataString
|
||||
def titleize?
|
||||
false
|
||||
end
|
||||
end
|
||||
|
|
26
app/models/metadata_title.rb
Normal file
26
app/models/metadata_title.rb
Normal 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
|
|
@ -12,9 +12,24 @@ class Post
|
|||
DEFAULT_ATTRIBUTES = %i[site document layout].freeze
|
||||
# Otros atributos que no vienen en los metadatos
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
# TODO: Modificar el historial de Git con callbacks en lugar de
|
||||
|
@ -30,6 +45,10 @@ class Post
|
|||
# a demanda?
|
||||
def find_layout(path)
|
||||
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
|
||||
|
||||
|
@ -46,16 +65,39 @@ class Post
|
|||
@layout = args[:layout]
|
||||
@site = args[:site]
|
||||
@document = args[:document]
|
||||
@attributes = layout.attributes + PUBLIC_ATTRIBUTES
|
||||
@attributes = (layout.attributes + PUBLIC_ATTRIBUTES).uniq
|
||||
@errors = {}
|
||||
@metadata = {}
|
||||
|
||||
# Inicializar valores
|
||||
layout.metadata = ATTRIBUTE_DEFINITIONS.merge(layout.metadata).with_indifferent_access
|
||||
|
||||
# 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
|
||||
|
||||
# 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|
|
||||
public_send(attr)&.value = args[attr] if args.key?(attr)
|
||||
self[attr].value = attrs[attr] if attrs.key?(attr) && self[attr].writable?
|
||||
end
|
||||
|
||||
document.read! unless new?
|
||||
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
|
||||
|
||||
def inspect
|
||||
|
@ -103,6 +145,7 @@ class Post
|
|||
src = element.attributes['src']
|
||||
|
||||
next unless src&.value&.start_with? 'public/'
|
||||
|
||||
file = MetadataFile.new(site: site, post: self, document: document, layout: layout)
|
||||
file.value['path'] = src.value
|
||||
|
||||
|
@ -164,14 +207,13 @@ class Post
|
|||
def method_missing(name, *_args)
|
||||
# Limpiar el nombre del atributo, para que todos los ayudantes
|
||||
# reciban el método en limpio
|
||||
unless attribute? name
|
||||
raise NoMethodError, I18n.t('exceptions.post.no_method',
|
||||
method: name)
|
||||
end
|
||||
raise NoMethodError, I18n.t('exceptions.post.no_method', method: name) unless attribute? name
|
||||
|
||||
define_singleton_method(name) do
|
||||
template = layout.metadata[name.to_s]
|
||||
|
||||
return public_send(template['alias'].to_sym) if template.key?('alias')
|
||||
|
||||
@metadata[name] ||=
|
||||
MetadataFactory.build(document: document,
|
||||
post: self,
|
||||
|
@ -187,55 +229,6 @@ class Post
|
|||
public_send name
|
||||
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.
|
||||
#
|
||||
# XXX: Nos gustaría no tener que instanciar Metadata acá, pero depende
|
||||
|
@ -386,11 +379,7 @@ class Post
|
|||
end
|
||||
|
||||
def update_attributes(hashable)
|
||||
hashable.to_hash.each do |attr, value|
|
||||
next unless self[attr].writable?
|
||||
|
||||
self[attr].value = value
|
||||
end
|
||||
assign_attributes(hashable)
|
||||
|
||||
save
|
||||
end
|
||||
|
@ -404,6 +393,38 @@ class Post
|
|||
@usuaries ||= document_usuaries.empty? ? [] : Usuarie.where(id: document_usuaries).to_a
|
||||
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
|
||||
|
||||
# Levanta un error si al construir el artículo no pasamos un atributo.
|
||||
|
|
|
@ -68,8 +68,7 @@ class Site < ApplicationRecord
|
|||
before_create :clone_skel!
|
||||
# Elimina el directorio al destruir un sitio
|
||||
before_destroy :remove_directories!
|
||||
# Cambiar el nombre del directorio
|
||||
before_update :update_name!
|
||||
|
||||
before_save :add_private_key_if_missing!
|
||||
# Guardar la configuración si hubo cambios
|
||||
after_save :sync_attributes_with_config!
|
||||
|
@ -234,7 +233,9 @@ class Site < ApplicationRecord
|
|||
# colecciones.
|
||||
def collections
|
||||
unless @read
|
||||
jekyll.reader.read_collections
|
||||
Site.one_at_a_time.synchronize do
|
||||
jekyll.reader.read_collections
|
||||
end
|
||||
|
||||
@read = true
|
||||
end
|
||||
|
@ -434,7 +435,9 @@ class Site < ApplicationRecord
|
|||
# Si estamos usando nuestro propio plugin de i18n, los posts están
|
||||
# en "colecciones"
|
||||
locales.map(&:to_s).each do |i|
|
||||
@configuration['collections'][i] = {}
|
||||
@configuration['collections'][i] = {
|
||||
'permalink' => configuration.send(:style_to_permalink, configuration['permalink'])
|
||||
}
|
||||
end
|
||||
|
||||
@configuration
|
||||
|
@ -504,12 +507,6 @@ class Site < ApplicationRecord
|
|||
FileUtils.rm_rf path
|
||||
end
|
||||
|
||||
def update_name!
|
||||
return unless name_changed?
|
||||
|
||||
FileUtils.mv path_was, path
|
||||
reload_jekyll!
|
||||
end
|
||||
|
||||
# Sincroniza algunos atributos del sitio con su configuración y
|
||||
# guarda los cambios
|
||||
|
@ -589,17 +586,34 @@ class Site < ApplicationRecord
|
|||
# * El archivo Gemfile.lock se modificó
|
||||
def install_gems
|
||||
return unless persisted?
|
||||
return unless (!gems_installed? || theme_path.blank?) || gemfile_updated? || gemfile_lock_updated?
|
||||
|
||||
deploy_local = deploys.find_by_type('DeployLocal')
|
||||
deploy_local.git_lfs
|
||||
|
||||
return unless !gems_installed? || gemfile_updated? || gemfile_lock_updated?
|
||||
|
||||
deploy_local.bundle
|
||||
deploys.find_by_type('DeployLocal').bundle
|
||||
touch
|
||||
FileUtils.touch(gemfile_path)
|
||||
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
|
||||
@gem_path ||=
|
||||
begin
|
||||
|
|
|
@ -46,11 +46,19 @@ class Site
|
|||
|
||||
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]
|
||||
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
|
||||
|
||||
# Calcula la diferencia entre el último commit indexado y el
|
||||
|
|
|
@ -13,11 +13,15 @@ class Site
|
|||
# Por defecto, si el sitio no lo soporta, se obtienen los layouts
|
||||
# ordenados alfabéticamente por traducción.
|
||||
#
|
||||
# @param [Usuarie,nil]
|
||||
# @return [Hash]
|
||||
def schema_organization
|
||||
def schema_organization(usuarie = nil)
|
||||
@schema_organization ||=
|
||||
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&.transform_values! do |ary|
|
||||
ary.map(&:to_sym)
|
||||
|
|
|
@ -76,12 +76,17 @@ class Site
|
|||
# escribir los cambios
|
||||
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
|
||||
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
|
||||
#
|
||||
# @return [Rugged::Commit]
|
||||
|
@ -111,10 +116,30 @@ class Site
|
|||
walker.each.to_a
|
||||
end
|
||||
|
||||
# Hay commits sin aplicar?
|
||||
def needs_pull?
|
||||
fetch
|
||||
!commits.empty?
|
||||
# Detecta si hay que hacer un pull o no
|
||||
#
|
||||
# @return [Boolean]
|
||||
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
|
||||
|
||||
# Guarda los cambios en git
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
# Usuarie de la plataforma
|
||||
class Usuarie < ApplicationRecord
|
||||
include Usuarie::Consent
|
||||
include ActivityPub::ModeratorConcern
|
||||
|
||||
devise :invitable, :database_authenticatable,
|
||||
:recoverable, :rememberable, :validatable,
|
||||
|
|
|
@ -3,25 +3,51 @@
|
|||
# Este servicio se encarga de crear artículos y guardarlos en git,
|
||||
# asignándoselos a une usuarie
|
||||
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
|
||||
#
|
||||
# @return Post
|
||||
def create
|
||||
self.post = site.posts(lang: locale)
|
||||
.build(layout: layout)
|
||||
post.usuaries << usuarie
|
||||
params[:post][:draft] = true if site.invitade? usuarie
|
||||
self.post ||= site.posts(lang: locale).build(layout: layout)
|
||||
params[base][: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?
|
||||
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!
|
||||
|
||||
# Devolver el post aunque no se haya salvado para poder rescatar los
|
||||
# errores
|
||||
auto_publish!
|
||||
post
|
||||
end
|
||||
|
||||
|
@ -34,28 +60,33 @@ PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do
|
|||
# Los artículos anónimos siempre son borradores
|
||||
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
|
||||
end
|
||||
|
||||
def update
|
||||
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.
|
||||
if post.update(post_params)
|
||||
rm = []
|
||||
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
|
||||
# relacionados.
|
||||
commit(action: :updated, add: update_related_posts, rm: rm)
|
||||
commit(action: :updated, add: files, rm: rm)
|
||||
|
||||
update_site_license!
|
||||
end
|
||||
|
||||
# Devolver el post aunque no se haya salvado para poder rescatar los
|
||||
# errores
|
||||
auto_publish!
|
||||
post
|
||||
end
|
||||
|
||||
|
@ -73,7 +104,7 @@ PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do
|
|||
#
|
||||
# { uuid => 2, uuid => 1, uuid => 0 }
|
||||
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)
|
||||
|
||||
files = posts.map do |post|
|
||||
|
@ -96,6 +127,22 @@ PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do
|
|||
|
||||
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: [])
|
||||
site.repository.commit(add: add,
|
||||
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
|
||||
def post_params
|
||||
params.require(:post).permit(post.params)
|
||||
@post_params ||= params.require(base).permit(post.params).to_h
|
||||
end
|
||||
|
||||
# Eliminar metadatos internos
|
||||
|
@ -119,11 +166,11 @@ PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do
|
|||
end
|
||||
|
||||
def locale
|
||||
params.dig(:post, :lang)&.to_sym || I18n.locale
|
||||
params.dig(base, :lang)&.to_sym || I18n.locale
|
||||
end
|
||||
|
||||
def layout
|
||||
params.dig(:post, :layout) || params[:layout]
|
||||
params.dig(base, :layout) || params[:layout]
|
||||
end
|
||||
|
||||
# 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
|
||||
|
||||
posts.map do |p|
|
||||
p.path.absolute if p.save(validate: false)
|
||||
end.compact << post.path.absolute
|
||||
next unless p.save(validate: false)
|
||||
|
||||
files << p.path.absolute
|
||||
end
|
||||
end
|
||||
|
||||
# Si les usuaries modifican o crean una licencia, considerarla
|
||||
# personalizada en el panel.
|
||||
def update_site_license!
|
||||
if site.usuarie?(usuarie) && post.layout.name == :license && !site.licencia.custom?
|
||||
site.update licencia: Licencia.find_by_icons('custom')
|
||||
return unless site.usuarie?(usuarie) && post.layout.name == :license && !site.licencia.custom?
|
||||
|
||||
site.update licencia: Licencia.find_by_icons('custom')
|
||||
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
|
||||
|
|
|
@ -3,6 +3,8 @@
|
|||
# Se encargar de guardar cambios en sitios
|
||||
# TODO: Implementar rollback en la configuración
|
||||
SiteService = Struct.new(:site, :usuarie, :params, keyword_init: true) do
|
||||
include AutoPublishConcern
|
||||
|
||||
def deploy
|
||||
site.enqueue!
|
||||
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
|
||||
# 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
|
||||
|
||||
|
@ -36,10 +40,14 @@ SiteService = Struct.new(:site, :usuarie, :params, keyword_init: true) do
|
|||
add_licencias &&
|
||||
add_code_of_conduct &&
|
||||
add_privacy_policy &&
|
||||
site.index_posts! &&
|
||||
deploy
|
||||
end
|
||||
|
||||
if site.persisted?
|
||||
site.index_posts!
|
||||
auto_publish!
|
||||
end
|
||||
|
||||
site
|
||||
end
|
||||
|
||||
|
@ -94,6 +102,25 @@ SiteService = Struct.new(:site, :usuarie, :params, keyword_init: true) do
|
|||
result.present?
|
||||
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
|
||||
|
||||
# Guarda los cambios de la configuración en el repositorio git
|
||||
|
|
|
@ -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 ]
|
|
@ -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
|
||||
|
|
13
app/views/bootstrap/_btn.haml
Normal file
13
app/views/bootstrap/_btn.haml
Normal 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
|
8
app/views/bootstrap/_card.haml
Normal file
8
app/views/bootstrap/_card.haml
Normal 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
|
|
@ -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
|
||||
%input.custom-control-input{ id: id, type: 'checkbox', name: name, value: value, required: required }
|
||||
.custom-control{ class: "custom-#{checkbox_attributes[:type]}" }
|
||||
%input.custom-control-input{ **checkbox_attributes }
|
||||
%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
|
||||
|
|
6
app/views/bootstrap/_custom_checkbox_for_field.haml
Normal file
6
app/views/bootstrap/_custom_checkbox_for_field.haml
Normal 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
Loading…
Reference in a new issue