5
0
Fork 0
mirror of https://0xacab.org/sutty/sutty synced 2024-11-22 09:56:21 +00:00

Merge branch 'issue-15109-1' into panel.testing.sutty.nl

This commit is contained in:
f 2024-03-25 12:18:34 -03:00
commit bead769943
No known key found for this signature in database
111 changed files with 1621 additions and 635 deletions

2
.env
View file

@ -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

View file

@ -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'

View file

@ -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)

View file

@ -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 {

View file

@ -8,27 +8,43 @@ 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
ActivityPub.transaction do
if action == :report
message = remote_flag_params(activity_pubs.first).dig(:remote_flag_attributes, :message)
@ -44,12 +60,9 @@ class ActivityPubsController < ApplicationController
end
end
ActivityPub.transaction do
activity_pubs.find_each do |activity_pub|
next unless activity_pub.public_send(may)
message = activity_pubs.public_send(method) ? :success : :error
activity_pub.public_send(method)
end
flash[message] = I18n.t("activity_pubs.action_on_several.#{message}")
end
end

View file

@ -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,32 +37,44 @@ 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
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
private

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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<String>]
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?
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

View file

@ -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

View file

@ -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

View file

@ -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<String>]
# @return [Array<String>]
def wildcardize(hostnames)
hostnames.map do |hostname|
"@*@#{hostname}"
end
end
end
end

View file

@ -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<Hash> o
# Array<String> 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

View file

@ -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) }

View file

@ -0,0 +1,8 @@
# frozen_string_literal: true
class ActivityPub
class Activity
# Boost
class Announce < ActivityPub::Activity; end
end
end

View file

@ -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!
ActiveRecord::Base.connection_pool.with_connection do
ActivityPub.transaction do
ActivityPub::Object.find_by(uri: ActivityPub.uri_from_object(content['object']))&.activity_pubs&.find_each(&:remove!)
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

View file

@ -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

View file

@ -0,0 +1,8 @@
# frozen_string_literal: true
class ActivityPub
class Activity
# Like
class Like < ActivityPub::Activity; end
end
end

View file

@ -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

View file

@ -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?

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,10 @@
# frozen_string_literal: true
# = Audio =
#
# Representa artículos
class ActivityPub
class Object
class Audio < ActivityPub::Object; end
end
end

View file

@ -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

View file

@ -0,0 +1,10 @@
# frozen_string_literal: true
# = Document =
#
# Representa artículos
class ActivityPub
class Object
class Document < ActivityPub::Object; end
end
end

View file

@ -0,0 +1,10 @@
# frozen_string_literal: true
# = Event =
#
# Representa artículos
class ActivityPub
class Object
class Event < ActivityPub::Object; end
end
end

View file

@ -0,0 +1,10 @@
# frozen_string_literal: true
# = Group =
class ActivityPub
class Object
class Group < ActivityPub::Object
include Concerns::ActorTypeConcern
end
end
end

View file

@ -0,0 +1,10 @@
# frozen_string_literal: true
# = Image =
#
# Representa artículos
class ActivityPub
class Object
class Image < ActivityPub::Object; end
end
end

View file

@ -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

View file

@ -0,0 +1,10 @@
# frozen_string_literal: true
# = Page =
#
# Representa artículos
class ActivityPub
class Object
class Page < ActivityPub::Object; end
end
end

View file

@ -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

View file

@ -0,0 +1,10 @@
# frozen_string_literal: true
# = Place =
#
# Representa artículos
class ActivityPub
class Object
class Place < ActivityPub::Object; end
end
end

View file

@ -0,0 +1,10 @@
# frozen_string_literal: true
# = Profile =
#
# Representa artículos
class ActivityPub
class Object
class Profile < ActivityPub::Object; end
end
end

View file

@ -0,0 +1,10 @@
# frozen_string_literal: true
# = Relationship =
#
# Representa artículos
class ActivityPub
class Object
class Relationship < ActivityPub::Object; end
end
end

View file

@ -0,0 +1,10 @@
# frozen_string_literal: true
# = Service =
class ActivityPub
class Object
class Service < ActivityPub::Object
include Concerns::ActorTypeConcern
end
end
end

View file

@ -0,0 +1,10 @@
# frozen_string_literal: true
# = Tombstone =
#
# Representa artículos
class ActivityPub
class Object
class Tombstone < ActivityPub::Object; end
end
end

View file

@ -0,0 +1,10 @@
# frozen_string_literal: true
# = Video =
#
# Representa artículos
class ActivityPub
class Object
class Video < ActivityPub::Object; end
end
end

View file

@ -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

View file

@ -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

View file

@ -16,7 +16,7 @@ module AasmEventsConcern
#
# @return [Array<Symbol>]
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

View file

@ -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

View file

@ -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<String>]
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?
fediblock.hostnames - other_enabled_hostnames
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?
end
end

View file

@ -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

View file

@ -0,0 +1,3 @@
# frozen_string_literal: true
ModerationQueue = Struct.new(:site)

5
app/models/que_job.rb Normal file
View file

@ -0,0 +1,5 @@
# frozen_string_literal: true
require 'que/active_record/model'
class QueJob < Que::ActiveRecord::Model; end

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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'|

View file

@ -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

View file

@ -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

View file

@ -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')

View file

@ -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

View file

@ -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

View file

@ -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|
- 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),
disabled: !activity_pub.public_send(:"may_#{event}?")
class: ('btn-secondary' if possible),
disabled: !possible,
data: local.dig(event, :data)

View file

@ -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

View file

@ -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

View file

@ -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))

View file

@ -11,7 +11,7 @@
controller: 'dropdown'
}
}
%button.btn.dropdown-toggle{
%button.btn.btn-outline-secondary.dropdown-toggle{
type: 'button',
class: button_classes,
data: {

View file

@ -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

View file

@ -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' }

View file

@ -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]

View file

@ -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

View file

@ -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

View file

@ -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))

View file

@ -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|
- 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_class,
disabled: !actor_moderation.public_send(:"may_#{actor_event}?")
class: ('btn-secondary' if possible),
disabled: !possible,
data: local.dig(actor_event, :data)

View file

@ -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

View file

@ -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

View file

@ -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))

View file

@ -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')

View file

@ -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

View file

@ -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
- 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

View file

@ -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
- next if actor_moderation.actor.content.empty?
%hr
= render 'account', actor_moderation: actor_moderation, profile: actor_moderation.actor.content, form_id: form_id
= render 'account', actor_moderation: actor_moderation, profile: actor_moderation.actor.content, form: form_id

View file

@ -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')

View file

@ -1,20 +1,34 @@
-#
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
- 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
= render 'layouts/time', time: text_plain(comment['published'])
- if in_reply_to.present?
@ -24,7 +38,7 @@
%dd.d-inline
%small
%a{ href: in_reply_to }= in_reply_to
.content
.content.mb-3
- if summary.present?
= render 'layouts/details', summary: summary, summary_class: 'h5' do
= sanitize comment['content']

View file

@ -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

View file

@ -1,12 +1,16 @@
- 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
- cache [instance_moderation, instance] do
%h4
%a{ href: instance.uri }= sanitize(instance.content['title']) || instance.hostname
%a{ href: instance.uri }= title || instance.hostname
- if title.present?
= "&nbsp;(#{instance.hostname})".html_safe
.content
= sanitize instance.content['description']
- if usuaries.present?

View file

@ -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
= render 'moderation_queue/instance', instance_moderation: instance_moderation, instance: instance_moderation.instance, form: form_id
%hr
%div

View file

@ -7,11 +7,11 @@
%main.row
%aside.menu.col-lg-3
.mb-3
= render 'sites/header', site: @site
= render 'sites/status', site: @site
= render 'sites/build', site: @site, class: 'btn-block mb-3'
= 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

View file

@ -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 &#x23FA;
%span.sr-only= t('.moderation_needed')

View file

@ -15,11 +15,6 @@
%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
@ -30,17 +25,17 @@
%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,
- 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 btn-secondary'
= button_to t('sites.invitations.reject'),
site_usuaries_reject_invitation_path(site),
method: :patch,
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 btn-secondary'
class: 'btn-secondary'
- else
- if policy(site).show?
= render 'layouts/btn_with_tooltip',
@ -48,10 +43,11 @@
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)
= render 'sites/build', site: site

View file

@ -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'

View file

@ -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

View file

@ -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'

View file

@ -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'

View file

@ -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

View file

@ -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

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