@ -0,0 +1,69 @@ | |||||
# frozen_string_literal: true | |||||
module Admin | |||||
class StatusesController < BaseController | |||||
include Authorization | |||||
helper_method :current_params | |||||
before_action :set_account | |||||
before_action :set_status, only: [:update, :destroy] | |||||
PAR_PAGE = 20 | |||||
def index | |||||
@statuses = @account.statuses | |||||
if params[:media] | |||||
account_media_status_ids = @account.media_attachments.attached.reorder(nil).select(:status_id).distinct | |||||
@statuses.merge!(Status.where(id: account_media_status_ids)) | |||||
end | |||||
@statuses = @statuses.preload(:media_attachments, :mentions).page(params[:page]).per(PAR_PAGE) | |||||
@form = Form::StatusBatch.new | |||||
end | |||||
def create | |||||
@form = Form::StatusBatch.new(form_status_batch_params) | |||||
flash[:alert] = t('admin.statuses.failed_to_execute') unless @form.save | |||||
redirect_to admin_account_statuses_path(@account.id, current_params) | |||||
end | |||||
def update | |||||
@status.update(status_params) | |||||
redirect_to admin_account_statuses_path(@account.id, current_params) | |||||
end | |||||
def destroy | |||||
authorize @status, :destroy? | |||||
RemovalWorker.perform_async(@status.id) | |||||
render json: @status | |||||
end | |||||
private | |||||
def status_params | |||||
params.require(:status).permit(:sensitive) | |||||
end | |||||
def form_status_batch_params | |||||
params.require(:form_status_batch).permit(:action, status_ids: []) | |||||
end | |||||
def set_status | |||||
@status = @account.statuses.find(params[:id]) | |||||
end | |||||
def set_account | |||||
@account = Account.find(params[:account_id]) | |||||
end | |||||
def current_params | |||||
page = (params[:page] || 1).to_i | |||||
{ | |||||
media: params[:media], | |||||
page: page > 1 && page, | |||||
}.select { |_, value| value.present? } | |||||
end | |||||
end | |||||
end |
@ -1 +1,10 @@ | |||||
import './web_push_notifications'; | import './web_push_notifications'; | ||||
// Cause a new version of a registered Service Worker to replace an existing one | |||||
// that is already installed, and replace the currently active worker on open pages. | |||||
self.addEventListener('install', function(event) { | |||||
event.waitUntil(self.skipWaiting()); | |||||
}); | |||||
self.addEventListener('activate', function(event) { | |||||
event.waitUntil(self.clients.claim()); | |||||
}); |
@ -0,0 +1,40 @@ | |||||
import { delegate } from 'rails-ujs'; | |||||
function handleDeleteStatus(event) { | |||||
const [data] = event.detail; | |||||
const element = document.querySelector(`[data-id="${data.id}"]`); | |||||
if (element) { | |||||
element.parentNode.removeChild(element); | |||||
} | |||||
} | |||||
[].forEach.call(document.querySelectorAll('.trash-button'), (content) => { | |||||
content.addEventListener('ajax:success', handleDeleteStatus); | |||||
}); | |||||
const batchCheckboxClassName = '.batch-checkbox input[type="checkbox"]'; | |||||
delegate(document, '#batch_checkbox_all', 'change', ({ target }) => { | |||||
[].forEach.call(document.querySelectorAll(batchCheckboxClassName), (content) => { | |||||
content.checked = target.checked; | |||||
}); | |||||
}); | |||||
delegate(document, batchCheckboxClassName, 'change', () => { | |||||
const checkAllElement = document.querySelector('#batch_checkbox_all'); | |||||
if (checkAllElement) { | |||||
checkAllElement.checked = [].every.call(document.querySelectorAll(batchCheckboxClassName), (content) => content.checked); | |||||
} | |||||
}); | |||||
delegate(document, '.media-spoiler-show-button', 'click', () => { | |||||
[].forEach.call(document.querySelectorAll('.activity-stream .media-spoiler-wrapper'), (content) => { | |||||
content.classList.add('media-spoiler-wrapper__visible'); | |||||
}); | |||||
}); | |||||
delegate(document, '.media-spoiler-hide-button', 'click', () => { | |||||
[].forEach.call(document.querySelectorAll('.activity-stream .media-spoiler-wrapper'), (content) => { | |||||
content.classList.remove('media-spoiler-wrapper__visible'); | |||||
}); | |||||
}); |
@ -1,6 +1,7 @@ | |||||
import main from '../mastodon/main'; | |||||
import loadPolyfills from '../mastodon/load_polyfills'; | import loadPolyfills from '../mastodon/load_polyfills'; | ||||
loadPolyfills().then(main).catch(e => { | |||||
loadPolyfills().then(() => { | |||||
require('../mastodon/main').default(); | |||||
}).catch(e => { | |||||
console.error(e); | console.error(e); | ||||
}); | }); |
@ -0,0 +1,50 @@ | |||||
# frozen_string_literal: true | |||||
class Ostatus::Activity::Base | |||||
def initialize(xml, account = nil) | |||||
@xml = xml | |||||
@account = account | |||||
end | |||||
def status? | |||||
[:activity, :note, :comment].include?(type) | |||||
end | |||||
def verb | |||||
raw = @xml.at_xpath('./activity:verb', activity: TagManager::AS_XMLNS).content | |||||
TagManager::VERBS.key(raw) | |||||
rescue | |||||
:post | |||||
end | |||||
def type | |||||
raw = @xml.at_xpath('./activity:object-type', activity: TagManager::AS_XMLNS).content | |||||
TagManager::TYPES.key(raw) | |||||
rescue | |||||
:activity | |||||
end | |||||
def id | |||||
@xml.at_xpath('./xmlns:id', xmlns: TagManager::XMLNS).content | |||||
end | |||||
def url | |||||
link = @xml.at_xpath('./xmlns:link[@rel="alternate"]', xmlns: TagManager::XMLNS) | |||||
link.nil? ? nil : link['href'] | |||||
end | |||||
private | |||||
def find_status(uri) | |||||
if TagManager.instance.local_id?(uri) | |||||
local_id = TagManager.instance.unique_tag_to_local_id(uri, 'Status') | |||||
return Status.find_by(id: local_id) | |||||
end | |||||
Status.find_by(uri: uri) | |||||
end | |||||
def redis | |||||
Redis.current | |||||
end | |||||
end |
@ -0,0 +1,149 @@ | |||||
# frozen_string_literal: true | |||||
class Ostatus::Activity::Creation < Ostatus::Activity::Base | |||||
def perform | |||||
if redis.exists("delete_upon_arrival:#{@account.id}:#{id}") | |||||
Rails.logger.debug "Delete for status #{id} was queued, ignoring" | |||||
return [nil, false] | |||||
end | |||||
return [nil, false] if @account.suspended? | |||||
Rails.logger.debug "Creating remote status #{id}" | |||||
# Return early if status already exists in db | |||||
status = find_status(id) | |||||
return [status, false] unless status.nil? | |||||
status = Status.create!( | |||||
uri: id, | |||||
url: url, | |||||
account: @account, | |||||
reblog: reblog, | |||||
text: content, | |||||
spoiler_text: content_warning, | |||||
created_at: published, | |||||
reply: thread?, | |||||
language: content_language, | |||||
visibility: visibility_scope, | |||||
conversation: find_or_create_conversation, | |||||
thread: thread? ? find_status(thread.first) : nil | |||||
) | |||||
save_mentions(status) | |||||
save_hashtags(status) | |||||
save_media(status) | |||||
if thread? && status.thread.nil? | |||||
Rails.logger.debug "Trying to attach #{status.id} (#{id}) to #{thread.first}" | |||||
ThreadResolveWorker.perform_async(status.id, thread.second) | |||||
end | |||||
Rails.logger.debug "Queuing remote status #{status.id} (#{id}) for distribution" | |||||
LinkCrawlWorker.perform_async(status.id) unless status.spoiler_text? | |||||
DistributionWorker.perform_async(status.id) | |||||
[status, true] | |||||
end | |||||
def content | |||||
@xml.at_xpath('./xmlns:content', xmlns: TagManager::XMLNS).content | |||||
end | |||||
def content_language | |||||
@xml.at_xpath('./xmlns:content', xmlns: TagManager::XMLNS)['xml:lang']&.presence || 'en' | |||||
end | |||||
def content_warning | |||||
@xml.at_xpath('./xmlns:summary', xmlns: TagManager::XMLNS)&.content || '' | |||||
end | |||||
def visibility_scope | |||||
@xml.at_xpath('./mastodon:scope', mastodon: TagManager::MTDN_XMLNS)&.content&.to_sym || :public | |||||
end | |||||
def published | |||||
@xml.at_xpath('./xmlns:published', xmlns: TagManager::XMLNS).content | |||||
end | |||||
def thread? | |||||
!@xml.at_xpath('./thr:in-reply-to', thr: TagManager::THR_XMLNS).nil? | |||||
end | |||||
def thread | |||||
thr = @xml.at_xpath('./thr:in-reply-to', thr: TagManager::THR_XMLNS) | |||||
[thr['ref'], thr['href']] | |||||
end | |||||
private | |||||
def find_or_create_conversation | |||||
uri = @xml.at_xpath('./ostatus:conversation', ostatus: TagManager::OS_XMLNS)&.attribute('ref')&.content | |||||
return if uri.nil? | |||||
if TagManager.instance.local_id?(uri) | |||||
local_id = TagManager.instance.unique_tag_to_local_id(uri, 'Conversation') | |||||
return Conversation.find_by(id: local_id) | |||||
end | |||||
Conversation.find_by(uri: uri) || Conversation.create!(uri: uri) | |||||
end | |||||
def save_mentions(parent) | |||||
processed_account_ids = [] | |||||
@xml.xpath('./xmlns:link[@rel="mentioned"]', xmlns: TagManager::XMLNS).each do |link| | |||||
next if [TagManager::TYPES[:group], TagManager::TYPES[:collection]].include? link['ostatus:object-type'] | |||||
mentioned_account = account_from_href(link['href']) | |||||
next if mentioned_account.nil? || processed_account_ids.include?(mentioned_account.id) | |||||
mentioned_account.mentions.where(status: parent).first_or_create(status: parent) | |||||
# So we can skip duplicate mentions | |||||
processed_account_ids << mentioned_account.id | |||||
end | |||||
end | |||||
def save_hashtags(parent) | |||||
tags = @xml.xpath('./xmlns:category', xmlns: TagManager::XMLNS).map { |category| category['term'] }.select(&:present?) | |||||
ProcessHashtagsService.new.call(parent, tags) | |||||
end | |||||
def save_media(parent) | |||||
do_not_download = DomainBlock.find_by(domain: parent.account.domain)&.reject_media? | |||||
@xml.xpath('./xmlns:link[@rel="enclosure"]', xmlns: TagManager::XMLNS).each do |link| | |||||
next unless link['href'] | |||||
media = MediaAttachment.where(status: parent, remote_url: link['href']).first_or_initialize(account: parent.account, status: parent, remote_url: link['href']) | |||||
parsed_url = Addressable::URI.parse(link['href']).normalize | |||||
next if !%w(http https).include?(parsed_url.scheme) || parsed_url.host.empty? | |||||
media.save | |||||
next if do_not_download | |||||
begin | |||||
media.file_remote_url = link['href'] | |||||
media.save! | |||||
rescue ActiveRecord::RecordInvalid | |||||
next | |||||
end | |||||
end | |||||
end | |||||
def account_from_href(href) | |||||
url = Addressable::URI.parse(href).normalize | |||||
if TagManager.instance.web_domain?(url.host) | |||||
Account.find_local(url.path.gsub('/users/', '')) | |||||
else | |||||
Account.where(uri: href).or(Account.where(url: href)).first || FetchRemoteAccountService.new.call(href) | |||||
end | |||||
end | |||||
end |
@ -0,0 +1,14 @@ | |||||
# frozen_string_literal: true | |||||
class Ostatus::Activity::Deletion < Ostatus::Activity::Base | |||||
def perform | |||||
Rails.logger.debug "Deleting remote status #{id}" | |||||
status = Status.find_by(uri: id, account: @account) | |||||
if status.nil? | |||||
redis.setex("delete_upon_arrival:#{@account.id}:#{id}", 6 * 3_600, id) | |||||
else | |||||
RemoveStatusService.new.call(status) | |||||
end | |||||
end | |||||
end |
@ -0,0 +1,20 @@ | |||||
# frozen_string_literal: true | |||||
class Ostatus::Activity::General < Ostatus::Activity::Base | |||||
def specialize | |||||
special_class&.new(@xml, @account) | |||||
end | |||||
private | |||||
def special_class | |||||
case verb | |||||
when :post | |||||
Ostatus::Activity::Post | |||||
when :share | |||||
Ostatus::Activity::Share | |||||
when :delete | |||||
Ostatus::Activity::Deletion | |||||
end | |||||
end | |||||
end |
@ -0,0 +1,23 @@ | |||||
# frozen_string_literal: true | |||||
class Ostatus::Activity::Post < Ostatus::Activity::Creation | |||||
def perform | |||||
status, just_created = super | |||||
if just_created | |||||
status.mentions.includes(:account).each do |mention| | |||||
mentioned_account = mention.account | |||||
next unless mentioned_account.local? | |||||
NotifyService.new.call(mentioned_account, mention) | |||||
end | |||||
end | |||||
status | |||||
end | |||||
private | |||||
def reblog | |||||
nil | |||||
end | |||||
end |
@ -0,0 +1,7 @@ | |||||
# frozen_string_literal: true | |||||
class Ostatus::Activity::Remote < Ostatus::Activity::Base | |||||
def perform | |||||
find_status(id) || FetchRemoteStatusService.new.call(url) | |||||
end | |||||
end |
@ -0,0 +1,26 @@ | |||||
# frozen_string_literal: true | |||||
class Ostatus::Activity::Share < Ostatus::Activity::Creation | |||||
def perform | |||||
return if reblog.nil? | |||||
status, just_created = super | |||||
NotifyService.new.call(reblog.account, status) if reblog.account.local? && just_created | |||||
status | |||||
end | |||||
def object | |||||
@xml.at_xpath('.//activity:object', activity: TagManager::AS_XMLNS) | |||||
end | |||||
private | |||||
def reblog | |||||
return @reblog if defined? @reblog | |||||
original_status = Ostatus::Activity::Remote.new(object).perform | |||||
return if original_status.nil? | |||||
@reblog = original_status.reblog? ? original_status.reblog : original_status | |||||
end | |||||
end |
@ -1,6 +1,6 @@ | |||||
# frozen_string_literal: true | # frozen_string_literal: true | ||||
class AtomSerializer | |||||
class Ostatus::AtomSerializer | |||||
include RoutingHelper | include RoutingHelper | ||||
include ActionView::Helpers::SanitizeHelper | include ActionView::Helpers::SanitizeHelper | ||||
@ -0,0 +1,39 @@ | |||||
# frozen_string_literal: true | |||||
class Form::StatusBatch | |||||
include ActiveModel::Model | |||||
attr_accessor :status_ids, :action | |||||
ACTION_TYPE = %w(nsfw_on nsfw_off delete).freeze | |||||
def save | |||||
case action | |||||
when 'nsfw_on', 'nsfw_off' | |||||
change_sensitive(action == 'nsfw_on') | |||||
when 'delete' | |||||
delete_statuses | |||||
end | |||||
end | |||||
private | |||||
def change_sensitive(sensitive) | |||||
media_attached_status_ids = MediaAttachment.where(status_id: status_ids).pluck(:status_id) | |||||
ApplicationRecord.transaction do | |||||
Status.where(id: media_attached_status_ids).find_each do |status| | |||||
status.update!(sensitive: sensitive) | |||||
end | |||||
end | |||||
true | |||||
rescue ActiveRecord::RecordInvalid | |||||
false | |||||
end | |||||
def delete_statuses | |||||
Status.where(id: status_ids).find_each do |status| | |||||
RemovalWorker.perform_async(status.id) | |||||
end | |||||
true | |||||
end | |||||
end |
@ -0,0 +1,47 @@ | |||||
- content_for :header_tags do | |||||
= javascript_pack_tag 'admin', integrity: true, async: true, crossorigin: 'anonymous' | |||||
- content_for :page_title do | |||||
= t('admin.statuses.title') | |||||
.back-link | |||||
= link_to admin_account_path(@account.id) do | |||||
%i.fa.fa-chevron-left.fa-fw | |||||
= t('admin.statuses.back_to_account') | |||||
.filters | |||||
.filter-subset | |||||
%strong= t('admin.statuses.media.title') | |||||
%ul | |||||
%li= link_to t('admin.statuses.no_media'), admin_account_statuses_path(@account.id, current_params.merge(media: nil)), class: !params[:media] && 'selected' | |||||
%li= link_to t('admin.statuses.with_media'), admin_account_statuses_path(@account.id, current_params.merge(media: true)), class: params[:media] && 'selected' | |||||
- if @statuses.empty? | |||||
.accounts-grid | |||||
= render 'accounts/nothing_here' | |||||
- else | |||||
= form_for(@form, url: admin_account_statuses_path(@account.id)) do |f| | |||||
= hidden_field_tag :page, params[:page] | |||||
= hidden_field_tag :media, params[:media] | |||||
.batch-form-box | |||||
.batch-checkbox-all | |||||
= check_box_tag :batch_checkbox_all, nil, false | |||||
= f.select :action, Form::StatusBatch::ACTION_TYPE.map{|action| [t("admin.statuses.batch.#{action}"), action]} | |||||
= f.submit t('admin.statuses.execute'), data: { confirm: t('admin.reports.are_you_sure') }, class: 'button' | |||||
.media-spoiler-toggle-buttons | |||||
.media-spoiler-show-button.button= t('admin.statuses.media.show') | |||||
.media-spoiler-hide-button.button= t('admin.statuses.media.hide') | |||||
- @statuses.each do |status| | |||||
.account-status{ data: { id: status.id } } | |||||
.batch-checkbox | |||||
= f.check_box :status_ids, { multiple: true, include_hidden: false }, status.id | |||||
.activity-stream.activity-stream-headless | |||||
.entry= render 'stream_entries/simple_status', status: status | |||||
.account-status__actions | |||||
- unless status.media_attachments.empty? | |||||
= link_to admin_account_status_path(@account.id, status, current_params.merge(status: { sensitive: !status.sensitive })), method: :patch, class: 'icon-button nsfw-button', title: t("admin.reports.nsfw.#{!status.sensitive}") do | |||||
= fa_icon status.sensitive? ? 'eye' : 'eye-slash' | |||||
= link_to admin_account_status_path(@account.id, status), method: :delete, class: 'icon-button trash-button', title: t('admin.reports.delete'), data: { confirm: t('admin.reports.are_you_sure') }, remote: true do | |||||
= fa_icon 'trash' | |||||
= paginate @statuses |
@ -0,0 +1,107 @@ | |||||
require 'rails_helper' | |||||
describe Admin::StatusesController do | |||||
render_views | |||||
let(:user) { Fabricate(:user, admin: true) } | |||||
let(:account) { Fabricate(:account) } | |||||
let!(:status) { Fabricate(:status, account: account) } | |||||
let(:media_attached_status) { Fabricate(:status, account: account, sensitive: !sensitive) } | |||||
let!(:media_attachment) { Fabricate(:media_attachment, account: account, status: media_attached_status) } | |||||
let(:sensitive) { true } | |||||
before do | |||||
sign_in user, scope: :user | |||||
end | |||||
describe 'GET #index' do | |||||
it 'returns http success with no media' do | |||||
get :index, params: { account_id: account.id } | |||||
statuses = assigns(:statuses).to_a | |||||
expect(statuses.size).to eq 2 | |||||
expect(response).to have_http_status(:success) | |||||
end | |||||
it 'returns http success with media' do | |||||
get :index, params: { account_id: account.id , media: true } | |||||
statuses = assigns(:statuses).to_a | |||||
expect(statuses.size).to eq 1 | |||||
expect(response).to have_http_status(:success) | |||||
end | |||||
end | |||||
describe 'POST #create' do | |||||
subject do | |||||
-> { post :create, params: { account_id: account.id, form_status_batch: { action: action, status_ids: status_ids } } } | |||||
end | |||||
let(:action) { 'nsfw_on' } | |||||
let(:status_ids) { [media_attached_status.id] } | |||||
context 'updates sensitive column to true' do | |||||
it 'updates sensitive column' do | |||||
is_expected.to change { | |||||
media_attached_status.reload.sensitive | |||||
}.from(false).to(true) | |||||
end | |||||
end | |||||
context 'updates sensitive column to false' do | |||||
let(:action) { 'nsfw_off' } | |||||
let(:sensitive) { false } | |||||
it 'updates sensitive column' do | |||||
is_expected.to change { | |||||
media_attached_status.reload.sensitive | |||||
}.from(true).to(false) | |||||
end | |||||
end | |||||
it 'redirects to account statuses page' do | |||||
subject.call | |||||
expect(response).to redirect_to(admin_account_statuses_path(account.id)) | |||||
end | |||||
end | |||||
describe 'PATCH #update' do | |||||
subject do | |||||
-> { patch :update, params: { account_id: account.id, id: media_attached_status, status: { sensitive: sensitive } } } | |||||
end | |||||
context 'updates sensitive column to true' do | |||||
it 'updates sensitive column' do | |||||
is_expected.to change { | |||||
media_attached_status.reload.sensitive | |||||
}.from(false).to(true) | |||||
end | |||||
end | |||||
context 'updates sensitive column to false' do | |||||
let(:sensitive) { false } | |||||
it 'updates sensitive column' do | |||||
is_expected.to change { | |||||
media_attached_status.reload.sensitive | |||||
}.from(true).to(false) | |||||
end | |||||
end | |||||
it 'redirects to account statuses page' do | |||||
subject.call | |||||
expect(response).to redirect_to(admin_account_statuses_path(account.id)) | |||||
end | |||||
end | |||||
describe 'DELETE #destroy' do | |||||
it 'removes a status' do | |||||
allow(RemovalWorker).to receive(:perform_async) | |||||
delete :destroy, params: { account_id: account.id, id: status } | |||||
expect(response).to have_http_status(:success) | |||||
expect(RemovalWorker). | |||||
to have_received(:perform_async).with(status.id) | |||||
end | |||||
end | |||||
end |
@ -0,0 +1,52 @@ | |||||
require 'rails_helper' | |||||
describe Form::StatusBatch do | |||||
let(:form) { Form::StatusBatch.new(action: action, status_ids: status_ids) } | |||||
let(:status) { Fabricate(:status) } | |||||
describe 'with nsfw action' do | |||||
let(:status_ids) { [status.id, nonsensitive_status.id, sensitive_status.id] } | |||||
let(:nonsensitive_status) { Fabricate(:status, sensitive: false) } | |||||
let(:sensitive_status) { Fabricate(:status, sensitive: true) } | |||||
let!(:shown_media_attachment) { Fabricate(:media_attachment, status: nonsensitive_status) } | |||||
let!(:hidden_media_attachment) { Fabricate(:media_attachment, status: sensitive_status) } | |||||
context 'nsfw_on' do | |||||
let(:action) { 'nsfw_on' } | |||||
it { expect(form.save).to be true } | |||||
it { expect { form.save }.to change { nonsensitive_status.reload.sensitive }.from(false).to(true) } | |||||
it { expect { form.save }.not_to change { sensitive_status.reload.sensitive } } | |||||
it { expect { form.save }.not_to change { status.reload.sensitive } } | |||||
end | |||||
context 'nsfw_off' do | |||||
let(:action) { 'nsfw_off' } | |||||
it { expect(form.save).to be true } | |||||
it { expect { form.save }.to change { sensitive_status.reload.sensitive }.from(true).to(false) } | |||||
it { expect { form.save }.not_to change { nonsensitive_status.reload.sensitive } } | |||||
it { expect { form.save }.not_to change { status.reload.sensitive } } | |||||
end | |||||
end | |||||
describe 'with delete action' do | |||||
let(:status_ids) { [status.id] } | |||||
let(:action) { 'delete' } | |||||
let!(:another_status) { Fabricate(:status) } | |||||
before do | |||||
allow(RemovalWorker).to receive(:perform_async) | |||||
end | |||||
it 'call RemovalWorker' do | |||||
form.save | |||||
expect(RemovalWorker).to have_received(:perform_async).with(status.id) | |||||
end | |||||
it 'do not call RemovalWorker' do | |||||
form.save | |||||
expect(RemovalWorker).not_to have_received(:perform_async).with(another_status.id) | |||||
end | |||||
end | |||||
end |