diff --git a/.env b/.env index 480175f8..fe503b11 100644 --- a/.env +++ b/.env @@ -39,3 +39,5 @@ GITLAB_PROJECT= GITLAB_TOKEN= PGVER=15 PGPID=/run/postgresql.pid +PANEL_ACTOR_MENTION=@sutty@sutty.nl +PANEL_ACTOR_SITE_ID=1 diff --git a/Gemfile b/Gemfile index d4f18575..765a1118 100644 --- a/Gemfile +++ b/Gemfile @@ -39,7 +39,7 @@ gem 'devise-i18n' gem 'devise_invitable' gem 'redis-client' gem 'hiredis-client' -gem 'distributed-press-api-client', '~> 0.4.0rc3' +gem 'distributed-press-api-client', '~> 0.4.1' gem 'email_address', git: 'https://github.com/fauno/email_address', branch: 'i18n' gem 'exception_notification' gem 'fast_blank' @@ -83,6 +83,7 @@ gem 'rubanok' gem 'after_commit_everywhere', '~> 1.0' gem 'aasm' +gem 'que-web' # database gem 'hairtrigger' diff --git a/Gemfile.lock b/Gemfile.lock index 069dde9b..1b176336 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -97,6 +97,7 @@ GEM ast (2.4.2) autoprefixer-rails (10.4.13.0) execjs (~> 2) + base64 (0.2.0) bcrypt (3.1.20-x86_64-linux-musl) bcrypt_pbkdf (1.1.0-x86_64-linux-musl) benchmark-ips (2.12.0) @@ -167,7 +168,7 @@ GEM devise_invitable (2.0.9) actionmailer (>= 5.0) devise (>= 4.6) - distributed-press-api-client (0.4.0rc3) + distributed-press-api-client (0.4.1) addressable (~> 2.3, >= 2.3.0) climate_control dry-schema @@ -371,6 +372,8 @@ GEM i18n (>= 0.6.10, < 2) request_store (~> 1.0) multi_xml (0.6.0) + mustermann (3.0.0) + ruby2_keywords (~> 0.0.1) net-imap (0.4.9) date net-protocol @@ -410,12 +413,18 @@ GEM pundit (2.3.1) activesupport (>= 3.0.0) que (2.2.1) + que-web (0.10.0) + que (>= 1) + sinatra racc (1.7.3-x86_64-linux-musl) rack (2.2.8) rack-cors (2.0.1) rack (>= 2.0.0) rack-mini-profiler (3.1.0) rack (>= 1.2.0) + rack-protection (3.2.0) + base64 (>= 0.1.0) + rack (~> 2.2, >= 2.2.4) rack-proxy (0.7.7) rack rack-test (2.1.0) @@ -514,6 +523,7 @@ GEM ruby-statistics (3.0.2) ruby-vips (2.2.0) ffi (~> 1.12) + ruby2_keywords (0.0.5) ruby2ruby (2.5.0) ruby_parser (~> 3.1) sexp_processor (~> 4.6) @@ -540,6 +550,11 @@ GEM sexp_processor (4.17.0) simpleidn (0.2.1) unf (~> 0.1.4) + sinatra (3.2.0) + mustermann (~> 3.0) + rack (~> 2.2, >= 2.2.4) + rack-protection (= 3.2.0) + tilt (~> 2.0) sourcemap (0.1.1) spring (4.1.1) spring-watcher-listen (2.1.0) @@ -627,7 +642,7 @@ DEPENDENCIES devise devise-i18n devise_invitable - distributed-press-api-client (~> 0.4.0rc3) + distributed-press-api-client (~> 0.4.1) dotenv-rails down ed25519 @@ -671,6 +686,7 @@ DEPENDENCIES puma pundit que + que-web rack-cors rack-mini-profiler rails (~> 6.1.0) diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index ea39800e..e4c4d343 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -34,6 +34,22 @@ $sizes: ( @import "bootstrap"; @import "editor"; +@each $color, $rgb in $theme-colors { + .#{$color} { + color: var(--#{$color}); + + &:focus { + color: var(--#{$color}); + } + + ::-moz-selection, + ::selection { + background: var(--#{$color}); + color: white; + } + } +} + .editor { .editor-content { figure { diff --git a/app/controllers/activity_pubs_controller.rb b/app/controllers/activity_pubs_controller.rb index c8f86ef0..428d5cb1 100644 --- a/app/controllers/activity_pubs_controller.rb +++ b/app/controllers/activity_pubs_controller.rb @@ -8,48 +8,61 @@ class ActivityPubsController < ApplicationController define_method(event) do authorize activity_pub - activity_pub.update(remote_flag_params(activity_pub)) if event == :report - activity_pub.public_send(:"#{event}!") if activity_pub.public_send(:"may_#{event}?") + if event == :report + remote_flag_params(activity_pub).tap do |p| + activity_pub.remote_flag_id = p[:remote_flag_attributes][:id] + activity_pub.update(p) + end + end + + message = + if activity_pub.public_send(:"may_#{event}?") && activity_pub.public_send(:"#{event}!") + :success + else + :error + end + + flash[message] = I18n.t("activity_pubs.#{event}.#{message}") redirect_to_moderation_queue! end end def action_on_several + redirect_to_moderation_queue! + activity_pubs = site.activity_pubs.where(id: params[:activity_pub]) + return if activity_pubs.count.zero? + authorize activity_pubs action = params[:activity_pub_action].to_sym - method = :"#{action}!" + method = :"#{action}_all!" may = :"may_#{action}?" - redirect_to_moderation_queue! - return unless ActivityPub.events.include? action # Crear una sola remote flag por autore - if action == :report - message = remote_flag_params(activity_pubs.first).dig(:remote_flag_attributes, :message) - - activity_pubs.distinct.pluck(:actor_id).each do |actor_id| - remote_flag = ActivityPub::RemoteFlag.find_or_initialize_by(actor_id: actor_id, site_id: site.id) - remote_flag.message = message - # Lo estamos actualizando, con lo que lo vamos a volver a enviar - remote_flag.requeue if remote_flag.persisted? - remote_flag.save - # XXX: Idealmente todas las ActivityPub que enviamos pueden - # cambiar de estado, pero chequeamos de todas formas. - remote_flag.activity_pubs << (activity_pubs.where(actor_id: actor_id).to_a.select { |a| a.public_send(may) }) - end - end - ActivityPub.transaction do - activity_pubs.find_each do |activity_pub| - next unless activity_pub.public_send(may) + if action == :report + message = remote_flag_params(activity_pubs.first).dig(:remote_flag_attributes, :message) - activity_pub.public_send(method) + activity_pubs.distinct.pluck(:actor_id).each do |actor_id| + remote_flag = ActivityPub::RemoteFlag.find_or_initialize_by(actor_id: actor_id, site_id: site.id) + remote_flag.message = message + # Lo estamos actualizando, con lo que lo vamos a volver a enviar + remote_flag.requeue if remote_flag.persisted? + remote_flag.save + # XXX: Idealmente todas las ActivityPub que enviamos pueden + # cambiar de estado, pero chequeamos de todas formas. + remote_flag.activity_pubs << (activity_pubs.where(actor_id: actor_id).to_a.select { |a| a.public_send(may) }) + end end + + message = activity_pubs.public_send(method) ? :success : :error + + flash[message] = I18n.t("activity_pubs.action_on_several.#{message}") end end diff --git a/app/controllers/actor_moderations_controller.rb b/app/controllers/actor_moderations_controller.rb index 6b924677..04d2603b 100644 --- a/app/controllers/actor_moderations_controller.rb +++ b/app/controllers/actor_moderations_controller.rb @@ -5,14 +5,31 @@ class ActorModerationsController < ApplicationController include ModerationConcern include ModerationFiltersConcern + before_action :authenticate_usuarie! + + breadcrumb -> { current_usuarie.email }, :edit_usuarie_registration_path + breadcrumb 'sites.index', :sites_path, match: :exact + ActorModeration.events.each do |actor_event| define_method(actor_event) do authorize actor_moderation # Crea una RemoteFlag si se envían los parámetros adecuados - actor_moderation.update(remote_flag_params(actor_moderation)) if actor_event == :report + if actor_event == :report + remote_flag_params(actor_moderation).tap do |p| + actor_moderation.remote_flag_id = p[:remote_flag_attributes][:id] + actor_moderation.update(p) + end + end - actor_moderation.public_send(:"#{actor_event}!") if actor_moderation.public_send(:"may_#{actor_event}?") + message = + if actor_moderation.public_send(:"may_#{actor_event}?") && actor_moderation.public_send(:"#{actor_event}!") + :success + else + :error + end + + flash[message] = I18n.t("actor_moderations.#{actor_event}.#{message}") redirect_to_moderation_queue! end @@ -20,31 +37,43 @@ class ActorModerationsController < ApplicationController # Ver el perfil remoto def show + breadcrumb site.title, site_posts_path(site) + breadcrumb I18n.t('moderation_queue.index.title'), site_moderation_queue_path(site) + @remote_profile = actor_moderation.actor.content - @moderation_queue = rubanok_process(site.activity_pubs.where(actor_id: actor_moderation.actor_id), with: ActivityPubProcessor) + @moderation_queue = rubanok_process(site.activity_pubs.where(actor_id: actor_moderation.actor_id), + with: ActivityPubProcessor) + + breadcrumb @remote_profile['name'] || actor_moderation.actor.mention || actor_moderation.actor.uri, '' end def action_on_several + redirect_to_moderation_queue! + actor_moderations = site.actor_moderations.where(id: params[:actor_moderation]) + return if actor_moderations.count.zero? + authorize actor_moderations action = params[:actor_moderation_action].to_sym - method = :"#{action}!" + method = :"#{action}_all!" may = :"may_#{action}?" - redirect_to_moderation_queue! - return unless ActorModeration.events.include? action ActorModeration.transaction do - actor_moderations.find_each do |actor_moderation| - next unless actor_moderation.public_send(may) + if action == :report + actor_moderations.find_each do |actor_moderation| + next unless actor_moderation.public_send(may) - actor_moderation.update(actor_moderation_params(actor_moderation)) if action == :report - - actor_moderation.public_send(method) + actor_moderation.update(actor_moderation_params(actor_moderation)) + end end + + message = actor_moderations.public_send(method) ? :success : :error + + flash[message] = I18n.t("actor_moderations.action_on_several.#{message}") end end diff --git a/app/controllers/api/v1/webhooks/social_inbox_controller.rb b/app/controllers/api/v1/webhooks/social_inbox_controller.rb index 548781fa..6ac91a51 100644 --- a/app/controllers/api/v1/webhooks/social_inbox_controller.rb +++ b/app/controllers/api/v1/webhooks/social_inbox_controller.rb @@ -5,194 +5,51 @@ module Api module Webhooks # Recibe webhooks de la Social Inbox # - # @todo Mover todo a un Job que obtenga el objeto remoto antes de - # instanciar el objeto localmente en lugar de arreglarlo después y - # poder responder lo más rápido posible el webhook. # @see {https://www.w3.org/TR/activitypub/} class SocialInboxController < BaseController include Api::V1::Webhooks::Concerns::WebhookConcern + # Validar que el token sea correcto + before_action :usuarie + # Cuando una actividad ingresa en la cola de moderación, la # recibimos por acá # - # Vamos a recibir Create, Update, Delete, Follow, Undo y obtener - # el objeto dentro de cada una para guardar un estado asociado - # al sitio. + # Vamos a recibir Create, Update, Delete, Follow, Undo, + # Announce, Like y obtener el objeto dentro de cada una para + # guardar un estado asociado al sitio. # # El objeto del estado puede ser un objeto o une actore, # dependiendo de la actividad. def moderationqueued - # Devuelve un error si el token no es válido - usuarie.present? + process! :paused - ::ActivityPub.transaction do - - # Crea todos los registros necesarios y actualiza el estado - actor.present? - instance.present? - object.present? - activity_pub.present? - - activity.update_activity_pub_state! - end - rescue ActiveRecord::RecordInvalid => e - ExceptionNotifier.notify_exception(e, - data: { site: site.name, usuarie: usuarie.email, - activity: original_activity }) - ensure head :accepted end # Cuando la Social Inbox acepta una actividad, la recibimos # igual y la guardamos por si cambiamos de idea. - # - # @todo DRY def onapproved - ::ActivityPub.transaction do - actor.present? - instance.present? - object.present? - activity.present? - activity_pub.approve! if activity_pub.may_approve? - end + process! :approved head :accepted end # Cuando la Social Inbox rechaza una actividad, la recibimos # igual y la guardamos por si cambiamos de idea. - # - # @todo DRY def onrejected - ::ActivityPub.transaction do - actor.present? - instance.present? - object.present? - activity.present? - activity_pub.reject! if activity_pub.may_reject? - end + process! :rejected head :accepted end private - # Si el objeto ya viene incorporado en la actividad o lo tenemos - # que traer remotamente. + # Envía la actividad para procesamiento por separado. # - # @return [Bool] - def object_embedded? - @object_embedded ||= original_activity[:object].is_a?(Hash) - end - - # Encuentra la URI del objeto o falla si no la encuentra. - # - # @return [String] - def object_uri - @object_uri ||= ::ActivityPub.uri_from_object(original_activity[:object]) - ensure - raise ActiveRecord::RecordNotFound, 'object id missing' if @object_uri.blank? - end - - # Atajo a la instancia - # - # @return [ActivityPub::Instance] - def instance - actor.instance - end - - # Genera un objeto a partir de la actividad. Si el objeto ya - # existe, actualiza su contenido. Si el objeto no viene - # incorporado, obtenemos el contenido más tarde. - # - # @return [ActivityPub::Object] - def object - @object ||= ::ActivityPub::Object.find_or_initialize_by(uri: object_uri).tap do |o| - # XXX: Si el objeto es una actividad, esto siempre va a ser - # Generic - o.type ||= 'ActivityPub::Object::Generic' - - if object_embedded? - o.content = original_object - begin - type = original_object[:type].presence - o.type = "ActivityPub::Object::#{type}".constantize if type - rescue NameError - end - end - - o.save! - - # XXX: el objeto necesita ser guardado antes de poder - # procesarlo - ::ActivityPub::FetchJob.perform_later(site: site, object: o) unless object_embedded? - end - end - - # Genera el seguimiento del estado del objeto con respecto al - # sitio. - # - # @return [ActivityPub] - def activity_pub - @activity_pub ||= site.activity_pubs.find_or_create_by!(site: site, actor: actor, instance: instance, object_id: object.id, object_type: object.type) - end - - # Crea la actividad y la vincula con el estado - # - # @return [ActivityPub::Activity] - def activity - @activity ||= - ::ActivityPub::Activity - .type_from(original_activity) - .find_or_initialize_by(uri: original_activity[:id], activity_pub: activity_pub, actor: actor).tap do |a| - a.content = original_activity.dup - a.content[:object] = object.uri - a.save! - end - end - - # Actor, si no hay instancia, la crea en el momento, junto con - # su estado de moderación. - # - # @return [Actor] - def actor - @actor ||= ::ActivityPub::Actor.find_or_initialize_by(uri: original_activity[:actor]).tap do |a| - unless a.instance - a.instance = ::ActivityPub::Instance.find_or_create_by(hostname: URI.parse(a.uri).hostname) - - site.instance_moderations.find_or_create_by(instance: a.instance) - - ::ActivityPub::InstanceFetchJob.perform_later(site: site, instance: a.instance) - end - - a.save! - - site.actor_moderations.find_or_create_by(actor: a) - - ::ActivityPub::ActorFetchJob.perform_later(site: site, actor: a) - end - end - - # Descubre la actividad recibida, generando un error si la - # actividad no está dirigida a nosotres. - # - # @todo Validar formato - # @return [Hash] - def original_activity - @original_activity ||= FastJsonparser.parse(request.raw_post).tap do |activity| - raise '@context missing' unless activity[:@context].presence - raise 'id missing' unless activity[:id].presence - raise 'object missing' unless activity[:object].presence - rescue RuntimeError => e - raise ActiveRecord::RecordNotFound, e.message - end - end - - # @return [Hash,String] - def original_object - @original_object ||= original_activity[:object].dup.tap do |o| - o[:@context] = original_activity[:@context].dup - end + # @param initial_state [Symbol] + def process!(initial_state) + ::ActivityPub::ProcessJob.perform_later(site: site, body: request.raw_post, initial_state: initial_state) end end end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 2ebb5494..451eb79c 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -12,10 +12,6 @@ class ApplicationController < ActionController::Base before_action :notify_unconfirmed_email, unless: :devise_controller? around_action :set_locale - rescue_from Pundit::NilPolicyError, with: :page_not_found - rescue_from ActionController::RoutingError, with: :page_not_found - rescue_from ActionController::ParameterMissing, with: :page_not_found - before_action do Rack::MiniProfiler.authorize_request if current_usuarie&.email&.ends_with?('@' + ENV.fetch('SUTTY', 'sutty.nl')) end @@ -75,11 +71,6 @@ class ApplicationController < ActionController::Base I18n.with_locale(current_locale, &action) end - # Muestra una página 404 - def page_not_found - render 'application/page_not_found', status: :not_found - end - # Necesario para poder acceder a Blazer. Solo les usuaries de este # sitio pueden acceder al panel. def require_usuarie diff --git a/app/controllers/concerns/exception_handler.rb b/app/controllers/concerns/exception_handler.rb index 8c4f54c8..7c1cd540 100644 --- a/app/controllers/concerns/exception_handler.rb +++ b/app/controllers/concerns/exception_handler.rb @@ -12,13 +12,31 @@ module ExceptionHandler 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::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 + reset_response! + + flash[:error] = I18n.t('errors.site_not_found') + redirect_to sites_path end def page_not_found - send_file Rails.root.join('public', '404.html') + reset_response! + + render 'application/page_not_found', status: :not_found + end + + private + + def reset_response! + self.response_body = nil + @_response_body = nil + + headers.delete('Location') end end diff --git a/app/controllers/concerns/moderation_concern.rb b/app/controllers/concerns/moderation_concern.rb index 3b9d818f..8340ec2a 100644 --- a/app/controllers/concerns/moderation_concern.rb +++ b/app/controllers/concerns/moderation_concern.rb @@ -16,7 +16,9 @@ module ModerationConcern end def remote_flag_params(model) - { remote_flag_attributes: { id: model.remote_flag_id, message: ''.dup } }.tap do |p| + remote_flag = ActivityPub::RemoteFlag.find_by(actor_id: model.actor_id) + + { remote_flag_attributes: { id: remote_flag&.id, message: ''.dup } }.tap do |p| p[:remote_flag_attributes][:site_id] = model.site_id p[:remote_flag_attributes][:actor_id] = model.actor_id diff --git a/app/controllers/fediblock_states_controller.rb b/app/controllers/fediblock_states_controller.rb index 6d9737c3..4d9cc968 100644 --- a/app/controllers/fediblock_states_controller.rb +++ b/app/controllers/fediblock_states_controller.rb @@ -11,11 +11,22 @@ class FediblockStatesController < ApplicationController elsif fediblock_state.may_disable? fediblock_state.disable! end + + flash[:success] = I18n.t('fediblock_states.action_on_several.success') + rescue Exception => e + ExceptionNotifier.notify_exception(e, data: { site: site.name }) + + flash.delete(:success) + flash[:error] = I18n.t('fediblock_states.action_on_several.error') end # Bloquear otras instancias if custom_blocklist.present? - ActivityPub::InstanceModerationJob.perform_later(site: site, hostnames: custom_blocklist) + if ActivityPub::InstanceModerationJob.perform_now(site: site, hostnames: custom_blocklist) + flash[:success] = I18n.t('fediblock_states.action_on_several.custom_blocklist_success') + else + flash[:error] = I18n.t('fediblock_states.action_on_several.custom_blocklist_error') + end end redirect_to site_moderation_queue_path diff --git a/app/controllers/instance_moderations_controller.rb b/app/controllers/instance_moderations_controller.rb index 270f0588..de990eb1 100644 --- a/app/controllers/instance_moderations_controller.rb +++ b/app/controllers/instance_moderations_controller.rb @@ -8,29 +8,37 @@ class InstanceModerationsController < ApplicationController define_method(event) do authorize instance_moderation - instance_moderation.public_send(:"#{event}!") if instance_moderation.public_send(:"may_#{event}?") + message = + if instance_moderation.public_send(:"may_#{event}?") && instance_moderation.public_send(:"#{event}!") + :success + else + :error + end + + flash[message] = I18n.t("instance_moderations.#{event}.#{message}") redirect_to_moderation_queue! end end def action_on_several + redirect_to_moderation_queue! + instance_moderations = site.instance_moderations.where(id: params[:instance_moderation]) + return if instance_moderations.count.zero? + authorize instance_moderations action = params[:instance_moderation_action].to_sym - method = :"#{action}!" - may = :"may_#{action}?" - - redirect_to_moderation_queue! + method = :"#{action}_all!" return unless InstanceModeration.events.include? action InstanceModeration.transaction do - instance_moderations.find_each do |instance_moderation| - instance_moderation.public_send(method) if instance_moderation.public_send(may) - end + message = instance_moderations.public_send(method) ? :success : :error + + flash[:message] = I18n.t("instance_moderations.action_on_several.#{message}") end end diff --git a/app/controllers/moderation_queue_controller.rb b/app/controllers/moderation_queue_controller.rb index eebd9eae..ef830c41 100644 --- a/app/controllers/moderation_queue_controller.rb +++ b/app/controllers/moderation_queue_controller.rb @@ -4,8 +4,19 @@ class ModerationQueueController < ApplicationController include ModerationFiltersConcern + before_action :authenticate_usuarie! + + breadcrumb -> { current_usuarie.email }, :edit_usuarie_registration_path + breadcrumb 'sites.index', :sites_path, match: :exact + # Cola de moderación viendo todo el sitio def index + authorize ModerationQueue.new(site) + breadcrumb site.title, site_posts_path(site) + breadcrumb I18n.t('moderation_queue.index.title'), '' + + site.moderation_checked! + # @todo cambiar el estado por query @activity_pubs = site.activity_pubs @instance_moderations = rubanok_process(site.instance_moderations, with: InstanceModerationProcessor) diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb index 94ded4b7..9d8c42a4 100644 --- a/app/controllers/posts_controller.rb +++ b/app/controllers/posts_controller.rb @@ -38,7 +38,6 @@ class PostsController < ApplicationController @usuarie = site.usuarie? current_usuarie @site_stat = SiteStat.new(site) - dummy_data end def show @@ -84,7 +83,6 @@ class PostsController < ApplicationController authorize post breadcrumb post.title.value, site_post_path(site, post, locale: locale), match: :exact breadcrumb 'posts.edit', '' - dummy_data end def update diff --git a/app/helpers/moderation_queue_helper.rb b/app/helpers/moderation_queue_helper.rb index 3681bec3..c69364ae 100644 --- a/app/helpers/moderation_queue_helper.rb +++ b/app/helpers/moderation_queue_helper.rb @@ -2,6 +2,14 @@ module ModerationQueueHelper def filter_states(**args) - params.permit(:state, :actor_state, :activity_pub_state).merge(**args) + params.permit(:instance_state, :actor_state, :activity_pub_state).merge(**args) + end + + def active?(states, state_name, state) + if params[state_name].present? + params[state_name] == state.to_s + else + states.first == state + end end end diff --git a/app/jobs/activity_pub/actor_fetch_job.rb b/app/jobs/activity_pub/actor_fetch_job.rb deleted file mode 100644 index 71107151..00000000 --- a/app/jobs/activity_pub/actor_fetch_job.rb +++ /dev/null @@ -1,26 +0,0 @@ -# frozen_string_literal: true - -# Obtiene o actualiza el contenido de un objeto, usando las credenciales -# del sitio. -# -# XXX: Esto usa las credenciales del sitio para volver el objeto -# disponible para todo el CMS. Asumimos que el objeto devuelto es el -# mismo para todo el mundo y las credenciales solo son para -# autenticación. -class ActivityPub - class ActorFetchJob < ApplicationJob - self.priority = 50 - - def perform(site:, actor:) - ActivityPub::Actor.transaction do - response = site.social_inbox.dereferencer.get(uri: actor.uri) - - # @todo Fallar cuando la respuesta no funcione? - return unless response.ok? - return if response.miss? && actor.content.present? - - actor.update(content: FastJsonparser.parse(response.body)) - end - end - end -end diff --git a/app/jobs/activity_pub/fetch_job.rb b/app/jobs/activity_pub/fetch_job.rb index e3fef993..b19d5e41 100644 --- a/app/jobs/activity_pub/fetch_job.rb +++ b/app/jobs/activity_pub/fetch_job.rb @@ -11,19 +11,36 @@ class ActivityPub class FetchJob < ApplicationJob self.priority = 50 - def perform(site:, object:) + def perform(site:, object_id:) ActivityPub::Object.transaction do + object = ::ActivityPub::Object.find(object_id) + + return if object.blank? return if object.activity_pubs.where(aasm_state: 'removed').count.positive? response = site.social_inbox.dereferencer.get(uri: object.uri) # @todo Fallar cuando la respuesta no funcione? - return unless response.ok? - return if response.miss? && object.content.present? + # @todo Eliminar en 410 Gone + return unless response.success? + # Ignorar si ya la caché fue revalidada y ya teníamos el + # contenido + return if response.hit? && object.content.present? + current_type = object.type content = FastJsonparser.parse(response.body) - object.update(content: content, type: ActivityPub::Object.type_from(content).name) + # Modificar atómicamente + ::ActivityPub::Object.lock.find(object_id).update!(content: content, type: ActivityPub::Object.type_from(content).name) + + object = ::ActivityPub::Object.find(object_id) + # Actualiza la mención + object.actor&.save! if object.actor_type? + + # Arreglar las relaciones con actividades también + ActivityPub.where(object_id: object.id).update_all(object_type: object.type, updated_at: Time.now) + rescue FastJsonparser::ParseError => e + ExceptionNotifier.notify_exception(e, data: { site: site.name, object: object.uri, body: response.body }) end end end diff --git a/app/jobs/activity_pub/inbox_job.rb b/app/jobs/activity_pub/inbox_job.rb new file mode 100644 index 00000000..cb807704 --- /dev/null +++ b/app/jobs/activity_pub/inbox_job.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class ActivityPub + class InboxJob < ApplicationJob + self.priority = 10 + + # @param :site [Site] + # @param :activity [String] + # @param :action [Symbol] + def perform(site:, activity:, action:) + response = site.social_inbox.inbox.public_send(action, id: activity) + + raise response.body unless response.success? + end + end +end diff --git a/app/jobs/activity_pub/instance_fetch_job.rb b/app/jobs/activity_pub/instance_fetch_job.rb index 9c562f7d..dc84caf2 100644 --- a/app/jobs/activity_pub/instance_fetch_job.rb +++ b/app/jobs/activity_pub/instance_fetch_job.rb @@ -15,7 +15,7 @@ class ActivityPub response = site.social_inbox.dereferencer.get(uri: uri) - next unless response.ok? + next unless response.success? # @todo Validate schema next unless response.parsed_response.is_a?(DistributedPress::V1::Social::ReferencedObject) diff --git a/app/jobs/activity_pub/instance_moderation_job.rb b/app/jobs/activity_pub/instance_moderation_job.rb index b205e68f..9da0627f 100644 --- a/app/jobs/activity_pub/instance_moderation_job.rb +++ b/app/jobs/activity_pub/instance_moderation_job.rb @@ -3,14 +3,13 @@ class ActivityPub # Bloquea varias instancias de una sola vez class InstanceModerationJob < ApplicationJob - self.priority = 50 - # @param :site [Site] # @param :hostnames [Array] - def perform(site:, hostnames:) + # @param :perform_remotely [Bool] + def perform(site:, hostnames:, perform_remotely: true) # Crear las instancias que no existan todavía hostnames.each do |hostname| - ActivityPub::Instance.find_or_create_by(hostname: hostname) + ActivityPub::Instance.lock.find_or_create_by(hostname: hostname) end instances = ActivityPub::Instance.where(hostname: hostnames) @@ -21,10 +20,18 @@ class ActivityPub instances.find_each do |instance| # Esto bloquea cada una individualmente en la Social Inbox, # idealmente son pocas instancias las que aparecen. - site.instance_moderations.find_or_create_by(instance: instance).tap do |instance_moderation| - instance_moderation.block! if instance_moderation.may_block? - end + site.instance_moderations.lock.find_or_create_by(instance: instance) end + + scope = site.instance_moderations.where(instance_id: instances.ids) + + if perform_remotely + scope.block_all! + else + scope.block_all_without_callbacks! + end + + ActivityPub::SyncListsJob.perform_later(site: site) end end end diff --git a/app/jobs/activity_pub/process_job.rb b/app/jobs/activity_pub/process_job.rb new file mode 100644 index 00000000..af686dbd --- /dev/null +++ b/app/jobs/activity_pub/process_job.rb @@ -0,0 +1,147 @@ +# frozen_string_literal: true + +class ActivityPub + # Procesar las actividades a medida que llegan + class ProcessJob < ApplicationJob + attr_reader :body + + # Procesa la actividad en segundo plano + # + # @param :body [String] + # @param :initial_state [Symbol,String] + def perform(site:, body:, initial_state: :paused) + @body = body + @site = site + + ActiveRecord::Base.connection_pool.with_connection do + ::ActivityPub.transaction do + # Crea todos los registros necesarios y actualiza el estado + actor.present? + instance.present? + object.present? + activity_pub.present? + activity_pub.update(aasm_state: initial_state) + + activity.update_activity_pub_state! + end + end + # Al generar una excepción, en lugar de seguir intentando, enviamos + # el reporte. + rescue Exception => e + ExceptionNotifier.notify_exception(e, data: { site: site.name, body: body, initial_state: initial_state, activity: original_activity, message: 'Esta acción se canceló automáticamente, para regenerarla, volver a correr el proceso con los mismos parámetros.' }) + end + + private + + # Si el objeto ya viene incorporado en la actividad o lo tenemos + # que traer remotamente. + # + # @return [Bool] + def object_embedded? + @object_embedded ||= original_activity[:object].is_a?(Hash) + end + + # Encuentra la URI del objeto o falla si no la encuentra. + # + # @return [String] + def object_uri + @object_uri ||= ::ActivityPub.uri_from_object(original_activity[:object]) + ensure + raise ActiveRecord::RecordNotFound, 'object id missing' if @object_uri.blank? + end + + # Atajo a la instancia + # + # @return [ActivityPub::Instance] + def instance + actor.instance + end + + # Genera un objeto a partir de la actividad. Si el objeto ya + # existe, actualiza su contenido. Si el objeto no viene + # incorporado, obtenemos el contenido más tarde. + # + # @return [ActivityPub::Object] + def object + @object ||= ::ActivityPub::Object.lock.find_or_initialize_by(uri: object_uri).tap do |o| + o.lock! if o.persisted? + o.content = original_object if object_embedded? + + o.save! + + # XXX: el objeto necesita ser guardado antes de poder + # procesarlo. No usamos GlobalID porque el tipo de objeto + # cambia y produce un error de deserialización. + ::ActivityPub::FetchJob.perform_later(site: site, object_id: o.id) unless object_embedded? + end + end + + # Genera el seguimiento del estado del objeto con respecto al + # sitio. + # + # @return [ActivityPub] + def activity_pub + @activity_pub ||= site.activity_pubs.lock.find_or_create_by!(site: site, actor: actor, instance: instance, + object_id: object.id, object_type: object.type) + end + + # Crea la actividad y la vincula con el estado + # + # @return [ActivityPub::Activity] + def activity + @activity ||= + ::ActivityPub::Activity + .type_from(original_activity) + .lock + .find_or_initialize_by(uri: original_activity[:id], activity_pub: activity_pub, actor: actor).tap do |a| + a.lock! if a.persisted? + a.content = original_activity.dup + a.content[:object] = object.uri + a.save! + end + end + + # Actor, si no hay instancia, la crea en el momento, junto con + # su estado de moderación. + # + # @return [Actor] + def actor + @actor ||= ::ActivityPub::Actor.lock.find_or_initialize_by(uri: original_activity[:actor]).tap do |a| + a.lock! if a.persisted? + + unless a.instance + a.instance = ::ActivityPub::Instance.lock.find_or_create_by(hostname: URI.parse(a.uri).hostname) + + ::ActivityPub::InstanceFetchJob.perform_later(site: site, instance: a.instance) + end + + site.instance_moderations.lock.find_or_create_by(instance: a.instance) + a.save! + + site.actor_moderations.lock.find_or_create_by(actor: a) + + ::ActivityPub::FetchJob.perform_later(site: site, object_id: a.object.id) + end + end + + # @return [Hash,String] + def original_object + @original_object ||= original_activity[:object].dup.tap do |o| + o[:@context] = original_activity[:@context].dup + end + end + + # Descubre la actividad recibida, generando un error si la + # actividad no está dirigida a nosotres. + # + # @todo Validar formato con Dry::Schema + # @return [Hash] + def original_activity + @original_activity ||= FastJsonparser.parse(body).tap do |activity| + raise '@context missing' unless activity[:@context].present? + raise 'id missing' unless activity[:id].present? + raise 'object missing' unless activity[:object].present? + end + end + end +end diff --git a/app/jobs/activity_pub/remote_flag_job.rb b/app/jobs/activity_pub/remote_flag_job.rb index 7d8131db..211f46fc 100644 --- a/app/jobs/activity_pub/remote_flag_job.rb +++ b/app/jobs/activity_pub/remote_flag_job.rb @@ -13,18 +13,24 @@ class ActivityPub self.priority = 30 def perform(remote_flag:) - return if remote_flag.can_queue? + return unless remote_flag.may_queue? + + inbox = remote_flag.actor&.content&.[]('inbox') + + raise 'Inbox is missing for actor' if inbox.blank? remote_flag.queue! - client = remote_flag.site.social_inbox.client_for(remote_flag.actor&.content['inbox']) - response = client.post(endpoint: '', body: remote_flag.content) + uri = URI.parse(inbox) + client = remote_flag.main_site.social_inbox.client_for(uri.origin) + response = client.post(endpoint: uri.path, body: remote_flag.content) - raise 'No se pudo enviar el reporte' unless response.ok? + raise 'No se pudo enviar el reporte' unless response.success? - remote_flag.send! + remote_flag.report! rescue Exception => e ExceptionNotifier.notify_exception(e, data: { remote_flag: remote_flag.id, response: response.parsed_response }) + remote_flag.resend! raise end end diff --git a/app/jobs/activity_pub/sync_lists_job.rb b/app/jobs/activity_pub/sync_lists_job.rb new file mode 100644 index 00000000..de71fe64 --- /dev/null +++ b/app/jobs/activity_pub/sync_lists_job.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +class ActivityPub + # Sincroniza las listas de bloqueo y permitidas con el estado actual + # de la base de datos. + class SyncListsJob < ApplicationJob + # Siempre correr al final + self.priority = 100 + + attr_reader :logs + + # Ejecuta todas las requests y consolida los posibles errores. + # + # @param site [Site] + def run(site:) + @logs = {} + + instance_scope = site.instance_moderations.joins(:instance) + actor_scope = site.actor_moderations.joins(:actor) + + blocklist = wildcardize(instance_scope.blocked.pluck(:hostname)) + actor_scope.blocked.distinct.pluck(:mention).compact + actor_scope.reported.distinct.pluck(:mention).compact + allowlist = wildcardize(instance_scope.allowed.pluck(:hostname)) + actor_scope.allowed.distinct.pluck(:mention).compact + pauselist = wildcardize(instance_scope.paused.pluck(:hostname)) + actor_scope.paused.distinct.pluck(:mention).compact + + if blocklist.present? + Rails.logger.info "Bloqueando: #{blocklist.join(', ')}" + process(:blocked) { site.social_inbox.allowlist.delete(list: blocklist) } + process(:blocked) { site.social_inbox.blocklist.post(list: blocklist) } + end + + if allowlist.present? + Rails.logger.info "Permitiendo: #{allowlist.join(', ')}" + process(:allowed) { site.social_inbox.blocklist.delete(list: allowlist) } + process(:allowed) { site.social_inbox.allowlist.post(list: allowlist) } + end + + if pauselist.present? + Rails.logger.info "Pausando: #{pauselist.join(', ')}" + process(:paused) { site.social_inbox.blocklist.delete(list: pauselist) } + process(:paused) { site.social_inbox.allowlist.delete(list: pauselist) } + end + + # Si alguna falló, reintentar + raise if logs.present? + rescue Exception => e + ExceptionNotifier.notify_exception(e, data: { site: site.name, logs: logs, blocklist: blocklist, allowlist: allowlist, pauselist: pauselist }) + + raise + end + + private + + def process(stage) + response = yield + + return if response.success? + + logs[stage] ||= [] + logs[stage] << { body: response.body, code: response.code } + end + + # @params hostnames [Array] + # @return [Array] + def wildcardize(hostnames) + hostnames.map do |hostname| + "@*@#{hostname}" + end + end + end +end diff --git a/app/models/activity_pub.rb b/app/models/activity_pub.rb index b07fe790..cd893406 100644 --- a/app/models/activity_pub.rb +++ b/app/models/activity_pub.rb @@ -6,19 +6,24 @@ # una actividad, puede estar destinada a varies actores dentro de Sutty, # con lo que generamos una cola para cada une. # +# +# @todo Ya que une actore puede hacer varias actividades sobre el mismo +# objeto, lo correcto sería que la actividad a moderar sea una sola en +# lugar de una lista acumulativa. Es decir cada ActivityPub representa +# el estado del conjunto (Actor, Object, Activity) +# # @see {https://www.w3.org/TR/activitypub/#client-to-server-interactions} class ActivityPub < ApplicationRecord - include AASM - include AasmEventsConcern + IGNORED_EVENTS = %i[pause remove].freeze + IGNORED_STATES = %i[removed].freeze - IGNORED_EVENTS = %i[remove] - IGNORED_STATES = %i[removed] + include AASM belongs_to :instance belongs_to :site belongs_to :object, polymorphic: true belongs_to :actor - belongs_to :remote_flag, class_name: 'ActivityPub::RemoteFlag' + belongs_to :remote_flag, optional: true, class_name: 'ActivityPub::RemoteFlag' has_many :activities validates :site_id, presence: true @@ -38,6 +43,42 @@ class ActivityPub < ApplicationRecord end end + # Obtiene el campo `url` de diversas formas. Si es una String, asumir + # que es una URL, si es un Hash, asumir que es un Link, si es un + # Array de Strings, obtener la primera, si es de Hash, obtener el + # primer link con rel=canonical y mediaType=text/html + # + # De lo contrario devolver el ID. + # + # @todo Refactorizar + # @param object [Hash] + # @return [String] + def self.url_from_object(object) + raise unless object.respond_to?(:[]) + + url = + case object['url'] + when String then object['url'] + when Hash then object['href'] + # Esto es un lío porque queremos saber si es un Array o + # Array o mezcla y obtener el que más nos convenga o + # adivinar uno. + when Array + links = object['url'].map.with_index do |link, i| + case link + when Hash then link + else { 'href' => link.to_s } + end + end + + links.find do |link| + link['rel'] == 'canonical' && link['mediaType'] == 'text/html' + end&.[]('href') || links.first&.[]('href') + end + + url || object['id'] + end + aasm do # Todavía no hay una decisión sobre el objeto state :paused, initial: true @@ -50,13 +91,26 @@ class ActivityPub < ApplicationRecord # Le actore eliminó el objeto state :removed + # Gestionar todos los errores + error_on_all_events do |e| + ExceptionNotifier.notify_exception(e, data: { site: site.name, activity_pub: self.id, activity: activities.first.uri }) + end + + # Se puede volver a pausa en caso de actualización remota, para + # revisar los cambios. + event :pause do + transitions to: :paused + end + # Recibir una acción de eliminación, eliminar el contenido de la # base de datos. Esto elimina el contenido para todos los sitios # porque estamos respetando lo que pidió le actore. event :remove do transitions to: :removed - before do + after do + next if object.blank? + object.update(content: {}) unless object.content.empty? end end @@ -67,9 +121,8 @@ class ActivityPub < ApplicationRecord event :approve do transitions from: %i[paused], to: :approved - before do - raise AASM::InvalidTransition unless - site.social_inbox.inbox.accept(id: object.uri).ok? + after do + ActivityPub::InboxJob.perform_later(site: site, activity: activities.first.uri, action: :accept) end end @@ -77,19 +130,22 @@ class ActivityPub < ApplicationRecord event :reject do transitions from: %i[paused approved], to: :rejected - before do - raise AASM::InvalidTransition unless - site.social_inbox.inbox.reject(id: object.uri).ok? + after do + ActivityPub::InboxJob.perform_later(site: site, activity: activities.first.uri, action: :reject) end end - # Solo podemos reportarla luego de rechazarla + # Reportarla implica rechazarla event :report do - transitions from: :rejected, to: :reported + transitions from: %i[paused approved rejected], to: :reported - before do + after do + ActivityPub::InboxJob.perform_later(site: site, activity: activities.first.uri, action: :reject) ActivityPub::RemoteFlagJob.perform_later(remote_flag: remote_flag) if remote_flag.waiting? end end end + + # Definir eventos en masa + include AasmEventsConcern end diff --git a/app/models/activity_pub/activity.rb b/app/models/activity_pub/activity.rb index 1147c5b8..af005ff3 100644 --- a/app/models/activity_pub/activity.rb +++ b/app/models/activity_pub/activity.rb @@ -20,6 +20,8 @@ class ActivityPub has_one :object, through: :activity_pub validates :activity_pub_id, presence: true + # Las actividades son únicas con respecto a su estado + validates :uri, presence: true, url: true, uniqueness: { scope: :activity_pub_id, message: 'estado duplicado' } # Siempre en orden descendiente para saber el último estado default_scope -> { order(created_at: :desc) } diff --git a/app/models/activity_pub/activity/announce.rb b/app/models/activity_pub/activity/announce.rb new file mode 100644 index 00000000..8ca58906 --- /dev/null +++ b/app/models/activity_pub/activity/announce.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class ActivityPub + class Activity + # Boost + class Announce < ActivityPub::Activity; end + end +end diff --git a/app/models/activity_pub/activity/delete.rb b/app/models/activity_pub/activity/delete.rb index f6ff6536..640c7ce9 100644 --- a/app/models/activity_pub/activity/delete.rb +++ b/app/models/activity_pub/activity/delete.rb @@ -13,10 +13,24 @@ class ActivityPub # lo haría la Social Inbox por nosotres. # @see {https://docs.joinmastodon.org/spec/security/#ld} def update_activity_pub_state! - ActivityPub.transaction do - ActivityPub::Object.find_by(uri: ActivityPub.uri_from_object(content['object']))&.activity_pubs&.find_each(&:remove!) + ActiveRecord::Base.connection_pool.with_connection do + ActivityPub.transaction do + object = ActivityPub::Object.find_by(uri: ActivityPub.uri_from_object(content['object'])) - activity_pub.remove! + if object.present? + object.activity_pubs.find_each do |activity_pub| + activity_pub.remove! if activity_pub.may_remove? + end + + # Encontrar todas las acciones de moderación de le actore + # eliminade y moverlas a eliminar. + if (actor = ActivityPub::Actor.find_by(uri: object.uri)).present? + ActorModeration.where(actor_id: actor.id).remove_all! + end + end + + activity_pub.remove! if activity_pub.may_remove? + end end end end diff --git a/app/models/activity_pub/activity/follow.rb b/app/models/activity_pub/activity/follow.rb index e383490a..b4c34d7a 100644 --- a/app/models/activity_pub/activity/follow.rb +++ b/app/models/activity_pub/activity/follow.rb @@ -4,8 +4,15 @@ # # Una actividad de seguimiento se refiere siempre a une actore (el # sitio) y proviene de otre actore. +# +# Por ahora las solicitudes de seguimiento se auto-aprueban. class ActivityPub class Activity - class Follow < ActivityPub::Activity; end + class Follow < ActivityPub::Activity + # Auto-aprobar la solicitud de seguimiento + def update_activity_pub_state! + activity_pub.approve! if activity_pub.may_approve? + end + end end end diff --git a/app/models/activity_pub/activity/like.rb b/app/models/activity_pub/activity/like.rb new file mode 100644 index 00000000..531cc32c --- /dev/null +++ b/app/models/activity_pub/activity/like.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class ActivityPub + class Activity + # Like + class Like < ActivityPub::Activity; end + end +end diff --git a/app/models/activity_pub/actor.rb b/app/models/activity_pub/actor.rb index fe6052bf..6a284025 100644 --- a/app/models/activity_pub/actor.rb +++ b/app/models/activity_pub/actor.rb @@ -15,15 +15,29 @@ class ActivityPub has_many :activities has_many :remote_flags + # Les actores son únicxs a toda la base de datos + validates :uri, presence: true, url: true, uniqueness: true + + before_save :mentionize! + # Obtiene el nombre de la Actor como mención, solo si obtuvimos el # contenido de antemano. # # @return [String, nil] - def mention + def mentionize! + return if mention.present? return if content['preferredUsername'].blank? return if instance.blank? - @mention ||= "@#{content['preferredUsername']}@#{instance.hostname}" + self.mention ||= "@#{content['preferredUsername']}@#{instance.hostname}" + end + + def object + @object ||= ActivityPub::Object.lock.find_or_create_by(uri: uri) + end + + def content + object.content end end end diff --git a/app/models/activity_pub/concerns/json_ld_concern.rb b/app/models/activity_pub/concerns/json_ld_concern.rb index bc30330c..282027df 100644 --- a/app/models/activity_pub/concerns/json_ld_concern.rb +++ b/app/models/activity_pub/concerns/json_ld_concern.rb @@ -6,8 +6,6 @@ class ActivityPub extend ActiveSupport::Concern included do - validates :uri, presence: true, uniqueness: true - # Cuando asignamos contenido, obtener la URI si no lo hicimos ya before_save :uri_from_content!, unless: :uri? diff --git a/app/models/activity_pub/fediblock.rb b/app/models/activity_pub/fediblock.rb index 4abcb80f..17897d79 100644 --- a/app/models/activity_pub/fediblock.rb +++ b/app/models/activity_pub/fediblock.rb @@ -31,8 +31,8 @@ class ActivityPub class FediblockDownloadError < ::StandardError; end - validates_presence_of :title, :url, :download_url, :format - validates_inclusion_of :format, in: %w[mastodon fediblock] + validates_presence_of :title, :url, :format + validates_inclusion_of :format, in: %w[mastodon fediblock none] HOSTNAME_HEADERS = { 'mastodon' => '#domain', @@ -52,7 +52,7 @@ class ActivityPub def process! response = client.get(download_url) - raise FediblockDownloadError unless response.ok? + raise FediblockDownloadError unless response.success? Fediblock.transaction do csv = response.parsed_response diff --git a/app/models/activity_pub/instance.rb b/app/models/activity_pub/instance.rb index 749d98ac..cd14ef23 100644 --- a/app/models/activity_pub/instance.rb +++ b/app/models/activity_pub/instance.rb @@ -9,7 +9,7 @@ class ActivityPub include AASM validates :aasm_state, presence: true, inclusion: { in: %w[paused allowed blocked] } - validates :hostname, uniqueness: true, hostname: true + validates :hostname, uniqueness: true, hostname: { allow_numeric_hostname: true } has_many :activity_pubs has_many :actors diff --git a/app/models/activity_pub/object.rb b/app/models/activity_pub/object.rb index c196160f..d37c9b88 100644 --- a/app/models/activity_pub/object.rb +++ b/app/models/activity_pub/object.rb @@ -5,13 +5,62 @@ class ActivityPub class Object < ApplicationRecord include ActivityPub::Concerns::JsonLdConcern + before_validation :type_from_content!, unless: :type? + + # Los objetos son únicos a toda la base de datos + validates :uri, presence: true, url: true, uniqueness: true + validate :uri_is_content_id?, if: :content? + has_many :activity_pubs, as: :object # Encontrar le Actor por su relación con el objeto # # @return [ActivityPub::Actor,nil] def actor - ActivityPub::Actor.find_by(uri: content['actor']) + ActivityPub::Actor.find_by(uri: actor_uri) + end + + # @return [String] + def actor_uri + content['attributedTo'] + end + + def actor_type? + false + end + + def object_type? + true + end + + # Poder explorar propiedades remotas + # + # @return [DistributedPress::V1::Social::ReferencedObject] + def referenced(site) + require 'distributed_press/v1/social/referenced_object' + + @referenced ||= DistributedPress::V1::Social::ReferencedObject.new(object: content, dereferencer: site.social_inbox.dereferencer) + end + + private + + def uri_is_content_id? + return if self.uri == content['id'] + + errors.add(:activity_pub_objects, 'El ID del objeto no coincide con su URI') + end + + # Encuentra el tipo a partir del contenido, si existe. + # + # XXX: Si el objeto es una actividad, esto siempre va a ser + # Generic + def type_from_content! + self.type = + begin + "ActivityPub::Object::#{content['type'].presence || 'Generic'}".constantize + rescue NameError + ActivityPub::Object::Generic + end end end end diff --git a/app/models/activity_pub/object/application.rb b/app/models/activity_pub/object/application.rb index 99ac935c..d26a7757 100644 --- a/app/models/activity_pub/object/application.rb +++ b/app/models/activity_pub/object/application.rb @@ -5,6 +5,8 @@ # Una aplicación o instancia class ActivityPub class Object - class Application < ActivityPub::Object; end + class Application < ActivityPub::Object + include Concerns::ActorTypeConcern + end end end diff --git a/app/models/activity_pub/object/audio.rb b/app/models/activity_pub/object/audio.rb new file mode 100644 index 00000000..48caea44 --- /dev/null +++ b/app/models/activity_pub/object/audio.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +# = Audio = +# +# Representa artículos +class ActivityPub + class Object + class Audio < ActivityPub::Object; end + end +end diff --git a/app/models/activity_pub/object/concerns/actor_type_concern.rb b/app/models/activity_pub/object/concerns/actor_type_concern.rb new file mode 100644 index 00000000..b2a643c7 --- /dev/null +++ b/app/models/activity_pub/object/concerns/actor_type_concern.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +class ActivityPub + class Object + module Concerns + module ActorTypeConcern + extend ActiveSupport::Concern + + included do + # La URI de le Actor en este caso es la misma id + # + # @return [String] + def actor_uri + uri + end + + # El objeto referencia a une Actor + # + # @see {https://www.w3.org/TR/activitystreams-vocabulary/#actor-types} + def actor_type? + true + end + + # El objeto es un objeto + # + # @see {https://www.w3.org/TR/activitystreams-vocabulary/#object-types} + def object_type? + false + end + end + end + end + end +end diff --git a/app/models/activity_pub/object/document.rb b/app/models/activity_pub/object/document.rb new file mode 100644 index 00000000..d7444514 --- /dev/null +++ b/app/models/activity_pub/object/document.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +# = Document = +# +# Representa artículos +class ActivityPub + class Object + class Document < ActivityPub::Object; end + end +end diff --git a/app/models/activity_pub/object/event.rb b/app/models/activity_pub/object/event.rb new file mode 100644 index 00000000..9fa1f6fc --- /dev/null +++ b/app/models/activity_pub/object/event.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +# = Event = +# +# Representa artículos +class ActivityPub + class Object + class Event < ActivityPub::Object; end + end +end diff --git a/app/models/activity_pub/object/group.rb b/app/models/activity_pub/object/group.rb new file mode 100644 index 00000000..08d11d0d --- /dev/null +++ b/app/models/activity_pub/object/group.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +# = Group = +class ActivityPub + class Object + class Group < ActivityPub::Object + include Concerns::ActorTypeConcern + end + end +end diff --git a/app/models/activity_pub/object/image.rb b/app/models/activity_pub/object/image.rb new file mode 100644 index 00000000..9939a14b --- /dev/null +++ b/app/models/activity_pub/object/image.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +# = Image = +# +# Representa artículos +class ActivityPub + class Object + class Image < ActivityPub::Object; end + end +end diff --git a/app/models/activity_pub/object/organization.rb b/app/models/activity_pub/object/organization.rb index e3385232..e820c305 100644 --- a/app/models/activity_pub/object/organization.rb +++ b/app/models/activity_pub/object/organization.rb @@ -5,6 +5,8 @@ # Una organización class ActivityPub class Object - class Organization < ActivityPub::Object; end + class Organization < ActivityPub::Object + include Concerns::ActorTypeConcern + end end end diff --git a/app/models/activity_pub/object/page.rb b/app/models/activity_pub/object/page.rb new file mode 100644 index 00000000..f05503e2 --- /dev/null +++ b/app/models/activity_pub/object/page.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +# = Page = +# +# Representa artículos +class ActivityPub + class Object + class Page < ActivityPub::Object; end + end +end diff --git a/app/models/activity_pub/object/person.rb b/app/models/activity_pub/object/person.rb index a6a85d43..5bcab596 100644 --- a/app/models/activity_pub/object/person.rb +++ b/app/models/activity_pub/object/person.rb @@ -5,6 +5,8 @@ # Una persona, el perfil de une actore class ActivityPub class Object - class Person < ActivityPub::Object; end + class Person < ActivityPub::Object + include Concerns::ActorTypeConcern + end end end diff --git a/app/models/activity_pub/object/place.rb b/app/models/activity_pub/object/place.rb new file mode 100644 index 00000000..f04032ed --- /dev/null +++ b/app/models/activity_pub/object/place.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +# = Place = +# +# Representa artículos +class ActivityPub + class Object + class Place < ActivityPub::Object; end + end +end diff --git a/app/models/activity_pub/object/profile.rb b/app/models/activity_pub/object/profile.rb new file mode 100644 index 00000000..8f7183a2 --- /dev/null +++ b/app/models/activity_pub/object/profile.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +# = Profile = +# +# Representa artículos +class ActivityPub + class Object + class Profile < ActivityPub::Object; end + end +end diff --git a/app/models/activity_pub/object/relationship.rb b/app/models/activity_pub/object/relationship.rb new file mode 100644 index 00000000..ece995b4 --- /dev/null +++ b/app/models/activity_pub/object/relationship.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +# = Relationship = +# +# Representa artículos +class ActivityPub + class Object + class Relationship < ActivityPub::Object; end + end +end diff --git a/app/models/activity_pub/object/service.rb b/app/models/activity_pub/object/service.rb new file mode 100644 index 00000000..a276ea5b --- /dev/null +++ b/app/models/activity_pub/object/service.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +# = Service = +class ActivityPub + class Object + class Service < ActivityPub::Object + include Concerns::ActorTypeConcern + end + end +end diff --git a/app/models/activity_pub/object/tombstone.rb b/app/models/activity_pub/object/tombstone.rb new file mode 100644 index 00000000..88f136b9 --- /dev/null +++ b/app/models/activity_pub/object/tombstone.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +# = Tombstone = +# +# Representa artículos +class ActivityPub + class Object + class Tombstone < ActivityPub::Object; end + end +end diff --git a/app/models/activity_pub/object/video.rb b/app/models/activity_pub/object/video.rb new file mode 100644 index 00000000..fa4bbffb --- /dev/null +++ b/app/models/activity_pub/object/video.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +# = Video = +# +# Representa artículos +class ActivityPub + class Object + class Video < ActivityPub::Object; end + end +end diff --git a/app/models/activity_pub/remote_flag.rb b/app/models/activity_pub/remote_flag.rb index 25f1b743..c3cc0fb0 100644 --- a/app/models/activity_pub/remote_flag.rb +++ b/app/models/activity_pub/remote_flag.rb @@ -2,8 +2,10 @@ class ActivityPub class RemoteFlag < ApplicationRecord + IGNORED_EVENTS = [].freeze + IGNORED_STATES = [].freeze + include AASM - include AasmEventsConcern aasm do state :waiting, initial: true @@ -14,7 +16,7 @@ class ActivityPub transitions from: :waiting, to: :queued end - event :send do + event :report do transitions from: :queued, to: :sent end @@ -23,6 +25,9 @@ class ActivityPub end end + # Definir eventos en masa + include AasmEventsConcern + belongs_to :actor belongs_to :site @@ -37,10 +42,18 @@ class ActivityPub '@context' => 'https://www.w3.org/ns/activitystreams', 'id' => Rails.application.routes.url_helpers.v1_activity_pub_remote_flag_url(self, host: site.social_inbox_hostname), 'type' => 'Flag', - 'actor' => ENV.fetch('PANEL_ACTOR_ID') { "https://#{ENV['SUTTY']}/about.jsonld" }, + 'actor' => main_site.social_inbox.actor_id, 'content' => message.to_s, - 'object' => [ actor.uri ] + objects.pluck(:uri) + 'object' => [actor.uri] + objects.pluck(:uri) } end + + # Este es el sitio principal que actúa como origen del reporte. + # Tiene que tener la Social Inbox habilitada al mismo tiempo. + # + # @return [Site] + def main_site + @main_site ||= Site.find(ENV.fetch('PANEL_ACTOR_SITE_ID') { 1 }) + end end end diff --git a/app/models/actor_moderation.rb b/app/models/actor_moderation.rb index d7eea709..1c3cf83a 100644 --- a/app/models/actor_moderation.rb +++ b/app/models/actor_moderation.rb @@ -2,86 +2,69 @@ # Mantiene la relación entre Site y Actor class ActorModeration < ApplicationRecord - include AASM - include AasmEventsConcern + IGNORED_EVENTS = %i[remove].freeze + IGNORED_STATES = %i[removed].freeze - IGNORED_EVENTS = [] - IGNORED_STATES = [] + include AASM belongs_to :site - belongs_to :remote_flag, class_name: 'ActivityPub::RemoteFlag' + belongs_to :remote_flag, optional: true, class_name: 'ActivityPub::RemoteFlag' belongs_to :actor, class_name: 'ActivityPub::Actor' accepts_nested_attributes_for :remote_flag - # Bloquea todes les Actores bloqueables - def self.block_all! - self.update_all(aasm_state: 'blocked', updated_at: Time.now) - end - - def self.pause_all! - self.update_all(aasm_state: 'paused', updated_at: Time.now) - end - aasm do state :paused, initial: true state :allowed state :blocked state :reported + state :removed + + error_on_all_events do |e| + ExceptionNotifier.notify_exception(e, data: { site: site.name, actor: actor.uri, actor_moderation: id }) + end event :pause do - transitions from: %i[allowed blocked reported], to: :paused - - before do - pause_remotely! - end + transitions from: %i[allowed blocked reported], to: :paused, after: :synchronize! end + # Al permitir una cuenta no se permiten todos los comentarios + # pendientes de moderación que ya hizo. event :allow do - transitions from: %i[paused blocked reported], to: :allowed - - before do - allow_remotely! - end + transitions from: %i[paused blocked reported], to: :allowed, after: :synchronize! end + # Al bloquear una cuenta no se bloquean todos los comentarios + # pendientes de moderación que hizo. event :block do - transitions from: %i[paused allowed], to: :blocked - - before do - block_remotely! - end + transitions from: %i[paused allowed], to: :blocked, after: :synchronize! end # Al reportar, necesitamos asociar una RemoteFlag para poder # enviarla. event :report do - transitions from: %i[blocked], to: :reported + transitions from: %i[pause allowed blocked], to: :reported, after: :synchronize! - before do + after do ActivityPub::RemoteFlagJob.perform_later(remote_flag: remote_flag) if remote_flag.waiting? end end + + # Si un perfil es eliminado remotamente, tenemos que dejar de + # mostrarlo y todas sus actividades. + event :remove do + transitions to: :removed + + after do + site.activity_pubs.where(actor_id: actor_id).remove_all! + end + end end - def pause_remotely! - raise AASM::InvalidTransition unless - actor.mention && - site.social_inbox.allowlist.delete(list: [actor.mention]).ok? && - site.social_inbox.blocklist.delete(list: [actor.mention]).ok? - end + # Definir eventos en masa + include AasmEventsConcern - def allow_remotely! - raise AASM::InvalidTransition unless - actor.mention && - site.social_inbox.allowlist.post(list: [actor.mention]).ok? && - site.social_inbox.blocklist.delete(list: [actor.mention]).ok? - end - - def block_remotely! - raise AASM::InvalidTransition unless - actor.mention && - site.social_inbox.allowlist.delete(list: [actor.mention]).ok? && - site.social_inbox.blocklist.post(list: [actor.mention]).ok? + def synchronize! + ActivityPub::SyncListsJob.perform_later(site: site) end end diff --git a/app/models/concerns/aasm_events_concern.rb b/app/models/concerns/aasm_events_concern.rb index 418368d8..788e9e1a 100644 --- a/app/models/concerns/aasm_events_concern.rb +++ b/app/models/concerns/aasm_events_concern.rb @@ -16,7 +16,7 @@ module AasmEventsConcern # # @return [Array] def self.transitionable_events(current_state) - self.events.select do |event| + events.select do |event| aasm.events.find { |x| x.name == event }.transitions_from_state? current_state end end @@ -27,5 +27,35 @@ module AasmEventsConcern def self.states aasm.states.map(&:name) - self::IGNORED_STATES end + + # Define un método que cambia el estado para todos los objetos del + # scope actual. + # + # @return [Bool] Si hubo al menos un error, devuelve false. + aasm.events.map(&:name).each do |event| + define_singleton_method(:"#{event}_all!") do + successes = [] + + find_each do |object| + successes << (object.public_send(:"may_#{event}?") && object.public_send(:"#{event}!")) + end + + successes.all? + end + + # Ejecuta la transición del evento en la base de datos sin + # ejecutar los callbacks, sin modificar los items del scope que no + # pueden transicionar. + # + # @return [Integer] Registros modificados + define_singleton_method(:"#{event}_all_without_callbacks!") do + aasm_event = aasm.events.find { |e| e.name == event } + to_state = aasm_event.transitions.map(&:to).first + from_states = aasm_event.transitions.map(&:from) + + unscope(where: :aasm_state).where(aasm_state: from_states).update_all(aasm_state: to_state, + updated_at: Time.now) + end + end end end diff --git a/app/models/deploy_social_distributed_press.rb b/app/models/deploy_social_distributed_press.rb index eec8189b..e7f97406 100644 --- a/app/models/deploy_social_distributed_press.rb +++ b/app/models/deploy_social_distributed_press.rb @@ -7,16 +7,16 @@ class DeploySocialDistributedPress < Deploy # Solo luego de publicar remotamente DEPENDENCIES = %i[deploy_distributed_press deploy_rsync deploy_full_rsync].freeze - after_save :create_hooks! - after_create :enable_fediblocks! - # Envía las notificaciones def deploy(output: false) with_tempfile(site.private_key_pem) do |file| key = Shellwords.escape file.path dest = Shellwords.escape destination - run %(bundle exec jekyll notify --trace --key #{key} --destination "#{dest}"), output: output + run(%(bundle exec jekyll notify --trace --key #{key} --destination "#{dest}"), output: output).tap do |_| + create_hooks! + enable_fediblocks! + end end end @@ -84,7 +84,7 @@ class DeploySocialDistributedPress < Deploy response = hook_client.put(event: event, hook: webhook) - raise ArgumentError, response.body unless response.ok? + raise ArgumentError, response.body unless response.success? rescue ArgumentError => e ExceptionNotifier.notify_exception(e, data: { site_id: site.name, usuarie_id: rol.usuarie_id }) end diff --git a/app/models/fediblock_state.rb b/app/models/fediblock_state.rb index 180a45b5..82912f76 100644 --- a/app/models/fediblock_state.rb +++ b/app/models/fediblock_state.rb @@ -20,84 +20,61 @@ class FediblockState < ApplicationRecord # Aunque queramos las listas habilitadas por defecto, tenemos que # habilitarlas luego de crearlas para poder generar la lista de # bloqueo en la Social Inbox. - state :disabled, initial: true - state :enabled + state :disabled, initial: true, before_enter: :pause_unique_instances! + state :enabled, before_enter: :block_instances! + + error_on_all_events do |e| + ExceptionNotifier.notify_exception(e, data: { site: site.name, fediblock: id }) + end event :enable do transitions from: :disabled, to: :enabled - - before do - enable_remotely! - - # Al actualizar el estado en masa garantizamos que las - # instancias que ya existen queden sincronizadas con el bloqueo - # en masa que acabamos de hacer. - instance_moderations.block_all! - - # Luego esta tarea crea las que falten e ignora las que ya se - # bloquearon. - ActivityPub::InstanceModerationJob.perform_now(site: site, hostnames: fediblock.hostnames) - - # Bloquear a todes les Actores de las instancias bloqueadas para - # indicarle a le usuarie que les tiene que desbloquear - # manualmente. - ActorModeration.where(actor_id: actor_ids).paused.block_all! - end end - # Al deshabilitar, las listas pasan a modo pausa. + # Al deshabilitar, las listas pasan a modo pausa, a menos que estén + # activas en otros listados. # # @todo No cambiar el estado si se habían habilitado manualmente, # pero esto implica que tenemos que encontrar las que sí y quitarlas # de list_names event :disable do - transitions from: :enabled, to: :disabled - - before do - disable_remotely! - - instance_moderations.pause_all! - - # Volver a pausar todes les actores de esta instancia que fueron - # bloqueades. - ActorModeration.where(actor_id: actor_ids).blocked.pause_all! - end + transitions from: :enabled, to: :disabled, after: :synchronize! end end private - def actor_ids - ActivityPub::Actor.where(instance_id: instance_ids).pluck(:id) + def block_instances! + ActivityPub::InstanceModerationJob.perform_later(site: site, hostnames: fediblock.hostnames, perform_remotely: false) end - def instance_ids - fediblock.instances.pluck(:id) + # Pausar todas las moderaciones de las instancias que no estén + # bloqueadas por otros fediblocks. + def pause_unique_instances! + instance_ids = ActivityPub::Instance.where(hostname: unique_hostnames).ids + site.instance_moderations.where(instance_id: instance_ids).pause_all_without_callbacks! end - # Todas las instancias de moderación de este sitio - def instance_moderations - site.instance_moderations.where(instance_id: instance_ids) + def synchronize! + ActivityPub::SyncListsJob.perform_later(site: site) end + # Devuelve los hostnames únicos a esta instancia. + # # @return [Array] - def list_names - @list_names ||= fediblock.instances.map do |instance| - "@*@#{instance}" - end - end + def unique_hostnames + @unique_hostnames ||= + begin + other_enabled_fediblock_ids = + site.fediblock_states.enabled.where.not(id: id).pluck(:fediblock_id) + other_enabled_hostnames = + ActivityPub::Fediblock + .where(id: other_enabled_fediblock_ids) + .pluck(:hostnames) + .flatten + .uniq - # Al deshabilitar, las instancias pasan a ser analizadas caso por caso - def disable_remotely! - raise AASM::InvalidTransition unless - site.social_inbox.blocklist.delete(list: list_names).ok? && - site.social_inbox.allowlist.delete(list: list_names).ok? - end - - # Al habilitar, se bloquean todas las instancias de la lista - def enable_remotely! - raise AASM::InvalidTransition unless - site.social_inbox.blocklist.post(list: list_names).ok? && - site.social_inbox.allowlist.delete(list: list_names).ok? + fediblock.hostnames - other_enabled_hostnames + end end end diff --git a/app/models/instance_moderation.rb b/app/models/instance_moderation.rb index 7447cc89..5a1a5ed6 100644 --- a/app/models/instance_moderation.rb +++ b/app/models/instance_moderation.rb @@ -2,84 +2,46 @@ # Mantiene el registro de relaciones entre sitios e instancias class InstanceModeration < ApplicationRecord - include AASM - include AasmEventsConcern + IGNORED_EVENTS = [].freeze + IGNORED_STATES = [].freeze - IGNORED_EVENTS = [] - IGNORED_STATES = [] + include AASM belongs_to :site belongs_to :instance, class_name: 'ActivityPub::Instance' - # Traer todas las instancias bloqueables, según la máquina de estados, - # todas las que no estén bloqueadas ya. - scope :may_block, -> { where.not(aasm_state: 'blocked') } - scope :may_pause, -> { where.not(aasm_state: 'paused') } - - # Bloquear instancias en masa - def self.block_all! - self.may_block.update_all(aasm_state: 'blocked', updated_at: Time.now) - end - - # Pausar instancias en masa - def self.pause_all! - self.may_pause.update_all(aasm_state: 'paused', updated_at: Time.now) - end - aasm do state :paused, initial: true state :allowed state :blocked + error_on_all_events do |e| + ExceptionNotifier.notify_exception(e, data: { site: site.name, instance: instance.hostname, instance_moderation: id }) + end + + after_all_events do + ActivityPub::SyncListsJob.perform_later(site: site) + end + + # Al volver la instancia a pausa no cambiamos el estado de + # moderación de actores pre-existente. event :pause do transitions from: %i[allowed blocked], to: :paused - - before do - pause_remotely! - end end + # Al permitir, solo bloqueamos la instancia, sin modificar el estado + # de les actores y comentarios retroactivamente. event :allow do transitions from: %i[paused blocked], to: :allowed - - before do - allow_remotely! - end end + # Al bloquear, solo bloqueamos la instancia, sin modificar el estado + # de les actores y comentarios retroactivamente. event :block do transitions from: %i[paused allowed], to: :blocked - - before do - block_remotely! - end end end - # Elimina la instancia de todas las listas - # - # @return [Boolean] - def pause_remotely! - raise AASM::InvalidTransition unless - site.social_inbox.blocklist.delete(list: [instance.list_name]).ok? && - site.social_inbox.allowlist.delete(list: [instance.list_name]).ok? - end - - # Deja de permitir la instancia - # - # @return [Boolean] - def block_remotely! - raise AASM::InvalidTransition unless - site.social_inbox.allowlist.delete(list: [instance.list_name]).ok? && - site.social_inbox.blocklist.post(list: [instance.list_name]).ok? - end - - # Permite la instancia - # - # @return [Boolean] - def allow_remotely! - raise AASM::InvalidTransition unless - site.social_inbox.blocklist.delete(list: [instance.list_name]).ok? && - site.social_inbox.allowlist.post(list: [instance.list_name]).ok? - end + # Definir eventos en masa + include AasmEventsConcern end diff --git a/app/models/moderation_queue.rb b/app/models/moderation_queue.rb new file mode 100644 index 00000000..31ca3c9b --- /dev/null +++ b/app/models/moderation_queue.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +ModerationQueue = Struct.new(:site) diff --git a/app/models/que_job.rb b/app/models/que_job.rb new file mode 100644 index 00000000..0bfffc92 --- /dev/null +++ b/app/models/que_job.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +require 'que/active_record/model' + +class QueJob < Que::ActiveRecord::Model; end diff --git a/app/models/site/social_distributed_press.rb b/app/models/site/social_distributed_press.rb index 0716a670..8d8d60d4 100644 --- a/app/models/site/social_distributed_press.rb +++ b/app/models/site/social_distributed_press.rb @@ -19,6 +19,29 @@ class Site before_save :generate_private_key_pem!, unless: :private_key_pem? + def moderation_enabled? + deploy_social_inbox.present? + end + + def deploy_social_inbox + @deploy_social_inbox ||= deploys.find_by(type: 'DeploySocialDistributedPress') + end + + def moderation_checked! + deploy_social_inbox.touch + end + + # @return [Bool] + def moderation_needed? + return false unless moderation_enabled? + + last_activity_pub = activity_pubs.order(updated_at: :desc).first&.updated_at + + return false if last_activity_pub.blank? + + last_activity_pub > deploy_social_inbox.updated_at + end + # @return [SocialInbox] def social_inbox @social_inbox ||= SocialInbox.new(site: self) diff --git a/app/policies/instance_moderation_policy.rb b/app/policies/instance_moderation_policy.rb index 13ebfeca..c6a229d3 100644 --- a/app/policies/instance_moderation_policy.rb +++ b/app/policies/instance_moderation_policy.rb @@ -11,6 +11,6 @@ InstanceModerationPolicy = Struct.new(:usuarie, :instance_moderation) do # En este paso tenemos varias instancias por moderar pero todas son # del mismo sitio. def action_on_several? - instance_moderation.first.site.usuarie? usuarie + instance_moderation.first.presence && instance_moderation.first.site.usuarie?(usuarie) end end diff --git a/app/policies/moderation_queue_policy.rb b/app/policies/moderation_queue_policy.rb new file mode 100644 index 00000000..75a4c45a --- /dev/null +++ b/app/policies/moderation_queue_policy.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +# Si la cola de moderación está activada y le usuarie tiene permisos de +# usuarie. +ModerationQueuePolicy = Struct.new(:usuarie, :moderation_queue) do + def index? + moderation_queue.site.moderation_enabled? && moderation_queue.site.usuarie?(usuarie) + end +end diff --git a/app/processors/activity_pub_processor.rb b/app/processors/activity_pub_processor.rb index 52cdb6d3..501b73a5 100644 --- a/app/processors/activity_pub_processor.rb +++ b/app/processors/activity_pub_processor.rb @@ -6,7 +6,14 @@ class ActivityPubProcessor < Rubanok::Processor # # Por ahora solo queremos moderar comentarios. prepare do - raw.where(object_type: %w[ActivityPub::Object::Note ActivityPub::Object::Article]).order(updated_at: :desc) + raw + .joins(:activities) + .where( + activity_pub_activities: { + type: %w[ActivityPub::Activity::Create ActivityPub::Activity::Update] + }, + object_type: %w[ActivityPub::Object::Note ActivityPub::Object::Article] + ).order(updated_at: :desc) end map :activity_pub_state, activate_always: true do |activity_pub_state: 'paused'| diff --git a/app/validators/url_validator.rb b/app/validators/url_validator.rb new file mode 100644 index 00000000..291f9288 --- /dev/null +++ b/app/validators/url_validator.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +# Valida URLs +# +# @see {https://storck.io/posts/better-http-url-validation-in-ruby-on-rails/} +class UrlValidator < ActiveModel::EachValidator + def validate_each(record, attribute, value) + if value.blank? + record.errors.add(attribute, :url_missing) + return + end + + uri = URI.parse(value) + + record.errors.add(attribute, :scheme_missing) if uri.scheme.blank? + record.errors.add(attribute, :host_missing) if uri.host.blank? + record.errors.add(attribute, :path_missing) if uri.path.blank? + rescue URI::Error + record.errors.add(attribute, :invalid) + end +end diff --git a/app/views/actor_moderations/show.haml b/app/views/actor_moderations/show.haml index 633c1be5..ca5764f4 100644 --- a/app/views/actor_moderations/show.haml +++ b/app/views/actor_moderations/show.haml @@ -5,4 +5,4 @@ .col-12.col-md-8 = render 'components/profiles_btn_box', actor_moderation: @actor_moderation .col-12.col-md-8 - = render 'moderation_queue/comments', moderation_queue: @moderation_queue + = render 'moderation_queue/comments', site: @site, moderation_queue: @moderation_queue diff --git a/app/views/components/_actor.haml b/app/views/components/_actor.haml index 3983d617..c58beae0 100644 --- a/app/views/components/_actor.haml +++ b/app/views/components/_actor.haml @@ -1,5 +1,7 @@ -# Componente Remote_Profile +- uri = text_plain(remote_profile['id']) + .py-2 %dl %dt= t('.profile_name') @@ -10,7 +12,7 @@ %dt= t('.profile_id') %dd - = link_to text_plain(remote_profile['id']) + = link_to uri, uri - if remote_profile['published'].present? %dt= t('.profile_published') diff --git a/app/views/components/_btn_base.haml b/app/views/components/_btn_base.haml index 4d8566d3..fed3254c 100644 --- a/app/views/components/_btn_base.haml +++ b/app/views/components/_btn_base.haml @@ -1,9 +1,8 @@ -# Componente Botón general Moderación - local_assigns[:method] ||= 'patch' -- local_assigns[:class] ||= 'btn-secondary' - local_assigns[:class] = "btn #{local_assigns[:class]}" +- local_assigns.delete(:text) --# @todo path es obligatorio -= button_to local_assigns[:path], **local_assigns do += button_to(path, **local_assigns.compact) do = text diff --git a/app/views/components/_checkbox.haml b/app/views/components/_checkbox.haml index 68f1a663..a58c85b7 100644 --- a/app/views/components/_checkbox.haml +++ b/app/views/components/_checkbox.haml @@ -1,5 +1,6 @@ -# Componente Checkbox - local_assigns[:name] ||= id + .custom-control.custom-checkbox - %input.custom-control-input{ form: local_assigns[:form_id], type: 'checkbox', id: id, **local_assigns } + %input.custom-control-input{ type: 'checkbox', id: id, **local_assigns.compact } %label.custom-control-label{ for: id }= yield diff --git a/app/views/components/_comments_btn_box.haml b/app/views/components/_comments_btn_box.haml index 285eefdb..578f6662 100644 --- a/app/views/components/_comments_btn_box.haml +++ b/app/views/components/_comments_btn_box.haml @@ -1,8 +1,14 @@ -# Componente Botonera de Comentarios +- local = { reject: { data: { confirm: t('.confirm_reject') } }, report: { class: 'ml-auto', data: { confirm: t('.confirm_report') } } } + .d-flex.flex-row - ActivityPub.events.each do |event| - = render 'components/btn_base', - text: t(".text_#{event}"), - path: public_send(:"site_activity_pub_#{event}_path", activity_pub_id: activity_pub), - disabled: !activity_pub.public_send(:"may_#{event}?") + - possible = activity_pub.public_send(:"may_#{event}?") + %div{ class: local.dig(event, :class) } + = render 'components/btn_base', + text: t(".text_#{event}"), + path: public_send(:"site_activity_pub_#{event}_path", activity_pub_id: activity_pub), + class: ('btn-secondary' if possible), + disabled: !possible, + data: local.dig(event, :data) diff --git a/app/views/components/_comments_checked_submenu.haml b/app/views/components/_comments_checked_submenu.haml index a09da426..d94e12a9 100644 --- a/app/views/components/_comments_checked_submenu.haml +++ b/app/views/components/_comments_checked_submenu.haml @@ -1,6 +1,9 @@ +-# + @param form [String] + - current_state = params[:activity_pub_state]&.to_sym || ActivityPub.states.first - ActivityPub.aasm.events.each do |event| - next if ActivityPub::IGNORED_EVENTS.include? event.name - next unless event.transitions_from_state?(current_state) - = render 'components/dropdown_button', form_id: form_id, text: t(".submenu_#{event.name}"), name: 'activity_pub_action', value: event.name + = render 'components/dropdown_button', form: form, text: t(".submenu_#{event.name}"), name: 'activity_pub_action', value: event.name diff --git a/app/views/components/_comments_filters.haml b/app/views/components/_comments_filters.haml index 35cd5dda..b2870c5a 100644 --- a/app/views/components/_comments_filters.haml +++ b/app/views/components/_comments_filters.haml @@ -1,9 +1,12 @@ +-# + @params form [String] + - current_state = params[:activity_pub_state]&.to_sym || ActivityPub.states.first -.d-flex.py-2 +.d-flex.flex-row.justify-content-between.py-2 - if ActivityPub.transitionable_events(current_state).present? = render 'components/dropdown', text: t('.text_checked') do - = render 'components/comments_checked_submenu', form_id: form_id + = render 'components/comments_checked_submenu', form: form = render 'components/dropdown', text: t('.text_show') do = render 'components/comments_show_submenu', activity_pubs: activity_pubs diff --git a/app/views/components/_comments_show_submenu.haml b/app/views/components/_comments_show_submenu.haml index 60c02501..9964a62a 100644 --- a/app/views/components/_comments_show_submenu.haml +++ b/app/views/components/_comments_show_submenu.haml @@ -1,4 +1,5 @@ - ActivityPub.states.each do |state| = render 'components/dropdown_item', text: t(".submenu_#{state}", count: activity_pubs.unscope(where: :aasm_state).public_send(state).count), - path: filter_states(activity_pub_state: state) + path: filter_states(activity_pub_state: state), + class: ('active' if active?(ActivityPub.states, :activity_pub_state, state)) diff --git a/app/views/components/_dropdown.haml b/app/views/components/_dropdown.haml index 54ddcffb..6f34950b 100644 --- a/app/views/components/_dropdown.haml +++ b/app/views/components/_dropdown.haml @@ -11,7 +11,7 @@ controller: 'dropdown' } } - %button.btn.dropdown-toggle{ + %button.btn.btn-outline-secondary.dropdown-toggle{ type: 'button', class: button_classes, data: { diff --git a/app/views/components/_dropdown_button.haml b/app/views/components/_dropdown_button.haml index c8c98209..d6de6c8e 100644 --- a/app/views/components/_dropdown_button.haml +++ b/app/views/components/_dropdown_button.haml @@ -1,4 +1,6 @@ -# @param name [String] @param value [String] -%button.dropdown-item{type: 'submit', data: { target: 'dropdown.item' }, name: name, value: value, form: local_assigns[:form_id] }= text + @param text [String] +- local_assigns.delete(:text) +%button.dropdown-item{type: 'submit', data: { target: 'dropdown.item' }, name: name, value: value, **local_assigns.compact }= text diff --git a/app/views/components/_dropdown_item.haml b/app/views/components/_dropdown_item.haml index e5b16950..a4d363a8 100644 --- a/app/views/components/_dropdown_item.haml +++ b/app/views/components/_dropdown_item.haml @@ -1,4 +1,5 @@ -# @param :text [String] Contenido del link @param :path [String,Hash] Link -= link_to text, path, class: 'dropdown-item', data: { target: 'dropdown.item' } +- local_assigns[:class] = "dropdown-item #{local_assigns[:class]}" += link_to text, path, class: local_assigns[:class], data: { target: 'dropdown.item' } diff --git a/app/views/components/_instances_btn_box.haml b/app/views/components/_instances_btn_box.haml index 74cad4a4..8c3a5f88 100644 --- a/app/views/components/_instances_btn_box.haml +++ b/app/views/components/_instances_btn_box.haml @@ -1,6 +1,11 @@ -# Componente botonera de moderación de Instancias -- btn_class = 'btn btn-secondary' -= render 'components/btn_base', path: site_instance_moderation_pause_path(instance_moderation_id: instance_moderation), text: t('.text_check'), class: btn_class, disabled: !instance_moderation.may_pause? -= render 'components/btn_base', path: site_instance_moderation_allow_path(instance_moderation_id: instance_moderation), text: t('.text_allow'), class: btn_class, disabled: !instance_moderation.may_allow? -= render 'components/btn_base', path: site_instance_moderation_block_path(instance_moderation_id: instance_moderation), text: t('.text_deny'), class: btn_class, disabled: !instance_moderation.may_block? +- local_data = {} +- InstanceModeration.events.each do |event| + - possible = instance_moderation.public_send(:"may_#{event}?") + = render 'components/btn_base', + path: public_send(:"site_instance_moderation_#{event}_path", instance_moderation_id: instance_moderation), + text: t(".text_#{event}"), + class: ('btn-secondary' if possible), + disabled: !possible, + data: local_data[event] diff --git a/app/views/components/_instances_checked_submenu.haml b/app/views/components/_instances_checked_submenu.haml index 4c45b7ab..7c9dbd87 100644 --- a/app/views/components/_instances_checked_submenu.haml +++ b/app/views/components/_instances_checked_submenu.haml @@ -1,2 +1,5 @@ +-# + @params form [String] + - InstanceModeration.transitionable_events(current_state).each do |event| - = render 'components/dropdown_button', text: t(".submenu_#{event}"), name: 'instance_moderation_action', value: event, form_id: form_id + = render 'components/dropdown_button', text: t(".submenu_#{event}"), name: 'instance_moderation_action', value: event, form: form diff --git a/app/views/components/_instances_filters.haml b/app/views/components/_instances_filters.haml index 2c23fd72..f2296c7b 100644 --- a/app/views/components/_instances_filters.haml +++ b/app/views/components/_instances_filters.haml @@ -1,9 +1,12 @@ +-# + @params form [String] + - current_state = params[:state]&.to_sym || InstanceModeration.states.first -.d-flex.py-2 +.d-flex.flex-row.justify-content-between.py-2 - if InstanceModeration.transitionable_events(current_state).present? = render 'components/dropdown', text: t('.text_checked') do - = render 'components/instances_checked_submenu', form_id: form_id, current_state: current_state + = render 'components/instances_checked_submenu', form: form, current_state: current_state = render 'components/dropdown', text: t('.text_show') do = render 'components/instances_show_submenu', instance_moderations: instance_moderations diff --git a/app/views/components/_instances_show_submenu.haml b/app/views/components/_instances_show_submenu.haml index c56df547..6b9b747e 100644 --- a/app/views/components/_instances_show_submenu.haml +++ b/app/views/components/_instances_show_submenu.haml @@ -1,4 +1,5 @@ - InstanceModeration.states.each do |state| = render 'components/dropdown_item', text: t(".submenu_#{state}", count: instance_moderations.unscope(where: :aasm_state).public_send(state).count), - path: filter_states(instance_state: state) + path: filter_states(instance_state: state), + class: ('active' if active?(InstanceModeration.states, :instance_state, state)) diff --git a/app/views/components/_profiles_btn_box.haml b/app/views/components/_profiles_btn_box.haml index 073c142e..8fc8dd39 100644 --- a/app/views/components/_profiles_btn_box.haml +++ b/app/views/components/_profiles_btn_box.haml @@ -1,9 +1,12 @@ -# Componente Botonera de Moderación de Cuentas (Remote_profile) -.d-flex.flex-row - - btn_class = 'btn-secondary' +.d-flex.flex-row.w-100 + - local = { report: { class: 'ml-auto', data: { confirm: t('.confirm_report') } } } - ActorModeration.events.each do |actor_event| - = render 'components/btn_base', - text: t(".text_#{actor_event}"), - path: public_send(:"site_actor_moderation_#{actor_event}_path", actor_moderation_id: actor_moderation), - class: btn_class, - disabled: !actor_moderation.public_send(:"may_#{actor_event}?") + - possible = !actor_moderation.public_send(:"may_#{actor_event}?") + %div{ class: local.dig(actor_event, :class) } + = render 'components/btn_base', + text: t(".text_#{actor_event}"), + path: public_send(:"site_actor_moderation_#{actor_event}_path", actor_moderation_id: actor_moderation), + class: ('btn-secondary' if possible), + disabled: !possible, + data: local.dig(actor_event, :data) diff --git a/app/views/components/_profiles_checked_submenu.haml b/app/views/components/_profiles_checked_submenu.haml index 66a0fa78..04c86fd4 100644 --- a/app/views/components/_profiles_checked_submenu.haml +++ b/app/views/components/_profiles_checked_submenu.haml @@ -1,2 +1,5 @@ +-# + @params form [String] + - ActorModeration.transitionable_events(current_state).each do |actor_event| - = render 'components/dropdown_button', text: t(".submenu_#{actor_event}"), name: 'actor_moderation_action', value: actor_event, form_id: form_id + = render 'components/dropdown_button', text: t(".submenu_#{actor_event}"), name: 'actor_moderation_action', value: actor_event, form: form diff --git a/app/views/components/_profiles_filters.haml b/app/views/components/_profiles_filters.haml index bf7fb48a..c2670944 100644 --- a/app/views/components/_profiles_filters.haml +++ b/app/views/components/_profiles_filters.haml @@ -1,9 +1,12 @@ +-# + @params form [String] + - current_state = params[:actor_state]&.to_sym || ActorModeration.states.first -.d-flex.py-2 +.d-flex.flex-row.justify-content-between.py-2 - if ActorModeration.transitionable_events(current_state).present? = render 'components/dropdown', text: t('.text_checked') do - = render 'components/profiles_checked_submenu', form_id: form_id, current_state: current_state + = render 'components/profiles_checked_submenu', form: form, current_state: current_state = render 'components/dropdown', text: t('.text_show') do = render 'components/profiles_show_submenu', actor_moderations: actor_moderations diff --git a/app/views/components/_profiles_show_submenu.haml b/app/views/components/_profiles_show_submenu.haml index 99694698..bebfbe20 100644 --- a/app/views/components/_profiles_show_submenu.haml +++ b/app/views/components/_profiles_show_submenu.haml @@ -1,4 +1,5 @@ - ActorModeration.states.each do |actor_state| = render 'components/dropdown_item', text: t(".submenu_#{actor_state}", count: actor_moderations.unscope(where: :aasm_state).public_send(actor_state).count), - path: filter_states(actor_state: actor_state) + path: filter_states(actor_state: actor_state), + class: ('active' if active?(ActorModeration.states, :actor_state, actor_state)) diff --git a/app/views/components/_select_all.haml b/app/views/components/_select_all.haml index 68711c4a..9778cd13 100644 --- a/app/views/components/_select_all.haml +++ b/app/views/components/_select_all.haml @@ -1,4 +1,4 @@ -# @param id [String] -= render 'components/checkbox', id: id, form: local_assigns[:form_id], data: { action: 'select-all#toggle', target: 'select-all.toggle' } do += render 'components/checkbox', id: id, data: { action: 'select-all#toggle', target: 'select-all.toggle', **local_assigns.compact } do %span.sr-only= t('.label') diff --git a/app/views/components/_select_all_container.haml b/app/views/components/_select_all_container.haml index 5fa91e2d..8c8d9426 100644 --- a/app/views/components/_select_all_container.haml +++ b/app/views/components/_select_all_container.haml @@ -7,7 +7,7 @@ navegador los va a asignar a este formulario. @param path [String] - @param form_id [String] + @param form [String] -= form_tag path, id: form_id, method: :patch do += form_tag path, id: form, method: :patch do -# nada diff --git a/app/views/moderation_queue/_account.haml b/app/views/moderation_queue/_account.haml index f63b6f6f..498d78f4 100644 --- a/app/views/moderation_queue/_account.haml +++ b/app/views/moderation_queue/_account.haml @@ -1,13 +1,16 @@ +-# + @params form [String] + .row.no-gutters.pt-2 .col-1 - = render 'components/checkbox', id: actor_moderation.id, form_id: form_id, name: 'actor_moderation[]', value: actor_moderation.id, data: { target: 'select-all.input' } + = render 'components/checkbox', id: actor_moderation.id, form: form, name: 'actor_moderation[]', value: actor_moderation.id, data: { target: 'select-all.input' } .col-11 - %h4 - = link_to text_plain(profile['name']), site_actor_moderation_path(id: actor_moderation) - .mb-3 - = sanitize profile['summary'] + - cache [actor_moderation, profile] do + %h4 + = link_to text_plain(profile['name']), site_actor_moderation_path(id: actor_moderation) + .mb-3 + = sanitize profile['summary'] -# Botones de Moderación - - cache actor_moderation do - .d-flex.pb-4 - = render 'components/profiles_btn_box', actor_moderation: actor_moderation + .d-flex.pb-4 + = render 'components/profiles_btn_box', actor_moderation: actor_moderation diff --git a/app/views/moderation_queue/_accounts.haml b/app/views/moderation_queue/_accounts.haml index 65ff953f..257b0fbf 100644 --- a/app/views/moderation_queue/_accounts.haml +++ b/app/views/moderation_queue/_accounts.haml @@ -1,17 +1,17 @@ - form_id = 'actor_moderations_action_on_several' -= render 'components/select_all_container', path: site_actor_moderations_action_on_several_path, form_id: form_id += render 'components/select_all_container', path: site_actor_moderations_action_on_several_path, form: form_id .row.no-gutters.pt-2{ data: { controller: 'select-all' } } .col-1.d-flex.align-items-center - = render 'components/select_all', id: 'actors', form_id: form_id + = render 'components/select_all', id: 'actors', form: form_id .col-11 -# Filtros - = render 'components/profiles_filters', actor_moderations: actor_moderations, form_id: form_id + = render 'components/profiles_filters', actor_moderations: actor_moderations, form: form_id .col-12 - if actor_moderations.count.zero? %h4= t('moderation_queue.nothing') - actor_moderations.find_each do |actor_moderation| - - cache [actor_moderation, actor_moderation.actor] do - %hr - = render 'account', actor_moderation: actor_moderation, profile: actor_moderation.actor.content, form_id: form_id + - next if actor_moderation.actor.content.empty? + %hr + = render 'account', actor_moderation: actor_moderation, profile: actor_moderation.actor.content, form: form_id diff --git a/app/views/moderation_queue/_block_instances_textarea.haml b/app/views/moderation_queue/_block_instances_textarea.haml index 9729d4de..7daf0410 100644 --- a/app/views/moderation_queue/_block_instances_textarea.haml +++ b/app/views/moderation_queue/_block_instances_textarea.haml @@ -1,3 +1,3 @@ .form-group = label_tag 'custom_blocklist', t('moderation_queue.instances.custom_block') - = text_area_tag 'custom_blocklist', nil, class: 'form-control' + = text_area_tag 'custom_blocklist', nil, class: 'form-control', placeholder: t('moderation_queue.instances.custom_block_placeholder') diff --git a/app/views/moderation_queue/_comment.haml b/app/views/moderation_queue/_comment.haml index 33ebc722..a80bd27c 100644 --- a/app/views/moderation_queue/_comment.haml +++ b/app/views/moderation_queue/_comment.haml @@ -1,33 +1,47 @@ -# Componente Comentario + @param site [Site] + @param form [String] @param profile [Hash] @param comment [Hash] @param activity_pub [ActivityPub] - in_reply_to = text_plain comment['inReplyTo'] +:ruby + begin + if in_reply_to && (remote_object = object.referenced(site)['inReplyTo']) + in_reply_to = ActivityPub.url_from_object(remote_object) + end + rescue Exception => e + ExceptionNotifier.notify_exception(e, data: { site: site.name, object: comment }) + end - summary = text_plain comment['summary'] +-# @todo Generar un desplegable con todas las opciones +- url = text_plain ActivityPub.url_from_object(comment) .row.no-gutters .col-1 - = render 'components/checkbox', id: activity_pub.id, name: 'activity_pub[]', value: activity_pub.id, data: { target: 'select-all.input' }, form: form_id + = render 'components/checkbox', id: activity_pub.id, name: 'activity_pub[]', value: activity_pub.id, data: { target: 'select-all.input' }, form: form .col-11 - .d-flex.flex-row.align-items-center.justify-content-between - %h4.mb-0 - %a{ href: text_plain(comment['attributedTo']) }= text_plain profile['preferredUsername'] - %small - = render 'layouts/time', time: text_plain(comment['published']) - - if in_reply_to.present? - %dl - %dt.d-inline - %small= t('.reply_to') - %dd.d-inline + - cache [activity_pub, comment] do + .d-flex.flex-row.align-items-center.justify-content-between + %h4.mb-0 + %a{ href: text_plain(comment['attributedTo']) }= text_plain profile['preferredUsername'] + %a{ href: url } %small - %a{ href: in_reply_to }= in_reply_to - .content - - if summary.present? - = render 'layouts/details', summary: summary, summary_class: 'h5' do + = render 'layouts/time', time: text_plain(comment['published']) + - if in_reply_to.present? + %dl + %dt.d-inline + %small= t('.reply_to') + %dd.d-inline + %small + %a{ href: in_reply_to }= in_reply_to + .content.mb-3 + - if summary.present? + = render 'layouts/details', summary: summary, summary_class: 'h5' do + = sanitize comment['content'] + - else = sanitize comment['content'] - - else - = sanitize comment['content'] = render 'components/comments_btn_box', activity_pub: activity_pub diff --git a/app/views/moderation_queue/_comments.haml b/app/views/moderation_queue/_comments.haml index 436777db..a7523517 100644 --- a/app/views/moderation_queue/_comments.haml +++ b/app/views/moderation_queue/_comments.haml @@ -1,17 +1,18 @@ - form_id = 'activity_pub_action_on_several' -= render 'components/select_all_container', path: site_activity_pubs_action_on_several_path, form_id: form_id += render 'components/select_all_container', path: site_activity_pubs_action_on_several_path, form: form_id .row.no-gutters.pt-2{ data: { controller: 'select-all' } } .col-1.d-flex.align-items-center - = render 'components/select_all', id: 'select-all-comments', form_id: form_id + = render 'components/select_all', id: 'select-all-comments', form: form_id .col-11 -# Filtros - = render 'components/comments_filters', activity_pubs: moderation_queue, form_id: form_id + = render 'components/comments_filters', activity_pubs: moderation_queue, form: form_id .col-12 - if moderation_queue.count.zero? %h4= t('moderation_queue.nothing') - moderation_queue.each do |activity_pub| - -# cache [activity_pub, activity_pub.object, activity_pub.actor] do + - next if activity_pub.object.content.empty? + - next if activity_pub.actor.content.empty? %hr - = render 'comment', comment: activity_pub.object.content, profile: activity_pub.actor.content, activity_pub: activity_pub, form_id: form_id + = render 'moderation_queue/comment', comment: activity_pub.object.content, profile: activity_pub.actor.content, activity_pub: activity_pub, form: form_id, site: site, object: activity_pub.object diff --git a/app/views/moderation_queue/_instance.haml b/app/views/moderation_queue/_instance.haml index 05510724..c380089a 100644 --- a/app/views/moderation_queue/_instance.haml +++ b/app/views/moderation_queue/_instance.haml @@ -1,18 +1,22 @@ - usuaries = instance.content.dig('usage', 'users', 'active_month') - usuaries ||= instance.content.dig('stats', 'user_count') +- title = sanitize(instance.content['title']) .row.no-gutters.pt-2 .col-1 - = render 'components/checkbox', id: instance.hostname, form_id: form_id, name: 'instance_moderation[]', value: instance_moderation.id, data: { target: 'select-all.input' } + = render 'components/checkbox', id: instance.hostname, form: form, name: 'instance_moderation[]', value: instance_moderation.id, data: { target: 'select-all.input' } .col-11 - %h4 - %a{ href: instance.uri }= sanitize(instance.content['title']) || instance.hostname - .content - = sanitize instance.content['description'] - - if usuaries.present? - %dl - %dt.d-inline= t('.users') - %dd.d-inline= text_plain usuaries.to_s + - cache [instance_moderation, instance] do + %h4 + %a{ href: instance.uri }= title || instance.hostname + - if title.present? + = " (#{instance.hostname})".html_safe + .content + = sanitize instance.content['description'] + - if usuaries.present? + %dl + %dt.d-inline= t('.users') + %dd.d-inline= text_plain usuaries.to_s -# Botones moderación .d-flex.pb-4 diff --git a/app/views/moderation_queue/_instances.haml b/app/views/moderation_queue/_instances.haml index 3954ce65..6bc08b95 100644 --- a/app/views/moderation_queue/_instances.haml +++ b/app/views/moderation_queue/_instances.haml @@ -1,22 +1,21 @@ - form_id = 'instance_moderation_action_on_several' %section - = render 'components/select_all_container', path: site_instance_moderations_action_on_several_path, form_id: form_id + = render 'components/select_all_container', path: site_instance_moderations_action_on_several_path, form: form_id .row.no-gutters.pt-2{ data: { controller: 'select-all' } } .col-1.d-flex.align-items-center - = render 'components/select_all', id: 'instances', form_id: form_id + = render 'components/select_all', id: 'instances', form: form_id .col-11 -# Filtros - = render 'components/instances_filters', instance_moderations: instance_moderations, form_id: form_id + = render 'components/instances_filters', instance_moderations: instance_moderations, form: form_id .col-12 - if instance_moderations.count.zero? %h4= t('moderation_queue.nothing') - instance_moderations.each do |instance_moderation| - - cache [instance_moderation.aasm_state, instance_moderation.instance] do - %hr - = render 'moderation_queue/instance', instance_moderation: instance_moderation, instance: instance_moderation.instance, form_id: form_id + %hr + = render 'moderation_queue/instance', instance_moderation: instance_moderation, instance: instance_moderation.instance, form: form_id %hr %div diff --git a/app/views/posts/index.haml b/app/views/posts/index.haml index b371192a..f20d9fc9 100644 --- a/app/views/posts/index.haml +++ b/app/views/posts/index.haml @@ -7,11 +7,11 @@ %main.row %aside.menu.col-lg-3 - = render 'sites/header', site: @site - - = render 'sites/status', site: @site - - = render 'sites/build', site: @site, class: 'btn-block mb-3' + .mb-3 + = render 'sites/header', site: @site + = render 'sites/status', site: @site + = render 'sites/build', site: @site, class: 'btn-block' + = render 'sites/moderation_queue', site: @site, class: 'btn-block' %h3= t('posts.new') %table.table.table-sm.mb-3 diff --git a/app/views/sites/_moderation_queue.haml b/app/views/sites/_moderation_queue.haml new file mode 100644 index 00000000..6b39d797 --- /dev/null +++ b/app/views/sites/_moderation_queue.haml @@ -0,0 +1,9 @@ +- if policy(ModerationQueue.new(site)).index? + - moderation_needed = site.moderation_needed? + + - local_assigns[:class] = "btn btn-secondary #{local_assigns[:class]}" + = link_to site_moderation_queue_path(site), class: local_assigns[:class], title: (t('.moderation_needed') if moderation_needed) do + = t('moderation_queue.index.title') + - if moderation_needed + %span.primary ⏺ + %span.sr-only= t('.moderation_needed') diff --git a/app/views/sites/index.haml b/app/views/sites/index.haml index 881039ff..89d01a24 100644 --- a/app/views/sites/index.haml +++ b/app/views/sites/index.haml @@ -15,43 +15,39 @@ %tbody - @sites.each do |site| - next unless site.jekyll? - - rol = current_usuarie.rol_for_site(site) - -# - TODO: Solo les usuaries cachean porque tenemos que separar - les botones por permisos. - - cache_if (rol.usuarie? && !rol.temporal), [site, I18n.locale] do - %tr - %td - %h2 - - if policy(site).show? - = link_to site.title, site_posts_path(site, locale: site.default_locale) - - else - = site.title - %p.lead= site.description - %br - = link_to t('.visit'), site.url, class: 'btn btn-secondary' - - if rol.temporal - = button_to t('sites.invitations.accept'), - site_usuaries_accept_invitation_path(site), - method: :patch, - title: t('help.sites.invitations.accept'), - class: 'btn btn-secondary' - = button_to t('sites.invitations.reject'), - site_usuaries_reject_invitation_path(site), - method: :patch, - title: t('help.sites.invitations.reject'), - class: 'btn btn-secondary' + %tr + %td + %h2 + - if policy(site).show? + = link_to site.title, site_posts_path(site, locale: site.default_locale) - else - - if policy(site).show? - = render 'layouts/btn_with_tooltip', - tooltip: t('help.sites.edit_posts'), - type: 'success', - link: site_path(site), - text: t('sites.posts') - - if policy(SiteUsuarie.new(site, current_usuarie)).index? - = render 'layouts/btn_with_tooltip', - tooltip: t('usuaries.index.help.self'), - text: t('usuaries.index.title'), - type: 'info', - link: site_usuaries_path(site) - = render 'sites/build', site: site + = site.title + %p.lead= site.description + %br + = link_to t('.visit'), site.url, class: 'btn btn-secondary' + - if current_usuarie.rol_for_site(site).temporal? + = render 'components/btn_base', + text: t('sites.invitations.accept'), + path: site_usuaries_accept_invitation_path(site), + title: t('help.sites.invitations.accept'), + class: 'btn-secondary' + = render 'components/btn_base', + text: t('sites.invitations.reject'), + path: site_usuaries_reject_invitation_path(site), + title: t('help.sites.invitations.reject'), + class: 'btn-secondary' + - else + - if policy(site).show? + = render 'layouts/btn_with_tooltip', + tooltip: t('help.sites.edit_posts'), + type: 'success', + link: site_path(site), + text: t('sites.posts') + = render 'sites/build', site: site + = render 'sites/moderation_queue', site: site + - if policy(SiteUsuarie.new(site, current_usuarie)).index? + = render 'layouts/btn_with_tooltip', + tooltip: t('usuaries.index.help.self'), + text: t('usuaries.index.title'), + type: 'info', + link: site_usuaries_path(site) diff --git a/config/environments/production.rb b/config/environments/production.rb index 5e089ff9..bc7cecd7 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -142,7 +142,7 @@ Rails.application.configure do } config.action_mailer.default_options = { from: ENV.fetch('DEFAULT_FROM', "noreply@sutty.nl") } - config.middleware.use ExceptionNotification::Rack, gitlab: {}, ignore_exceptions: ['DeployJob::DeployAlreadyRunningException'] + config.middleware.use ExceptionNotification::Rack, gitlab: {}, error_grouping: true, ignore_exceptions: ['DeployJob::DeployAlreadyRunningException'] Rails.application.routes.default_url_options[:host] = "panel.#{ENV.fetch('SUTTY', 'sutty.nl')}" Rails.application.routes.default_url_options[:protocol] = 'https' diff --git a/config/initializers/que_web.rb b/config/initializers/que_web.rb new file mode 100644 index 00000000..192256db --- /dev/null +++ b/config/initializers/que_web.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +Que::Web.use(Rack::Auth::Basic) do |user, password| + [user, password] == [ENV['HTTP_BASIC_USER'], ENV['HTTP_BASIC_PASSWORD']] +end diff --git a/config/locales/en.yml b/config/locales/en.yml index fb0f2595..07d1f7e6 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -50,12 +50,20 @@ en: pm: pm format: '%-I:%M %p' components: + actor: + user: Username + profile: Profile + profile_name: Profile name + preferred_name: Name in Fediverse + profile_id: ID + profile_published: Published + profile_summary: Summary block_list: know_more: Know more instances_blocked: Instances blocked instances_filters: text_show: Show - text_checked: With selected + text_checked: With selected... instances_checked_submenu: submenu_pause: Moderate submenu_allow: Allow @@ -66,7 +74,7 @@ en: submenu_blocked: "Blocked (%{count})" comments_filters: text_show: Show - text_checked: With selected + text_checked: With selected... comments_checked_submenu: submenu_pause: Pause submenu_approve: Approve @@ -79,7 +87,7 @@ en: submenu_reported: "Reported (%{count})" profiles_filters: text_show: Show - text_checked: With selected + text_checked: With selected... profiles_checked_submenu: submenu_pause: Pause submenu_allow: Allow @@ -98,26 +106,68 @@ en: text_reject: Reject text_reply: Reply text_report: Report + confirm_report: "Send report to the remote instance? This action will also reject the comment." + confirm_reject: "Reject this comment? Please notice we can't undo this action at this moment." instances_btn_box: - text_check: Check case by case + text_pause: Check case by case text_allow: Allow everything - text_deny: Block instance + text_block: Block instance profiles_btn_box: text_pause: Always check text_allow: Always approve text_block: Block text_report: Report - actor_moderations: - show: - user: Username - profile: Profile - profile_name: Profile name - preferred_name: Name in Fediverse - profile_id: ID - profile_published: Published - profile_summary: Summary + confirm_report: "Send report to the remote instance? This action will also block the account." remote_flags: report_message: "Hi! Someone using Sutty CMS reported this account on your instance. We don't have support for customized report messages yet, but we will soon. You can reach us at %{panel_actor_mention}." + activity_pubs: + action_on_several: + success: "Several comments have changed moderation state. You can find them using the filters on the Comments section." + error: "There was an error while changing moderation state. We received a report and will be acting on it soon." + approve: + success: "Comment approved." + error: "There was an error while approving the comment. We received a report and will be acting on it soon." + reject: + success: "Comment rejected. You can report it using the Report button." + error: "There was an error while rejecting the comment. We received a report and will be acting on it soon." + report: + success: "Comment reported." + error: "There was an error while reporting the comment. We received a report and will be acting on it soon." + actor_moderations: + action_on_several: + success: "Several accounts have changed moderation state. You can find them using the filters on the Accounts section. No action was performed over existing Comments." + error: "There was an error while changing moderation state. We received a report and will be acting on it soon." + pause: + success: "Account paused. No action was performed on existing Comments." + error: "There was an error while pausing the account. We received a report and will be acting on it soon." + allow: + success: "Account allowed. All of their comments from now on will be approved automatically. No action was performed over existing Comments." + error: "There was an error while allowing the account. We received a report and will be acting on it soon." + block: + success: "Account blocked. All of their comments from now on will be rejected automatically. No action was performed over existing Comments. If you want to report it to their instance, please use the Report button." + error: "There was an error while blocking the account. We received a report and will be acting on it soon." + report: + success: "Account reported." + error: "There was an error while reporting the account. We received a report and will be acting on it soon." + instance_moderations: + action_on_several: + success: "Several instances have changed moderation state. You can find them using the filters on the Instances section. No action was performed over existing Accounts and Comments." + error: "There was an error while changing moderation state. We received a report and will be acting on it soon." + pause: + success: "Instance paused. All of their comments and accounts from now on will need to be moderated individually. No action was performed over existing Accounts and Comments." + error: "There was an error while pausing the instance. We received a report and will be acting on it soon." + allow: + success: "Instance allowed. All of their comments and accounts from now on will be approved automatically. No action was performed over existing Accounts and Comments." + error: "There was an error while allowing the instance. We received a report and will be acting on it soon." + block: + success: "Instance blocked. All of their comments and accounts from now on will be rejected automatically. No action was performed over existing Accounts and Comments." + error: "There was an error while blocking the instance. We received a report and will be acting on it soon." + fediblock_states: + action_on_several: + success: "Blocklists have been enabled, you can find their instances by filtering by Blocked. You can approve them individually on the Accounts section. No action was performed over existing Accounts and Comments." + error: "There was an error while enabling or disabling blocklists. We received a report and will be acting on it soon." + custom_blocklist_success: "Custom blocklist has been added, you can find the instances by filtering by Blocked. No action was performed over existing Accounts and Comments." + custom_blocklist_error: "There was an error while adding a custom blocklist. We received a report and will be acting on it soon." moderation_queue: everything: 'Select all' nothing: "There's nothing for this filter" @@ -131,8 +181,11 @@ en: reply_to: Reply to instances: title: My block lists - description: Description + description: "Blocklists contain instances known for hosting hate speech, promote fascism, violence, sexual/gendered abuse and/or misinformation." custom_block: Custom block lists + custom_block_placeholder: | + a.doma.in + per.li.ne submit: Save block lists instance: users: "Users:" @@ -339,6 +392,7 @@ en: lang: not_available: "This language is not yet available, would you help us by translating Sutty into it?" errors: + site_not_found: "Site not found, or maybe you don't have access to it." argument_error: 'Argument `%{argument}` must be an instance of %{class}' unknown_locale: 'Unknown %{locale} locale' posts: @@ -515,6 +569,8 @@ en: column: "Country" empty: "(couldn't detect country)" sites: + moderation_queue: + moderation_needed: "There are new activities pending revision since the last time you moderated." donations: url: 'https://donaciones.sutty.nl/en/' text: 'Support us' diff --git a/config/locales/es.yml b/config/locales/es.yml index 1641a793..a5430277 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -50,12 +50,20 @@ es: pm: pm format: '%-H:%M' components: + actor: + user: Nombre de usuarie + profile: Cuenta de Origen + profile_name: Nombre de la cuenta + preferred_name: Nombre en el Fediverso + profile_id: ID + profile_published: Publicada + profile_summary: Presentación block_list: know_more: Saber más (en inglés) instances_blocked: Instancias bloqueadas instances_filters: text_show: Ver - text_checked: Con los marcados + text_checked: Con los marcados... instances_checked_submenu: submenu_pause: Moderar caso por caso submenu_allow: Permitir todo @@ -66,7 +74,7 @@ es: submenu_blocked: "Bloqueadas (%{count})" comments_filters: text_show: Ver - text_checked: Con los marcados + text_checked: Con los marcados... comments_checked_submenu: submenu_pause: Pausar submenu_approve: Aprobar @@ -79,7 +87,7 @@ es: submenu_reported: "Reportados (%{count})" profiles_filters: text_show: Ver - text_checked: Con los marcados + text_checked: Con los marcados... profiles_checked_submenu: submenu_pause: Pausar submenu_allow: Aceptar @@ -97,26 +105,68 @@ es: text_approve: Aceptar text_reject: Rechazar text_report: Reportar + confirm_report: "¿Enviar el reporte a la instancia remota? Esta acción también rechazará el comentario." + confirm_reject: "¿Rechazar este comentario? Tené en cuenta que por el momento no es posible deshacer esta acción." instances_btn_box: - text_check: Moderar caso por caso + text_pause: Moderar caso por caso text_allow: Permitir todo - text_deny: Bloquear instancia + text_block: Bloquear instancia profiles_btn_box: text_pause: Revisar siempre text_allow: Aprobar siempre text_block: Bloquear text_report: Reportar - actor_moderations: - show: - user: Nombre de usuarie - profile: Cuenta de Origen - profile_name: Nombre de la cuenta - preferred_name: Nombre en el Fediverso - profile_id: ID - profile_published: Publicada - profile_summary: Presentación + confirm_report: "¿Enviar el reporte a la instancia remota? Esta acción también bloqueará la cuenta." remote_flags: report_message: "¡Hola! Une usuarie de Sutty CMS reportó esta cuenta en tu instancia. Todavía no tenemos soporte para mensajes personalizados. Podés contactarnos en %{panel_actor_mention}." + activity_pubs: + action_on_several: + success: "Se ha modificado el estado de moderación de varios comentarios. Podés encontrarlos usando los filtros en la sección Comentarios." + error: "Hubo un error al modificar el estado de moderación de varios comentarios. Hemos recibido el reporte y lo estaremos verificando." + approve: + success: "Comentario aprobado." + error: "No se puedo aprobar el comentario. Hemos recibido el reporte y lo estaremos verificando." + reject: + success: "Comentario rechazado. Podés reportarlo usando el botón Reportar." + error: "No se puedo rechazar el comentario. Hemos recibido el reporte y lo estaremos verificando." + report: + success: "Comentario reportado." + error: "No se puedo reportar el comentario. Hemos recibido el reporte y lo estaremos verificando." + actor_moderations: + action_on_several: + success: "Se ha modificado el estado de moderación de varias cuentas. Podés encontrarlas usando los filtros en la sección Cuentas. No se modificaron comentarios pre-existentes." + error: "Hubo un error al modificar el estado de moderación de varias cuentas. Hemos recibido el reporte y lo estaremos verificando." + pause: + success: "Cuenta pausada. Todos los comentarios que haga necesitan ser aprobados manualmente en la sección Comentarios. No se modificaron comentarios pre-existentes." + error: "No se pudo pausar la cuenta. Hemos recibido el reporte y lo estaremos verificando." + allow: + success: "Cuenta permitida. Todos los comentarios que haga serán aprobados inmediatamente. No se modificaron comentarios pre-existentes." + error: "No se pudo permitir la cuenta. Hemos recibido el reporte y lo estaremos verificando." + block: + success: "Cuenta bloqueada. Todos los comentarios que haga serán rechazados inmediatamente. Si querés reportarla a su instancia, podés usar el botón Reportar. No se modificaron comentarios pre-existentes." + error: "No se pudo bloquear la cuenta. Hemos recibido el reporte y lo estaremos verificando." + report: + success: "Cuenta reportada a su instancia." + error: "No se pudo reportar la cuenta. Hemos recibido el reporte y lo estaremos verificando." + instance_moderations: + action_on_several: + success: "Se ha modificado el estado de moderación de varias instancias. Podés encontrarlas usando los filtros en la sección Instancias. No se modificaron cuentas y comentarios pre-existentes." + error: "Hubo un error al modificar el estado de moderación de varias instancias. Hemos recibido el reporte y lo estaremos verificando." + pause: + success: "Instancia pausada. A partir de ahora, todos los comentarios y cuentas de esta instancia necesitan ser aprobados manualmente. No se ha modificado el estado de moderación de cuentas ni comentarios pre-existentes." + error: "No se pudo pausar la instancia. Hemos recibido el reporte y lo estaremos verificando." + allow: + success: "Instancia permitida. A partir de ahora, todos los comentarios y cuentas pendientes serán aprobados inmediatamente. No se modificaron cuentas ni comentarios pre-existentes." + error: "No se pudo permitir la instancia. Hemos recibido el reporte y lo estaremos verificando." + block: + success: "Instancia bloqueada. A partir de ahora, todos los comentarios y cuentas serán rechazados inmediatamente. No se modificaron cuentas ni comentarios pre-existentes." + error: "No se pudo bloquear la instancia. Hemos recibido el reporte y lo estaremos verificando." + fediblock_states: + action_on_several: + success: "Se habilitaron las listas de bloqueo, podés encontrar las instancias filtrando por Bloqueadas. Podés activarlas individualmente en la sección Cuentas. No se modificaron cuentas ni comentarios pre-existentes." + error: "Hubo un error al activar o desactivar listas de bloqueo, ya recibimos el reporte y lo estaremos verificando." + custom_blocklist_success: "Se agregaron las instancias personalizadas a la lista de bloqueo, podés encontrarlas filtrando por Bloqueadas. Podés aprobarlas individualmente en la sección Cuentas. No se modificaron cuentas ni comentarios pre-existentes." + custom_blocklist_error: "Hubo un error al agregar instancias personalizadas a la lista de bloqueo, ya recibimos el reporte y lo estaremos verificando." moderation_queue: everything: 'Seleccionar todo' nothing: 'No hay nada para este filtro' @@ -130,8 +180,11 @@ es: reply_to: En respuesta a instances: title: Mis listas de bloqueo - description: Descripción de listas de bloqueo + description: "Las listas de bloqueo contienen instancias conocidas por alojar discurso de odio, promover el fascismo, la violencia, abuso sexual y/o desinformación." custom_block: Lista personalizada de bloqueo + custom_block_placeholder: | + un.domin.io + por.lin.ea submit: Guardar listas de bloqueo instance: users: "Usuaries:" @@ -338,6 +391,7 @@ es: lang: not_available: "Este idioma todavía no está disponible, ¿nos ayudas a agregarlo y mantenerlo?" errors: + site_not_found: "No encontramos ese sitio o quizás no tengas acceso." argument_error: 'El argumento `%{argument}` debe ser una instancia de %{class}' unknown_locale: 'El idioma %{locale} es desconocido' posts: @@ -519,6 +573,8 @@ es: column: "País" empty: "(no se pudo detectar el país)" sites: + moderation_queue: + moderation_needed: "Hay actividades pendientes de revisión desde la última vez que moderaste." donations: url: 'https://donaciones.sutty.nl/' text: 'Apoyá nuestro trabajo' diff --git a/config/routes.rb b/config/routes.rb index 054b7f4d..4d43d66a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -4,6 +4,9 @@ Rails.application.routes.draw do devise_for :usuaries get '/.well-known/change-password', to: redirect('/usuaries/edit') + require 'que/web' + mount Que::Web => '/que' + root 'application#index' constraints(Constraints::ApiSubdomain.new) do diff --git a/db/migrate/20240307201510_remove_actor_moderations.rb b/db/migrate/20240307201510_remove_actor_moderations.rb new file mode 100644 index 00000000..b451c589 --- /dev/null +++ b/db/migrate/20240307201510_remove_actor_moderations.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +# Mover todes les actores eliminades +class RemoveActorModerations < ActiveRecord::Migration[6.1] + def up + actor_ids = + ActivityPub.where(aasm_state: 'removed', object_type: 'ActivityPub::Object::Person').distinct.pluck(:actor_id) + + ActorModeration.where(actor_id: actor_ids).remove_all! + end + + def down; end +end diff --git a/db/migrate/20240307203039_remove_actor_moderations2.rb b/db/migrate/20240307203039_remove_actor_moderations2.rb new file mode 100644 index 00000000..555a4ffe --- /dev/null +++ b/db/migrate/20240307203039_remove_actor_moderations2.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +# Algunes quedaron como genéricxs +class RemoveActorModerations2 < ActiveRecord::Migration[6.1] + def up + actor_uris = ActivityPub::Activity.unscope(:order).where(type: 'ActivityPub::Activity::Delete').distinct.pluck(Arel.sql("content->>'object'")) + actor_ids = ActivityPub::Actor.where(uri: actor_uris).ids + + ActorModeration.where(actor_id: actor_ids).remove_all! + end + + def down; end +end diff --git a/db/migrate/20240313192134_fix_fetch_jobs.rb b/db/migrate/20240313192134_fix_fetch_jobs.rb new file mode 100644 index 00000000..54ffa7e6 --- /dev/null +++ b/db/migrate/20240313192134_fix_fetch_jobs.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class FixFetchJobs < ActiveRecord::Migration[6.1] + def up + QueJob.where("last_error_message like '%ActiveJob::DeserializationError%'").find_each do |job| + job.error_count = 0 + job.run_at = Time.now + + job.args.first['arguments'].first['_aj_ruby2_keywords'].delete('object') + job.args.first['arguments'].first['_aj_ruby2_keywords'] << 'object_id' + + object = job.args.first['arguments'].first.delete('object')['_aj_globalid'] + job.args.first['arguments'].first['object_id'] = object.split('/').last + + job.save + end + end + + def down; end +end diff --git a/db/migrate/20240313204105_brs_decompressor_corrupted_source_error.rb b/db/migrate/20240313204105_brs_decompressor_corrupted_source_error.rb new file mode 100644 index 00000000..a0c29311 --- /dev/null +++ b/db/migrate/20240313204105_brs_decompressor_corrupted_source_error.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +# Comprueba que se pueden volver a correr las tareas que dieron error de +# decompresión +class BrsDecompressorCorruptedSourceError < ActiveRecord::Migration[6.1] + def up + raise unless HTTParty.get("https://mas.to/api/v2/instance", headers: { "Accept-Encoding": "br;q=1.0,gzip;q=1.0,deflate;q=0.6,identity;q=0.3" }).ok? + + QueJob.where("last_error_message like '%BRS::DecompressorCorruptedSourceError%'").update_all(error_count: 0, run_at: Time.now) + end + + def down; end +end diff --git a/db/migrate/20240314141536_remove_actor_moderations_for_generic_objects.rb b/db/migrate/20240314141536_remove_actor_moderations_for_generic_objects.rb new file mode 100644 index 00000000..a60e755a --- /dev/null +++ b/db/migrate/20240314141536_remove_actor_moderations_for_generic_objects.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +# Elimina actores que no pudieron ser eliminades porque su perfil ya no +# existe. +class RemoveActorModerationsForGenericObjects < ActiveRecord::Migration[6.1] + def up + object_ids = ActivityPub.removed.where(object_type: 'ActivityPub::Object::Generic').distinct.pluck(:object_id) + uris = ActivityPub::Object.where(id: object_ids).pluck(:uri) + actor_ids = ActivityPub::Actor.where(uri: uris).ids + + ActorModeration.where(actor_id: actor_ids).remove_all! + end + + def down; end +end diff --git a/db/migrate/20240314153017_fix_object_type_on_activity_pubs.rb b/db/migrate/20240314153017_fix_object_type_on_activity_pubs.rb new file mode 100644 index 00000000..d5475f71 --- /dev/null +++ b/db/migrate/20240314153017_fix_object_type_on_activity_pubs.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +# Arregla la relación rota entre ActivityPub y Objects +class FixObjectTypeOnActivityPubs < ActiveRecord::Migration[6.1] + def up + ActivityPub::Object.where.not(type: 'ActivityPub::Object::Generic').find_each do |object| + ActivityPub.where(object_id: object.id).update_all(object_type: object.type, updated_at: Time.now) + end + end + + def down; end +end diff --git a/db/migrate/20240314205923_fix_activity_type.rb b/db/migrate/20240314205923_fix_activity_type.rb new file mode 100644 index 00000000..042de8eb --- /dev/null +++ b/db/migrate/20240314205923_fix_activity_type.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +# Soportar nuevos tipos +class FixActivityType < ActiveRecord::Migration[6.1] + def up + %w[Like Announce].each do |type| + ActivityPub::Activity.where(Arel.sql("content->>'type' = '#{type}'")).update_all(type: "ActivityPub::Activity::#{type}", updated_at: Time.now) + end + end + + def down; end +end diff --git a/db/migrate/20240316203721_add_mention_to_actors.rb b/db/migrate/20240316203721_add_mention_to_actors.rb new file mode 100644 index 00000000..caa4f526 --- /dev/null +++ b/db/migrate/20240316203721_add_mention_to_actors.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +# Guarda la mención en la tabla de actores +class AddMentionToActors < ActiveRecord::Migration[6.1] + def up + add_column :activity_pub_actors, :mention, :string, null: true + + actor_types = %w[ + ActivityPub::Object::Application + ActivityPub::Object::Group + ActivityPub::Object::Organization + ActivityPub::Object::Person + ActivityPub::Object::Service + ] + + ActivityPub::Object.where(type: actor_types).where.not(content: {}).find_each do |object| + ActivityPub::Actor.find_by_uri(object.uri)&.save + end + end + + def down + remove_column :activity_pub_actors, :mention + end +end diff --git a/db/migrate/20240318183846_fix_duplicate_objects.rb b/db/migrate/20240318183846_fix_duplicate_objects.rb new file mode 100644 index 00000000..88d23c6f --- /dev/null +++ b/db/migrate/20240318183846_fix_duplicate_objects.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +# De alguna forma se guardaron objetos duplicados! +class FixDuplicateObjects < ActiveRecord::Migration[6.1] + def up + ActivityPub::Object.group(:uri).count.select { |_, v| v > 1 }.keys.each do |uri| + objects = ActivityPub::Object.where(uri: uri) + deleted_ids = objects[1..].map(&:delete).map(&:id) + + ActivityPub.where(object_id: deleted_ids).update_all(object_id: objects.first.id, updated_at: Time.now) + end + end + + def down; end +end diff --git a/db/migrate/20240319124212_add_fedipact_to_fediblocks.rb b/db/migrate/20240319124212_add_fedipact_to_fediblocks.rb new file mode 100644 index 00000000..d78439b2 --- /dev/null +++ b/db/migrate/20240319124212_add_fedipact_to_fediblocks.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +# Agrega threads.net a las listas de bloqueo +class AddFedipactToFediblocks < ActiveRecord::Migration[6.1] + def up + change_column :activity_pub_fediblocks, :download_url, :string, null: true + + fedipact = + ActivityPub::Fediblock.create( + hostnames: %w[threads.net], + title: 'Fedipact', + url: 'https://fedipact.online/', + format: 'none' + ) + + DeploySocialDistributedPress.find_each do |deploy| + FediblockState.create(site: deploy.site, fediblock: fedipact, aasm_state: 'disabled').tap do |f| + f.enable! + end + end + end + + def down + fedipact = ActivityPub::Fediblock.find_by(url: 'https://fedipact.online/').delete + FediblockState.where(fediblock_id: fedipact.id).delete_all + change_column :activity_pub_fediblocks, :download_url, :string, null: false + end +end diff --git a/db/migrate/20240319144735_add_missing_unique_indexes.rb b/db/migrate/20240319144735_add_missing_unique_indexes.rb new file mode 100644 index 00000000..7d18c8e8 --- /dev/null +++ b/db/migrate/20240319144735_add_missing_unique_indexes.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +# Parece que la sintaxis que veníamos usando para los índices únicos ya +# no es válida y por eso teníamos objetos duplicados. +class AddMissingUniqueIndexes < ActiveRecord::Migration[6.1] + def up + ActivityPub::Object.group(:uri).count.select { |_, v| v > 1 }.keys.each do |uri| + objects = ActivityPub::Object.where(uri: uri) + deleted_ids = objects[1..].map(&:delete).map(&:id) + + ActivityPub.where(object_id: deleted_ids).update_all(object_id: objects.first.id, updated_at: Time.now) + end + + ActivityPub::Actor.group(:uri).count.select { |_, v| v > 1 }.keys.each do |uri| + objects = ActivityPub::Actor.where(uri: uri) + deleted_ids = objects[1..].map(&:delete).map(&:id) + + ActivityPub.where(actor_id: deleted_ids).update_all(actor_id: objects.first.id, updated_at: Time.now) + ActorModeration.where(actor_id: deleted_ids).update_all(actor_id: objects.first.id, updated_at: Time.now) + ActivityPub::Activity.where(actor_id: deleted_ids).update_all(actor_id: objects.first.id, updated_at: Time.now) + ActivityPub::RemoteFlag.where(actor_id: deleted_ids).update_all(actor_id: objects.first.id, updated_at: Time.now) + end + + ActivityPub::Instance.group(:hostname).count.select { |_, v| v > 1 }.keys.each do |hostname| + objects = ActivityPub::Instance.where(hostname: hostname) + deleted_ids = objects[1..].map(&:delete).map(&:id) + + ActivityPub.where(instance_id: deleted_ids).update_all(instance_id: objects.first.id, updated_at: Time.now) + InstanceModeration.where(instance_id: deleted_ids).update_all(instance_id: objects.first.id, updated_at: Time.now) + ActivityPub::Actor.where(instance_id: deleted_ids).update_all(instance_id: objects.first.id, updated_at: Time.now) + end + + remove_index :activity_pub_instances, :hostname + remove_index :activity_pub_actors, :uri + add_index :activity_pub_instances, :hostname, unique: true + add_index :activity_pub_objects, :uri, unique: true + add_index :activity_pub_actors, :uri, unique: true + end + + def down + remove_index :activity_pub_instances, :hostname, unique: true + remove_index :activity_pub_objects, :uri, unique: true + remove_index :activity_pub_actors, :uri, unique: true + add_index :activity_pub_instances, :hostname + add_index :activity_pub_objects, :uri + add_index :activity_pub_actors, :uri + end +end diff --git a/db/structure.sql b/db/structure.sql index 52dcbf70..61cbd6f9 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -519,7 +519,8 @@ CREATE TABLE public.activity_pub_actors ( created_at timestamp(6) without time zone NOT NULL, updated_at timestamp(6) without time zone NOT NULL, instance_id uuid NOT NULL, - uri character varying NOT NULL + uri character varying NOT NULL, + mention character varying ); @@ -533,7 +534,7 @@ CREATE TABLE public.activity_pub_fediblocks ( updated_at timestamp(6) without time zone NOT NULL, title character varying NOT NULL, url character varying NOT NULL, - download_url character varying NOT NULL, + download_url character varying, format character varying NOT NULL, hostnames jsonb DEFAULT '[]'::jsonb ); @@ -578,6 +579,7 @@ CREATE TABLE public.activity_pub_remote_flags ( site_id bigint, actor_id uuid, message text, + content jsonb, aasm_state character varying DEFAULT 'waiting'::character varying NOT NULL ); @@ -2159,14 +2161,21 @@ CREATE INDEX index_activity_pub_actors_on_instance_id ON public.activity_pub_act -- Name: index_activity_pub_actors_on_uri; Type: INDEX; Schema: public; Owner: - -- -CREATE INDEX index_activity_pub_actors_on_uri ON public.activity_pub_actors USING btree (uri); +CREATE UNIQUE INDEX index_activity_pub_actors_on_uri ON public.activity_pub_actors USING btree (uri); -- -- Name: index_activity_pub_instances_on_hostname; Type: INDEX; Schema: public; Owner: - -- -CREATE INDEX index_activity_pub_instances_on_hostname ON public.activity_pub_instances USING btree (hostname); +CREATE UNIQUE INDEX index_activity_pub_instances_on_hostname ON public.activity_pub_instances USING btree (hostname); + + +-- +-- Name: index_activity_pub_objects_on_uri; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_activity_pub_objects_on_uri ON public.activity_pub_objects USING btree (uri); -- @@ -2727,6 +2736,17 @@ INSERT INTO "schema_migrations" (version) VALUES ('20240301194154'), ('20240301202955'), ('20240305164653'), -('20240305184854'); +('20240305184854'), +('20240307201510'), +('20240307203039'), +('20240313192134'), +('20240313204105'), +('20240314141536'), +('20240314153017'), +('20240314205923'), +('20240316203721'), +('20240318183846'), +('20240319124212'), +('20240319144735');