diff --git a/Dockerfile b/Dockerfile index 394a81e5..a73a96cc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,8 +22,6 @@ RUN apk add npm && npm install -g pnpm@~7 && apk del npm COPY ./monit.conf /etc/monit.d/sutty.conf -RUN apk add npm && npm install -g pnpm && apk del npm - VOLUME "/srv" EXPOSE 3000 diff --git a/Gemfile b/Gemfile index 5d673dd0..1c185253 100644 --- a/Gemfile +++ b/Gemfile @@ -79,8 +79,8 @@ gem 'webpacker' gem 'yaml_db', git: 'https://0xacab.org/sutty/yaml_db.git' gem 'kaminari' gem 'device_detector' -gem 'htmlbeautifier' gem 'dry-schema' +gem 'htmlbeautifier' gem 'rubanok' gem 'after_commit_everywhere', '~> 1.0' @@ -118,9 +118,8 @@ group :development, :test do gem 'derailed_benchmarks' gem 'dotenv-rails' gem 'pry' - # Adds support for Capybara system testing and selenium driver - gem 'capybara', '~> 2.13' - gem 'selenium-webdriver', '~> 4.8.0' + gem 'capybara' + gem 'selenium-webdriver' gem 'sqlite3' end diff --git a/Gemfile.lock b/Gemfile.lock index e3be85c1..fc802206 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -118,13 +118,15 @@ GEM bundler-audit (0.9.1) bundler (>= 1.2.0, < 3) thor (~> 1.0) - capybara (2.18.0) + capybara (3.40.0) addressable + matrix mini_mime (>= 0.1.3) - nokogiri (>= 1.3.3) - rack (>= 1.0.0) - rack-test (>= 0.5.4) - xpath (>= 2.0, < 4.0) + nokogiri (~> 1.11) + rack (>= 1.6.0) + rack-test (>= 0.6.3) + regexp_parser (>= 1.5, < 3.0) + xpath (~> 3.2) chartkick (5.0.2) climate_control (1.2.0) coderay (1.1.3) @@ -360,6 +362,7 @@ GEM net-pop net-smtp marcel (1.0.4) + matrix (0.4.2) memory_profiler (1.0.1) mercenary (0.4.0) method_source (1.1.0) @@ -542,7 +545,7 @@ GEM sprockets (> 3.0) sprockets-rails tilt - selenium-webdriver (4.8.6) + selenium-webdriver (4.9.1) rexml (~> 3.2, >= 3.2.5) rubyzip (>= 1.2.2, < 3.0) websocket (~> 1.0) @@ -632,7 +635,7 @@ DEPENDENCIES bootstrap (~> 4) brakeman bundler-audit - capybara (~> 2.13) + capybara chartkick commonmarker concurrent-ruby-ext @@ -707,7 +710,7 @@ DEPENDENCIES safe_yaml safely_block (~> 0.3.0) sassc-rails - selenium-webdriver (~> 4.8.0) + selenium-webdriver sourcemap spring spring-watcher-listen diff --git a/Procfile b/Procfile index a74f613b..d3d8207d 100644 --- a/Procfile +++ b/Procfile @@ -1,13 +1,6 @@ -migrate: bundle exec rake db:prepare db:seed -sutty: bundle exec puma config.ru -blazer_5m: bundle exec rake blazer:run_checks SCHEDULE="5 minutes" -blazer_1h: bundle exec rake blazer:run_checks SCHEDULE="1 hour" -blazer_1d: bundle exec rake blazer:run_checks SCHEDULE="1 day" -blazer: bundle exec rake blazer:send_failing_checks -prometheus: bundle exec prometheus_exporter -b 0.0.0.0 --prefix "sutty_" -distributed_press_tokens_renew: bundle exec rake distributed_press:tokens:renew cleanup: bundle exec rake cleanup:everything +distributed_press_tokens_renew: bundle exec rake distributed_press:tokens:renew emergency_cleanup: bundle exec rake cleanup:everything BEFORE=7 -stats: bundle exec rake stats:process_all que: daemonize -c /srv/ -p /srv/tmp/que.pid -u rails /usr/local/bin/syslogize bundle exec que +stats: bundle exec rake stats:process_all fediblock: bundle exec rails activity_pub:fediblocks diff --git a/_sites/_storage/.keep b/_sites/_storage/.keep deleted file mode 100644 index e69de29b..00000000 diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index 4d1d0848..8482ede0 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -11,6 +11,21 @@ $colors: ( "magenta": $magenta ); +// TODO: Encontrar la forma de generar esto desde los locales de Rails +$custom-file-text: ( + en: "Browse", + es: "Buscar archivo", + pt: "Buscar ficheiro", + pt-BR: "Buscar arquivo" +); + +$custom-file-text-replace: ( + en: "Replace file", + es: "Reemplazar archivo", + pt: "substituir ficheiro", + pt-BR: "substituir arquivo" +); + // Redefinir variables de Bootstrap $primary: $magenta; $secondary: $black; @@ -20,6 +35,19 @@ $form-feedback-valid-color: $black; $form-feedback-invalid-color: $magenta; $form-feedback-icon-valid-color: $black; $component-active-bg: $magenta; +$btn-white-space: nowrap; +$font-weight-bolder: 700; +$zindex-modal-backdrop: 0; +$modal-content-bg: var(--background); +$modal-content-border-color: var(--modal-content-border-color); +$card-bg: var(--background); +$card-border-color: var(--card-border-color); +$input-bg: var(--background); +$input-color: var(--foreground); +$btn-bg-color: var(--btn-bg-color); +$btn-color: var(--btn-color); +$input-group-addon-bg: var(--btn-bg-color); +$custom-file-color: var(--btn-color); $spacers: ( 2-plus: 0.75rem @@ -32,6 +60,16 @@ $sizes: ( @import "bootstrap"; @import "editor"; +.custom-file-input { + &.replace-image { + @each $lang, $value in $custom-file-text-replace { + &:lang(#{$lang}) ~ .custom-file-label::after { + content: $value; + } + } + } +} + @each $color, $rgb in $theme-colors { .#{$color} { color: var(--#{$color}); @@ -60,6 +98,10 @@ $sizes: ( --foreground: #{$black}; --background: #{$white}; --color: #{$magenta}; + --card-border-color: #{rgba($black, .125)}; + --btn-bg-color: #{$black}; + --btn-color: #{$white}; + --modal-content-border-color: rgba(#{$black}, .2); } @media (prefers-color-scheme: dark) { @@ -67,34 +109,28 @@ $sizes: ( --foreground: #{$white}; --background: #{$black}; --color: #{$cyan}; + --card-border-color: #{rgba($white, .250)}; + --btn-bg-color: #{$white}; + --btn-color: #{$black}; + --modal-content-border-color: #{rgba($white, .2)}; } + .btn-secondary { - background-color: $white; - color: $black; border: none; + } - &:hover { - color: $black; - background-color: $cyan; - } + @include form-validation-state("valid", $cyan, url("data:image/svg+xml,")); - &:active { - background-color: $cyan; - } - - &:focus { - box-shadow: 0 0 0 0.2rem $cyan; + .custom-checkbox { + .custom-control-input:checked ~ .custom-control-label { + &::after { + background-image: url("data:image/svg+xml,"); + } } } } -// TODO: Encontrar la forma de generar esto desde los locales de Rails -$custom-file-text: ( - en: 'Browse', - es: 'Buscar archivo' -); - @font-face { font-family: 'Saira'; font-style: normal; @@ -135,6 +171,10 @@ a { color: var(--color); } + &:focus { + outline: 1px solid var(--color); + } + &[target=_blank] { /* TODO: Convertir a base64 para no hacer peticiones extra */ &:after { @@ -241,6 +281,8 @@ svg { .btn { margin-right: 0.3rem; margin-bottom: 0.3rem; + background-color: $btn-bg-color; + color: $btn-color; &:hover { color: var(--background); @@ -256,6 +298,10 @@ svg { } } +.badge { + white-space: break-spaces; +} + .btn-sm { @extend .badge } @@ -318,10 +364,6 @@ svg { } } -.custom-control-label { - font-weight: bold; -} - .designs { .design { margin-top: 1rem; @@ -621,3 +663,33 @@ $bezier: cubic-bezier(0.75, 0, 0.25, 1); } } } + +// https://getbootstrap.com/docs/5.1/components/placeholders/ +.placeholder { + display: inline-block; + min-height: $spacer; + cursor: wait; + vertical-align: middle; + opacity: .5; + background-color: $grey; + animation: placeholder-glow 2s ease-in-out infinite; +} + +.placeholder-glow { + .placeholder { + -webkit-animation: placeholder-glow 2s ease-in-out infinite; + animation: placeholder-glow 2s ease-in-out infinite; + } + + @-webkit-keyframes placeholder-glow { + 50% { + opacity: .2; + } + } + + @keyframes placeholder-glow { + 50% { + opacity: .2; + } + } +} diff --git a/app/assets/stylesheets/dark.scss b/app/assets/stylesheets/dark.scss index f7f3a09d..e8ed1862 100644 --- a/app/assets/stylesheets/dark.scss +++ b/app/assets/stylesheets/dark.scss @@ -6,29 +6,13 @@ $cyan: #13fefe; --foreground: #{$white}; --background: #{$black}; --color: #{$cyan}; -} - -.btn { - background-color: $white; + --card-border-color: #{rgba($white, .250)}; + --btn-bg-color: #{$white}; + --btn-color: #{$black}; } .btn-secondary { - background-color: $white; - color: $black; border: none; - - &:hover { - color: $black; - background-color: $cyan; - } - - &:active { - background-color: $cyan; - } - - &:focus { - box-shadow: 0 0 0 0.2rem $cyan; - } } diff --git a/app/controllers/active_storage/disk_controller_decorator.rb b/app/controllers/active_storage/disk_controller_decorator.rb index ec3ac0b4..21c0ed33 100644 --- a/app/controllers/active_storage/disk_controller_decorator.rb +++ b/app/controllers/active_storage/disk_controller_decorator.rb @@ -11,7 +11,7 @@ module ActiveStorage # Permitir incrustar archivos subidos (especialmente PDFs) desde # otros sitios. def show - original_show.tap do |s| + original_show.tap do |_s| response.headers.delete 'X-Frame-Options' end end @@ -24,7 +24,7 @@ module ActiveStorage if (token = decode_verified_token) if acceptable_content?(token) blob = ActiveStorage::Blob.find_by_key! token[:key] - site = Site.find_by_name token[:service_name] + site = Site.find_by_name! token[:service_name] if remote_file?(token) begin @@ -32,18 +32,20 @@ module ActiveStorage body = Down.download(url, max_size: 111.megabytes) checksum = Digest::MD5.file(body.path).base64digest blob.metadata[:url] = url - blob.update_columns checksum: checksum, byte_size: body.size, metadata: blob.metadata + blob.update_columns checksum:, byte_size: body.size, metadata: blob.metadata rescue StandardError => e - ExceptionNotifier.notify_exception(e, data: { key: token[:key], url: url, site: site.name }) + ExceptionNotifier.notify_exception(e, data: { key: token[:key], url:, site: site.name }) head :content_too_large + + return end else body = request.body checksum = token[:checksum] end - named_disk_service(token[:service_name]).upload token[:key], body, checksum: checksum + named_disk_service(site.name).upload(token[:key], body, checksum:) site.static_files.attach(blob) else @@ -52,8 +54,14 @@ module ActiveStorage else head :not_found end - rescue ActiveStorage::IntegrityError + rescue ActiveRecord::ActiveRecordError, ActiveStorage::Error => e + ExceptionNotifier.notify_exception(e, data: { token: }) + head :unprocessable_entity + rescue Down::Error => e + ExceptionNotifier.notify_exception(e, data: { key: token[:key], url: url, site: site.name }) + + head :payload_too_large end private @@ -64,7 +72,10 @@ module ActiveStorage def page_not_found(exception) head :not_found - ExceptionNotifier.notify_exception(exception, data: {params: params.to_hash}) + + params.permit! + + ExceptionNotifier.notify_exception(exception, data: { params: params.to_hash }) end end end diff --git a/app/controllers/api/v1/sites_controller.rb b/app/controllers/api/v1/sites_controller.rb index 05abc38a..8f3cbaa2 100644 --- a/app/controllers/api/v1/sites_controller.rb +++ b/app/controllers/api/v1/sites_controller.rb @@ -4,51 +4,88 @@ module Api module V1 # API para sitios class SitesController < BaseController - http_basic_authenticate_with name: ENV['HTTP_BASIC_USER'], - password: ENV['HTTP_BASIC_PASSWORD'] + SUBDOMAIN = ".#{Site.domain}" + TESTING_SUBDOMAIN = ".testing.#{Site.domain}" + PARTS = Site.domain.split('.').count + + if Rails.env.production? + http_basic_authenticate_with name: ENV['HTTP_BASIC_USER'], + password: ENV['HTTP_BASIC_PASSWORD'] + end # Lista de nombres de dominios a emitir certificados def index - render json: alternative_names + api_names + www_names + all_names = sites_names.concat(alternative_names).concat(www_names).concat(api_names).uniq.map do |name| + canonicalize name + end.reject do |name| + subdomain? name + end.reject do |name| + testing? name + end.uniq + + render json: all_names end private + # @param query [ActiveRecord::Relation] + # @return [Array] + def hostname_of(query) + query.pluck(Arel.sql("values->>'hostname'")).compact.uniq + end + def canonicalize(name) name.end_with?('.') ? name[0..-2] : "#{name}.#{Site.domain}" end + # Es un subdominio directo del dominio principal + # + # @param name [String] + # @return [Bool] def subdomain?(name) - name.end_with? ".#{Site.domain}" + name.end_with?(SUBDOMAIN) && name.split('.').count == (PARTS + 1) end - # Dominios alternativos + # Es un dominio de prueba + # + # @param name [String] + # @return [Bool] + def testing?(name) + name.end_with?(TESTING_SUBDOMAIN) && name.split('.').count == (PARTS + 2) + end + + # Nombres de los sitios + # + # @param name [String] + # @return [Array] + def sites_names + Site.all.order(:name).pluck(:name) + end + + # Dominios alternativos, incluyendo todas las clases derivadas de + # esta. + # + # @return [Array] def alternative_names - (DeployAlternativeDomain.all.map(&:hostname) + DeployLocalizedDomain.all.map(&:hostname)).map do |name| - canonicalize name - end.reject do |name| - subdomain? name - end + hostname_of(DeployAlternativeDomain.all) end # Obtener todos los sitios con API habilitada, es decir formulario # de contacto y/o colaboración anónima. # - # TODO: Optimizar + # @return [Array] def api_names Site.where(contact: true) .or(Site.where(colaboracion_anonima: true)) - .select("'api.' || name as name").map(&:name).map do |name| - canonicalize name - end.reject do |name| - subdomain? name + .pluck(:name).map do |name| + "api.#{name}" end end # Todos los dominios con WWW habilitado def www_names - Site.where(id: DeployWww.all.pluck(:site_id)).select("'www.' || name as name").map(&:name).map do |name| - canonicalize name + Site.where(id: DeployWww.all.pluck(:site_id)).pluck(:name).map do |name| + "www.#{name}" end end end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 117be995..0fc2440f 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -2,7 +2,7 @@ # Forma de ingreso a Sutty class ApplicationController < ActionController::Base - include ExceptionHandler + include ExceptionHandler if Rails.env.production? include Pundit::Authorization protect_from_forgery with: :null_session, prepend: true @@ -117,4 +117,9 @@ class ApplicationController < ActionController::Base session[:usuarie_return_to] = request.fullpath end + + # Detecta si una petición fue hecha por HTMX + def htmx? + request.headers.key? 'HX-Request' + end end diff --git a/app/controllers/collaborations_controller.rb b/app/controllers/collaborations_controller.rb index 2caa1272..8140b03e 100644 --- a/app/controllers/collaborations_controller.rb +++ b/app/controllers/collaborations_controller.rb @@ -5,9 +5,10 @@ # No necesitamos autenticación aun class CollaborationsController < ApplicationController include Pundit + include StrongParamsHelper def collaborate - @site = Site.find_by_name(params[:site_id]) + @site = Site.find_by_name(pluck_param(:site_id)) authorize Collaboration.new(@site) @invitade = current_usuarie || @site.usuaries.build @@ -21,7 +22,7 @@ class CollaborationsController < ApplicationController # # * Si le usuarie existe y no está logueade, pedirle la contraseña def accept_collaboration - @site = Site.find_by_name(params[:site_id]) + @site = Site.find_by_name(pluck_param(:site_id)) authorize Collaboration.new(@site) @invitade = current_usuarie diff --git a/app/controllers/concerns/exception_handler.rb b/app/controllers/concerns/exception_handler.rb index 7c1cd540..3925f42c 100644 --- a/app/controllers/concerns/exception_handler.rb +++ b/app/controllers/concerns/exception_handler.rb @@ -10,25 +10,41 @@ module ExceptionHandler included do rescue_from SiteNotFound, with: :site_not_found rescue_from PageNotFound, with: :page_not_found - rescue_from ActionController::RoutingError, with: :page_not_found - rescue_from Pundit::NilPolicyError, with: :page_not_found + rescue_from Pundit::Error, with: :page_not_found + rescue_from Pundit::NotAuthorizedError, with: :page_unauthorized rescue_from Pundit::NilPolicyError, with: :page_not_found rescue_from ActionController::RoutingError, with: :page_not_found rescue_from ActionController::ParameterMissing, with: :page_not_found end - def site_not_found + def site_not_found(exception) reset_response! flash[:error] = I18n.t('errors.site_not_found') + ExceptionNotifier.notify_exception(exception, data: { usuarie: current_usuarie&.id, path: request.fullpath }) + redirect_to sites_path end - def page_not_found + def page_unauthorized(exception) reset_response! - render 'application/page_not_found', status: :not_found + flash[:error] = I18n.t('errors.page_unauthorized') + + ExceptionNotifier.notify_exception(exception, data: { usuarie: current_usuarie&.id, path: request.fullpath }) + + redirect_to site_path(site) + end + + def page_not_found(exception) + reset_response! + + flash[:error] = I18n.t('errors.page_not_found') + + ExceptionNotifier.notify_exception(exception) + + redirect_to site_path(site) end private diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb index 057c3068..7974d317 100644 --- a/app/controllers/posts_controller.rb +++ b/app/controllers/posts_controller.rb @@ -2,6 +2,8 @@ # Controlador para artículos class PostsController < ApplicationController + include StrongParamsHelper + before_action :authenticate_usuarie! before_action :service_for_direct_upload, only: %i[new edit] @@ -13,6 +15,84 @@ class PostsController < ApplicationController # Las URLs siempre llevan el idioma actual o el de le usuarie def default_url_options { locale: locale } + rescue SiteNotFound + {} + end + + # @todo Mover a tu propio scope + def new_array + @value = pluck_param(:value) + @name = pluck_param(:name) + id = pluck_param(:id) + + headers['HX-Trigger-After-Swap'] = 'htmx:resetForm' + + render layout: false + end + + def new_array_value + @value = pluck_param(:value) + + render layout: false + end + + def new_related_post + @uuid = pluck_param(:value) + + @indexed_post = site.indexed_posts.find_by!(post_id: @uuid) + + render layout: false + end + + def new_has_one + @uuid = pluck_param(:value) + + @indexed_post = site.indexed_posts.find_by!(post_id: @uuid) + + render layout: false + end + + # El formulario de un Post, si pasamos el UUID, estamos editando, sino + # estamos creando. + def form + uuid = pluck_param(:uuid, optional: true) + locale + + @post = + if uuid.present? + site.indexed_posts.find_by!(post_id: uuid).post + else + # @todo Usar la base de datos + site.posts(lang: locale).build(layout: pluck_param(:layout)) + end + + swap_modals + + render layout: false + end + + # Genera un modal completo + # + # @todo recibir el atributo anterior + # @param :uuid [String] UUID del post (opcional) + # @param :layout [String] El layout a cargar (opcional) + def modal + uuid = pluck_param(:uuid, optional: true) + locale + + # @todo hacer que si el uuid no existe se genera un post, para poder + # pasar el uuid sabiendolo + @post = + if uuid.present? + site.indexed_posts.find_by!(post_id: uuid).post + else + # @todo Usar la base de datos + site.posts(lang: locale).build(layout: pluck_param(:layout)) + end + + swap_modals + + render layout: false end def index @@ -55,7 +135,7 @@ class PostsController < ApplicationController def new authorize Post - @post = site.posts(lang: locale).build(layout: params[:layout]) + @post = site.posts(lang: locale).build(layout: pluck_param(:layout)) breadcrumb I18n.t('loaf.breadcrumbs.posts.new', layout: @post.layout.humanized_name.downcase), '' end @@ -65,13 +145,34 @@ class PostsController < ApplicationController service = PostService.new(site: site, usuarie: current_usuarie, params: params) - @post = service.create + @post = service.create_or_update - if @post.persisted? + if post.persisted? site.touch forget_content + end - redirect_to site_post_path(@site, @post) + # @todo Enviar la creación a otro endpoint para evitar tantas + # condiciones. + if htmx? + if post.persisted? + triggers = { 'notification:show' => { 'id' => pluck_param(:saved, optional: true) } } + + swap_modals(triggers) + + @value = post.title.value + @uuid = post.uuid.value + @name = pluck_param(:name) + + render render_path_from_attribute, layout: false + else + headers['HX-Retarget'] = "##{pluck_param(:form)}" + headers['HX-Reswap'] = 'outerHTML' + + render 'posts/form', layout: false, post: post, site: site, **params.permit(:form, :base, :dir, :locale) + end + elsif post.persisted? + redirect_to site_post_path(site, post) else render 'posts/new' end @@ -83,6 +184,16 @@ class PostsController < ApplicationController breadcrumb 'posts.edit', '' end + # Este endpoint se encarga de actualizar el post. Si el post se edita + # desde el formulario principal, re-renderizamos el formulario si hay + # errores o enviamos a otro lado al guardar. + # + # Si los datos llegaron por HTMX, hay que regenerar el formulario + # y reemplazarlo en su modal (?) o responder con su tarjeta para + # reemplazarla donde sea que esté. + # + # @todo la re-renderización del formulario no es necesaria si tenemos + # validación client-side. def update authorize post @@ -94,7 +205,37 @@ class PostsController < ApplicationController if service.update.persisted? site.touch forget_content + end + if htmx? + if post.persisted? + triggers = { 'notification:show' => pluck_param(:saved, optional: true) } + + swap_modals(triggers) + + @value = post.title.value + @uuid = post.uuid.value + + if (result_id = pluck_param(:result_id, optional: true)) + headers['HX-Retarget'] = "##{result_id}" + headers['HX-Reswap'] = 'outerHTML' + + @indexed_post = site.indexed_posts.find_by_post_id(post.uuid.value) + + render 'posts/new_related_post', layout: false + # @todo Confirmar que esta ruta no esté transitada + else + @name = pluck_param(:name) + + render render_path_from_attribute, layout: false + end + else + headers['HX-Retarget'] = "##{params.require(:form)}" + headers['HX-Reswap'] = 'outerHTML' + + render 'posts/form', layout: false, post: post, site: site, **params.permit(:form, :base, :dir, :locale) + end + elsif post.persisted? redirect_to site_post_path(site, post) else render 'posts/edit' @@ -168,4 +309,24 @@ class PostsController < ApplicationController def service_for_direct_upload session[:service_name] = site.name.to_sym end + + # @param triggers [Hash] Otros disparadores + def swap_modals(triggers = {}) + params.permit(:show, :hide).each_pair do |key, value| + triggers["modal:#{key}"] = { id: value } if value.present? + end + + headers['HX-Trigger'] = triggers.to_json if triggers.present? + end + + # @return [String] + def render_path_from_attribute + case pluck_param(:attribute) + when 'new_has_many' then 'posts/new_has_many_value' + when 'new_belongs_to' then 'posts/new_belongs_to_value' + when 'new_has_and_belongs_to_many' then 'posts/new_has_many_value' + when 'new_has_one' then 'posts/new_has_one_value' + else 'nothing' + end + end end diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb new file mode 100644 index 00000000..b976f514 --- /dev/null +++ b/app/controllers/registrations_controller.rb @@ -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 diff --git a/app/controllers/usuaries_controller.rb b/app/controllers/usuaries_controller.rb index 6924c860..cba349c5 100644 --- a/app/controllers/usuaries_controller.rb +++ b/app/controllers/usuaries_controller.rb @@ -29,7 +29,7 @@ class UsuariesController < ApplicationController @usuarie = Usuarie.find(params[:id]) - if @site.usuaries.count > 1 + if @site.invitade?(@usuarie) || @site.usuaries.count > 1 # Mágicamente elimina el rol @usuarie.sites.delete(@site) else diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index fcbd4074..3d074aed 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -2,6 +2,19 @@ # Helpers module ApplicationHelper + BRACKETS = /[\[\]]/.freeze + ALPHA_LARGE = [*'a'..'z', *'A'..'Z'].freeze + + # Devuelve un indentificador aleatorio que puede usarse como atributo + # HTML. Reemplaza Nanoid. El primer caracter siempre es alfabético. + # + # @return [String] + def random_id + SecureRandom.urlsafe_base64.tap do |s| + s[0] = ALPHA_LARGE.sample + end + end + # Devuelve el atributo name de un campo anidado en el formato que # esperan los helpers *_field # @@ -19,6 +32,14 @@ module ApplicationHelper [root, name] end + # Obtiene un ID + # + # @param base [String] + # @param attribute [String, Symbol] + def id_for(base, attribute) + "#{base.gsub(BRACKETS, '_')}_#{attribute}".squeeze('_') + end + def plain_field_name_for(*names) root, name = field_name_for(*names) @@ -134,9 +155,17 @@ module ApplicationHelper private + # Obtiene la traducción desde el esquema en el idioma actual, o por + # defecto en el idioma del sitio. De lo contrario trae una traducción + # genérica. + # + # Si el idioma por defecto tiene un String vacía, se asume que no + # texto. + # + # @return [String,nil] def post_t(*attribute, post:, type:) - post.layout.metadata.dig(*attribute, type.to_s, I18n.locale.to_s) || - post.layout.metadata.dig(*attribute, type.to_s, I18n.default_locale.to_s) || - I18n.t("posts.attributes.#{attribute.join('.')}.#{type}") + post.layout.metadata.dig(*attribute, type.to_s, I18n.locale.to_s).presence || + post.layout.metadata.dig(*attribute, type.to_s, post.site.default_locale.to_s) || + I18n.t("posts.attributes.#{attribute.join('.')}.#{type}").presence end end diff --git a/app/helpers/strong_params_helper.rb b/app/helpers/strong_params_helper.rb new file mode 100644 index 00000000..f248cc50 --- /dev/null +++ b/app/helpers/strong_params_helper.rb @@ -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 diff --git a/app/javascript/controllers/array_controller.js b/app/javascript/controllers/array_controller.js new file mode 100644 index 00000000..5540e839 --- /dev/null +++ b/app/javascript/controllers/array_controller.js @@ -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, + ); + } +} diff --git a/app/javascript/controllers/details_controller.js b/app/javascript/controllers/details_controller.js index 57935e1e..170f482e 100644 --- a/app/javascript/controllers/details_controller.js +++ b/app/javascript/controllers/details_controller.js @@ -1,4 +1,4 @@ -import { Controller } from "stimulus"; +import { Controller } from "@hotwired/stimulus"; export default class extends Controller { static targets = []; diff --git a/app/javascript/controllers/dropdown_controller.js b/app/javascript/controllers/dropdown_controller.js index e2b657fd..bbcc7aec 100644 --- a/app/javascript/controllers/dropdown_controller.js +++ b/app/javascript/controllers/dropdown_controller.js @@ -1,4 +1,4 @@ -import { Controller } from "stimulus"; +import { Controller } from "@hotwired/stimulus"; // https://getbootstrap.com/docs/4.6/components/dropdowns/#single-button export default class extends Controller { diff --git a/app/javascript/controllers/enter_controller.js b/app/javascript/controllers/enter_controller.js new file mode 100644 index 00000000..7f851e8a --- /dev/null +++ b/app/javascript/controllers/enter_controller.js @@ -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(); + } +} diff --git a/app/javascript/controllers/file_preview_controller.js b/app/javascript/controllers/file_preview_controller.js index 9eaaab2d..58c60496 100644 --- a/app/javascript/controllers/file_preview_controller.js +++ b/app/javascript/controllers/file_preview_controller.js @@ -1,4 +1,4 @@ -import { Controller } from 'stimulus' +import { Controller } from '@hotwired/stimulus' import bsCustomFileInput from "bs-custom-file-input"; document.addEventListener("turbolinks:load", () => { diff --git a/app/javascript/controllers/form_validation_controller.js b/app/javascript/controllers/form_validation_controller.js new file mode 100644 index 00000000..5672a69b --- /dev/null +++ b/app/javascript/controllers/form_validation_controller.js @@ -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; + } +} diff --git a/app/javascript/controllers/geo_controller.js b/app/javascript/controllers/geo_controller.js index 0018b01a..db3dab59 100644 --- a/app/javascript/controllers/geo_controller.js +++ b/app/javascript/controllers/geo_controller.js @@ -1,4 +1,4 @@ -import { Controller } from 'stimulus' +import { Controller } from '@hotwired/stimulus' require("leaflet/dist/leaflet.css") import L from 'leaflet' diff --git a/app/javascript/controllers/htmx_controller.js b/app/javascript/controllers/htmx_controller.js new file mode 100644 index 00000000..e7bba7f9 --- /dev/null +++ b/app/javascript/controllers/htmx_controller.js @@ -0,0 +1,107 @@ +import { Controller } from "@hotwired/stimulus"; + +/* + * Un controlador que imita a HTMX + */ +export default class extends Controller { + connect() { + // @todo Convertir en