5
0
Fork 0
mirror of https://0xacab.org/sutty/sutty synced 2024-11-22 16:16: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= GITLAB_TOKEN=
PGVER=15 PGVER=15
PGPID=/run/postgresql.pid 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 'devise_invitable'
gem 'redis-client' gem 'redis-client'
gem 'hiredis-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 'email_address', git: 'https://github.com/fauno/email_address', branch: 'i18n'
gem 'exception_notification' gem 'exception_notification'
gem 'fast_blank' gem 'fast_blank'
@ -83,6 +83,7 @@ gem 'rubanok'
gem 'after_commit_everywhere', '~> 1.0' gem 'after_commit_everywhere', '~> 1.0'
gem 'aasm' gem 'aasm'
gem 'que-web'
# database # database
gem 'hairtrigger' gem 'hairtrigger'

View file

@ -97,6 +97,7 @@ GEM
ast (2.4.2) ast (2.4.2)
autoprefixer-rails (10.4.13.0) autoprefixer-rails (10.4.13.0)
execjs (~> 2) execjs (~> 2)
base64 (0.2.0)
bcrypt (3.1.20-x86_64-linux-musl) bcrypt (3.1.20-x86_64-linux-musl)
bcrypt_pbkdf (1.1.0-x86_64-linux-musl) bcrypt_pbkdf (1.1.0-x86_64-linux-musl)
benchmark-ips (2.12.0) benchmark-ips (2.12.0)
@ -167,7 +168,7 @@ GEM
devise_invitable (2.0.9) devise_invitable (2.0.9)
actionmailer (>= 5.0) actionmailer (>= 5.0)
devise (>= 4.6) devise (>= 4.6)
distributed-press-api-client (0.4.0rc3) distributed-press-api-client (0.4.1)
addressable (~> 2.3, >= 2.3.0) addressable (~> 2.3, >= 2.3.0)
climate_control climate_control
dry-schema dry-schema
@ -371,6 +372,8 @@ GEM
i18n (>= 0.6.10, < 2) i18n (>= 0.6.10, < 2)
request_store (~> 1.0) request_store (~> 1.0)
multi_xml (0.6.0) multi_xml (0.6.0)
mustermann (3.0.0)
ruby2_keywords (~> 0.0.1)
net-imap (0.4.9) net-imap (0.4.9)
date date
net-protocol net-protocol
@ -410,12 +413,18 @@ GEM
pundit (2.3.1) pundit (2.3.1)
activesupport (>= 3.0.0) activesupport (>= 3.0.0)
que (2.2.1) que (2.2.1)
que-web (0.10.0)
que (>= 1)
sinatra
racc (1.7.3-x86_64-linux-musl) racc (1.7.3-x86_64-linux-musl)
rack (2.2.8) rack (2.2.8)
rack-cors (2.0.1) rack-cors (2.0.1)
rack (>= 2.0.0) rack (>= 2.0.0)
rack-mini-profiler (3.1.0) rack-mini-profiler (3.1.0)
rack (>= 1.2.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-proxy (0.7.7)
rack rack
rack-test (2.1.0) rack-test (2.1.0)
@ -514,6 +523,7 @@ GEM
ruby-statistics (3.0.2) ruby-statistics (3.0.2)
ruby-vips (2.2.0) ruby-vips (2.2.0)
ffi (~> 1.12) ffi (~> 1.12)
ruby2_keywords (0.0.5)
ruby2ruby (2.5.0) ruby2ruby (2.5.0)
ruby_parser (~> 3.1) ruby_parser (~> 3.1)
sexp_processor (~> 4.6) sexp_processor (~> 4.6)
@ -540,6 +550,11 @@ GEM
sexp_processor (4.17.0) sexp_processor (4.17.0)
simpleidn (0.2.1) simpleidn (0.2.1)
unf (~> 0.1.4) 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) sourcemap (0.1.1)
spring (4.1.1) spring (4.1.1)
spring-watcher-listen (2.1.0) spring-watcher-listen (2.1.0)
@ -627,7 +642,7 @@ DEPENDENCIES
devise devise
devise-i18n devise-i18n
devise_invitable devise_invitable
distributed-press-api-client (~> 0.4.0rc3) distributed-press-api-client (~> 0.4.1)
dotenv-rails dotenv-rails
down down
ed25519 ed25519
@ -671,6 +686,7 @@ DEPENDENCIES
puma puma
pundit pundit
que que
que-web
rack-cors rack-cors
rack-mini-profiler rack-mini-profiler
rails (~> 6.1.0) rails (~> 6.1.0)

View file

@ -34,6 +34,22 @@ $sizes: (
@import "bootstrap"; @import "bootstrap";
@import "editor"; @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 {
.editor-content { .editor-content {
figure { figure {

View file

@ -8,27 +8,43 @@ class ActivityPubsController < ApplicationController
define_method(event) do define_method(event) do
authorize activity_pub authorize activity_pub
activity_pub.update(remote_flag_params(activity_pub)) if event == :report if event == :report
activity_pub.public_send(:"#{event}!") if activity_pub.public_send(:"may_#{event}?") 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! redirect_to_moderation_queue!
end end
end end
def action_on_several def action_on_several
redirect_to_moderation_queue!
activity_pubs = site.activity_pubs.where(id: params[:activity_pub]) activity_pubs = site.activity_pubs.where(id: params[:activity_pub])
return if activity_pubs.count.zero?
authorize activity_pubs authorize activity_pubs
action = params[:activity_pub_action].to_sym action = params[:activity_pub_action].to_sym
method = :"#{action}!" method = :"#{action}_all!"
may = :"may_#{action}?" may = :"may_#{action}?"
redirect_to_moderation_queue!
return unless ActivityPub.events.include? action return unless ActivityPub.events.include? action
# Crear una sola remote flag por autore # Crear una sola remote flag por autore
ActivityPub.transaction do
if action == :report if action == :report
message = remote_flag_params(activity_pubs.first).dig(:remote_flag_attributes, :message) message = remote_flag_params(activity_pubs.first).dig(:remote_flag_attributes, :message)
@ -44,12 +60,9 @@ class ActivityPubsController < ApplicationController
end end
end end
ActivityPub.transaction do message = activity_pubs.public_send(method) ? :success : :error
activity_pubs.find_each do |activity_pub|
next unless activity_pub.public_send(may)
activity_pub.public_send(method) flash[message] = I18n.t("activity_pubs.action_on_several.#{message}")
end
end end
end end

View file

@ -5,14 +5,31 @@ class ActorModerationsController < ApplicationController
include ModerationConcern include ModerationConcern
include ModerationFiltersConcern 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| ActorModeration.events.each do |actor_event|
define_method(actor_event) do define_method(actor_event) do
authorize actor_moderation authorize actor_moderation
# Crea una RemoteFlag si se envían los parámetros adecuados # 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! redirect_to_moderation_queue!
end end
@ -20,32 +37,44 @@ class ActorModerationsController < ApplicationController
# Ver el perfil remoto # Ver el perfil remoto
def show 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 @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 end
def action_on_several def action_on_several
redirect_to_moderation_queue!
actor_moderations = site.actor_moderations.where(id: params[:actor_moderation]) actor_moderations = site.actor_moderations.where(id: params[:actor_moderation])
return if actor_moderations.count.zero?
authorize actor_moderations authorize actor_moderations
action = params[:actor_moderation_action].to_sym action = params[:actor_moderation_action].to_sym
method = :"#{action}!" method = :"#{action}_all!"
may = :"may_#{action}?" may = :"may_#{action}?"
redirect_to_moderation_queue!
return unless ActorModeration.events.include? action return unless ActorModeration.events.include? action
ActorModeration.transaction do ActorModeration.transaction do
if action == :report
actor_moderations.find_each do |actor_moderation| actor_moderations.find_each do |actor_moderation|
next unless actor_moderation.public_send(may) next unless actor_moderation.public_send(may)
actor_moderation.update(actor_moderation_params(actor_moderation)) if action == :report actor_moderation.update(actor_moderation_params(actor_moderation))
actor_moderation.public_send(method)
end end
end end
message = actor_moderations.public_send(method) ? :success : :error
flash[message] = I18n.t("actor_moderations.action_on_several.#{message}")
end
end end
private private

View file

@ -5,194 +5,51 @@ module Api
module Webhooks module Webhooks
# Recibe webhooks de la Social Inbox # 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/} # @see {https://www.w3.org/TR/activitypub/}
class SocialInboxController < BaseController class SocialInboxController < BaseController
include Api::V1::Webhooks::Concerns::WebhookConcern 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 # Cuando una actividad ingresa en la cola de moderación, la
# recibimos por acá # recibimos por acá
# #
# Vamos a recibir Create, Update, Delete, Follow, Undo y obtener # Vamos a recibir Create, Update, Delete, Follow, Undo,
# el objeto dentro de cada una para guardar un estado asociado # Announce, Like y obtener el objeto dentro de cada una para
# al sitio. # guardar un estado asociado al sitio.
# #
# El objeto del estado puede ser un objeto o une actore, # El objeto del estado puede ser un objeto o une actore,
# dependiendo de la actividad. # dependiendo de la actividad.
def moderationqueued def moderationqueued
# Devuelve un error si el token no es válido process! :paused
usuarie.present?
::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 head :accepted
end end
# Cuando la Social Inbox acepta una actividad, la recibimos # Cuando la Social Inbox acepta una actividad, la recibimos
# igual y la guardamos por si cambiamos de idea. # igual y la guardamos por si cambiamos de idea.
#
# @todo DRY
def onapproved def onapproved
::ActivityPub.transaction do process! :approved
actor.present?
instance.present?
object.present?
activity.present?
activity_pub.approve! if activity_pub.may_approve?
end
head :accepted head :accepted
end end
# Cuando la Social Inbox rechaza una actividad, la recibimos # Cuando la Social Inbox rechaza una actividad, la recibimos
# igual y la guardamos por si cambiamos de idea. # igual y la guardamos por si cambiamos de idea.
#
# @todo DRY
def onrejected def onrejected
::ActivityPub.transaction do process! :rejected
actor.present?
instance.present?
object.present?
activity.present?
activity_pub.reject! if activity_pub.may_reject?
end
head :accepted head :accepted
end end
private private
# Si el objeto ya viene incorporado en la actividad o lo tenemos # Envía la actividad para procesamiento por separado.
# que traer remotamente.
# #
# @return [Bool] # @param initial_state [Symbol]
def object_embedded? def process!(initial_state)
@object_embedded ||= original_activity[:object].is_a?(Hash) ::ActivityPub::ProcessJob.perform_later(site: site, body: request.raw_post, initial_state: initial_state)
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
end end
end end
end end

View file

@ -12,10 +12,6 @@ class ApplicationController < ActionController::Base
before_action :notify_unconfirmed_email, unless: :devise_controller? before_action :notify_unconfirmed_email, unless: :devise_controller?
around_action :set_locale 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 before_action do
Rack::MiniProfiler.authorize_request if current_usuarie&.email&.ends_with?('@' + ENV.fetch('SUTTY', 'sutty.nl')) Rack::MiniProfiler.authorize_request if current_usuarie&.email&.ends_with?('@' + ENV.fetch('SUTTY', 'sutty.nl'))
end end
@ -75,11 +71,6 @@ class ApplicationController < ActionController::Base
I18n.with_locale(current_locale, &action) I18n.with_locale(current_locale, &action)
end 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 # Necesario para poder acceder a Blazer. Solo les usuaries de este
# sitio pueden acceder al panel. # sitio pueden acceder al panel.
def require_usuarie def require_usuarie

View file

@ -12,13 +12,31 @@ module ExceptionHandler
rescue_from PageNotFound, with: :page_not_found rescue_from PageNotFound, with: :page_not_found
rescue_from ActionController::RoutingError, 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 Pundit::NilPolicyError, with: :page_not_found
rescue_from ActionController::RoutingError, with: :page_not_found
rescue_from ActionController::ParameterMissing, with: :page_not_found
end end
def site_not_found def site_not_found
reset_response!
flash[:error] = I18n.t('errors.site_not_found')
redirect_to sites_path redirect_to sites_path
end end
def page_not_found 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
end end

View file

@ -16,7 +16,9 @@ module ModerationConcern
end end
def remote_flag_params(model) 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][:site_id] = model.site_id
p[:remote_flag_attributes][:actor_id] = model.actor_id p[:remote_flag_attributes][:actor_id] = model.actor_id

View file

@ -11,11 +11,22 @@ class FediblockStatesController < ApplicationController
elsif fediblock_state.may_disable? elsif fediblock_state.may_disable?
fediblock_state.disable! fediblock_state.disable!
end 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 end
# Bloquear otras instancias # Bloquear otras instancias
if custom_blocklist.present? 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 end
redirect_to site_moderation_queue_path redirect_to site_moderation_queue_path

View file

@ -8,29 +8,37 @@ class InstanceModerationsController < ApplicationController
define_method(event) do define_method(event) do
authorize instance_moderation 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! redirect_to_moderation_queue!
end end
end end
def action_on_several def action_on_several
redirect_to_moderation_queue!
instance_moderations = site.instance_moderations.where(id: params[:instance_moderation]) instance_moderations = site.instance_moderations.where(id: params[:instance_moderation])
return if instance_moderations.count.zero?
authorize instance_moderations authorize instance_moderations
action = params[:instance_moderation_action].to_sym action = params[:instance_moderation_action].to_sym
method = :"#{action}!" method = :"#{action}_all!"
may = :"may_#{action}?"
redirect_to_moderation_queue!
return unless InstanceModeration.events.include? action return unless InstanceModeration.events.include? action
InstanceModeration.transaction do InstanceModeration.transaction do
instance_moderations.find_each do |instance_moderation| message = instance_moderations.public_send(method) ? :success : :error
instance_moderation.public_send(method) if instance_moderation.public_send(may)
end flash[:message] = I18n.t("instance_moderations.action_on_several.#{message}")
end end
end end

View file

@ -4,8 +4,19 @@
class ModerationQueueController < ApplicationController class ModerationQueueController < ApplicationController
include ModerationFiltersConcern 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 # Cola de moderación viendo todo el sitio
def index 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 # @todo cambiar el estado por query
@activity_pubs = site.activity_pubs @activity_pubs = site.activity_pubs
@instance_moderations = rubanok_process(site.instance_moderations, with: InstanceModerationProcessor) @instance_moderations = rubanok_process(site.instance_moderations, with: InstanceModerationProcessor)

View file

@ -38,7 +38,6 @@ class PostsController < ApplicationController
@usuarie = site.usuarie? current_usuarie @usuarie = site.usuarie? current_usuarie
@site_stat = SiteStat.new(site) @site_stat = SiteStat.new(site)
dummy_data
end end
def show def show
@ -84,7 +83,6 @@ class PostsController < ApplicationController
authorize post authorize post
breadcrumb post.title.value, site_post_path(site, post, locale: locale), match: :exact breadcrumb post.title.value, site_post_path(site, post, locale: locale), match: :exact
breadcrumb 'posts.edit', '' breadcrumb 'posts.edit', ''
dummy_data
end end
def update def update

View file

@ -2,6 +2,14 @@
module ModerationQueueHelper module ModerationQueueHelper
def filter_states(**args) 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
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 class FetchJob < ApplicationJob
self.priority = 50 self.priority = 50
def perform(site:, object:) def perform(site:, object_id:)
ActivityPub::Object.transaction do 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? return if object.activity_pubs.where(aasm_state: 'removed').count.positive?
response = site.social_inbox.dereferencer.get(uri: object.uri) response = site.social_inbox.dereferencer.get(uri: object.uri)
# @todo Fallar cuando la respuesta no funcione? # @todo Fallar cuando la respuesta no funcione?
return unless response.ok? # @todo Eliminar en 410 Gone
return if response.miss? && object.content.present? 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) 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 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) response = site.social_inbox.dereferencer.get(uri: uri)
next unless response.ok? next unless response.success?
# @todo Validate schema # @todo Validate schema
next unless response.parsed_response.is_a?(DistributedPress::V1::Social::ReferencedObject) next unless response.parsed_response.is_a?(DistributedPress::V1::Social::ReferencedObject)

View file

@ -3,14 +3,13 @@
class ActivityPub class ActivityPub
# Bloquea varias instancias de una sola vez # Bloquea varias instancias de una sola vez
class InstanceModerationJob < ApplicationJob class InstanceModerationJob < ApplicationJob
self.priority = 50
# @param :site [Site] # @param :site [Site]
# @param :hostnames [Array<String>] # @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 # Crear las instancias que no existan todavía
hostnames.each do |hostname| hostnames.each do |hostname|
ActivityPub::Instance.find_or_create_by(hostname: hostname) ActivityPub::Instance.lock.find_or_create_by(hostname: hostname)
end end
instances = ActivityPub::Instance.where(hostname: hostnames) instances = ActivityPub::Instance.where(hostname: hostnames)
@ -21,10 +20,18 @@ class ActivityPub
instances.find_each do |instance| instances.find_each do |instance|
# Esto bloquea cada una individualmente en la Social Inbox, # Esto bloquea cada una individualmente en la Social Inbox,
# idealmente son pocas instancias las que aparecen. # idealmente son pocas instancias las que aparecen.
site.instance_moderations.find_or_create_by(instance: instance).tap do |instance_moderation| site.instance_moderations.lock.find_or_create_by(instance: instance)
instance_moderation.block! if instance_moderation.may_block? end
end
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 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 self.priority = 30
def perform(remote_flag:) 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! remote_flag.queue!
client = remote_flag.site.social_inbox.client_for(remote_flag.actor&.content['inbox']) uri = URI.parse(inbox)
response = client.post(endpoint: '', body: remote_flag.content) 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 rescue Exception => e
ExceptionNotifier.notify_exception(e, data: { remote_flag: remote_flag.id, response: response.parsed_response }) ExceptionNotifier.notify_exception(e, data: { remote_flag: remote_flag.id, response: response.parsed_response })
remote_flag.resend!
raise raise
end end
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, # una actividad, puede estar destinada a varies actores dentro de Sutty,
# con lo que generamos una cola para cada une. # 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} # @see {https://www.w3.org/TR/activitypub/#client-to-server-interactions}
class ActivityPub < ApplicationRecord class ActivityPub < ApplicationRecord
include AASM IGNORED_EVENTS = %i[pause remove].freeze
include AasmEventsConcern IGNORED_STATES = %i[removed].freeze
IGNORED_EVENTS = %i[remove] include AASM
IGNORED_STATES = %i[removed]
belongs_to :instance belongs_to :instance
belongs_to :site belongs_to :site
belongs_to :object, polymorphic: true belongs_to :object, polymorphic: true
belongs_to :actor belongs_to :actor
belongs_to :remote_flag, class_name: 'ActivityPub::RemoteFlag' belongs_to :remote_flag, optional: true, class_name: 'ActivityPub::RemoteFlag'
has_many :activities has_many :activities
validates :site_id, presence: true validates :site_id, presence: true
@ -38,6 +43,42 @@ class ActivityPub < ApplicationRecord
end end
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 aasm do
# Todavía no hay una decisión sobre el objeto # Todavía no hay una decisión sobre el objeto
state :paused, initial: true state :paused, initial: true
@ -50,13 +91,26 @@ class ActivityPub < ApplicationRecord
# Le actore eliminó el objeto # Le actore eliminó el objeto
state :removed 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 # Recibir una acción de eliminación, eliminar el contenido de la
# base de datos. Esto elimina el contenido para todos los sitios # base de datos. Esto elimina el contenido para todos los sitios
# porque estamos respetando lo que pidió le actore. # porque estamos respetando lo que pidió le actore.
event :remove do event :remove do
transitions to: :removed transitions to: :removed
before do after do
next if object.blank?
object.update(content: {}) unless object.content.empty? object.update(content: {}) unless object.content.empty?
end end
end end
@ -67,9 +121,8 @@ class ActivityPub < ApplicationRecord
event :approve do event :approve do
transitions from: %i[paused], to: :approved transitions from: %i[paused], to: :approved
before do after do
raise AASM::InvalidTransition unless ActivityPub::InboxJob.perform_later(site: site, activity: activities.first.uri, action: :accept)
site.social_inbox.inbox.accept(id: object.uri).ok?
end end
end end
@ -77,19 +130,22 @@ class ActivityPub < ApplicationRecord
event :reject do event :reject do
transitions from: %i[paused approved], to: :rejected transitions from: %i[paused approved], to: :rejected
before do after do
raise AASM::InvalidTransition unless ActivityPub::InboxJob.perform_later(site: site, activity: activities.first.uri, action: :reject)
site.social_inbox.inbox.reject(id: object.uri).ok?
end end
end end
# Solo podemos reportarla luego de rechazarla # Reportarla implica rechazarla
event :report do 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? ActivityPub::RemoteFlagJob.perform_later(remote_flag: remote_flag) if remote_flag.waiting?
end end
end end
end end
# Definir eventos en masa
include AasmEventsConcern
end end

View file

@ -20,6 +20,8 @@ class ActivityPub
has_one :object, through: :activity_pub has_one :object, through: :activity_pub
validates :activity_pub_id, presence: true 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 # Siempre en orden descendiente para saber el último estado
default_scope -> { order(created_at: :desc) } 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. # lo haría la Social Inbox por nosotres.
# @see {https://docs.joinmastodon.org/spec/security/#ld} # @see {https://docs.joinmastodon.org/spec/security/#ld}
def update_activity_pub_state! def update_activity_pub_state!
ActiveRecord::Base.connection_pool.with_connection do
ActivityPub.transaction 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 end
end end

View file

@ -4,8 +4,15 @@
# #
# Una actividad de seguimiento se refiere siempre a une actore (el # Una actividad de seguimiento se refiere siempre a une actore (el
# sitio) y proviene de otre actore. # sitio) y proviene de otre actore.
#
# Por ahora las solicitudes de seguimiento se auto-aprueban.
class ActivityPub class ActivityPub
class Activity 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
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 :activities
has_many :remote_flags 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 # Obtiene el nombre de la Actor como mención, solo si obtuvimos el
# contenido de antemano. # contenido de antemano.
# #
# @return [String, nil] # @return [String, nil]
def mention def mentionize!
return if mention.present?
return if content['preferredUsername'].blank? return if content['preferredUsername'].blank?
return if instance.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 end
end end

View file

@ -6,8 +6,6 @@ class ActivityPub
extend ActiveSupport::Concern extend ActiveSupport::Concern
included do included do
validates :uri, presence: true, uniqueness: true
# Cuando asignamos contenido, obtener la URI si no lo hicimos ya # Cuando asignamos contenido, obtener la URI si no lo hicimos ya
before_save :uri_from_content!, unless: :uri? before_save :uri_from_content!, unless: :uri?

View file

@ -31,8 +31,8 @@ class ActivityPub
class FediblockDownloadError < ::StandardError; end class FediblockDownloadError < ::StandardError; end
validates_presence_of :title, :url, :download_url, :format validates_presence_of :title, :url, :format
validates_inclusion_of :format, in: %w[mastodon fediblock] validates_inclusion_of :format, in: %w[mastodon fediblock none]
HOSTNAME_HEADERS = { HOSTNAME_HEADERS = {
'mastodon' => '#domain', 'mastodon' => '#domain',
@ -52,7 +52,7 @@ class ActivityPub
def process! def process!
response = client.get(download_url) response = client.get(download_url)
raise FediblockDownloadError unless response.ok? raise FediblockDownloadError unless response.success?
Fediblock.transaction do Fediblock.transaction do
csv = response.parsed_response csv = response.parsed_response

View file

@ -9,7 +9,7 @@ class ActivityPub
include AASM include AASM
validates :aasm_state, presence: true, inclusion: { in: %w[paused allowed blocked] } 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 :activity_pubs
has_many :actors has_many :actors

View file

@ -5,13 +5,62 @@ class ActivityPub
class Object < ApplicationRecord class Object < ApplicationRecord
include ActivityPub::Concerns::JsonLdConcern 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 has_many :activity_pubs, as: :object
# Encontrar le Actor por su relación con el objeto # Encontrar le Actor por su relación con el objeto
# #
# @return [ActivityPub::Actor,nil] # @return [ActivityPub::Actor,nil]
def actor 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 end
end end

View file

@ -5,6 +5,8 @@
# Una aplicación o instancia # Una aplicación o instancia
class ActivityPub class ActivityPub
class Object class Object
class Application < ActivityPub::Object; end class Application < ActivityPub::Object
include Concerns::ActorTypeConcern
end
end 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 # Una organización
class ActivityPub class ActivityPub
class Object class Object
class Organization < ActivityPub::Object; end class Organization < ActivityPub::Object
include Concerns::ActorTypeConcern
end
end 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 # Una persona, el perfil de une actore
class ActivityPub class ActivityPub
class Object class Object
class Person < ActivityPub::Object; end class Person < ActivityPub::Object
include Concerns::ActorTypeConcern
end
end 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 ActivityPub
class RemoteFlag < ApplicationRecord class RemoteFlag < ApplicationRecord
IGNORED_EVENTS = [].freeze
IGNORED_STATES = [].freeze
include AASM include AASM
include AasmEventsConcern
aasm do aasm do
state :waiting, initial: true state :waiting, initial: true
@ -14,7 +16,7 @@ class ActivityPub
transitions from: :waiting, to: :queued transitions from: :waiting, to: :queued
end end
event :send do event :report do
transitions from: :queued, to: :sent transitions from: :queued, to: :sent
end end
@ -23,6 +25,9 @@ class ActivityPub
end end
end end
# Definir eventos en masa
include AasmEventsConcern
belongs_to :actor belongs_to :actor
belongs_to :site belongs_to :site
@ -37,10 +42,18 @@ class ActivityPub
'@context' => 'https://www.w3.org/ns/activitystreams', '@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), 'id' => Rails.application.routes.url_helpers.v1_activity_pub_remote_flag_url(self, host: site.social_inbox_hostname),
'type' => 'Flag', 'type' => 'Flag',
'actor' => ENV.fetch('PANEL_ACTOR_ID') { "https://#{ENV['SUTTY']}/about.jsonld" }, 'actor' => main_site.social_inbox.actor_id,
'content' => message.to_s, 'content' => message.to_s,
'object' => [actor.uri] + objects.pluck(:uri) 'object' => [actor.uri] + objects.pluck(:uri)
} }
end 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
end end

View file

@ -2,86 +2,69 @@
# Mantiene la relación entre Site y Actor # Mantiene la relación entre Site y Actor
class ActorModeration < ApplicationRecord class ActorModeration < ApplicationRecord
include AASM IGNORED_EVENTS = %i[remove].freeze
include AasmEventsConcern IGNORED_STATES = %i[removed].freeze
IGNORED_EVENTS = [] include AASM
IGNORED_STATES = []
belongs_to :site 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' belongs_to :actor, class_name: 'ActivityPub::Actor'
accepts_nested_attributes_for :remote_flag 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 aasm do
state :paused, initial: true state :paused, initial: true
state :allowed state :allowed
state :blocked state :blocked
state :reported 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 event :pause do
transitions from: %i[allowed blocked reported], to: :paused transitions from: %i[allowed blocked reported], to: :paused, after: :synchronize!
before do
pause_remotely!
end
end end
# Al permitir una cuenta no se permiten todos los comentarios
# pendientes de moderación que ya hizo.
event :allow do event :allow do
transitions from: %i[paused blocked reported], to: :allowed transitions from: %i[paused blocked reported], to: :allowed, after: :synchronize!
before do
allow_remotely!
end
end end
# Al bloquear una cuenta no se bloquean todos los comentarios
# pendientes de moderación que hizo.
event :block do event :block do
transitions from: %i[paused allowed], to: :blocked transitions from: %i[paused allowed], to: :blocked, after: :synchronize!
before do
block_remotely!
end
end end
# Al reportar, necesitamos asociar una RemoteFlag para poder # Al reportar, necesitamos asociar una RemoteFlag para poder
# enviarla. # enviarla.
event :report do 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? ActivityPub::RemoteFlagJob.perform_later(remote_flag: remote_flag) if remote_flag.waiting?
end end
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 end
def pause_remotely! # Definir eventos en masa
raise AASM::InvalidTransition unless include AasmEventsConcern
actor.mention &&
site.social_inbox.allowlist.delete(list: [actor.mention]).ok? &&
site.social_inbox.blocklist.delete(list: [actor.mention]).ok?
end
def allow_remotely! def synchronize!
raise AASM::InvalidTransition unless ActivityPub::SyncListsJob.perform_later(site: site)
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?
end end
end end

View file

@ -16,7 +16,7 @@ module AasmEventsConcern
# #
# @return [Array<Symbol>] # @return [Array<Symbol>]
def self.transitionable_events(current_state) 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 aasm.events.find { |x| x.name == event }.transitions_from_state? current_state
end end
end end
@ -27,5 +27,35 @@ module AasmEventsConcern
def self.states def self.states
aasm.states.map(&:name) - self::IGNORED_STATES aasm.states.map(&:name) - self::IGNORED_STATES
end 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
end end

View file

@ -7,16 +7,16 @@ class DeploySocialDistributedPress < Deploy
# Solo luego de publicar remotamente # Solo luego de publicar remotamente
DEPENDENCIES = %i[deploy_distributed_press deploy_rsync deploy_full_rsync].freeze DEPENDENCIES = %i[deploy_distributed_press deploy_rsync deploy_full_rsync].freeze
after_save :create_hooks!
after_create :enable_fediblocks!
# Envía las notificaciones # Envía las notificaciones
def deploy(output: false) def deploy(output: false)
with_tempfile(site.private_key_pem) do |file| with_tempfile(site.private_key_pem) do |file|
key = Shellwords.escape file.path key = Shellwords.escape file.path
dest = Shellwords.escape destination 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
end end
@ -84,7 +84,7 @@ class DeploySocialDistributedPress < Deploy
response = hook_client.put(event: event, hook: webhook) 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 rescue ArgumentError => e
ExceptionNotifier.notify_exception(e, data: { site_id: site.name, usuarie_id: rol.usuarie_id }) ExceptionNotifier.notify_exception(e, data: { site_id: site.name, usuarie_id: rol.usuarie_id })
end end

View file

@ -20,84 +20,61 @@ class FediblockState < ApplicationRecord
# Aunque queramos las listas habilitadas por defecto, tenemos que # Aunque queramos las listas habilitadas por defecto, tenemos que
# habilitarlas luego de crearlas para poder generar la lista de # habilitarlas luego de crearlas para poder generar la lista de
# bloqueo en la Social Inbox. # bloqueo en la Social Inbox.
state :disabled, initial: true state :disabled, initial: true, before_enter: :pause_unique_instances!
state :enabled 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 event :enable do
transitions from: :disabled, to: :enabled 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 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, # @todo No cambiar el estado si se habían habilitado manualmente,
# pero esto implica que tenemos que encontrar las que sí y quitarlas # pero esto implica que tenemos que encontrar las que sí y quitarlas
# de list_names # de list_names
event :disable do event :disable do
transitions from: :enabled, to: :disabled transitions from: :enabled, to: :disabled, after: :synchronize!
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
end end
end end
private private
def actor_ids def block_instances!
ActivityPub::Actor.where(instance_id: instance_ids).pluck(:id) ActivityPub::InstanceModerationJob.perform_later(site: site, hostnames: fediblock.hostnames, perform_remotely: false)
end end
def instance_ids # Pausar todas las moderaciones de las instancias que no estén
fediblock.instances.pluck(:id) # 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 end
# Todas las instancias de moderación de este sitio def synchronize!
def instance_moderations ActivityPub::SyncListsJob.perform_later(site: site)
site.instance_moderations.where(instance_id: instance_ids)
end end
# Devuelve los hostnames únicos a esta instancia.
#
# @return [Array<String>] # @return [Array<String>]
def list_names def unique_hostnames
@list_names ||= fediblock.instances.map do |instance| @unique_hostnames ||=
"@*@#{instance}" begin
end other_enabled_fediblock_ids =
end 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 fediblock.hostnames - other_enabled_hostnames
def disable_remotely! end
raise AASM::InvalidTransition unless
site.social_inbox.blocklist.delete(list: list_names).ok? &&
site.social_inbox.allowlist.delete(list: list_names).ok?
end
# Al habilitar, se bloquean todas las instancias de la lista
def enable_remotely!
raise AASM::InvalidTransition unless
site.social_inbox.blocklist.post(list: list_names).ok? &&
site.social_inbox.allowlist.delete(list: list_names).ok?
end end
end end

View file

@ -2,84 +2,46 @@
# Mantiene el registro de relaciones entre sitios e instancias # Mantiene el registro de relaciones entre sitios e instancias
class InstanceModeration < ApplicationRecord class InstanceModeration < ApplicationRecord
include AASM IGNORED_EVENTS = [].freeze
include AasmEventsConcern IGNORED_STATES = [].freeze
IGNORED_EVENTS = [] include AASM
IGNORED_STATES = []
belongs_to :site belongs_to :site
belongs_to :instance, class_name: 'ActivityPub::Instance' 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 aasm do
state :paused, initial: true state :paused, initial: true
state :allowed state :allowed
state :blocked 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 event :pause do
transitions from: %i[allowed blocked], to: :paused transitions from: %i[allowed blocked], to: :paused
before do
pause_remotely!
end
end end
# Al permitir, solo bloqueamos la instancia, sin modificar el estado
# de les actores y comentarios retroactivamente.
event :allow do event :allow do
transitions from: %i[paused blocked], to: :allowed transitions from: %i[paused blocked], to: :allowed
before do
allow_remotely!
end
end end
# Al bloquear, solo bloqueamos la instancia, sin modificar el estado
# de les actores y comentarios retroactivamente.
event :block do event :block do
transitions from: %i[paused allowed], to: :blocked transitions from: %i[paused allowed], to: :blocked
before do
block_remotely!
end
end end
end end
# Elimina la instancia de todas las listas # Definir eventos en masa
# include AasmEventsConcern
# @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
end 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? 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] # @return [SocialInbox]
def social_inbox def social_inbox
@social_inbox ||= SocialInbox.new(site: self) @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 # En este paso tenemos varias instancias por moderar pero todas son
# del mismo sitio. # del mismo sitio.
def action_on_several? def action_on_several?
instance_moderation.first.site.usuarie? usuarie instance_moderation.first.presence && instance_moderation.first.site.usuarie?(usuarie)
end end
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. # Por ahora solo queremos moderar comentarios.
prepare do 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 end
map :activity_pub_state, activate_always: true do |activity_pub_state: 'paused'| 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 .col-12.col-md-8
= render 'components/profiles_btn_box', actor_moderation: @actor_moderation = render 'components/profiles_btn_box', actor_moderation: @actor_moderation
.col-12.col-md-8 .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 -# Componente Remote_Profile
- uri = text_plain(remote_profile['id'])
.py-2 .py-2
%dl %dl
%dt= t('.profile_name') %dt= t('.profile_name')
@ -10,7 +12,7 @@
%dt= t('.profile_id') %dt= t('.profile_id')
%dd %dd
= link_to text_plain(remote_profile['id']) = link_to uri, uri
- if remote_profile['published'].present? - if remote_profile['published'].present?
%dt= t('.profile_published') %dt= t('.profile_published')

View file

@ -1,9 +1,8 @@
-# Componente Botón general Moderación -# Componente Botón general Moderación
- local_assigns[:method] ||= 'patch' - local_assigns[:method] ||= 'patch'
- local_assigns[:class] ||= 'btn-secondary'
- local_assigns[:class] = "btn #{local_assigns[:class]}" - local_assigns[:class] = "btn #{local_assigns[:class]}"
- local_assigns.delete(:text)
-# @todo path es obligatorio = button_to(path, **local_assigns.compact) do
= button_to local_assigns[:path], **local_assigns do
= text = text

View file

@ -1,5 +1,6 @@
-# Componente Checkbox -# Componente Checkbox
- local_assigns[:name] ||= id - local_assigns[:name] ||= id
.custom-control.custom-checkbox .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 %label.custom-control-label{ for: id }= yield

View file

@ -1,8 +1,14 @@
-# Componente Botonera de Comentarios -# Componente Botonera de Comentarios
- local = { reject: { data: { confirm: t('.confirm_reject') } }, report: { class: 'ml-auto', data: { confirm: t('.confirm_report') } } }
.d-flex.flex-row .d-flex.flex-row
- ActivityPub.events.each do |event| - ActivityPub.events.each do |event|
- possible = activity_pub.public_send(:"may_#{event}?")
%div{ class: local.dig(event, :class) }
= render 'components/btn_base', = render 'components/btn_base',
text: t(".text_#{event}"), text: t(".text_#{event}"),
path: public_send(:"site_activity_pub_#{event}_path", activity_pub_id: activity_pub), 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 - current_state = params[:activity_pub_state]&.to_sym || ActivityPub.states.first
- ActivityPub.aasm.events.each do |event| - ActivityPub.aasm.events.each do |event|
- next if ActivityPub::IGNORED_EVENTS.include? event.name - next if ActivityPub::IGNORED_EVENTS.include? event.name
- next unless event.transitions_from_state?(current_state) - 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 - 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? - if ActivityPub.transitionable_events(current_state).present?
= render 'components/dropdown', text: t('.text_checked') do = 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/dropdown', text: t('.text_show') do
= render 'components/comments_show_submenu', activity_pubs: activity_pubs = render 'components/comments_show_submenu', activity_pubs: activity_pubs

View file

@ -1,4 +1,5 @@
- ActivityPub.states.each do |state| - ActivityPub.states.each do |state|
= render 'components/dropdown_item', = render 'components/dropdown_item',
text: t(".submenu_#{state}", count: activity_pubs.unscope(where: :aasm_state).public_send(state).count), 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' controller: 'dropdown'
} }
} }
%button.btn.dropdown-toggle{ %button.btn.btn-outline-secondary.dropdown-toggle{
type: 'button', type: 'button',
class: button_classes, class: button_classes,
data: { data: {

View file

@ -1,4 +1,6 @@
-# -#
@param name [String] @param name [String]
@param value [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 :text [String] Contenido del link
@param :path [String,Hash] 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 -# Componente botonera de moderación de Instancias
- btn_class = 'btn btn-secondary' - local_data = {}
= 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? - InstanceModeration.events.each do |event|
= 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? - possible = instance_moderation.public_send(:"may_#{event}?")
= 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? = 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| - 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 - 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? - if InstanceModeration.transitionable_events(current_state).present?
= render 'components/dropdown', text: t('.text_checked') do = 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/dropdown', text: t('.text_show') do
= render 'components/instances_show_submenu', instance_moderations: instance_moderations = render 'components/instances_show_submenu', instance_moderations: instance_moderations

View file

@ -1,4 +1,5 @@
- InstanceModeration.states.each do |state| - InstanceModeration.states.each do |state|
= render 'components/dropdown_item', = render 'components/dropdown_item',
text: t(".submenu_#{state}", count: instance_moderations.unscope(where: :aasm_state).public_send(state).count), 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) -# Componente Botonera de Moderación de Cuentas (Remote_profile)
.d-flex.flex-row .d-flex.flex-row.w-100
- btn_class = 'btn-secondary' - local = { report: { class: 'ml-auto', data: { confirm: t('.confirm_report') } } }
- ActorModeration.events.each do |actor_event| - 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', = render 'components/btn_base',
text: t(".text_#{actor_event}"), text: t(".text_#{actor_event}"),
path: public_send(:"site_actor_moderation_#{actor_event}_path", actor_moderation_id: actor_moderation), path: public_send(:"site_actor_moderation_#{actor_event}_path", actor_moderation_id: actor_moderation),
class: btn_class, class: ('btn-secondary' if possible),
disabled: !actor_moderation.public_send(:"may_#{actor_event}?") 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| - 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 - 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? - if ActorModeration.transitionable_events(current_state).present?
= render 'components/dropdown', text: t('.text_checked') do = 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/dropdown', text: t('.text_show') do
= render 'components/profiles_show_submenu', actor_moderations: actor_moderations = render 'components/profiles_show_submenu', actor_moderations: actor_moderations

View file

@ -1,4 +1,5 @@
- ActorModeration.states.each do |actor_state| - ActorModeration.states.each do |actor_state|
= render 'components/dropdown_item', = render 'components/dropdown_item',
text: t(".submenu_#{actor_state}", count: actor_moderations.unscope(where: :aasm_state).public_send(actor_state).count), 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] @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') %span.sr-only= t('.label')

View file

@ -7,7 +7,7 @@
navegador los va a asignar a este formulario. navegador los va a asignar a este formulario.
@param path [String] @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 -# nada

View file

@ -1,13 +1,16 @@
-#
@params form [String]
.row.no-gutters.pt-2 .row.no-gutters.pt-2
.col-1 .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 .col-11
- cache [actor_moderation, profile] do
%h4 %h4
= link_to text_plain(profile['name']), site_actor_moderation_path(id: actor_moderation) = link_to text_plain(profile['name']), site_actor_moderation_path(id: actor_moderation)
.mb-3 .mb-3
= sanitize profile['summary'] = sanitize profile['summary']
-# Botones de Moderación -# Botones de Moderación
- cache actor_moderation do
.d-flex.pb-4 .d-flex.pb-4
= render 'components/profiles_btn_box', actor_moderation: actor_moderation = render 'components/profiles_btn_box', actor_moderation: actor_moderation

View file

@ -1,17 +1,17 @@
- form_id = 'actor_moderations_action_on_several' - 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' } } .row.no-gutters.pt-2{ data: { controller: 'select-all' } }
.col-1.d-flex.align-items-center .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 .col-11
-# Filtros -# 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 .col-12
- if actor_moderations.count.zero? - if actor_moderations.count.zero?
%h4= t('moderation_queue.nothing') %h4= t('moderation_queue.nothing')
- actor_moderations.find_each do |actor_moderation| - actor_moderations.find_each do |actor_moderation|
- cache [actor_moderation, actor_moderation.actor] do - next if actor_moderation.actor.content.empty?
%hr %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 .form-group
= label_tag 'custom_blocklist', t('moderation_queue.instances.custom_block') = 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 Componente Comentario
@param site [Site]
@param form [String]
@param profile [Hash] @param profile [Hash]
@param comment [Hash] @param comment [Hash]
@param activity_pub [ActivityPub] @param activity_pub [ActivityPub]
- in_reply_to = text_plain comment['inReplyTo'] - 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'] - summary = text_plain comment['summary']
-# @todo Generar un desplegable con todas las opciones
- url = text_plain ActivityPub.url_from_object(comment)
.row.no-gutters .row.no-gutters
.col-1 .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 .col-11
- cache [activity_pub, comment] do
.d-flex.flex-row.align-items-center.justify-content-between .d-flex.flex-row.align-items-center.justify-content-between
%h4.mb-0 %h4.mb-0
%a{ href: text_plain(comment['attributedTo']) }= text_plain profile['preferredUsername'] %a{ href: text_plain(comment['attributedTo']) }= text_plain profile['preferredUsername']
%a{ href: url }
%small %small
= render 'layouts/time', time: text_plain(comment['published']) = render 'layouts/time', time: text_plain(comment['published'])
- if in_reply_to.present? - if in_reply_to.present?
@ -24,7 +38,7 @@
%dd.d-inline %dd.d-inline
%small %small
%a{ href: in_reply_to }= in_reply_to %a{ href: in_reply_to }= in_reply_to
.content .content.mb-3
- if summary.present? - if summary.present?
= render 'layouts/details', summary: summary, summary_class: 'h5' do = render 'layouts/details', summary: summary, summary_class: 'h5' do
= sanitize comment['content'] = sanitize comment['content']

View file

@ -1,17 +1,18 @@
- form_id = 'activity_pub_action_on_several' - 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' } } .row.no-gutters.pt-2{ data: { controller: 'select-all' } }
.col-1.d-flex.align-items-center .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 .col-11
-# Filtros -# 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 .col-12
- if moderation_queue.count.zero? - if moderation_queue.count.zero?
%h4= t('moderation_queue.nothing') %h4= t('moderation_queue.nothing')
- moderation_queue.each do |activity_pub| - 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 %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('usage', 'users', 'active_month')
- usuaries ||= instance.content.dig('stats', 'user_count') - usuaries ||= instance.content.dig('stats', 'user_count')
- title = sanitize(instance.content['title'])
.row.no-gutters.pt-2 .row.no-gutters.pt-2
.col-1 .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 .col-11
- cache [instance_moderation, instance] do
%h4 %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 .content
= sanitize instance.content['description'] = sanitize instance.content['description']
- if usuaries.present? - if usuaries.present?

View file

@ -1,22 +1,21 @@
- form_id = 'instance_moderation_action_on_several' - form_id = 'instance_moderation_action_on_several'
%section %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' } } .row.no-gutters.pt-2{ data: { controller: 'select-all' } }
.col-1.d-flex.align-items-center .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 .col-11
-# Filtros -# 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 .col-12
- if instance_moderations.count.zero? - if instance_moderations.count.zero?
%h4= t('moderation_queue.nothing') %h4= t('moderation_queue.nothing')
- instance_moderations.each do |instance_moderation| - instance_moderations.each do |instance_moderation|
- cache [instance_moderation.aasm_state, instance_moderation.instance] do
%hr %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 %hr
%div %div

View file

@ -7,11 +7,11 @@
%main.row %main.row
%aside.menu.col-lg-3 %aside.menu.col-lg-3
.mb-3
= render 'sites/header', site: @site = render 'sites/header', site: @site
= render 'sites/status', site: @site = render 'sites/status', site: @site
= render 'sites/build', site: @site, class: 'btn-block'
= render 'sites/build', site: @site, class: 'btn-block mb-3' = render 'sites/moderation_queue', site: @site, class: 'btn-block'
%h3= t('posts.new') %h3= t('posts.new')
%table.table.table-sm.mb-3 %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 %tbody
- @sites.each do |site| - @sites.each do |site|
- next unless site.jekyll? - 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 %tr
%td %td
%h2 %h2
@ -30,17 +25,17 @@
%p.lead= site.description %p.lead= site.description
%br %br
= link_to t('.visit'), site.url, class: 'btn btn-secondary' = link_to t('.visit'), site.url, class: 'btn btn-secondary'
- if rol.temporal - if current_usuarie.rol_for_site(site).temporal?
= button_to t('sites.invitations.accept'), = render 'components/btn_base',
site_usuaries_accept_invitation_path(site), text: t('sites.invitations.accept'),
method: :patch, path: site_usuaries_accept_invitation_path(site),
title: t('help.sites.invitations.accept'), title: t('help.sites.invitations.accept'),
class: 'btn btn-secondary' class: 'btn-secondary'
= button_to t('sites.invitations.reject'), = render 'components/btn_base',
site_usuaries_reject_invitation_path(site), text: t('sites.invitations.reject'),
method: :patch, path: site_usuaries_reject_invitation_path(site),
title: t('help.sites.invitations.reject'), title: t('help.sites.invitations.reject'),
class: 'btn btn-secondary' class: 'btn-secondary'
- else - else
- if policy(site).show? - if policy(site).show?
= render 'layouts/btn_with_tooltip', = render 'layouts/btn_with_tooltip',
@ -48,10 +43,11 @@
type: 'success', type: 'success',
link: site_path(site), link: site_path(site),
text: t('sites.posts') text: t('sites.posts')
= render 'sites/build', site: site
= render 'sites/moderation_queue', site: site
- if policy(SiteUsuarie.new(site, current_usuarie)).index? - if policy(SiteUsuarie.new(site, current_usuarie)).index?
= render 'layouts/btn_with_tooltip', = render 'layouts/btn_with_tooltip',
tooltip: t('usuaries.index.help.self'), tooltip: t('usuaries.index.help.self'),
text: t('usuaries.index.title'), text: t('usuaries.index.title'),
type: 'info', type: 'info',
link: site_usuaries_path(site) 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.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[:host] = "panel.#{ENV.fetch('SUTTY', 'sutty.nl')}"
Rails.application.routes.default_url_options[:protocol] = 'https' 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 pm: pm
format: '%-I:%M %p' format: '%-I:%M %p'
components: 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: block_list:
know_more: Know more know_more: Know more
instances_blocked: Instances blocked instances_blocked: Instances blocked
instances_filters: instances_filters:
text_show: Show text_show: Show
text_checked: With selected text_checked: With selected...
instances_checked_submenu: instances_checked_submenu:
submenu_pause: Moderate submenu_pause: Moderate
submenu_allow: Allow submenu_allow: Allow
@ -66,7 +74,7 @@ en:
submenu_blocked: "Blocked (%{count})" submenu_blocked: "Blocked (%{count})"
comments_filters: comments_filters:
text_show: Show text_show: Show
text_checked: With selected text_checked: With selected...
comments_checked_submenu: comments_checked_submenu:
submenu_pause: Pause submenu_pause: Pause
submenu_approve: Approve submenu_approve: Approve
@ -79,7 +87,7 @@ en:
submenu_reported: "Reported (%{count})" submenu_reported: "Reported (%{count})"
profiles_filters: profiles_filters:
text_show: Show text_show: Show
text_checked: With selected text_checked: With selected...
profiles_checked_submenu: profiles_checked_submenu:
submenu_pause: Pause submenu_pause: Pause
submenu_allow: Allow submenu_allow: Allow
@ -98,26 +106,68 @@ en:
text_reject: Reject text_reject: Reject
text_reply: Reply text_reply: Reply
text_report: Report 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: instances_btn_box:
text_check: Check case by case text_pause: Check case by case
text_allow: Allow everything text_allow: Allow everything
text_deny: Block instance text_block: Block instance
profiles_btn_box: profiles_btn_box:
text_pause: Always check text_pause: Always check
text_allow: Always approve text_allow: Always approve
text_block: Block text_block: Block
text_report: Report text_report: Report
actor_moderations: confirm_report: "Send report to the remote instance? This action will also block the account."
show:
user: Username
profile: Profile
profile_name: Profile name
preferred_name: Name in Fediverse
profile_id: ID
profile_published: Published
profile_summary: Summary
remote_flags: 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}." 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: moderation_queue:
everything: 'Select all' everything: 'Select all'
nothing: "There's nothing for this filter" nothing: "There's nothing for this filter"
@ -131,8 +181,11 @@ en:
reply_to: Reply to reply_to: Reply to
instances: instances:
title: My block lists 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: Custom block lists
custom_block_placeholder: |
a.doma.in
per.li.ne
submit: Save block lists submit: Save block lists
instance: instance:
users: "Users:" users: "Users:"
@ -339,6 +392,7 @@ en:
lang: lang:
not_available: "This language is not yet available, would you help us by translating Sutty into it?" not_available: "This language is not yet available, would you help us by translating Sutty into it?"
errors: 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}' argument_error: 'Argument `%{argument}` must be an instance of %{class}'
unknown_locale: 'Unknown %{locale} locale' unknown_locale: 'Unknown %{locale} locale'
posts: posts:
@ -515,6 +569,8 @@ en:
column: "Country" column: "Country"
empty: "(couldn't detect country)" empty: "(couldn't detect country)"
sites: sites:
moderation_queue:
moderation_needed: "There are new activities pending revision since the last time you moderated."
donations: donations:
url: 'https://donaciones.sutty.nl/en/' url: 'https://donaciones.sutty.nl/en/'
text: 'Support us' text: 'Support us'

View file

@ -50,12 +50,20 @@ es:
pm: pm pm: pm
format: '%-H:%M' format: '%-H:%M'
components: 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: block_list:
know_more: Saber más (en inglés) know_more: Saber más (en inglés)
instances_blocked: Instancias bloqueadas instances_blocked: Instancias bloqueadas
instances_filters: instances_filters:
text_show: Ver text_show: Ver
text_checked: Con los marcados text_checked: Con los marcados...
instances_checked_submenu: instances_checked_submenu:
submenu_pause: Moderar caso por caso submenu_pause: Moderar caso por caso
submenu_allow: Permitir todo submenu_allow: Permitir todo
@ -66,7 +74,7 @@ es:
submenu_blocked: "Bloqueadas (%{count})" submenu_blocked: "Bloqueadas (%{count})"
comments_filters: comments_filters:
text_show: Ver text_show: Ver
text_checked: Con los marcados text_checked: Con los marcados...
comments_checked_submenu: comments_checked_submenu:
submenu_pause: Pausar submenu_pause: Pausar
submenu_approve: Aprobar submenu_approve: Aprobar
@ -79,7 +87,7 @@ es:
submenu_reported: "Reportados (%{count})" submenu_reported: "Reportados (%{count})"
profiles_filters: profiles_filters:
text_show: Ver text_show: Ver
text_checked: Con los marcados text_checked: Con los marcados...
profiles_checked_submenu: profiles_checked_submenu:
submenu_pause: Pausar submenu_pause: Pausar
submenu_allow: Aceptar submenu_allow: Aceptar
@ -97,26 +105,68 @@ es:
text_approve: Aceptar text_approve: Aceptar
text_reject: Rechazar text_reject: Rechazar
text_report: Reportar 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: instances_btn_box:
text_check: Moderar caso por caso text_pause: Moderar caso por caso
text_allow: Permitir todo text_allow: Permitir todo
text_deny: Bloquear instancia text_block: Bloquear instancia
profiles_btn_box: profiles_btn_box:
text_pause: Revisar siempre text_pause: Revisar siempre
text_allow: Aprobar siempre text_allow: Aprobar siempre
text_block: Bloquear text_block: Bloquear
text_report: Reportar text_report: Reportar
actor_moderations: confirm_report: "¿Enviar el reporte a la instancia remota? Esta acción también bloqueará la cuenta."
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
remote_flags: 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}." 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: moderation_queue:
everything: 'Seleccionar todo' everything: 'Seleccionar todo'
nothing: 'No hay nada para este filtro' nothing: 'No hay nada para este filtro'
@ -130,8 +180,11 @@ es:
reply_to: En respuesta a reply_to: En respuesta a
instances: instances:
title: Mis listas de bloqueo 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: Lista personalizada de bloqueo
custom_block_placeholder: |
un.domin.io
por.lin.ea
submit: Guardar listas de bloqueo submit: Guardar listas de bloqueo
instance: instance:
users: "Usuaries:" users: "Usuaries:"
@ -338,6 +391,7 @@ es:
lang: lang:
not_available: "Este idioma todavía no está disponible, ¿nos ayudas a agregarlo y mantenerlo?" not_available: "Este idioma todavía no está disponible, ¿nos ayudas a agregarlo y mantenerlo?"
errors: 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}' argument_error: 'El argumento `%{argument}` debe ser una instancia de %{class}'
unknown_locale: 'El idioma %{locale} es desconocido' unknown_locale: 'El idioma %{locale} es desconocido'
posts: posts:
@ -519,6 +573,8 @@ es:
column: "País" column: "País"
empty: "(no se pudo detectar el país)" empty: "(no se pudo detectar el país)"
sites: sites:
moderation_queue:
moderation_needed: "Hay actividades pendientes de revisión desde la última vez que moderaste."
donations: donations:
url: 'https://donaciones.sutty.nl/' url: 'https://donaciones.sutty.nl/'
text: 'Apoyá nuestro trabajo' text: 'Apoyá nuestro trabajo'

View file

@ -4,6 +4,9 @@ Rails.application.routes.draw do
devise_for :usuaries devise_for :usuaries
get '/.well-known/change-password', to: redirect('/usuaries/edit') get '/.well-known/change-password', to: redirect('/usuaries/edit')
require 'que/web'
mount Que::Web => '/que'
root 'application#index' root 'application#index'
constraints(Constraints::ApiSubdomain.new) do 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