mirror of
https://0xacab.org/sutty/sutty
synced 2025-03-14 21:08:18 +00:00
Merge branch 'production.panel.sutty.nl' into 'rails'
Draft: Producción Closes #17598, #17571, #17593, #17530, #17575, #17576, #17577, #17580, #17581, #17582, #17584, #17586, #17588, #17589, #17590, #17534, #17533, #16100, #13603, #12410, and #12753 See merge request sutty/sutty!265
This commit is contained in:
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
|
COPY ./monit.conf /etc/monit.d/sutty.conf
|
||||||
|
|
||||||
RUN apk add npm && npm install -g pnpm && apk del npm
|
|
||||||
|
|
||||||
VOLUME "/srv"
|
VOLUME "/srv"
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
7
Gemfile
7
Gemfile
|
@ -79,8 +79,8 @@ gem 'webpacker'
|
||||||
gem 'yaml_db', git: 'https://0xacab.org/sutty/yaml_db.git'
|
gem 'yaml_db', git: 'https://0xacab.org/sutty/yaml_db.git'
|
||||||
gem 'kaminari'
|
gem 'kaminari'
|
||||||
gem 'device_detector'
|
gem 'device_detector'
|
||||||
gem 'htmlbeautifier'
|
|
||||||
gem 'dry-schema'
|
gem 'dry-schema'
|
||||||
|
gem 'htmlbeautifier'
|
||||||
gem 'rubanok'
|
gem 'rubanok'
|
||||||
|
|
||||||
gem 'after_commit_everywhere', '~> 1.0'
|
gem 'after_commit_everywhere', '~> 1.0'
|
||||||
|
@ -118,9 +118,8 @@ group :development, :test do
|
||||||
gem 'derailed_benchmarks'
|
gem 'derailed_benchmarks'
|
||||||
gem 'dotenv-rails'
|
gem 'dotenv-rails'
|
||||||
gem 'pry'
|
gem 'pry'
|
||||||
# Adds support for Capybara system testing and selenium driver
|
gem 'capybara'
|
||||||
gem 'capybara', '~> 2.13'
|
gem 'selenium-webdriver'
|
||||||
gem 'selenium-webdriver', '~> 4.8.0'
|
|
||||||
gem 'sqlite3'
|
gem 'sqlite3'
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
19
Gemfile.lock
19
Gemfile.lock
|
@ -118,13 +118,15 @@ GEM
|
||||||
bundler-audit (0.9.1)
|
bundler-audit (0.9.1)
|
||||||
bundler (>= 1.2.0, < 3)
|
bundler (>= 1.2.0, < 3)
|
||||||
thor (~> 1.0)
|
thor (~> 1.0)
|
||||||
capybara (2.18.0)
|
capybara (3.40.0)
|
||||||
addressable
|
addressable
|
||||||
|
matrix
|
||||||
mini_mime (>= 0.1.3)
|
mini_mime (>= 0.1.3)
|
||||||
nokogiri (>= 1.3.3)
|
nokogiri (~> 1.11)
|
||||||
rack (>= 1.0.0)
|
rack (>= 1.6.0)
|
||||||
rack-test (>= 0.5.4)
|
rack-test (>= 0.6.3)
|
||||||
xpath (>= 2.0, < 4.0)
|
regexp_parser (>= 1.5, < 3.0)
|
||||||
|
xpath (~> 3.2)
|
||||||
chartkick (5.0.2)
|
chartkick (5.0.2)
|
||||||
climate_control (1.2.0)
|
climate_control (1.2.0)
|
||||||
coderay (1.1.3)
|
coderay (1.1.3)
|
||||||
|
@ -360,6 +362,7 @@ GEM
|
||||||
net-pop
|
net-pop
|
||||||
net-smtp
|
net-smtp
|
||||||
marcel (1.0.4)
|
marcel (1.0.4)
|
||||||
|
matrix (0.4.2)
|
||||||
memory_profiler (1.0.1)
|
memory_profiler (1.0.1)
|
||||||
mercenary (0.4.0)
|
mercenary (0.4.0)
|
||||||
method_source (1.1.0)
|
method_source (1.1.0)
|
||||||
|
@ -542,7 +545,7 @@ GEM
|
||||||
sprockets (> 3.0)
|
sprockets (> 3.0)
|
||||||
sprockets-rails
|
sprockets-rails
|
||||||
tilt
|
tilt
|
||||||
selenium-webdriver (4.8.6)
|
selenium-webdriver (4.9.1)
|
||||||
rexml (~> 3.2, >= 3.2.5)
|
rexml (~> 3.2, >= 3.2.5)
|
||||||
rubyzip (>= 1.2.2, < 3.0)
|
rubyzip (>= 1.2.2, < 3.0)
|
||||||
websocket (~> 1.0)
|
websocket (~> 1.0)
|
||||||
|
@ -632,7 +635,7 @@ DEPENDENCIES
|
||||||
bootstrap (~> 4)
|
bootstrap (~> 4)
|
||||||
brakeman
|
brakeman
|
||||||
bundler-audit
|
bundler-audit
|
||||||
capybara (~> 2.13)
|
capybara
|
||||||
chartkick
|
chartkick
|
||||||
commonmarker
|
commonmarker
|
||||||
concurrent-ruby-ext
|
concurrent-ruby-ext
|
||||||
|
@ -707,7 +710,7 @@ DEPENDENCIES
|
||||||
safe_yaml
|
safe_yaml
|
||||||
safely_block (~> 0.3.0)
|
safely_block (~> 0.3.0)
|
||||||
sassc-rails
|
sassc-rails
|
||||||
selenium-webdriver (~> 4.8.0)
|
selenium-webdriver
|
||||||
sourcemap
|
sourcemap
|
||||||
spring
|
spring
|
||||||
spring-watcher-listen
|
spring-watcher-listen
|
||||||
|
|
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
|
cleanup: bundle exec rake cleanup:everything
|
||||||
|
distributed_press_tokens_renew: bundle exec rake distributed_press:tokens:renew
|
||||||
emergency_cleanup: bundle exec rake cleanup:everything BEFORE=7
|
emergency_cleanup: bundle exec rake cleanup:everything BEFORE=7
|
||||||
stats: bundle exec rake stats:process_all
|
|
||||||
que: daemonize -c /srv/ -p /srv/tmp/que.pid -u rails /usr/local/bin/syslogize bundle exec que
|
que: daemonize -c /srv/ -p /srv/tmp/que.pid -u rails /usr/local/bin/syslogize bundle exec que
|
||||||
|
stats: bundle exec rake stats:process_all
|
||||||
fediblock: bundle exec rails activity_pub:fediblocks
|
fediblock: bundle exec rails activity_pub:fediblocks
|
||||||
|
|
|
@ -11,6 +11,21 @@ $colors: (
|
||||||
"magenta": $magenta
|
"magenta": $magenta
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// TODO: Encontrar la forma de generar esto desde los locales de Rails
|
||||||
|
$custom-file-text: (
|
||||||
|
en: "Browse",
|
||||||
|
es: "Buscar archivo",
|
||||||
|
pt: "Buscar ficheiro",
|
||||||
|
pt-BR: "Buscar arquivo"
|
||||||
|
);
|
||||||
|
|
||||||
|
$custom-file-text-replace: (
|
||||||
|
en: "Replace file",
|
||||||
|
es: "Reemplazar archivo",
|
||||||
|
pt: "substituir ficheiro",
|
||||||
|
pt-BR: "substituir arquivo"
|
||||||
|
);
|
||||||
|
|
||||||
// Redefinir variables de Bootstrap
|
// Redefinir variables de Bootstrap
|
||||||
$primary: $magenta;
|
$primary: $magenta;
|
||||||
$secondary: $black;
|
$secondary: $black;
|
||||||
|
@ -20,6 +35,19 @@ $form-feedback-valid-color: $black;
|
||||||
$form-feedback-invalid-color: $magenta;
|
$form-feedback-invalid-color: $magenta;
|
||||||
$form-feedback-icon-valid-color: $black;
|
$form-feedback-icon-valid-color: $black;
|
||||||
$component-active-bg: $magenta;
|
$component-active-bg: $magenta;
|
||||||
|
$btn-white-space: nowrap;
|
||||||
|
$font-weight-bolder: 700;
|
||||||
|
$zindex-modal-backdrop: 0;
|
||||||
|
$modal-content-bg: var(--background);
|
||||||
|
$modal-content-border-color: var(--modal-content-border-color);
|
||||||
|
$card-bg: var(--background);
|
||||||
|
$card-border-color: var(--card-border-color);
|
||||||
|
$input-bg: var(--background);
|
||||||
|
$input-color: var(--foreground);
|
||||||
|
$btn-bg-color: var(--btn-bg-color);
|
||||||
|
$btn-color: var(--btn-color);
|
||||||
|
$input-group-addon-bg: var(--btn-bg-color);
|
||||||
|
$custom-file-color: var(--btn-color);
|
||||||
|
|
||||||
$spacers: (
|
$spacers: (
|
||||||
2-plus: 0.75rem
|
2-plus: 0.75rem
|
||||||
|
@ -32,6 +60,16 @@ $sizes: (
|
||||||
@import "bootstrap";
|
@import "bootstrap";
|
||||||
@import "editor";
|
@import "editor";
|
||||||
|
|
||||||
|
.custom-file-input {
|
||||||
|
&.replace-image {
|
||||||
|
@each $lang, $value in $custom-file-text-replace {
|
||||||
|
&:lang(#{$lang}) ~ .custom-file-label::after {
|
||||||
|
content: $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@each $color, $rgb in $theme-colors {
|
@each $color, $rgb in $theme-colors {
|
||||||
.#{$color} {
|
.#{$color} {
|
||||||
color: var(--#{$color});
|
color: var(--#{$color});
|
||||||
|
@ -60,6 +98,10 @@ $sizes: (
|
||||||
--foreground: #{$black};
|
--foreground: #{$black};
|
||||||
--background: #{$white};
|
--background: #{$white};
|
||||||
--color: #{$magenta};
|
--color: #{$magenta};
|
||||||
|
--card-border-color: #{rgba($black, .125)};
|
||||||
|
--btn-bg-color: #{$black};
|
||||||
|
--btn-color: #{$white};
|
||||||
|
--modal-content-border-color: rgba(#{$black}, .2);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
|
@ -67,33 +109,27 @@ $sizes: (
|
||||||
--foreground: #{$white};
|
--foreground: #{$white};
|
||||||
--background: #{$black};
|
--background: #{$black};
|
||||||
--color: #{$cyan};
|
--color: #{$cyan};
|
||||||
|
--card-border-color: #{rgba($white, .250)};
|
||||||
|
--btn-bg-color: #{$white};
|
||||||
|
--btn-color: #{$black};
|
||||||
|
--modal-content-border-color: #{rgba($white, .2)};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
background-color: $white;
|
|
||||||
color: $black;
|
|
||||||
border: none;
|
border: none;
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: $black;
|
|
||||||
background-color: $cyan;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&:active {
|
@include form-validation-state("valid", $cyan, url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 8 8'><path fill='#{$white}' d='M2.3 6.73L.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/></svg>"));
|
||||||
background-color: $cyan;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:focus {
|
.custom-checkbox {
|
||||||
box-shadow: 0 0 0 0.2rem $cyan;
|
.custom-control-input:checked ~ .custom-control-label {
|
||||||
|
&::after {
|
||||||
|
background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 8 8'><path fill='#{$black}' d='M6.564.75l-3.59 3.612-1.538-1.55L0 4.26l2.974 2.99L8 2.193z'/></svg>");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Encontrar la forma de generar esto desde los locales de Rails
|
|
||||||
$custom-file-text: (
|
|
||||||
en: 'Browse',
|
|
||||||
es: 'Buscar archivo'
|
|
||||||
);
|
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Saira';
|
font-family: 'Saira';
|
||||||
|
@ -135,6 +171,10 @@ a {
|
||||||
color: var(--color);
|
color: var(--color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: 1px solid var(--color);
|
||||||
|
}
|
||||||
|
|
||||||
&[target=_blank] {
|
&[target=_blank] {
|
||||||
/* TODO: Convertir a base64 para no hacer peticiones extra */
|
/* TODO: Convertir a base64 para no hacer peticiones extra */
|
||||||
&:after {
|
&:after {
|
||||||
|
@ -241,6 +281,8 @@ svg {
|
||||||
.btn {
|
.btn {
|
||||||
margin-right: 0.3rem;
|
margin-right: 0.3rem;
|
||||||
margin-bottom: 0.3rem;
|
margin-bottom: 0.3rem;
|
||||||
|
background-color: $btn-bg-color;
|
||||||
|
color: $btn-color;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: var(--background);
|
color: var(--background);
|
||||||
|
@ -256,6 +298,10 @@ svg {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
white-space: break-spaces;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-sm {
|
.btn-sm {
|
||||||
@extend .badge
|
@extend .badge
|
||||||
}
|
}
|
||||||
|
@ -318,10 +364,6 @@ svg {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-control-label {
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.designs {
|
.designs {
|
||||||
.design {
|
.design {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
|
@ -621,3 +663,33 @@ $bezier: cubic-bezier(0.75, 0, 0.25, 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// https://getbootstrap.com/docs/5.1/components/placeholders/
|
||||||
|
.placeholder {
|
||||||
|
display: inline-block;
|
||||||
|
min-height: $spacer;
|
||||||
|
cursor: wait;
|
||||||
|
vertical-align: middle;
|
||||||
|
opacity: .5;
|
||||||
|
background-color: $grey;
|
||||||
|
animation: placeholder-glow 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder-glow {
|
||||||
|
.placeholder {
|
||||||
|
-webkit-animation: placeholder-glow 2s ease-in-out infinite;
|
||||||
|
animation: placeholder-glow 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@-webkit-keyframes placeholder-glow {
|
||||||
|
50% {
|
||||||
|
opacity: .2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes placeholder-glow {
|
||||||
|
50% {
|
||||||
|
opacity: .2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -6,29 +6,13 @@ $cyan: #13fefe;
|
||||||
--foreground: #{$white};
|
--foreground: #{$white};
|
||||||
--background: #{$black};
|
--background: #{$black};
|
||||||
--color: #{$cyan};
|
--color: #{$cyan};
|
||||||
}
|
--card-border-color: #{rgba($white, .250)};
|
||||||
|
--btn-bg-color: #{$white};
|
||||||
.btn {
|
--btn-color: #{$black};
|
||||||
background-color: $white;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
background-color: $white;
|
|
||||||
color: $black;
|
|
||||||
border: none;
|
border: none;
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: $black;
|
|
||||||
background-color: $cyan;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
background-color: $cyan;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
box-shadow: 0 0 0 0.2rem $cyan;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -11,7 +11,7 @@ module ActiveStorage
|
||||||
# Permitir incrustar archivos subidos (especialmente PDFs) desde
|
# Permitir incrustar archivos subidos (especialmente PDFs) desde
|
||||||
# otros sitios.
|
# otros sitios.
|
||||||
def show
|
def show
|
||||||
original_show.tap do |s|
|
original_show.tap do |_s|
|
||||||
response.headers.delete 'X-Frame-Options'
|
response.headers.delete 'X-Frame-Options'
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -24,7 +24,7 @@ module ActiveStorage
|
||||||
if (token = decode_verified_token)
|
if (token = decode_verified_token)
|
||||||
if acceptable_content?(token)
|
if acceptable_content?(token)
|
||||||
blob = ActiveStorage::Blob.find_by_key! token[:key]
|
blob = ActiveStorage::Blob.find_by_key! token[:key]
|
||||||
site = Site.find_by_name token[:service_name]
|
site = Site.find_by_name! token[:service_name]
|
||||||
|
|
||||||
if remote_file?(token)
|
if remote_file?(token)
|
||||||
begin
|
begin
|
||||||
|
@ -32,18 +32,20 @@ module ActiveStorage
|
||||||
body = Down.download(url, max_size: 111.megabytes)
|
body = Down.download(url, max_size: 111.megabytes)
|
||||||
checksum = Digest::MD5.file(body.path).base64digest
|
checksum = Digest::MD5.file(body.path).base64digest
|
||||||
blob.metadata[:url] = url
|
blob.metadata[:url] = url
|
||||||
blob.update_columns checksum: checksum, byte_size: body.size, metadata: blob.metadata
|
blob.update_columns checksum:, byte_size: body.size, metadata: blob.metadata
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
ExceptionNotifier.notify_exception(e, data: { key: token[:key], url: url, site: site.name })
|
ExceptionNotifier.notify_exception(e, data: { key: token[:key], url:, site: site.name })
|
||||||
|
|
||||||
head :content_too_large
|
head :content_too_large
|
||||||
|
|
||||||
|
return
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
body = request.body
|
body = request.body
|
||||||
checksum = token[:checksum]
|
checksum = token[:checksum]
|
||||||
end
|
end
|
||||||
|
|
||||||
named_disk_service(token[:service_name]).upload token[:key], body, checksum: checksum
|
named_disk_service(site.name).upload(token[:key], body, checksum:)
|
||||||
|
|
||||||
site.static_files.attach(blob)
|
site.static_files.attach(blob)
|
||||||
else
|
else
|
||||||
|
@ -52,8 +54,14 @@ module ActiveStorage
|
||||||
else
|
else
|
||||||
head :not_found
|
head :not_found
|
||||||
end
|
end
|
||||||
rescue ActiveStorage::IntegrityError
|
rescue ActiveRecord::ActiveRecordError, ActiveStorage::Error => e
|
||||||
|
ExceptionNotifier.notify_exception(e, data: { token: })
|
||||||
|
|
||||||
head :unprocessable_entity
|
head :unprocessable_entity
|
||||||
|
rescue Down::Error => e
|
||||||
|
ExceptionNotifier.notify_exception(e, data: { key: token[:key], url: url, site: site.name })
|
||||||
|
|
||||||
|
head :payload_too_large
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
@ -64,6 +72,9 @@ module ActiveStorage
|
||||||
|
|
||||||
def page_not_found(exception)
|
def page_not_found(exception)
|
||||||
head :not_found
|
head :not_found
|
||||||
|
|
||||||
|
params.permit!
|
||||||
|
|
||||||
ExceptionNotifier.notify_exception(exception, data: { params: params.to_hash })
|
ExceptionNotifier.notify_exception(exception, data: { params: params.to_hash })
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -4,51 +4,88 @@ module Api
|
||||||
module V1
|
module V1
|
||||||
# API para sitios
|
# API para sitios
|
||||||
class SitesController < BaseController
|
class SitesController < BaseController
|
||||||
|
SUBDOMAIN = ".#{Site.domain}"
|
||||||
|
TESTING_SUBDOMAIN = ".testing.#{Site.domain}"
|
||||||
|
PARTS = Site.domain.split('.').count
|
||||||
|
|
||||||
|
if Rails.env.production?
|
||||||
http_basic_authenticate_with name: ENV['HTTP_BASIC_USER'],
|
http_basic_authenticate_with name: ENV['HTTP_BASIC_USER'],
|
||||||
password: ENV['HTTP_BASIC_PASSWORD']
|
password: ENV['HTTP_BASIC_PASSWORD']
|
||||||
|
end
|
||||||
|
|
||||||
# Lista de nombres de dominios a emitir certificados
|
# Lista de nombres de dominios a emitir certificados
|
||||||
def index
|
def index
|
||||||
render json: alternative_names + api_names + www_names
|
all_names = sites_names.concat(alternative_names).concat(www_names).concat(api_names).uniq.map do |name|
|
||||||
|
canonicalize name
|
||||||
|
end.reject do |name|
|
||||||
|
subdomain? name
|
||||||
|
end.reject do |name|
|
||||||
|
testing? name
|
||||||
|
end.uniq
|
||||||
|
|
||||||
|
render json: all_names
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
# @param query [ActiveRecord::Relation]
|
||||||
|
# @return [Array<String>]
|
||||||
|
def hostname_of(query)
|
||||||
|
query.pluck(Arel.sql("values->>'hostname'")).compact.uniq
|
||||||
|
end
|
||||||
|
|
||||||
def canonicalize(name)
|
def canonicalize(name)
|
||||||
name.end_with?('.') ? name[0..-2] : "#{name}.#{Site.domain}"
|
name.end_with?('.') ? name[0..-2] : "#{name}.#{Site.domain}"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Es un subdominio directo del dominio principal
|
||||||
|
#
|
||||||
|
# @param name [String]
|
||||||
|
# @return [Bool]
|
||||||
def subdomain?(name)
|
def subdomain?(name)
|
||||||
name.end_with? ".#{Site.domain}"
|
name.end_with?(SUBDOMAIN) && name.split('.').count == (PARTS + 1)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Dominios alternativos
|
# Es un dominio de prueba
|
||||||
def alternative_names
|
#
|
||||||
(DeployAlternativeDomain.all.map(&:hostname) + DeployLocalizedDomain.all.map(&:hostname)).map do |name|
|
# @param name [String]
|
||||||
canonicalize name
|
# @return [Bool]
|
||||||
end.reject do |name|
|
def testing?(name)
|
||||||
subdomain? name
|
name.end_with?(TESTING_SUBDOMAIN) && name.split('.').count == (PARTS + 2)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Nombres de los sitios
|
||||||
|
#
|
||||||
|
# @param name [String]
|
||||||
|
# @return [Array<String>]
|
||||||
|
def sites_names
|
||||||
|
Site.all.order(:name).pluck(:name)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Dominios alternativos, incluyendo todas las clases derivadas de
|
||||||
|
# esta.
|
||||||
|
#
|
||||||
|
# @return [Array<String>]
|
||||||
|
def alternative_names
|
||||||
|
hostname_of(DeployAlternativeDomain.all)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Obtener todos los sitios con API habilitada, es decir formulario
|
# Obtener todos los sitios con API habilitada, es decir formulario
|
||||||
# de contacto y/o colaboración anónima.
|
# de contacto y/o colaboración anónima.
|
||||||
#
|
#
|
||||||
# TODO: Optimizar
|
# @return [Array<String>]
|
||||||
def api_names
|
def api_names
|
||||||
Site.where(contact: true)
|
Site.where(contact: true)
|
||||||
.or(Site.where(colaboracion_anonima: true))
|
.or(Site.where(colaboracion_anonima: true))
|
||||||
.select("'api.' || name as name").map(&:name).map do |name|
|
.pluck(:name).map do |name|
|
||||||
canonicalize name
|
"api.#{name}"
|
||||||
end.reject do |name|
|
|
||||||
subdomain? name
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Todos los dominios con WWW habilitado
|
# Todos los dominios con WWW habilitado
|
||||||
def www_names
|
def www_names
|
||||||
Site.where(id: DeployWww.all.pluck(:site_id)).select("'www.' || name as name").map(&:name).map do |name|
|
Site.where(id: DeployWww.all.pluck(:site_id)).pluck(:name).map do |name|
|
||||||
canonicalize name
|
"www.#{name}"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
# Forma de ingreso a Sutty
|
# Forma de ingreso a Sutty
|
||||||
class ApplicationController < ActionController::Base
|
class ApplicationController < ActionController::Base
|
||||||
include ExceptionHandler
|
include ExceptionHandler if Rails.env.production?
|
||||||
include Pundit::Authorization
|
include Pundit::Authorization
|
||||||
|
|
||||||
protect_from_forgery with: :null_session, prepend: true
|
protect_from_forgery with: :null_session, prepend: true
|
||||||
|
@ -117,4 +117,9 @@ class ApplicationController < ActionController::Base
|
||||||
|
|
||||||
session[:usuarie_return_to] = request.fullpath
|
session[:usuarie_return_to] = request.fullpath
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Detecta si una petición fue hecha por HTMX
|
||||||
|
def htmx?
|
||||||
|
request.headers.key? 'HX-Request'
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -5,9 +5,10 @@
|
||||||
# No necesitamos autenticación aun
|
# No necesitamos autenticación aun
|
||||||
class CollaborationsController < ApplicationController
|
class CollaborationsController < ApplicationController
|
||||||
include Pundit
|
include Pundit
|
||||||
|
include StrongParamsHelper
|
||||||
|
|
||||||
def collaborate
|
def collaborate
|
||||||
@site = Site.find_by_name(params[:site_id])
|
@site = Site.find_by_name(pluck_param(:site_id))
|
||||||
authorize Collaboration.new(@site)
|
authorize Collaboration.new(@site)
|
||||||
|
|
||||||
@invitade = current_usuarie || @site.usuaries.build
|
@invitade = current_usuarie || @site.usuaries.build
|
||||||
|
@ -21,7 +22,7 @@ class CollaborationsController < ApplicationController
|
||||||
#
|
#
|
||||||
# * Si le usuarie existe y no está logueade, pedirle la contraseña
|
# * Si le usuarie existe y no está logueade, pedirle la contraseña
|
||||||
def accept_collaboration
|
def accept_collaboration
|
||||||
@site = Site.find_by_name(params[:site_id])
|
@site = Site.find_by_name(pluck_param(:site_id))
|
||||||
authorize Collaboration.new(@site)
|
authorize Collaboration.new(@site)
|
||||||
|
|
||||||
@invitade = current_usuarie
|
@invitade = current_usuarie
|
||||||
|
|
|
@ -10,25 +10,41 @@ module ExceptionHandler
|
||||||
included do
|
included do
|
||||||
rescue_from SiteNotFound, with: :site_not_found
|
rescue_from SiteNotFound, with: :site_not_found
|
||||||
rescue_from PageNotFound, with: :page_not_found
|
rescue_from PageNotFound, with: :page_not_found
|
||||||
rescue_from ActionController::RoutingError, with: :page_not_found
|
rescue_from Pundit::Error, with: :page_not_found
|
||||||
rescue_from Pundit::NilPolicyError, with: :page_not_found
|
rescue_from Pundit::NotAuthorizedError, with: :page_unauthorized
|
||||||
rescue_from Pundit::NilPolicyError, with: :page_not_found
|
rescue_from Pundit::NilPolicyError, with: :page_not_found
|
||||||
rescue_from ActionController::RoutingError, with: :page_not_found
|
rescue_from ActionController::RoutingError, with: :page_not_found
|
||||||
rescue_from ActionController::ParameterMissing, with: :page_not_found
|
rescue_from ActionController::ParameterMissing, with: :page_not_found
|
||||||
end
|
end
|
||||||
|
|
||||||
def site_not_found
|
def site_not_found(exception)
|
||||||
reset_response!
|
reset_response!
|
||||||
|
|
||||||
flash[:error] = I18n.t('errors.site_not_found')
|
flash[:error] = I18n.t('errors.site_not_found')
|
||||||
|
|
||||||
|
ExceptionNotifier.notify_exception(exception, data: { usuarie: current_usuarie&.id, path: request.fullpath })
|
||||||
|
|
||||||
redirect_to sites_path
|
redirect_to sites_path
|
||||||
end
|
end
|
||||||
|
|
||||||
def page_not_found
|
def page_unauthorized(exception)
|
||||||
reset_response!
|
reset_response!
|
||||||
|
|
||||||
render 'application/page_not_found', status: :not_found
|
flash[:error] = I18n.t('errors.page_unauthorized')
|
||||||
|
|
||||||
|
ExceptionNotifier.notify_exception(exception, data: { usuarie: current_usuarie&.id, path: request.fullpath })
|
||||||
|
|
||||||
|
redirect_to site_path(site)
|
||||||
|
end
|
||||||
|
|
||||||
|
def page_not_found(exception)
|
||||||
|
reset_response!
|
||||||
|
|
||||||
|
flash[:error] = I18n.t('errors.page_not_found')
|
||||||
|
|
||||||
|
ExceptionNotifier.notify_exception(exception)
|
||||||
|
|
||||||
|
redirect_to site_path(site)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
|
@ -2,6 +2,8 @@
|
||||||
|
|
||||||
# Controlador para artículos
|
# Controlador para artículos
|
||||||
class PostsController < ApplicationController
|
class PostsController < ApplicationController
|
||||||
|
include StrongParamsHelper
|
||||||
|
|
||||||
before_action :authenticate_usuarie!
|
before_action :authenticate_usuarie!
|
||||||
before_action :service_for_direct_upload, only: %i[new edit]
|
before_action :service_for_direct_upload, only: %i[new edit]
|
||||||
|
|
||||||
|
@ -13,6 +15,84 @@ class PostsController < ApplicationController
|
||||||
# Las URLs siempre llevan el idioma actual o el de le usuarie
|
# Las URLs siempre llevan el idioma actual o el de le usuarie
|
||||||
def default_url_options
|
def default_url_options
|
||||||
{ locale: locale }
|
{ locale: locale }
|
||||||
|
rescue SiteNotFound
|
||||||
|
{}
|
||||||
|
end
|
||||||
|
|
||||||
|
# @todo Mover a tu propio scope
|
||||||
|
def new_array
|
||||||
|
@value = pluck_param(:value)
|
||||||
|
@name = pluck_param(:name)
|
||||||
|
id = pluck_param(:id)
|
||||||
|
|
||||||
|
headers['HX-Trigger-After-Swap'] = 'htmx:resetForm'
|
||||||
|
|
||||||
|
render layout: false
|
||||||
|
end
|
||||||
|
|
||||||
|
def new_array_value
|
||||||
|
@value = pluck_param(:value)
|
||||||
|
|
||||||
|
render layout: false
|
||||||
|
end
|
||||||
|
|
||||||
|
def new_related_post
|
||||||
|
@uuid = pluck_param(:value)
|
||||||
|
|
||||||
|
@indexed_post = site.indexed_posts.find_by!(post_id: @uuid)
|
||||||
|
|
||||||
|
render layout: false
|
||||||
|
end
|
||||||
|
|
||||||
|
def new_has_one
|
||||||
|
@uuid = pluck_param(:value)
|
||||||
|
|
||||||
|
@indexed_post = site.indexed_posts.find_by!(post_id: @uuid)
|
||||||
|
|
||||||
|
render layout: false
|
||||||
|
end
|
||||||
|
|
||||||
|
# El formulario de un Post, si pasamos el UUID, estamos editando, sino
|
||||||
|
# estamos creando.
|
||||||
|
def form
|
||||||
|
uuid = pluck_param(:uuid, optional: true)
|
||||||
|
locale
|
||||||
|
|
||||||
|
@post =
|
||||||
|
if uuid.present?
|
||||||
|
site.indexed_posts.find_by!(post_id: uuid).post
|
||||||
|
else
|
||||||
|
# @todo Usar la base de datos
|
||||||
|
site.posts(lang: locale).build(layout: pluck_param(:layout))
|
||||||
|
end
|
||||||
|
|
||||||
|
swap_modals
|
||||||
|
|
||||||
|
render layout: false
|
||||||
|
end
|
||||||
|
|
||||||
|
# Genera un modal completo
|
||||||
|
#
|
||||||
|
# @todo recibir el atributo anterior
|
||||||
|
# @param :uuid [String] UUID del post (opcional)
|
||||||
|
# @param :layout [String] El layout a cargar (opcional)
|
||||||
|
def modal
|
||||||
|
uuid = pluck_param(:uuid, optional: true)
|
||||||
|
locale
|
||||||
|
|
||||||
|
# @todo hacer que si el uuid no existe se genera un post, para poder
|
||||||
|
# pasar el uuid sabiendolo
|
||||||
|
@post =
|
||||||
|
if uuid.present?
|
||||||
|
site.indexed_posts.find_by!(post_id: uuid).post
|
||||||
|
else
|
||||||
|
# @todo Usar la base de datos
|
||||||
|
site.posts(lang: locale).build(layout: pluck_param(:layout))
|
||||||
|
end
|
||||||
|
|
||||||
|
swap_modals
|
||||||
|
|
||||||
|
render layout: false
|
||||||
end
|
end
|
||||||
|
|
||||||
def index
|
def index
|
||||||
|
@ -55,7 +135,7 @@ class PostsController < ApplicationController
|
||||||
|
|
||||||
def new
|
def new
|
||||||
authorize Post
|
authorize Post
|
||||||
@post = site.posts(lang: locale).build(layout: params[:layout])
|
@post = site.posts(lang: locale).build(layout: pluck_param(:layout))
|
||||||
|
|
||||||
breadcrumb I18n.t('loaf.breadcrumbs.posts.new', layout: @post.layout.humanized_name.downcase), ''
|
breadcrumb I18n.t('loaf.breadcrumbs.posts.new', layout: @post.layout.humanized_name.downcase), ''
|
||||||
end
|
end
|
||||||
|
@ -65,13 +145,34 @@ class PostsController < ApplicationController
|
||||||
service = PostService.new(site: site,
|
service = PostService.new(site: site,
|
||||||
usuarie: current_usuarie,
|
usuarie: current_usuarie,
|
||||||
params: params)
|
params: params)
|
||||||
@post = service.create
|
@post = service.create_or_update
|
||||||
|
|
||||||
if @post.persisted?
|
if post.persisted?
|
||||||
site.touch
|
site.touch
|
||||||
forget_content
|
forget_content
|
||||||
|
end
|
||||||
|
|
||||||
redirect_to site_post_path(@site, @post)
|
# @todo Enviar la creación a otro endpoint para evitar tantas
|
||||||
|
# condiciones.
|
||||||
|
if htmx?
|
||||||
|
if post.persisted?
|
||||||
|
triggers = { 'notification:show' => { 'id' => pluck_param(:saved, optional: true) } }
|
||||||
|
|
||||||
|
swap_modals(triggers)
|
||||||
|
|
||||||
|
@value = post.title.value
|
||||||
|
@uuid = post.uuid.value
|
||||||
|
@name = pluck_param(:name)
|
||||||
|
|
||||||
|
render render_path_from_attribute, layout: false
|
||||||
|
else
|
||||||
|
headers['HX-Retarget'] = "##{pluck_param(:form)}"
|
||||||
|
headers['HX-Reswap'] = 'outerHTML'
|
||||||
|
|
||||||
|
render 'posts/form', layout: false, post: post, site: site, **params.permit(:form, :base, :dir, :locale)
|
||||||
|
end
|
||||||
|
elsif post.persisted?
|
||||||
|
redirect_to site_post_path(site, post)
|
||||||
else
|
else
|
||||||
render 'posts/new'
|
render 'posts/new'
|
||||||
end
|
end
|
||||||
|
@ -83,6 +184,16 @@ class PostsController < ApplicationController
|
||||||
breadcrumb 'posts.edit', ''
|
breadcrumb 'posts.edit', ''
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Este endpoint se encarga de actualizar el post. Si el post se edita
|
||||||
|
# desde el formulario principal, re-renderizamos el formulario si hay
|
||||||
|
# errores o enviamos a otro lado al guardar.
|
||||||
|
#
|
||||||
|
# Si los datos llegaron por HTMX, hay que regenerar el formulario
|
||||||
|
# y reemplazarlo en su modal (?) o responder con su tarjeta para
|
||||||
|
# reemplazarla donde sea que esté.
|
||||||
|
#
|
||||||
|
# @todo la re-renderización del formulario no es necesaria si tenemos
|
||||||
|
# validación client-side.
|
||||||
def update
|
def update
|
||||||
authorize post
|
authorize post
|
||||||
|
|
||||||
|
@ -94,7 +205,37 @@ class PostsController < ApplicationController
|
||||||
if service.update.persisted?
|
if service.update.persisted?
|
||||||
site.touch
|
site.touch
|
||||||
forget_content
|
forget_content
|
||||||
|
end
|
||||||
|
|
||||||
|
if htmx?
|
||||||
|
if post.persisted?
|
||||||
|
triggers = { 'notification:show' => pluck_param(:saved, optional: true) }
|
||||||
|
|
||||||
|
swap_modals(triggers)
|
||||||
|
|
||||||
|
@value = post.title.value
|
||||||
|
@uuid = post.uuid.value
|
||||||
|
|
||||||
|
if (result_id = pluck_param(:result_id, optional: true))
|
||||||
|
headers['HX-Retarget'] = "##{result_id}"
|
||||||
|
headers['HX-Reswap'] = 'outerHTML'
|
||||||
|
|
||||||
|
@indexed_post = site.indexed_posts.find_by_post_id(post.uuid.value)
|
||||||
|
|
||||||
|
render 'posts/new_related_post', layout: false
|
||||||
|
# @todo Confirmar que esta ruta no esté transitada
|
||||||
|
else
|
||||||
|
@name = pluck_param(:name)
|
||||||
|
|
||||||
|
render render_path_from_attribute, layout: false
|
||||||
|
end
|
||||||
|
else
|
||||||
|
headers['HX-Retarget'] = "##{params.require(:form)}"
|
||||||
|
headers['HX-Reswap'] = 'outerHTML'
|
||||||
|
|
||||||
|
render 'posts/form', layout: false, post: post, site: site, **params.permit(:form, :base, :dir, :locale)
|
||||||
|
end
|
||||||
|
elsif post.persisted?
|
||||||
redirect_to site_post_path(site, post)
|
redirect_to site_post_path(site, post)
|
||||||
else
|
else
|
||||||
render 'posts/edit'
|
render 'posts/edit'
|
||||||
|
@ -168,4 +309,24 @@ class PostsController < ApplicationController
|
||||||
def service_for_direct_upload
|
def service_for_direct_upload
|
||||||
session[:service_name] = site.name.to_sym
|
session[:service_name] = site.name.to_sym
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# @param triggers [Hash] Otros disparadores
|
||||||
|
def swap_modals(triggers = {})
|
||||||
|
params.permit(:show, :hide).each_pair do |key, value|
|
||||||
|
triggers["modal:#{key}"] = { id: value } if value.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
headers['HX-Trigger'] = triggers.to_json if triggers.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
# @return [String]
|
||||||
|
def render_path_from_attribute
|
||||||
|
case pluck_param(:attribute)
|
||||||
|
when 'new_has_many' then 'posts/new_has_many_value'
|
||||||
|
when 'new_belongs_to' then 'posts/new_belongs_to_value'
|
||||||
|
when 'new_has_and_belongs_to_many' then 'posts/new_has_many_value'
|
||||||
|
when 'new_has_one' then 'posts/new_has_one_value'
|
||||||
|
else 'nothing'
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
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])
|
@usuarie = Usuarie.find(params[:id])
|
||||||
|
|
||||||
if @site.usuaries.count > 1
|
if @site.invitade?(@usuarie) || @site.usuaries.count > 1
|
||||||
# Mágicamente elimina el rol
|
# Mágicamente elimina el rol
|
||||||
@usuarie.sites.delete(@site)
|
@usuarie.sites.delete(@site)
|
||||||
else
|
else
|
||||||
|
|
|
@ -2,6 +2,19 @@
|
||||||
|
|
||||||
# Helpers
|
# Helpers
|
||||||
module ApplicationHelper
|
module ApplicationHelper
|
||||||
|
BRACKETS = /[\[\]]/.freeze
|
||||||
|
ALPHA_LARGE = [*'a'..'z', *'A'..'Z'].freeze
|
||||||
|
|
||||||
|
# Devuelve un indentificador aleatorio que puede usarse como atributo
|
||||||
|
# HTML. Reemplaza Nanoid. El primer caracter siempre es alfabético.
|
||||||
|
#
|
||||||
|
# @return [String]
|
||||||
|
def random_id
|
||||||
|
SecureRandom.urlsafe_base64.tap do |s|
|
||||||
|
s[0] = ALPHA_LARGE.sample
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# Devuelve el atributo name de un campo anidado en el formato que
|
# Devuelve el atributo name de un campo anidado en el formato que
|
||||||
# esperan los helpers *_field
|
# esperan los helpers *_field
|
||||||
#
|
#
|
||||||
|
@ -19,6 +32,14 @@ module ApplicationHelper
|
||||||
[root, name]
|
[root, name]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Obtiene un ID
|
||||||
|
#
|
||||||
|
# @param base [String]
|
||||||
|
# @param attribute [String, Symbol]
|
||||||
|
def id_for(base, attribute)
|
||||||
|
"#{base.gsub(BRACKETS, '_')}_#{attribute}".squeeze('_')
|
||||||
|
end
|
||||||
|
|
||||||
def plain_field_name_for(*names)
|
def plain_field_name_for(*names)
|
||||||
root, name = field_name_for(*names)
|
root, name = field_name_for(*names)
|
||||||
|
|
||||||
|
@ -134,9 +155,17 @@ module ApplicationHelper
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
# Obtiene la traducción desde el esquema en el idioma actual, o por
|
||||||
|
# defecto en el idioma del sitio. De lo contrario trae una traducción
|
||||||
|
# genérica.
|
||||||
|
#
|
||||||
|
# Si el idioma por defecto tiene un String vacía, se asume que no
|
||||||
|
# texto.
|
||||||
|
#
|
||||||
|
# @return [String,nil]
|
||||||
def post_t(*attribute, post:, type:)
|
def post_t(*attribute, post:, type:)
|
||||||
post.layout.metadata.dig(*attribute, type.to_s, I18n.locale.to_s) ||
|
post.layout.metadata.dig(*attribute, type.to_s, I18n.locale.to_s).presence ||
|
||||||
post.layout.metadata.dig(*attribute, type.to_s, I18n.default_locale.to_s) ||
|
post.layout.metadata.dig(*attribute, type.to_s, post.site.default_locale.to_s) ||
|
||||||
I18n.t("posts.attributes.#{attribute.join('.')}.#{type}")
|
I18n.t("posts.attributes.#{attribute.join('.')}.#{type}").presence
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
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 {
|
export default class extends Controller {
|
||||||
static targets = [];
|
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
|
// https://getbootstrap.com/docs/4.6/components/dropdowns/#single-button
|
||||||
export default class extends Controller {
|
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";
|
import bsCustomFileInput from "bs-custom-file-input";
|
||||||
|
|
||||||
document.addEventListener("turbolinks:load", () => {
|
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")
|
require("leaflet/dist/leaflet.css")
|
||||||
import L from 'leaflet'
|
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.
|
// Load all the controllers within this directory and all subdirectories.
|
||||||
// Controller files must be named *_controller.js.
|
// Controller files must be named *_controller.js.
|
||||||
|
|
||||||
import { Application } from "stimulus"
|
import { Application } from "@hotwired/stimulus"
|
||||||
import { definitionsFromContext } from "stimulus/webpack-helpers"
|
import { definitionsFromContext } from "@hotwired/stimulus-webpack-helpers"
|
||||||
|
|
||||||
const application = Application.start()
|
const application = Application.start()
|
||||||
const context = require.context("controllers", true, /_controller\.js$/)
|
const context = require.context("controllers", true, /_controller\.js$/)
|
||||||
|
|
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")
|
require("leaflet/dist/leaflet.css")
|
||||||
import L from 'leaflet'
|
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.
|
* 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 {
|
export default class extends Controller {
|
||||||
static targets = ["toggle", "input"];
|
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");
|
window.htmx.trigger(hx, "htmx:abort");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.addEventListener("htmx:resetForm", (event) => {
|
||||||
|
event.target.reset();
|
||||||
|
});
|
||||||
|
|
|
@ -4,6 +4,4 @@ import './input-tag'
|
||||||
import './prosemirror'
|
import './prosemirror'
|
||||||
import './timezone'
|
import './timezone'
|
||||||
import './turbolinks-anchors'
|
import './turbolinks-anchors'
|
||||||
import './validation'
|
|
||||||
import './new_editor'
|
|
||||||
import './htmx_abort'
|
import './htmx_abort'
|
||||||
|
|
|
@ -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()
|
Turbolinks.start()
|
||||||
ActiveStorage.start()
|
ActiveStorage.start()
|
||||||
|
|
||||||
window.htmx = require('htmx.org/dist/htmx.js')
|
window.htmx = require("@suttyweb/htmx.org/dist/htmx.cjs.js");
|
||||||
|
window.htmx.config.selfRequestsOnly = true;
|
||||||
|
|
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
|
class ApplicationJob < ActiveJob::Base
|
||||||
include Que::ActiveJob::JobExtensions
|
include Que::ActiveJob::JobExtensions
|
||||||
|
|
||||||
|
attr_reader :site
|
||||||
|
|
||||||
# Esperar una cantidad random de segundos primos, para que no se
|
# Esperar una cantidad random de segundos primos, para que no se
|
||||||
# superpongan tareas
|
# superpongan tareas
|
||||||
#
|
#
|
||||||
|
@ -15,8 +17,6 @@ class ApplicationJob < ActiveJob::Base
|
||||||
RANDOM_WAIT.sample.seconds
|
RANDOM_WAIT.sample.seconds
|
||||||
end
|
end
|
||||||
|
|
||||||
attr_reader :site
|
|
||||||
|
|
||||||
# Si falla por cualquier cosa informar y descartar
|
# Si falla por cualquier cosa informar y descartar
|
||||||
discard_on(Exception) do |job, error|
|
discard_on(Exception) do |job, error|
|
||||||
ExceptionNotifier.notify_exception(error, data: { job: job })
|
ExceptionNotifier.notify_exception(error, data: { job: job })
|
||||||
|
|
|
@ -77,6 +77,8 @@ class DeployJob < ApplicationJob
|
||||||
t << ([type.to_s] + row.values)
|
t << ([type.to_s] + row.values)
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
|
rescue DeployTimedOutException => e
|
||||||
|
notify_exception e
|
||||||
ensure
|
ensure
|
||||||
if site.present?
|
if site.present?
|
||||||
site.update status: 'waiting'
|
site.update status: 'waiting'
|
||||||
|
|
|
@ -1,18 +1,29 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
# Permite traer los cambios desde webhooks
|
# Permite traer los cambios desde el repositorio remoto
|
||||||
|
|
||||||
class GitPullJob < ApplicationJob
|
class GitPullJob < ApplicationJob
|
||||||
# @param :site [Site]
|
# @param :site [Site]
|
||||||
# @param :usuarie [Usuarie]
|
# @param :usuarie [Usuarie]
|
||||||
|
# @param :message [String]
|
||||||
# @return [nil]
|
# @return [nil]
|
||||||
def perform(site, usuarie)
|
def perform(site, usuarie, message)
|
||||||
@site = site
|
@site = site
|
||||||
|
|
||||||
return unless site.repository.origin
|
return unless site.repository.origin
|
||||||
return unless site.repository.fetch.positive?
|
|
||||||
|
|
||||||
site.repository.merge(usuarie)
|
site.repository.fetch
|
||||||
|
|
||||||
|
return if site.repository.up_to_date?
|
||||||
|
|
||||||
|
if site.repository.fast_forward?
|
||||||
|
site.repository.fast_forward!
|
||||||
|
else
|
||||||
|
site.repository.merge(usuarie, message)
|
||||||
|
end
|
||||||
|
|
||||||
|
site.repository.git_lfs_checkout
|
||||||
site.reindex_changes!
|
site.reindex_changes!
|
||||||
|
|
||||||
|
nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
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 ActivityPub
|
||||||
class Activity
|
class Activity
|
||||||
class Flag < ActivityPub::Activity; end
|
class Flag < ActivityPub::Activity
|
||||||
|
# Notifica a todes les moderadores
|
||||||
|
def update_activity_pub_state!
|
||||||
|
ActivityPub::ActorFlaggedMailer.with(
|
||||||
|
content: content['content'],
|
||||||
|
object: content['object'],
|
||||||
|
actor: content['actor'],
|
||||||
|
site_id: activity_pub.site_id
|
||||||
|
).notify_moderators.deliver_later
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
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`.
|
# :attributes`.
|
||||||
class Deploy < ApplicationRecord
|
class Deploy < ApplicationRecord
|
||||||
belongs_to :site
|
belongs_to :site
|
||||||
belongs_to :rol
|
belongs_to :rol, optional: true
|
||||||
|
|
||||||
has_many :build_stats, dependent: :destroy
|
has_many :build_stats, dependent: :destroy
|
||||||
|
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
# Soportar dominios alternativos
|
# Soportar dominios alternativos
|
||||||
class DeployAlternativeDomain < Deploy
|
class DeployAlternativeDomain < Deploy
|
||||||
store :values, accessors: %i[hostname], coder: JSON
|
store_accessor :values, :hostname
|
||||||
|
|
||||||
DEPENDENCIES = %i[deploy_local]
|
DEPENDENCIES = %i[deploy_local]
|
||||||
|
|
||||||
|
|
|
@ -12,7 +12,10 @@ require 'distributed_press/v1/client/site'
|
||||||
# Al ser publicado, envía los archivos en un tarball y actualiza la
|
# Al ser publicado, envía los archivos en un tarball y actualiza la
|
||||||
# información.
|
# información.
|
||||||
class DeployDistributedPress < Deploy
|
class DeployDistributedPress < Deploy
|
||||||
store :values, accessors: %i[hostname remote_site_id remote_info distributed_press_publisher_id], coder: JSON
|
store_accessor :values, :hostname
|
||||||
|
store_accessor :values, :remote_site_id
|
||||||
|
store_accessor :values, :remote_info
|
||||||
|
store_accessor :values, :distributed_press_publisher_id
|
||||||
|
|
||||||
before_create :create_remote_site!
|
before_create :create_remote_site!
|
||||||
before_destroy :delete_remote_site!
|
before_destroy :delete_remote_site!
|
||||||
|
@ -23,7 +26,7 @@ class DeployDistributedPress < Deploy
|
||||||
#
|
#
|
||||||
# @param :output [Bool]
|
# @param :output [Bool]
|
||||||
# @return [Bool]
|
# @return [Bool]
|
||||||
def deploy
|
def deploy(output: true)
|
||||||
status = false
|
status = false
|
||||||
log = []
|
log = []
|
||||||
|
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
# Genera una versión onion
|
# Genera una versión onion
|
||||||
class DeployHiddenService < DeployWww
|
class DeployHiddenService < DeployWww
|
||||||
store :values, accessors: %i[onion], coder: JSON
|
store_accessor :values, :onion
|
||||||
|
|
||||||
before_create :create_hidden_service!
|
before_create :create_hidden_service!
|
||||||
|
|
||||||
|
|
|
@ -3,14 +3,12 @@
|
||||||
# Alojamiento local, solo genera el sitio, con lo que no necesita hacer
|
# Alojamiento local, solo genera el sitio, con lo que no necesita hacer
|
||||||
# nada más
|
# nada más
|
||||||
class DeployLocal < Deploy
|
class DeployLocal < Deploy
|
||||||
store :values, accessors: %i[], coder: JSON
|
|
||||||
|
|
||||||
before_destroy :remove_destination!
|
before_destroy :remove_destination!
|
||||||
|
|
||||||
def bundle(output: false)
|
def bundle(output: false)
|
||||||
run %(bundle config set --local clean 'true'), output: output
|
run %(bundle config set --local clean 'true'), output: output
|
||||||
run(%(bundle config set --local deployment 'true'), output: output) if site.gemfile_lock_path?
|
run %(bundle config set --local deployment 'true'), output: output if site.gemfile_lock_path?
|
||||||
run %(bundle config set --local path '#{gems_dir}'), output: output
|
run %(bundle config set --local path '#{site.bundle_path}'), output: output
|
||||||
run %(bundle config set --local without 'test development'), output: output
|
run %(bundle config set --local without 'test development'), output: output
|
||||||
run %(bundle config set --local cache_all 'false'), output: output
|
run %(bundle config set --local cache_all 'false'), output: output
|
||||||
run %(bundle install), output: output
|
run %(bundle install), output: output
|
||||||
|
|
|
@ -2,7 +2,8 @@
|
||||||
|
|
||||||
# Soportar dominios localizados
|
# Soportar dominios localizados
|
||||||
class DeployLocalizedDomain < DeployAlternativeDomain
|
class DeployLocalizedDomain < DeployAlternativeDomain
|
||||||
store :values, accessors: %i[hostname locale], coder: JSON
|
store_accessor :values, :hostname
|
||||||
|
store_accessor :values, :locale
|
||||||
|
|
||||||
# Generar un link simbólico del sitio principal al alternativo
|
# Generar un link simbólico del sitio principal al alternativo
|
||||||
def deploy(**)
|
def deploy(**)
|
||||||
|
|
|
@ -2,7 +2,9 @@
|
||||||
|
|
||||||
# Reindexa los artículos al terminar la compilación
|
# Reindexa los artículos al terminar la compilación
|
||||||
class DeployReindex < Deploy
|
class DeployReindex < Deploy
|
||||||
def deploy(**)
|
def deploy(output: true)
|
||||||
|
puts 'Reindex' if output
|
||||||
|
|
||||||
time_start
|
time_start
|
||||||
|
|
||||||
site.reset
|
site.reset
|
||||||
|
|
|
@ -3,11 +3,15 @@
|
||||||
# Sincroniza sitios a servidores remotos usando Rsync. El servidor
|
# Sincroniza sitios a servidores remotos usando Rsync. El servidor
|
||||||
# remoto tiene que tener rsync instalado.
|
# remoto tiene que tener rsync instalado.
|
||||||
class DeployRsync < Deploy
|
class DeployRsync < Deploy
|
||||||
store :values, accessors: %i[hostname destination host_keys], coder: JSON
|
store_accessor :values, :hostname
|
||||||
|
store_accessor :values, :destination
|
||||||
|
store_accessor :values, :host_keys
|
||||||
|
|
||||||
DEPENDENCIES = %i[deploy_local deploy_zip]
|
DEPENDENCIES = %i[deploy_local deploy_zip]
|
||||||
|
|
||||||
def deploy(output: false)
|
def deploy(output: false)
|
||||||
|
raise(ArgumentError, 'destination no está configurado') if destination.blank?
|
||||||
|
|
||||||
ssh? && rsync(output: output)
|
ssh? && rsync(output: output)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -18,13 +22,6 @@ class DeployRsync < Deploy
|
||||||
deploy_local.size
|
deploy_local.size
|
||||||
end
|
end
|
||||||
|
|
||||||
# Devolver el destino o lanzar un error si no está configurado
|
|
||||||
def destination
|
|
||||||
values[:destination].tap do |d|
|
|
||||||
raise(ArgumentError, 'destination no está configurado') if d.blank?
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# @return [String]
|
# @return [String]
|
||||||
def url
|
def url
|
||||||
"https://#{hostname}/"
|
"https://#{hostname}/"
|
||||||
|
@ -43,9 +40,9 @@ class DeployRsync < Deploy
|
||||||
ssh_available = false
|
ssh_available = false
|
||||||
|
|
||||||
Net::SSH.start(host, user, verify_host_key: tofu, timeout: 5) do |ssh|
|
Net::SSH.start(host, user, verify_host_key: tofu, timeout: 5) do |ssh|
|
||||||
if values[:host_keys].blank?
|
if self.host_keys.blank?
|
||||||
# Guardar las llaves que se encontraron en la primera conexión
|
# Guardar las llaves que se encontraron en la primera conexión
|
||||||
values[:host_keys] = ssh.transport.host_keys.map do |host_key|
|
self.host_keys = ssh.transport.host_keys.map do |host_key|
|
||||||
"#{host_key.ssh_type} #{host_key.fingerprint}"
|
"#{host_key.ssh_type} #{host_key.fingerprint}"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -74,14 +71,14 @@ class DeployRsync < Deploy
|
||||||
#
|
#
|
||||||
# @return [Symbol]
|
# @return [Symbol]
|
||||||
def tofu
|
def tofu
|
||||||
values[:host_keys].present? ? :always : :accept_new
|
self.host_keys.present? ? :always : :accept_new
|
||||||
end
|
end
|
||||||
|
|
||||||
# Devuelve el par user host
|
# Devuelve el par user host
|
||||||
#
|
#
|
||||||
# @return [Array]
|
# @return [Array]
|
||||||
def user_host
|
def user_host
|
||||||
destination.split(':', 2).first.split('@', 2).tap do |d|
|
@user_host ||= destination.split(':', 2).first.split('@', 2).tap do |d|
|
||||||
next unless d.size == 1
|
next unless d.size == 1
|
||||||
|
|
||||||
d.insert(0, nil)
|
d.insert(0, nil)
|
||||||
|
|
|
@ -2,8 +2,6 @@
|
||||||
|
|
||||||
# Vincula la versión del sitio con www a la versión sin
|
# Vincula la versión del sitio con www a la versión sin
|
||||||
class DeployWww < Deploy
|
class DeployWww < Deploy
|
||||||
store :values, accessors: %i[], coder: JSON
|
|
||||||
|
|
||||||
DEPENDENCIES = %i[deploy_local]
|
DEPENDENCIES = %i[deploy_local]
|
||||||
|
|
||||||
before_destroy :remove_destination!
|
before_destroy :remove_destination!
|
||||||
|
|
|
@ -6,8 +6,6 @@ require 'zip'
|
||||||
#
|
#
|
||||||
# TODO: Firmar con minisign
|
# TODO: Firmar con minisign
|
||||||
class DeployZip < Deploy
|
class DeployZip < Deploy
|
||||||
store :values, accessors: %i[], coder: JSON
|
|
||||||
|
|
||||||
DEPENDENCIES = %i[deploy_local]
|
DEPENDENCIES = %i[deploy_local]
|
||||||
|
|
||||||
# Una vez que el sitio está generado, tomar todos los archivos y
|
# Una vez que el sitio está generado, tomar todos los archivos y
|
||||||
|
|
|
@ -19,8 +19,12 @@ class MetadataArray < MetadataTemplate
|
||||||
true && !private?
|
true && !private?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def titleize?
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
def to_s
|
def to_s
|
||||||
value.join(', ')
|
value.select(&:present?).join(', ')
|
||||||
end
|
end
|
||||||
|
|
||||||
# Obtiene el valor desde el documento, convirtiéndolo a Array si no lo
|
# Obtiene el valor desde el documento, convirtiéndolo a Array si no lo
|
||||||
|
|
|
@ -13,6 +13,10 @@ class MetadataBelongsTo < MetadataRelatedPosts
|
||||||
''
|
''
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def to_s
|
||||||
|
belongs_to.try(:title).try(:value).to_s
|
||||||
|
end
|
||||||
|
|
||||||
# Obtiene el valor desde el documento.
|
# Obtiene el valor desde el documento.
|
||||||
#
|
#
|
||||||
# @return [String]
|
# @return [String]
|
||||||
|
@ -20,14 +24,6 @@ class MetadataBelongsTo < MetadataRelatedPosts
|
||||||
document.data[name.to_s]
|
document.data[name.to_s]
|
||||||
end
|
end
|
||||||
|
|
||||||
def validate
|
|
||||||
super
|
|
||||||
|
|
||||||
errors << I18n.t('metadata.belongs_to.missing_post') unless post_exists?
|
|
||||||
|
|
||||||
errors.empty?
|
|
||||||
end
|
|
||||||
|
|
||||||
# Guardar y guardar la relación inversa también, eliminando la
|
# Guardar y guardar la relación inversa también, eliminando la
|
||||||
# relación anterior si existía.
|
# relación anterior si existía.
|
||||||
def save
|
def save
|
||||||
|
@ -40,13 +36,23 @@ class MetadataBelongsTo < MetadataRelatedPosts
|
||||||
# Si estamos cambiando la relación, tenemos que eliminar la relación
|
# Si estamos cambiando la relación, tenemos que eliminar la relación
|
||||||
# anterior
|
# anterior
|
||||||
if belonged_to.present?
|
if belonged_to.present?
|
||||||
|
if belonged_to[inverse].respond_to? :has_one
|
||||||
|
belonged_to[inverse].value = ''
|
||||||
|
else
|
||||||
belonged_to[inverse].value = belonged_to[inverse].value.reject do |rej|
|
belonged_to[inverse].value = belonged_to[inverse].value.reject do |rej|
|
||||||
rej == post.uuid.value
|
rej == post.uuid.value
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# No duplicar las relaciones
|
# No duplicar las relaciones
|
||||||
|
if belongs_to.present?
|
||||||
|
if belongs_to[inverse].respond_to? :has_one
|
||||||
|
belongs_to[inverse].value = post.uuid.value
|
||||||
|
else
|
||||||
belongs_to[inverse].value = (belongs_to[inverse].value.dup << post.uuid.value) unless belongs_to.blank? || included?
|
belongs_to[inverse].value = (belongs_to[inverse].value.dup << post.uuid.value) unless belongs_to.blank? || included?
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
true
|
true
|
||||||
end
|
end
|
||||||
|
@ -97,6 +103,6 @@ class MetadataBelongsTo < MetadataRelatedPosts
|
||||||
end
|
end
|
||||||
|
|
||||||
def sanitize(uuid)
|
def sanitize(uuid)
|
||||||
uuid.to_s.gsub(/[^a-f0-9\-]/i, '')
|
uuid.to_s.gsub(/[^a-f0-9-]/i, '')
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -58,8 +58,13 @@ class MetadataContent < MetadataTemplate
|
||||||
|
|
||||||
uri = URI element['src']
|
uri = URI element['src']
|
||||||
|
|
||||||
# No permitimos recursos externos
|
# No permitimos recursos externos, solo si sabemos cuales son
|
||||||
raise URI::Error unless Rails.application.config.hosts.include?(uri.hostname)
|
# los recursos locales
|
||||||
|
if Rails.application.config.hosts.present?
|
||||||
|
unless Rails.application.config.hosts.include?(uri.hostname)
|
||||||
|
raise URI::Error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
element['src'] = convert_src_to_internal_path uri
|
element['src'] = convert_src_to_internal_path uri
|
||||||
|
|
||||||
|
|
|
@ -1,8 +1,30 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class MetadataDate < MetadataTemplate
|
class MetadataDate < MetadataTemplate
|
||||||
|
# La fecha de hoy si no hay nada. Podemos traer un valor por defecto
|
||||||
|
# desde el esquema, siempre y cuando pueda considerarse una fecha
|
||||||
|
# válida.
|
||||||
|
#
|
||||||
|
# @return [Date,nil]
|
||||||
def default_value
|
def default_value
|
||||||
Date.today
|
if (dv = super.presence)
|
||||||
|
begin
|
||||||
|
Date.parse(dv)
|
||||||
|
# XXX: Notificar para que sepamos que el esquema no es válido.
|
||||||
|
# TODO: Validar el valor por defecto en sutty-schema-validator.
|
||||||
|
rescue Date::Error => e
|
||||||
|
ExceptionNotifier.notify_exception(e, data: { site: site.name, post: post.id, name:, type: })
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Delegar el formato al valor, para uso dentro de date_field()
|
||||||
|
#
|
||||||
|
# @param format [String]
|
||||||
|
# @return [String,nil]
|
||||||
|
def strftime(format)
|
||||||
|
value&.strftime(format)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Devuelve una fecha, si no hay ninguna es la fecha de hoy.
|
# Devuelve una fecha, si no hay ninguna es la fecha de hoy.
|
||||||
|
|
|
@ -18,6 +18,18 @@ class MetadataFile < MetadataTemplate
|
||||||
# XXX: Esto ayuda a deserializar en {Site#everything_of}
|
# XXX: Esto ayuda a deserializar en {Site#everything_of}
|
||||||
def values; end
|
def values; end
|
||||||
|
|
||||||
|
# Usar la descripción
|
||||||
|
def titleize?
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
|
# Devolver la descripción
|
||||||
|
#
|
||||||
|
# @return [String]
|
||||||
|
def to_s
|
||||||
|
value['description'].to_s
|
||||||
|
end
|
||||||
|
|
||||||
def validate
|
def validate
|
||||||
super
|
super
|
||||||
|
|
||||||
|
@ -49,6 +61,8 @@ class MetadataFile < MetadataTemplate
|
||||||
value['path'] = relative_destination_path_with_filename.to_s if static_file
|
value['path'] = relative_destination_path_with_filename.to_s if static_file
|
||||||
end
|
end
|
||||||
|
|
||||||
|
self[:value] = self[:value].to_h
|
||||||
|
|
||||||
true
|
true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -14,7 +14,7 @@ class MetadataGeo < MetadataTemplate
|
||||||
return true unless changed?
|
return true unless changed?
|
||||||
return true if empty?
|
return true if empty?
|
||||||
|
|
||||||
self[:value] = value.transform_values(&:to_f)
|
self[:value] = value.transform_values(&:to_f).to_h
|
||||||
self[:value] = encrypt(value) if private?
|
self[:value] = encrypt(value) if private?
|
||||||
|
|
||||||
true
|
true
|
||||||
|
|
|
@ -36,10 +36,15 @@ class MetadataHasMany < MetadataRelatedPosts
|
||||||
def save
|
def save
|
||||||
super
|
super
|
||||||
|
|
||||||
|
self[:value] = self[:value].uniq
|
||||||
|
|
||||||
return true unless changed?
|
return true unless changed?
|
||||||
return true unless inverse?
|
return true unless inverse?
|
||||||
|
|
||||||
(had_many - has_many).each do |remove|
|
(had_many - has_many).each do |remove|
|
||||||
|
# No modificar nada si la relación ya estaba deshecha
|
||||||
|
next unless remove[inverse]&.value == post.uuid.value
|
||||||
|
|
||||||
remove[inverse]&.value = remove[inverse].default_value
|
remove[inverse]&.value = remove[inverse].default_value
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
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
|
false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Devuelve la URL actual del sitio
|
||||||
|
#
|
||||||
|
# @return [String, nil]
|
||||||
|
def default_value
|
||||||
|
if post.written?
|
||||||
|
super.presence || document.url
|
||||||
|
else
|
||||||
|
super.presence
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
# Al hacer limpieza, validamos la ruta. Eliminamos / multiplicadas,
|
# Al hacer limpieza, validamos la ruta. Eliminamos / multiplicadas,
|
||||||
# puntos suspensivos, la primera / para que siempre sea relativa y
|
# puntos suspensivos, la primera / para que siempre sea relativa y
|
||||||
# agregamos una / al final si la ruta no tiene extensión.
|
# agregamos una / al final si la ruta no tiene extensión.
|
||||||
def sanitize(value)
|
def sanitize(value)
|
||||||
|
return value.strip if value.blank?
|
||||||
|
|
||||||
value = value.strip.unicode_normalize.gsub('..', '/').gsub('./', '').squeeze('/')
|
value = value.strip.unicode_normalize.gsub('..', '/').gsub('./', '').squeeze('/')
|
||||||
value = value[1..-1] if value.start_with? '/'
|
value = value[1..-1] if value.start_with? '/'
|
||||||
value += '/' if File.extname(value).blank?
|
value += '/' if File.extname(value).blank?
|
||||||
|
|
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]
|
[v[I18n.locale.to_s], k]
|
||||||
end&.to_h
|
end&.to_h
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Devolver los valores legibles por humanes
|
||||||
|
#
|
||||||
|
# @todo Debería devolver los valores en el idioma del post, no de le
|
||||||
|
# usuarie
|
||||||
|
# @return [String]
|
||||||
|
def to_s
|
||||||
|
values.invert.select { |x, k| value.include?(x) }.values.join(', ')
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -10,6 +10,10 @@ class MetadataPredefinedValue < MetadataString
|
||||||
@values ||= layout.dig(:metadata, name, 'values', I18n.locale.to_s)&.invert || {}
|
@values ||= layout.dig(:metadata, name, 'values', I18n.locale.to_s)&.invert || {}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def to_s
|
||||||
|
values.invert[value].to_s
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
# Solo permite almacenar los valores predefinidos.
|
# Solo permite almacenar los valores predefinidos.
|
||||||
|
|
|
@ -22,10 +22,21 @@ class MetadataRelatedPosts < MetadataArray
|
||||||
false
|
false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def titleize?
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
def indexable_values
|
def indexable_values
|
||||||
posts.where(uuid: value).map(&:title).map(&:value)
|
posts.where(uuid: value).map(&:title).map(&:value)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Encuentra el filtro
|
||||||
|
#
|
||||||
|
# @return [Hash]
|
||||||
|
def filter
|
||||||
|
layout.metadata.dig(name, 'filter')&.to_h&.symbolize_keys || {}
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
# Obtiene todos los posts y opcionalmente los filtra
|
# Obtiene todos los posts y opcionalmente los filtra
|
||||||
|
@ -34,17 +45,12 @@ class MetadataRelatedPosts < MetadataArray
|
||||||
end
|
end
|
||||||
|
|
||||||
def title(post)
|
def title(post)
|
||||||
"#{post&.title&.value || post&.slug&.value} #{post&.date&.value.strftime('%F')} (#{post.layout.humanized_name})"
|
"#{post&.title&.value || post&.slug&.value} #{post&.date&.value&.strftime('%F')} (#{post.layout.humanized_name})"
|
||||||
end
|
|
||||||
|
|
||||||
# Encuentra el filtro
|
|
||||||
def filter
|
|
||||||
layout.metadata.dig(name, 'filter')&.to_h&.symbolize_keys || {}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def sanitize(uuid)
|
def sanitize(uuid)
|
||||||
super(uuid.map do |u|
|
super(uuid.map do |u|
|
||||||
u.to_s.gsub(/[^a-f0-9\-]/i, '')
|
u.to_s.gsub(/[^a-f0-9-]/i, '')
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -25,7 +25,7 @@ require 'jekyll/utils'
|
||||||
class MetadataSlug < MetadataTemplate
|
class MetadataSlug < MetadataTemplate
|
||||||
# Trae el slug desde el título si existe o una string al azar
|
# Trae el slug desde el título si existe o una string al azar
|
||||||
def default_value
|
def default_value
|
||||||
title ? Jekyll::Utils.slugify(title, mode: site.slugify_mode) : SecureRandom.uuid
|
Jekyll::Utils.slugify(title || SecureRandom.uuid, mode: site.slugify_mode)
|
||||||
end
|
end
|
||||||
|
|
||||||
def value
|
def value
|
||||||
|
|
|
@ -11,6 +11,10 @@ class MetadataString < MetadataTemplate
|
||||||
true && !private?
|
true && !private?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def titleize?
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
# No se permite HTML en las strings
|
# No se permite HTML en las strings
|
||||||
|
|
|
@ -12,6 +12,15 @@ MetadataTemplate = Struct.new(:site, :document, :name, :label, :type,
|
||||||
false
|
false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def nested?
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
|
# El valor puede ser parte de un título auto-generado
|
||||||
|
def titleize?
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
def inspect
|
def inspect
|
||||||
"#<#{self.class} site=#{site.name.inspect} post=#{post.id.inspect} value=#{value.inspect}>"
|
"#<#{self.class} site=#{site.name.inspect} post=#{post.id.inspect} value=#{value.inspect}>"
|
||||||
end
|
end
|
||||||
|
@ -38,18 +47,10 @@ MetadataTemplate = Struct.new(:site, :document, :name, :label, :type,
|
||||||
"#{cache_key}-#{cache_version}"
|
"#{cache_key}-#{cache_version}"
|
||||||
end
|
end
|
||||||
|
|
||||||
# XXX: Deberíamos sanitizar durante la asignación?
|
|
||||||
def value=(new_value)
|
|
||||||
@value_was = value
|
|
||||||
self[:value] = new_value
|
|
||||||
end
|
|
||||||
|
|
||||||
# Siempre obtener el valor actual y solo obtenerlo del documento una
|
# Siempre obtener el valor actual y solo obtenerlo del documento una
|
||||||
# vez.
|
# vez.
|
||||||
def value_was
|
def value_was
|
||||||
return @value_was if instance_variable_defined? '@value_was'
|
@value_was ||= document_value.nil? ? default_value : document_value
|
||||||
|
|
||||||
@value_was = document_value
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def changed?
|
def changed?
|
||||||
|
@ -169,7 +170,7 @@ MetadataTemplate = Struct.new(:site, :document, :name, :label, :type,
|
||||||
# once => el campo solo se puede modificar si estaba vacío
|
# once => el campo solo se puede modificar si estaba vacío
|
||||||
def writable?
|
def writable?
|
||||||
case layout.metadata.dig(name, 'writable')
|
case layout.metadata.dig(name, 'writable')
|
||||||
when 'once' then value.blank?
|
when 'once' then value_was.blank?
|
||||||
else true
|
else true
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,4 +1,8 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
# Un campo de texto largo
|
# Un campo de texto largo
|
||||||
class MetadataText < MetadataString; end
|
class MetadataText < MetadataString
|
||||||
|
def titleize?
|
||||||
|
false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
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
|
DEFAULT_ATTRIBUTES = %i[site document layout].freeze
|
||||||
# Otros atributos que no vienen en los metadatos
|
# Otros atributos que no vienen en los metadatos
|
||||||
PRIVATE_ATTRIBUTES = %i[path slug attributes errors].freeze
|
PRIVATE_ATTRIBUTES = %i[path slug attributes errors].freeze
|
||||||
PUBLIC_ATTRIBUTES = %i[lang date uuid created_at].freeze
|
PUBLIC_ATTRIBUTES = %i[title lang date uuid created_at].freeze
|
||||||
|
ALIASED_ATTRIBUTES = %i[locale].freeze
|
||||||
ATTR_SUFFIXES = %w[? =].freeze
|
ATTR_SUFFIXES = %w[? =].freeze
|
||||||
|
|
||||||
|
ATTRIBUTE_DEFINITIONS = {
|
||||||
|
'title' => { 'type' => 'title', 'required' => true },
|
||||||
|
'lang' => { 'type' => 'lang', 'required' => true },
|
||||||
|
'date' => { 'type' => 'document_date', 'required' => true },
|
||||||
|
'uuid' => { 'type' => 'uuid', 'required' => true },
|
||||||
|
'created_at' => { 'type' => 'created_at', 'required' => true },
|
||||||
|
'slug' => { 'type' => 'slug', 'required' => true },
|
||||||
|
'path' => { 'type' => 'path', 'required' => true },
|
||||||
|
'locale' => { 'alias' => 'lang' }
|
||||||
|
}.freeze
|
||||||
|
|
||||||
|
class PostError < StandardError; end
|
||||||
|
class UnknownAttributeError < PostError; end
|
||||||
|
|
||||||
attr_reader :attributes, :errors, :layout, :site, :document
|
attr_reader :attributes, :errors, :layout, :site, :document
|
||||||
|
|
||||||
# TODO: Modificar el historial de Git con callbacks en lugar de
|
# TODO: Modificar el historial de Git con callbacks en lugar de
|
||||||
|
@ -30,6 +45,10 @@ class Post
|
||||||
# a demanda?
|
# a demanda?
|
||||||
def find_layout(path)
|
def find_layout(path)
|
||||||
File.foreach(path).lazy.grep(/^layout: /).take(1).first&.split(' ')&.last&.tr('\'', '')&.tr('"', '')&.to_sym
|
File.foreach(path).lazy.grep(/^layout: /).take(1).first&.split(' ')&.last&.tr('\'', '')&.tr('"', '')&.to_sym
|
||||||
|
rescue Errno::ENOENT => e
|
||||||
|
ExceptionNotifier.notify(e)
|
||||||
|
|
||||||
|
:post
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -46,16 +65,39 @@ class Post
|
||||||
@layout = args[:layout]
|
@layout = args[:layout]
|
||||||
@site = args[:site]
|
@site = args[:site]
|
||||||
@document = args[:document]
|
@document = args[:document]
|
||||||
@attributes = layout.attributes + PUBLIC_ATTRIBUTES
|
@attributes = (layout.attributes + PUBLIC_ATTRIBUTES).uniq
|
||||||
@errors = {}
|
@errors = {}
|
||||||
@metadata = {}
|
@metadata = {}
|
||||||
|
|
||||||
# Inicializar valores
|
layout.metadata = ATTRIBUTE_DEFINITIONS.merge(layout.metadata).with_indifferent_access
|
||||||
attributes.each do |attr|
|
|
||||||
public_send(attr)&.value = args[attr] if args.key?(attr)
|
# Leer el documento si existe
|
||||||
|
# @todo Asignar todos los valores a self[:value] luego de leer
|
||||||
|
document&.read! unless new?
|
||||||
|
|
||||||
|
# Inicializar valores o modificar los que vengan del documento
|
||||||
|
assignable_attributes = args.slice(*attributes)
|
||||||
|
assign_attributes(assignable_attributes) if assignable_attributes.present?
|
||||||
end
|
end
|
||||||
|
|
||||||
document.read! unless new?
|
# Asignar atributos, ignorando atributos que no se pueden modificar
|
||||||
|
# o inexistentes
|
||||||
|
#
|
||||||
|
# @param attrs [Hash]
|
||||||
|
def assign_attributes(attrs)
|
||||||
|
attrs = attrs.transform_keys(&:to_sym)
|
||||||
|
|
||||||
|
attributes.each do |attr|
|
||||||
|
self[attr].value = attrs[attr] if attrs.key?(attr) && self[attr].writable?
|
||||||
|
end
|
||||||
|
|
||||||
|
unknown_attrs = attrs.keys.map(&:to_sym) - attributes
|
||||||
|
|
||||||
|
if unknown_attrs.present?
|
||||||
|
raise UnknownAttributeError, "Unknown attribute(s) #{unknown_attrs.map(&:to_s).join(', ')} for Post"
|
||||||
|
end
|
||||||
|
|
||||||
|
nil
|
||||||
end
|
end
|
||||||
|
|
||||||
def inspect
|
def inspect
|
||||||
|
@ -103,6 +145,7 @@ class Post
|
||||||
src = element.attributes['src']
|
src = element.attributes['src']
|
||||||
|
|
||||||
next unless src&.value&.start_with? 'public/'
|
next unless src&.value&.start_with? 'public/'
|
||||||
|
|
||||||
file = MetadataFile.new(site: site, post: self, document: document, layout: layout)
|
file = MetadataFile.new(site: site, post: self, document: document, layout: layout)
|
||||||
file.value['path'] = src.value
|
file.value['path'] = src.value
|
||||||
|
|
||||||
|
@ -164,14 +207,13 @@ class Post
|
||||||
def method_missing(name, *_args)
|
def method_missing(name, *_args)
|
||||||
# Limpiar el nombre del atributo, para que todos los ayudantes
|
# Limpiar el nombre del atributo, para que todos los ayudantes
|
||||||
# reciban el método en limpio
|
# reciban el método en limpio
|
||||||
unless attribute? name
|
raise NoMethodError, I18n.t('exceptions.post.no_method', method: name) unless attribute? name
|
||||||
raise NoMethodError, I18n.t('exceptions.post.no_method',
|
|
||||||
method: name)
|
|
||||||
end
|
|
||||||
|
|
||||||
define_singleton_method(name) do
|
define_singleton_method(name) do
|
||||||
template = layout.metadata[name.to_s]
|
template = layout.metadata[name.to_s]
|
||||||
|
|
||||||
|
return public_send(template['alias'].to_sym) if template.key?('alias')
|
||||||
|
|
||||||
@metadata[name] ||=
|
@metadata[name] ||=
|
||||||
MetadataFactory.build(document: document,
|
MetadataFactory.build(document: document,
|
||||||
post: self,
|
post: self,
|
||||||
|
@ -187,55 +229,6 @@ class Post
|
||||||
public_send name
|
public_send name
|
||||||
end
|
end
|
||||||
|
|
||||||
# TODO: Mover a method_missing
|
|
||||||
def slug
|
|
||||||
@metadata[:slug] ||= MetadataSlug.new(document: document, site: site, layout: layout, name: :slug, type: :slug,
|
|
||||||
post: self, required: true)
|
|
||||||
end
|
|
||||||
|
|
||||||
# TODO: Mover a method_missing
|
|
||||||
def date
|
|
||||||
@metadata[:date] ||= MetadataDocumentDate.new(document: document, site: site, layout: layout, name: :date,
|
|
||||||
type: :document_date, post: self, required: true)
|
|
||||||
end
|
|
||||||
|
|
||||||
# TODO: Mover a method_missing
|
|
||||||
def path
|
|
||||||
@metadata[:path] ||= MetadataPath.new(document: document, site: site, layout: layout, name: :path, type: :path,
|
|
||||||
post: self, required: true)
|
|
||||||
end
|
|
||||||
|
|
||||||
# TODO: Mover a method_missing
|
|
||||||
def lang
|
|
||||||
@metadata[:lang] ||= MetadataLang.new(document: document, site: site, layout: layout, name: :lang, type: :lang,
|
|
||||||
post: self, required: true)
|
|
||||||
end
|
|
||||||
|
|
||||||
alias locale lang
|
|
||||||
|
|
||||||
# TODO: Mover a method_missing
|
|
||||||
def uuid
|
|
||||||
@metadata[:uuid] ||= MetadataUuid.new(document: document, site: site, layout: layout, name: :uuid, type: :uuid,
|
|
||||||
post: self, required: true)
|
|
||||||
end
|
|
||||||
|
|
||||||
# La fecha de creación inmodificable del post
|
|
||||||
def created_at
|
|
||||||
@metadata[:created_at] ||= MetadataCreatedAt.new(document: document, site: site, layout: layout, name: :created_at, type: :created_at, post: self, required: true)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Detecta si es un atributo válido o no, a partir de la tabla de la
|
|
||||||
# plantilla
|
|
||||||
def attribute?(mid)
|
|
||||||
included = DEFAULT_ATTRIBUTES.include?(mid) ||
|
|
||||||
PRIVATE_ATTRIBUTES.include?(mid) ||
|
|
||||||
PUBLIC_ATTRIBUTES.include?(mid)
|
|
||||||
|
|
||||||
included = attributes.include? mid if !included && singleton_class.method_defined?(:attributes)
|
|
||||||
|
|
||||||
included
|
|
||||||
end
|
|
||||||
|
|
||||||
# Devuelve los strong params para el layout.
|
# Devuelve los strong params para el layout.
|
||||||
#
|
#
|
||||||
# XXX: Nos gustaría no tener que instanciar Metadata acá, pero depende
|
# XXX: Nos gustaría no tener que instanciar Metadata acá, pero depende
|
||||||
|
@ -386,11 +379,7 @@ class Post
|
||||||
end
|
end
|
||||||
|
|
||||||
def update_attributes(hashable)
|
def update_attributes(hashable)
|
||||||
hashable.to_hash.each do |attr, value|
|
assign_attributes(hashable)
|
||||||
next unless self[attr].writable?
|
|
||||||
|
|
||||||
self[attr].value = value
|
|
||||||
end
|
|
||||||
|
|
||||||
save
|
save
|
||||||
end
|
end
|
||||||
|
@ -404,6 +393,38 @@ class Post
|
||||||
@usuaries ||= document_usuaries.empty? ? [] : Usuarie.where(id: document_usuaries).to_a
|
@usuaries ||= document_usuaries.empty? ? [] : Usuarie.where(id: document_usuaries).to_a
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Todos los atributos anidados
|
||||||
|
#
|
||||||
|
# @return [Array<Symbol>]
|
||||||
|
def nested_attributes
|
||||||
|
@nested_attributes ||= attributes.map { |a| self[a] }.select(&:nested?).map(&:name)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Detecta si es un atributo válido o no, a partir de la tabla de la
|
||||||
|
# plantilla
|
||||||
|
def attribute?(mid)
|
||||||
|
included = DEFAULT_ATTRIBUTES.include?(mid) ||
|
||||||
|
PRIVATE_ATTRIBUTES.include?(mid) ||
|
||||||
|
PUBLIC_ATTRIBUTES.include?(mid) ||
|
||||||
|
ALIASED_ATTRIBUTES.include?(mid)
|
||||||
|
|
||||||
|
included = attributes.include? mid if !included && singleton_class.method_defined?(:attributes)
|
||||||
|
|
||||||
|
included
|
||||||
|
end
|
||||||
|
|
||||||
|
# Devuelve la URL absoluta
|
||||||
|
#
|
||||||
|
# @return [String, nil]
|
||||||
|
def absolute_url
|
||||||
|
return unless written?
|
||||||
|
|
||||||
|
@absolute_url ||=
|
||||||
|
URI.parse(site.url).tap do |uri|
|
||||||
|
uri.path = document.url
|
||||||
|
end.to_s
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
# Levanta un error si al construir el artículo no pasamos un atributo.
|
# Levanta un error si al construir el artículo no pasamos un atributo.
|
||||||
|
|
|
@ -68,8 +68,7 @@ class Site < ApplicationRecord
|
||||||
before_create :clone_skel!
|
before_create :clone_skel!
|
||||||
# Elimina el directorio al destruir un sitio
|
# Elimina el directorio al destruir un sitio
|
||||||
before_destroy :remove_directories!
|
before_destroy :remove_directories!
|
||||||
# Cambiar el nombre del directorio
|
|
||||||
before_update :update_name!
|
|
||||||
before_save :add_private_key_if_missing!
|
before_save :add_private_key_if_missing!
|
||||||
# Guardar la configuración si hubo cambios
|
# Guardar la configuración si hubo cambios
|
||||||
after_save :sync_attributes_with_config!
|
after_save :sync_attributes_with_config!
|
||||||
|
@ -234,7 +233,9 @@ class Site < ApplicationRecord
|
||||||
# colecciones.
|
# colecciones.
|
||||||
def collections
|
def collections
|
||||||
unless @read
|
unless @read
|
||||||
|
Site.one_at_a_time.synchronize do
|
||||||
jekyll.reader.read_collections
|
jekyll.reader.read_collections
|
||||||
|
end
|
||||||
|
|
||||||
@read = true
|
@read = true
|
||||||
end
|
end
|
||||||
|
@ -434,7 +435,9 @@ class Site < ApplicationRecord
|
||||||
# Si estamos usando nuestro propio plugin de i18n, los posts están
|
# Si estamos usando nuestro propio plugin de i18n, los posts están
|
||||||
# en "colecciones"
|
# en "colecciones"
|
||||||
locales.map(&:to_s).each do |i|
|
locales.map(&:to_s).each do |i|
|
||||||
@configuration['collections'][i] = {}
|
@configuration['collections'][i] = {
|
||||||
|
'permalink' => configuration.send(:style_to_permalink, configuration['permalink'])
|
||||||
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
@configuration
|
@configuration
|
||||||
|
@ -504,12 +507,6 @@ class Site < ApplicationRecord
|
||||||
FileUtils.rm_rf path
|
FileUtils.rm_rf path
|
||||||
end
|
end
|
||||||
|
|
||||||
def update_name!
|
|
||||||
return unless name_changed?
|
|
||||||
|
|
||||||
FileUtils.mv path_was, path
|
|
||||||
reload_jekyll!
|
|
||||||
end
|
|
||||||
|
|
||||||
# Sincroniza algunos atributos del sitio con su configuración y
|
# Sincroniza algunos atributos del sitio con su configuración y
|
||||||
# guarda los cambios
|
# guarda los cambios
|
||||||
|
@ -589,17 +586,34 @@ class Site < ApplicationRecord
|
||||||
# * El archivo Gemfile.lock se modificó
|
# * El archivo Gemfile.lock se modificó
|
||||||
def install_gems
|
def install_gems
|
||||||
return unless persisted?
|
return unless persisted?
|
||||||
|
return unless (!gems_installed? || theme_path.blank?) || gemfile_updated? || gemfile_lock_updated?
|
||||||
|
|
||||||
deploy_local = deploys.find_by_type('DeployLocal')
|
deploys.find_by_type('DeployLocal').bundle
|
||||||
deploy_local.git_lfs
|
|
||||||
|
|
||||||
return unless !gems_installed? || gemfile_updated? || gemfile_lock_updated?
|
|
||||||
|
|
||||||
deploy_local.bundle
|
|
||||||
touch
|
touch
|
||||||
FileUtils.touch(gemfile_path)
|
FileUtils.touch(gemfile_path)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# El sitio tiene una plantilla
|
||||||
|
#
|
||||||
|
# @return [Bool]
|
||||||
|
def theme?
|
||||||
|
config['theme'].present?
|
||||||
|
end
|
||||||
|
|
||||||
|
# El directorio donde se encuentran los archivos de la plantilla. Si
|
||||||
|
# es nil es que las dependencias todavía no se instalaron.
|
||||||
|
#
|
||||||
|
# @return [String,nil]
|
||||||
|
def theme_path
|
||||||
|
@theme_path ||=
|
||||||
|
if theme?
|
||||||
|
Dir[gem_path.join('gems', "#{config['theme']}-*").to_s].first
|
||||||
|
else
|
||||||
|
path
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# @return [Pathname]
|
||||||
def gem_path
|
def gem_path
|
||||||
@gem_path ||=
|
@gem_path ||=
|
||||||
begin
|
begin
|
||||||
|
|
|
@ -46,11 +46,19 @@ class Site
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
# Trae el último commit indexado desde el repositorio
|
# Trae el último commit indexado desde el repositorio, o si no
|
||||||
|
# existe, trae el primer commit.
|
||||||
#
|
#
|
||||||
# @return [Rugged::Commit]
|
# @return [Rugged::Commit]
|
||||||
def indexed_commit
|
def indexed_commit
|
||||||
@indexed_commit ||= repository.rugged.lookup(last_indexed_commit)
|
@indexed_commit ||=
|
||||||
|
if repository.rugged.exists?(last_indexed_commit)
|
||||||
|
repository.rugged.lookup(last_indexed_commit)
|
||||||
|
else
|
||||||
|
repository.rugged.lookup(
|
||||||
|
repository.rugged.references[repository.rugged.head.canonical_name].log.first[:id_new]
|
||||||
|
)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Calcula la diferencia entre el último commit indexado y el
|
# Calcula la diferencia entre el último commit indexado y el
|
||||||
|
|
|
@ -13,11 +13,15 @@ class Site
|
||||||
# Por defecto, si el sitio no lo soporta, se obtienen los layouts
|
# Por defecto, si el sitio no lo soporta, se obtienen los layouts
|
||||||
# ordenados alfabéticamente por traducción.
|
# ordenados alfabéticamente por traducción.
|
||||||
#
|
#
|
||||||
|
# @param [Usuarie,nil]
|
||||||
# @return [Hash]
|
# @return [Hash]
|
||||||
def schema_organization
|
def schema_organization(usuarie = nil)
|
||||||
@schema_organization ||=
|
@schema_organization ||=
|
||||||
begin
|
begin
|
||||||
schema_organization = data.dig('schema', 'organization')
|
# XXX: retrocompatibilidad
|
||||||
|
key = (usuarie.blank? || usuarie?(usuarie)) ? 'organization' : 'organization_guest'
|
||||||
|
schema_organization = data.dig('schema', key)
|
||||||
|
schema_organization ||= data.dig('schema', 'organization')
|
||||||
schema_organization&.symbolize_keys!
|
schema_organization&.symbolize_keys!
|
||||||
schema_organization&.transform_values! do |ary|
|
schema_organization&.transform_values! do |ary|
|
||||||
ary.map(&:to_sym)
|
ary.map(&:to_sym)
|
||||||
|
|
|
@ -76,12 +76,17 @@ class Site
|
||||||
# escribir los cambios
|
# escribir los cambios
|
||||||
rugged.checkout 'HEAD', strategy: :force
|
rugged.checkout 'HEAD', strategy: :force
|
||||||
|
|
||||||
git_sh("git", "lfs", "fetch", "origin", default_branch)
|
|
||||||
# reemplaza los pointers por los archivos correspondientes
|
|
||||||
git_sh("git", "lfs", "checkout")
|
|
||||||
commit
|
commit
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Trae todos los archivos desde LFS
|
||||||
|
#
|
||||||
|
# @return [Boolean]
|
||||||
|
def git_lfs_checkout
|
||||||
|
git_sh('git', 'lfs', 'fetch', 'origin', default_branch)
|
||||||
|
git_sh('git', 'lfs', 'checkout')
|
||||||
|
end
|
||||||
|
|
||||||
# El último commit
|
# El último commit
|
||||||
#
|
#
|
||||||
# @return [Rugged::Commit]
|
# @return [Rugged::Commit]
|
||||||
|
@ -111,10 +116,30 @@ class Site
|
||||||
walker.each.to_a
|
walker.each.to_a
|
||||||
end
|
end
|
||||||
|
|
||||||
# Hay commits sin aplicar?
|
# Detecta si hay que hacer un pull o no
|
||||||
def needs_pull?
|
#
|
||||||
fetch
|
# @return [Boolean]
|
||||||
!commits.empty?
|
def up_to_date?
|
||||||
|
rugged.merge_analysis(remote_head_commit).include?(:up_to_date)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Detecta si es posible adelantar la historia local a la remota o
|
||||||
|
# necesitamos un merge
|
||||||
|
#
|
||||||
|
# @return [Boolean]
|
||||||
|
def fast_forward?
|
||||||
|
rugged.merge_analysis(remote_head_commit).include?(:fastforward)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Mueve la historia local a la remota
|
||||||
|
#
|
||||||
|
# @see {https://stackoverflow.com/a/27077322}
|
||||||
|
# @return [nil]
|
||||||
|
def fast_forward!
|
||||||
|
rugged.checkout_tree(remote_head_commit)
|
||||||
|
rugged.references.update(rugged.head.resolve, remote_head_commit.oid)
|
||||||
|
|
||||||
|
nil
|
||||||
end
|
end
|
||||||
|
|
||||||
# Guarda los cambios en git
|
# Guarda los cambios en git
|
||||||
|
|
|
@ -3,6 +3,7 @@
|
||||||
# Usuarie de la plataforma
|
# Usuarie de la plataforma
|
||||||
class Usuarie < ApplicationRecord
|
class Usuarie < ApplicationRecord
|
||||||
include Usuarie::Consent
|
include Usuarie::Consent
|
||||||
|
include ActivityPub::ModeratorConcern
|
||||||
|
|
||||||
devise :invitable, :database_authenticatable,
|
devise :invitable, :database_authenticatable,
|
||||||
:recoverable, :rememberable, :validatable,
|
:recoverable, :rememberable, :validatable,
|
||||||
|
|
|
@ -3,25 +3,51 @@
|
||||||
# Este servicio se encarga de crear artículos y guardarlos en git,
|
# Este servicio se encarga de crear artículos y guardarlos en git,
|
||||||
# asignándoselos a une usuarie
|
# asignándoselos a une usuarie
|
||||||
PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do
|
PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do
|
||||||
|
include AutoPublishConcern
|
||||||
|
|
||||||
|
# Si estamos pasando el UUID con los parámetros, el post quizás
|
||||||
|
# existe.
|
||||||
|
#
|
||||||
|
# @return [Post]
|
||||||
|
def create_or_update
|
||||||
|
uuid = params.require(base).permit(:uuid).values.first
|
||||||
|
|
||||||
|
if uuid.blank?
|
||||||
|
create
|
||||||
|
elsif (indexed_post = site.indexed_posts.find_by(post_id: uuid)).present?
|
||||||
|
self.post = indexed_post.post
|
||||||
|
update
|
||||||
|
else
|
||||||
|
create
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# Crea un artículo nuevo
|
# Crea un artículo nuevo
|
||||||
#
|
#
|
||||||
# @return Post
|
# @return Post
|
||||||
def create
|
def create
|
||||||
self.post = site.posts(lang: locale)
|
self.post ||= site.posts(lang: locale).build(layout: layout)
|
||||||
.build(layout: layout)
|
params[base][:draft] = true if site.invitade? usuarie
|
||||||
post.usuaries << usuarie
|
|
||||||
params[:post][:draft] = true if site.invitade? usuarie
|
|
||||||
|
|
||||||
params.require(:post).permit(:slug).tap do |p|
|
post.usuaries << usuarie
|
||||||
|
post.assign_attributes(post_params)
|
||||||
|
|
||||||
|
params.require(base).permit(:slug).tap do |p|
|
||||||
post.slug.value = p[:slug] if p[:slug].present?
|
post.slug.value = p[:slug] if p[:slug].present?
|
||||||
end
|
end
|
||||||
|
|
||||||
commit(action: :created, add: update_related_posts) if post.update(post_params)
|
# Crea los posts anidados
|
||||||
|
create_nested_posts! post, params[base]
|
||||||
|
post.save
|
||||||
|
update_related_posts
|
||||||
|
|
||||||
|
commit(action: :created, add: files) if post.valid?
|
||||||
|
|
||||||
update_site_license!
|
update_site_license!
|
||||||
|
|
||||||
# Devolver el post aunque no se haya salvado para poder rescatar los
|
# Devolver el post aunque no se haya salvado para poder rescatar los
|
||||||
# errores
|
# errores
|
||||||
|
auto_publish!
|
||||||
post
|
post
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -34,28 +60,33 @@ PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do
|
||||||
# Los artículos anónimos siempre son borradores
|
# Los artículos anónimos siempre son borradores
|
||||||
params[:draft] = true
|
params[:draft] = true
|
||||||
|
|
||||||
commit(action: :created, add: [post.path.absolute]) if post.update(anon_post_params)
|
commit(action: :created, add: files) if post.update(anon_post_params)
|
||||||
|
auto_publish!
|
||||||
post
|
post
|
||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
post.usuaries << usuarie
|
post.usuaries << usuarie
|
||||||
params[:post][:draft] = true if site.invitade? usuarie
|
params[base][:draft] = true if site.invitade? usuarie
|
||||||
|
|
||||||
# Eliminar ("mover") el archivo si cambió de ubicación.
|
# Eliminar ("mover") el archivo si cambió de ubicación.
|
||||||
if post.update(post_params)
|
if post.update(post_params)
|
||||||
rm = []
|
rm = []
|
||||||
rm << post.path.value_was if post.path.changed?
|
rm << post.path.value_was if post.path.changed?
|
||||||
|
|
||||||
|
create_nested_posts! post, params[base]
|
||||||
|
update_related_posts
|
||||||
|
|
||||||
# Es importante que el artículo se guarde primero y luego los
|
# Es importante que el artículo se guarde primero y luego los
|
||||||
# relacionados.
|
# relacionados.
|
||||||
commit(action: :updated, add: update_related_posts, rm: rm)
|
commit(action: :updated, add: files, rm: rm)
|
||||||
|
|
||||||
update_site_license!
|
update_site_license!
|
||||||
end
|
end
|
||||||
|
|
||||||
# Devolver el post aunque no se haya salvado para poder rescatar los
|
# Devolver el post aunque no se haya salvado para poder rescatar los
|
||||||
# errores
|
# errores
|
||||||
|
auto_publish!
|
||||||
post
|
post
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -73,7 +104,7 @@ PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do
|
||||||
#
|
#
|
||||||
# { uuid => 2, uuid => 1, uuid => 0 }
|
# { uuid => 2, uuid => 1, uuid => 0 }
|
||||||
def reorder
|
def reorder
|
||||||
reorder = params.require(:post).permit(reorder: {})&.dig(:reorder)&.transform_values(&:to_i)
|
reorder = params.require(base).permit(reorder: {})&.dig(:reorder)&.transform_values(&:to_i)
|
||||||
posts = site.posts(lang: locale).where(uuid: reorder.keys)
|
posts = site.posts(lang: locale).where(uuid: reorder.keys)
|
||||||
|
|
||||||
files = posts.map do |post|
|
files = posts.map do |post|
|
||||||
|
@ -96,6 +127,22 @@ PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
# La base donde buscar los parámetros
|
||||||
|
#
|
||||||
|
# @return [Symbol]
|
||||||
|
def base
|
||||||
|
@base ||= params.permit(:base).try(:[], :base).try(:to_sym) || :post
|
||||||
|
end
|
||||||
|
|
||||||
|
# Una lista de archivos a modificar
|
||||||
|
#
|
||||||
|
# @return [Set]
|
||||||
|
def files
|
||||||
|
@files ||= Set.new.tap do |f|
|
||||||
|
f << post.path.absolute
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def commit(action:, add: [], rm: [])
|
def commit(action:, add: [], rm: [])
|
||||||
site.repository.commit(add: add,
|
site.repository.commit(add: add,
|
||||||
rm: rm,
|
rm: rm,
|
||||||
|
@ -108,7 +155,7 @@ PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do
|
||||||
|
|
||||||
# Solo permitir cambiar estos atributos de cada articulo
|
# Solo permitir cambiar estos atributos de cada articulo
|
||||||
def post_params
|
def post_params
|
||||||
params.require(:post).permit(post.params)
|
@post_params ||= params.require(base).permit(post.params).to_h
|
||||||
end
|
end
|
||||||
|
|
||||||
# Eliminar metadatos internos
|
# Eliminar metadatos internos
|
||||||
|
@ -119,11 +166,11 @@ PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do
|
||||||
end
|
end
|
||||||
|
|
||||||
def locale
|
def locale
|
||||||
params.dig(:post, :lang)&.to_sym || I18n.locale
|
params.dig(base, :lang)&.to_sym || I18n.locale
|
||||||
end
|
end
|
||||||
|
|
||||||
def layout
|
def layout
|
||||||
params.dig(:post, :layout) || params[:layout]
|
params.dig(base, :layout) || params[:layout]
|
||||||
end
|
end
|
||||||
|
|
||||||
# Actualiza los artículos relacionados según los métodos que los
|
# Actualiza los artículos relacionados según los métodos que los
|
||||||
|
@ -146,15 +193,34 @@ PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do
|
||||||
end
|
end
|
||||||
|
|
||||||
posts.map do |p|
|
posts.map do |p|
|
||||||
p.path.absolute if p.save(validate: false)
|
next unless p.save(validate: false)
|
||||||
end.compact << post.path.absolute
|
|
||||||
|
files << p.path.absolute
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Si les usuaries modifican o crean una licencia, considerarla
|
# Si les usuaries modifican o crean una licencia, considerarla
|
||||||
# personalizada en el panel.
|
# personalizada en el panel.
|
||||||
def update_site_license!
|
def update_site_license!
|
||||||
if site.usuarie?(usuarie) && post.layout.name == :license && !site.licencia.custom?
|
return unless site.usuarie?(usuarie) && post.layout.name == :license && !site.licencia.custom?
|
||||||
|
|
||||||
site.update licencia: Licencia.find_by_icons('custom')
|
site.update licencia: Licencia.find_by_icons('custom')
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Encuentra todos los posts anidados y los crea o modifica
|
||||||
|
def create_nested_posts!(post, params)
|
||||||
|
post.nested_attributes.each do |nested_attribute|
|
||||||
|
nested_metadata = post[nested_attribute]
|
||||||
|
next unless params[nested_metadata].present?
|
||||||
|
# @todo find_or_initialize
|
||||||
|
nested_post = nested_metadata.has_one || site.posts(lang: post.lang.value).build(layout: nested_metadata.nested)
|
||||||
|
nested_params = params.require(nested_attribute).permit(nested_post.params).to_hash
|
||||||
|
|
||||||
|
# Completa la relación 1:1
|
||||||
|
nested_params[nested_metadata.inverse.to_s] = post.uuid.value
|
||||||
|
post[nested_attribute].value = nested_post.uuid.value
|
||||||
|
|
||||||
|
files << nested_post.path.absolute if nested_post.update(nested_params)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -3,6 +3,8 @@
|
||||||
# Se encargar de guardar cambios en sitios
|
# Se encargar de guardar cambios en sitios
|
||||||
# TODO: Implementar rollback en la configuración
|
# TODO: Implementar rollback en la configuración
|
||||||
SiteService = Struct.new(:site, :usuarie, :params, keyword_init: true) do
|
SiteService = Struct.new(:site, :usuarie, :params, keyword_init: true) do
|
||||||
|
include AutoPublishConcern
|
||||||
|
|
||||||
def deploy
|
def deploy
|
||||||
site.enqueue!
|
site.enqueue!
|
||||||
DeployJob.perform_later site
|
DeployJob.perform_later site
|
||||||
|
@ -24,7 +26,9 @@ SiteService = Struct.new(:site, :usuarie, :params, keyword_init: true) do
|
||||||
#
|
#
|
||||||
# TODO: hacer que el repositorio se cree cuando es necesario, para
|
# TODO: hacer que el repositorio se cree cuando es necesario, para
|
||||||
# que no haya estados intermedios.
|
# que no haya estados intermedios.
|
||||||
site.locales = [usuarie.lang] + I18n.available_locales
|
site.locales = [usuarie.lang]
|
||||||
|
|
||||||
|
add_role_to_deploys! role
|
||||||
|
|
||||||
add_role_to_deploys! role
|
add_role_to_deploys! role
|
||||||
|
|
||||||
|
@ -36,10 +40,14 @@ SiteService = Struct.new(:site, :usuarie, :params, keyword_init: true) do
|
||||||
add_licencias &&
|
add_licencias &&
|
||||||
add_code_of_conduct &&
|
add_code_of_conduct &&
|
||||||
add_privacy_policy &&
|
add_privacy_policy &&
|
||||||
site.index_posts! &&
|
|
||||||
deploy
|
deploy
|
||||||
end
|
end
|
||||||
|
|
||||||
|
if site.persisted?
|
||||||
|
site.index_posts!
|
||||||
|
auto_publish!
|
||||||
|
end
|
||||||
|
|
||||||
site
|
site
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -94,6 +102,25 @@ SiteService = Struct.new(:site, :usuarie, :params, keyword_init: true) do
|
||||||
result.present?
|
result.present?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def rename(name)
|
||||||
|
return if name == site.name
|
||||||
|
|
||||||
|
moved = false
|
||||||
|
site.name = name
|
||||||
|
|
||||||
|
Site.transaction do
|
||||||
|
raise ActiveRecord::Rollback if File.exist?(site.path)
|
||||||
|
|
||||||
|
FileUtils.mv(site.path_was, site.path)
|
||||||
|
moved = true
|
||||||
|
ActiveStorage::Blob.where(service_name: site.name_was).update_all(service_name: site.name)
|
||||||
|
site.save
|
||||||
|
rescue StandardError
|
||||||
|
FileUtils.mv(site.path, site.path_was) if moved
|
||||||
|
raise
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
# Guarda los cambios de la configuración en el repositorio git
|
# Guarda los cambios de la configuración en el repositorio git
|
||||||
|
|
|
@ -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
|
= 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
|
.custom-control{ class: "custom-#{checkbox_attributes[:type]}" }
|
||||||
%input.custom-control-input{ id: id, type: 'checkbox', name: name, value: value, required: required }
|
%input.custom-control-input{ **checkbox_attributes }
|
||||||
%label.custom-control-label{ for: id, aria: { describedby: help_id } }= content
|
%label.custom-control-label{ for: id, aria: { describedby: help_id } }= content
|
||||||
%small.form-text.text-muted{ id: help_id }= yield
|
- if (block = yield).present?
|
||||||
|
%small.form-text.text-muted{ id: help_id }= block
|
||||||
|
|
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