Account domain blocks (#2381)

* Add <ostatus:conversation /> tag to Atom input/output

Only uses ref attribute (not href) because href would be
the alternate link that's always included also.

Creates new conversation for every non-reply status. Carries
over conversation for every reply. Keeps remote URIs verbatim,
generates local URIs on the fly like the rest of them.

* Conversation muting - prevents notifications that reference a conversation
(including replies, favourites, reblogs) from being created. API endpoints
/api/v1/statuses/:id/mute and /api/v1/statuses/:id/unmute

Currently no way to tell when a status/conversation is muted, so the web UI
only has a "disable notifications" button, doesn't work as a toggle

* Display "Dismiss notifications" on all statuses in notifications column, not just own

* Add "muted" as a boolean attribute on statuses JSON

For now always false on contained reblogs, since it's only relevant for
statuses returned from the notifications endpoint, which are not nested

Remove "Disable notifications" from detailed status view, since it's
only relevant in the notifications column

* Up max class length

* Remove pending test for conversation mute

* Add tests, clean up

* Rename to "mute conversation" and "unmute conversation"

* Raise validation error when trying to mute/unmute status without conversation

* Adding account domain blocks that filter notifications and public timelines

* Add tests for domain blocks in notifications, public timelines
Filter reblogs of blocked domains from home

* Add API for listing and creating account domain blocks

* API for creating/deleting domain blocks, tests for Status#ancestors
and Status#descendants, filter domain blocks from them

* Filter domains in streaming API

* Update account_domain_block_spec.rb
This commit is contained in:
Eugen Rochko 2017-05-19 01:14:30 +02:00 committed by GitHub
parent 8ec8410651
commit 620d0d8029
20 changed files with 420 additions and 124 deletions

View file

@ -0,0 +1,38 @@
# frozen_string_literal: true
class Api::V1::DomainBlocksController < ApiController
before_action -> { doorkeeper_authorize! :follow }
before_action :require_user!
respond_to :json
def show
@blocks = AccountDomainBlock.where(account: current_account).paginate_by_max_id(limit_param(100), params[:max_id], params[:since_id])
next_path = api_v1_domain_blocks_url(pagination_params(max_id: @blocks.last.id)) if @blocks.size == limit_param(100)
prev_path = api_v1_domain_blocks_url(pagination_params(since_id: @blocks.first.id)) unless @blocks.empty?
set_pagination_headers(next_path, prev_path)
render json: @blocks.map(&:domain)
end
def create
current_account.block_domain!(domain_block_params[:domain])
render_empty
end
def destroy
current_account.unblock_domain!(domain_block_params[:domain])
render_empty
end
private
def pagination_params(core_params)
params.permit(:limit).merge(core_params)
end
def domain_block_params
params.permit(:domain)
end
end

View file

@ -98,7 +98,7 @@ class FeedManager
return true if Mute.where(account_id: receiver_id, target_account_id: check_for_mutes).any? return true if Mute.where(account_id: receiver_id, target_account_id: check_for_mutes).any?
check_for_blocks = status.mentions.map(&:account_id) check_for_blocks = status.mentions.pluck(:account_id)
check_for_blocks.concat([status.reblog.account_id]) if status.reblog? check_for_blocks.concat([status.reblog.account_id]) if status.reblog?
return true if Block.where(account_id: receiver_id, target_account_id: check_for_blocks).any? return true if Block.where(account_id: receiver_id, target_account_id: check_for_blocks).any?
@ -109,7 +109,9 @@ class FeedManager
should_filter &&= !(status.account_id == status.in_reply_to_account_id) # and it's not a self-reply should_filter &&= !(status.account_id == status.in_reply_to_account_id) # and it's not a self-reply
return should_filter return should_filter
elsif status.reblog? # Filter out a reblog elsif status.reblog? # Filter out a reblog
return Block.where(account_id: status.reblog.account_id, target_account_id: receiver_id).exists? # or if the author of the reblogged status is blocking me should_filter = Block.where(account_id: status.reblog.account_id, target_account_id: receiver_id).exists? # or if the author of the reblogged status is blocking me
should_filter ||= AccountDomainBlock.where(account_id: receiver_id, domain: status.reblog.account.domain).exists? # or the author's domain is blocked
return should_filter
end end
false false

View file

@ -43,6 +43,7 @@ class Account < ApplicationRecord
include AccountAvatar include AccountAvatar
include AccountHeader include AccountHeader
include AccountInteractions
include Attachmentable include Attachmentable
include Remotable include Remotable
include Targetable include Targetable
@ -67,26 +68,6 @@ class Account < ApplicationRecord
has_many :mentions, inverse_of: :account, dependent: :destroy has_many :mentions, inverse_of: :account, dependent: :destroy
has_many :notifications, inverse_of: :account, dependent: :destroy has_many :notifications, inverse_of: :account, dependent: :destroy
# Follow relations
has_many :follow_requests, dependent: :destroy
has_many :active_relationships, class_name: 'Follow', foreign_key: 'account_id', dependent: :destroy
has_many :passive_relationships, class_name: 'Follow', foreign_key: 'target_account_id', dependent: :destroy
has_many :following, -> { order('follows.id desc') }, through: :active_relationships, source: :target_account
has_many :followers, -> { order('follows.id desc') }, through: :passive_relationships, source: :account
# Block relationships
has_many :block_relationships, class_name: 'Block', foreign_key: 'account_id', dependent: :destroy
has_many :blocking, -> { order('blocks.id desc') }, through: :block_relationships, source: :target_account
has_many :blocked_by_relationships, class_name: 'Block', foreign_key: :target_account_id, dependent: :destroy
has_many :blocked_by, -> { order('blocks.id desc') }, through: :blocked_by_relationships, source: :account
# Mute relationships
has_many :mute_relationships, class_name: 'Mute', foreign_key: 'account_id', dependent: :destroy
has_many :muting, -> { order('mutes.id desc') }, through: :mute_relationships, source: :target_account
has_many :conversation_mutes
# Media # Media
has_many :media_attachments, dependent: :destroy has_many :media_attachments, dependent: :destroy
@ -120,62 +101,6 @@ class Account < ApplicationRecord
delegate :allowed_languages, to: :user, prefix: false, allow_nil: true delegate :allowed_languages, to: :user, prefix: false, allow_nil: true
def follow!(other_account)
active_relationships.find_or_create_by!(target_account: other_account)
end
def block!(other_account)
block_relationships.find_or_create_by!(target_account: other_account)
end
def mute!(other_account)
mute_relationships.find_or_create_by!(target_account: other_account)
end
def mute_conversation!(conversation)
conversation_mutes.find_or_create_by!(conversation: conversation)
end
def unfollow!(other_account)
follow = active_relationships.find_by(target_account: other_account)
follow&.destroy
end
def unblock!(other_account)
block = block_relationships.find_by(target_account: other_account)
block&.destroy
end
def unmute!(other_account)
mute = mute_relationships.find_by(target_account: other_account)
mute&.destroy
end
def unmute_conversation!(conversation)
mute = conversation_mutes.find_by(conversation: conversation)
mute&.destroy!
end
def following?(other_account)
following.include?(other_account)
end
def blocking?(other_account)
blocking.include?(other_account)
end
def muting?(other_account)
muting.include?(other_account)
end
def muting_conversation?(conversation)
conversation_mutes.where(conversation: conversation).exists?
end
def requested?(other_account)
follow_requests.where(target_account: other_account).exists?
end
def local? def local?
domain.nil? domain.nil?
end end
@ -200,14 +125,6 @@ class Account < ApplicationRecord
followers.reorder(nil).pluck('distinct accounts.domain') followers.reorder(nil).pluck('distinct accounts.domain')
end end
def favourited?(status)
status.proper.favourites.where(account: self).exists?
end
def reblogged?(status)
status.proper.reblogs.where(account: self).exists?
end
def keypair def keypair
OpenSSL::PKey::RSA.new(private_key || public_key) OpenSSL::PKey::RSA.new(private_key || public_key)
end end
@ -238,6 +155,10 @@ class Account < ApplicationRecord
Rails.cache.fetch("exclude_account_ids_for:#{id}") { blocking.pluck(:target_account_id) + blocked_by.pluck(:account_id) + muting.pluck(:target_account_id) } Rails.cache.fetch("exclude_account_ids_for:#{id}") { blocking.pluck(:target_account_id) + blocked_by.pluck(:account_id) + muting.pluck(:target_account_id) }
end end
def excluded_from_timeline_domains
Rails.cache.fetch("exclude_domains_for:#{id}") { domain_blocks.pluck(:domain) }
end
class << self class << self
def find_local!(username) def find_local!(username)
find_remote!(username, nil) find_remote!(username, nil)
@ -321,26 +242,6 @@ class Account < ApplicationRecord
find_by_sql([sql, account.id, account.id, limit]) find_by_sql([sql, account.id, account.id, limit])
end end
def following_map(target_account_ids, account_id)
follow_mapping(Follow.where(target_account_id: target_account_ids, account_id: account_id), :target_account_id)
end
def followed_by_map(target_account_ids, account_id)
follow_mapping(Follow.where(account_id: target_account_ids, target_account_id: account_id), :account_id)
end
def blocking_map(target_account_ids, account_id)
follow_mapping(Block.where(target_account_id: target_account_ids, account_id: account_id), :target_account_id)
end
def muting_map(target_account_ids, account_id)
follow_mapping(Mute.where(target_account_id: target_account_ids, account_id: account_id), :target_account_id)
end
def requested_map(target_account_ids, account_id)
follow_mapping(FollowRequest.where(target_account_id: target_account_ids, account_id: account_id), :target_account_id)
end
private private
def generate_query_for_search(terms) def generate_query_for_search(terms)

View file

@ -0,0 +1,26 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: account_domain_blocks
#
# id :integer not null, primary key
# account_id :integer
# domain :string
# created_at :datetime not null
# updated_at :datetime not null
#
class AccountDomainBlock < ApplicationRecord
include Paginable
belongs_to :account, required: true
after_create :remove_blocking_cache
after_destroy :remove_blocking_cache
private
def remove_blocking_cache
Rails.cache.delete("exclude_domains_for:#{account_id}")
end
end

View file

@ -2,6 +2,7 @@
module AccountAvatar module AccountAvatar
extend ActiveSupport::Concern extend ActiveSupport::Concern
IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif'].freeze IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif'].freeze
class_methods do class_methods do
@ -10,6 +11,7 @@ module AccountAvatar
styles[:static] = { format: 'png' } if file.content_type == 'image/gif' styles[:static] = { format: 'png' } if file.content_type == 'image/gif'
styles styles
end end
private :avatar_styles private :avatar_styles
end end

View file

@ -2,6 +2,7 @@
module AccountHeader module AccountHeader
extend ActiveSupport::Concern extend ActiveSupport::Concern
IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif'].freeze IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif'].freeze
class_methods do class_methods do
@ -10,6 +11,7 @@ module AccountHeader
styles[:static] = { format: 'png' } if file.content_type == 'image/gif' styles[:static] = { format: 'png' } if file.content_type == 'image/gif'
styles styles
end end
private :header_styles private :header_styles
end end

View file

@ -0,0 +1,127 @@
# frozen_string_literal: true
module AccountInteractions
extend ActiveSupport::Concern
class_methods do
def following_map(target_account_ids, account_id)
follow_mapping(Follow.where(target_account_id: target_account_ids, account_id: account_id), :target_account_id)
end
def followed_by_map(target_account_ids, account_id)
follow_mapping(Follow.where(account_id: target_account_ids, target_account_id: account_id), :account_id)
end
def blocking_map(target_account_ids, account_id)
follow_mapping(Block.where(target_account_id: target_account_ids, account_id: account_id), :target_account_id)
end
def muting_map(target_account_ids, account_id)
follow_mapping(Mute.where(target_account_id: target_account_ids, account_id: account_id), :target_account_id)
end
def requested_map(target_account_ids, account_id)
follow_mapping(FollowRequest.where(target_account_id: target_account_ids, account_id: account_id), :target_account_id)
end
end
included do
# Follow relations
has_many :follow_requests, dependent: :destroy
has_many :active_relationships, class_name: 'Follow', foreign_key: 'account_id', dependent: :destroy
has_many :passive_relationships, class_name: 'Follow', foreign_key: 'target_account_id', dependent: :destroy
has_many :following, -> { order('follows.id desc') }, through: :active_relationships, source: :target_account
has_many :followers, -> { order('follows.id desc') }, through: :passive_relationships, source: :account
# Block relationships
has_many :block_relationships, class_name: 'Block', foreign_key: 'account_id', dependent: :destroy
has_many :blocking, -> { order('blocks.id desc') }, through: :block_relationships, source: :target_account
has_many :blocked_by_relationships, class_name: 'Block', foreign_key: :target_account_id, dependent: :destroy
has_many :blocked_by, -> { order('blocks.id desc') }, through: :blocked_by_relationships, source: :account
# Mute relationships
has_many :mute_relationships, class_name: 'Mute', foreign_key: 'account_id', dependent: :destroy
has_many :muting, -> { order('mutes.id desc') }, through: :mute_relationships, source: :target_account
has_many :conversation_mutes, dependent: :destroy
has_many :domain_blocks, class_name: 'AccountDomainBlock', dependent: :destroy
def follow!(other_account)
active_relationships.find_or_create_by!(target_account: other_account)
end
def block!(other_account)
block_relationships.find_or_create_by!(target_account: other_account)
end
def mute!(other_account)
mute_relationships.find_or_create_by!(target_account: other_account)
end
def mute_conversation!(conversation)
conversation_mutes.find_or_create_by!(conversation: conversation)
end
def block_domain!(other_domain)
domain_blocks.find_or_create_by!(domain: other_domain)
end
def unfollow!(other_account)
follow = active_relationships.find_by(target_account: other_account)
follow&.destroy
end
def unblock!(other_account)
block = block_relationships.find_by(target_account: other_account)
block&.destroy
end
def unmute!(other_account)
mute = mute_relationships.find_by(target_account: other_account)
mute&.destroy
end
def unmute_conversation!(conversation)
mute = conversation_mutes.find_by(conversation: conversation)
mute&.destroy!
end
def unblock_domain!(other_domain)
block = domain_blocks.find_by(domain: other_domain)
block&.destroy
end
def following?(other_account)
active_relationships.where(target_account: other_account).exists?
end
def blocking?(other_account)
block_relationships.where(target_account: other_account).exists?
end
def domain_blocking?(other_domain)
domain_blocks.where(domain: other_domain).exists?
end
def muting?(other_account)
mute_relationships.where(target_account: other_account).exists?
end
def muting_conversation?(conversation)
conversation_mutes.where(conversation: conversation).exists?
end
def requested?(other_account)
follow_requests.where(target_account: other_account).exists?
end
def favourited?(status)
status.proper.favourites.where(account: self).exists?
end
def reblogged?(status)
status.proper.reblogs.where(account: self).exists?
end
end
end

View file

@ -67,7 +67,7 @@ class Status < ApplicationRecord
scope :local_only, -> { left_outer_joins(:account).where(accounts: { domain: nil }) } scope :local_only, -> { left_outer_joins(:account).where(accounts: { domain: nil }) }
scope :excluding_silenced_accounts, -> { left_outer_joins(:account).where(accounts: { silenced: false }) } scope :excluding_silenced_accounts, -> { left_outer_joins(:account).where(accounts: { silenced: false }) }
scope :including_silenced_accounts, -> { left_outer_joins(:account).where(accounts: { silenced: true }) } scope :including_silenced_accounts, -> { left_outer_joins(:account).where(accounts: { silenced: true }) }
scope :not_excluded_by_account, ->(account) { where.not(account_id: account.excluded_from_timeline_account_ids) } scope :not_excluded_by_account, ->(account) { where.not(account_id: account.excluded_from_timeline_account_ids, accounts: { domain: account.excluded_from_timeline_domains }) }
cache_associated :account, :application, :media_attachments, :tags, :stream_entry, mentions: :account, reblog: [:account, :application, :stream_entry, :tags, :media_attachments, mentions: :account], thread: :account cache_associated :account, :application, :media_attachments, :tags, :stream_entry, mentions: :account, reblog: [:account, :application, :stream_entry, :tags, :media_attachments, mentions: :account], thread: :account
@ -284,7 +284,9 @@ class Status < ApplicationRecord
end end
def find_statuses_from_tree_path(ids, account) def find_statuses_from_tree_path(ids, account)
statuses = Status.where(id: ids).to_a statuses = Status.where(id: ids).includes(:account).to_a
# FIXME: n+1 bonanza
statuses.reject! { |status| filter_from_context?(status, account) } statuses.reject! { |status| filter_from_context?(status, account) }
# Order ancestors/descendants by tree path # Order ancestors/descendants by tree path
@ -292,6 +294,11 @@ class Status < ApplicationRecord
end end
def filter_from_context?(status, account) def filter_from_context?(status, account)
account&.blocking?(status.account_id) || account&.muting?(status.account_id) || (status.account.silenced? && !account&.following?(status.account_id)) || !status.permitted?(account) should_filter = account&.blocking?(status.account_id)
should_filter ||= account&.domain_blocking?(status.account.domain)
should_filter ||= account&.muting?(status.account_id)
should_filter ||= (status.account.silenced? && !account&.following?(status.account_id))
should_filter ||= !status.permitted?(account)
should_filter
end end
end end

View file

@ -39,6 +39,7 @@ class NotifyService < BaseService
def blocked? def blocked?
blocked = @recipient.suspended? # Skip if the recipient account is suspended anyway blocked = @recipient.suspended? # Skip if the recipient account is suspended anyway
blocked ||= @recipient.id == @notification.from_account.id # Skip for interactions with self blocked ||= @recipient.id == @notification.from_account.id # Skip for interactions with self
blocked ||= @recipient.domain_blocking?(@notification.from_account.domain) # Skip for domain blocked accounts
blocked ||= @recipient.blocking?(@notification.from_account) # Skip for blocked accounts blocked ||= @recipient.blocking?(@notification.from_account) # Skip for blocked accounts
blocked ||= (@notification.from_account.silenced? && !@recipient.following?(@notification.from_account)) # Hellban blocked ||= (@notification.from_account.silenced? && !@recipient.following?(@notification.from_account)) # Hellban
blocked ||= (@recipient.user.settings.interactions['must_be_follower'] && !@notification.from_account.following?(@recipient)) # Options blocked ||= (@recipient.user.settings.interactions['must_be_follower'] && !@notification.from_account.following?(@recipient)) # Options

View file

@ -151,6 +151,7 @@ Rails.application.routes.draw do
resources :reports, only: [:index, :create] resources :reports, only: [:index, :create]
resource :instance, only: [:show] resource :instance, only: [:show]
resource :domain_blocks, only: [:show, :create, :destroy]
resources :follow_requests, only: [:index] do resources :follow_requests, only: [:index] do
member do member do

View file

@ -0,0 +1,12 @@
class CreateAccountDomainBlocks < ActiveRecord::Migration[5.0]
def change
create_table :account_domain_blocks do |t|
t.integer :account_id
t.string :domain
t.timestamps
end
add_index :account_domain_blocks, [:account_id, :domain], unique: true
end
end

View file

@ -10,11 +10,19 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 20170516072309) do ActiveRecord::Schema.define(version: 20170517205741) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "plpgsql" enable_extension "plpgsql"
create_table "account_domain_blocks", force: :cascade do |t|
t.integer "account_id"
t.string "domain"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id", "domain"], name: "index_account_domain_blocks_on_account_id_and_domain", unique: true, using: :btree
end
create_table "accounts", force: :cascade do |t| create_table "accounts", force: :cascade do |t|
t.string "username", default: "", null: false t.string "username", default: "", null: false
t.string "domain" t.string "domain"

View file

@ -0,0 +1,55 @@
require 'rails_helper'
RSpec.describe Api::V1::DomainBlocksController, type: :controller do
render_views
let(:user) { Fabricate(:user, account: Fabricate(:account, username: 'alice')) }
let(:token) { double acceptable?: true, resource_owner_id: user.id }
before do
user.account.block_domain!('example.com')
allow(controller).to receive(:doorkeeper_token) { token }
end
describe 'GET #show' do
before do
get :show
end
it 'returns http success' do
expect(response).to have_http_status(:success)
end
it 'returns blocked domains' do
expect(body_as_json.first).to eq 'example.com'
end
end
describe 'POST #create' do
before do
post :create, params: { domain: 'example.org' }
end
it 'returns http success' do
expect(response).to have_http_status(:success)
end
it 'creates a domain block' do
expect(user.account.domain_blocking?('example.org')).to be true
end
end
describe 'DELETE #destroy' do
before do
delete :destroy, params: { domain: 'example.com' }
end
it 'returns http success' do
expect(response).to have_http_status(:success)
end
it 'deletes a domain block' do
expect(user.account.domain_blocking?('example.com')).to be false
end
end
end

View file

@ -55,7 +55,6 @@ RSpec.describe Api::V1::MediaController, type: :controller do
end end
end end
context 'video/webm' do context 'video/webm' do
before do before do
post :create, params: { file: fixture_file_upload('files/attachment.webm', 'video/webm') } post :create, params: { file: fixture_file_upload('files/attachment.webm', 'video/webm') }

View file

@ -0,0 +1,4 @@
Fabricator(:account_domain_block) do
account_id 1
domain "MyString"
end

View file

@ -11,7 +11,7 @@ RSpec.describe FeedManager do
describe '#filter?' do describe '#filter?' do
let(:alice) { Fabricate(:account, username: 'alice') } let(:alice) { Fabricate(:account, username: 'alice') }
let(:bob) { Fabricate(:account, username: 'bob') } let(:bob) { Fabricate(:account, username: 'bob', domain: 'example.com') }
let(:jeff) { Fabricate(:account, username: 'jeff') } let(:jeff) { Fabricate(:account, username: 'jeff') }
context 'for home feed' do context 'for home feed' do
@ -93,6 +93,14 @@ RSpec.describe FeedManager do
status = PostStatusService.new.call(alice, 'Hey @jeff') status = PostStatusService.new.call(alice, 'Hey @jeff')
expect(FeedManager.instance.filter?(:home, status, bob.id)).to be true expect(FeedManager.instance.filter?(:home, status, bob.id)).to be true
end end
it 'returns true for reblog of a personally blocked domain' do
alice.block_domain!('example.com')
alice.follow!(jeff)
status = Fabricate(:status, text: 'Hello world', account: bob)
reblog = Fabricate(:status, reblog: status, account: jeff)
expect(FeedManager.instance.filter?(:home, reblog, alice.id)).to be true
end
end end
context 'for mentions feed' do context 'for mentions feed' do

View file

@ -0,0 +1,5 @@
require 'rails_helper'
RSpec.describe AccountDomainBlock, type: :model do
end

View file

@ -180,6 +180,46 @@ RSpec.describe Status, type: :model do
end end
describe '#ancestors' do describe '#ancestors' do
let!(:alice) { Fabricate(:account, username: 'alice') }
let!(:bob) { Fabricate(:account, username: 'bob', domain: 'example.com') }
let!(:jeff) { Fabricate(:account, username: 'jeff') }
let!(:status) { Fabricate(:status, account: alice) }
let!(:reply1) { Fabricate(:status, thread: status, account: jeff) }
let!(:reply2) { Fabricate(:status, thread: reply1, account: bob) }
let!(:reply3) { Fabricate(:status, thread: reply2, account: alice) }
let!(:viewer) { Fabricate(:account, username: 'viewer') }
it 'returns conversation history' do
expect(reply3.ancestors).to include(status, reply1, reply2)
end
it 'does not return conversation history user is not allowed to see' do
reply1.update(visibility: :private)
status.update(visibility: :direct)
expect(reply3.ancestors(viewer)).to_not include(reply1, status)
end
it 'does not return conversation history from blocked users' do
viewer.block!(jeff)
expect(reply3.ancestors(viewer)).to_not include(reply1)
end
it 'does not return conversation history from muted users' do
viewer.mute!(jeff)
expect(reply3.ancestors(viewer)).to_not include(reply1)
end
it 'does not return conversation history from silenced and not followed users' do
jeff.update(silenced: true)
expect(reply3.ancestors(viewer)).to_not include(reply1)
end
it 'does not return conversation history from blocked domains' do
viewer.block_domain!('example.com')
expect(reply3.ancestors(viewer)).to_not include(reply2)
end
it 'ignores deleted records' do it 'ignores deleted records' do
first_status = Fabricate(:status, account: bob) first_status = Fabricate(:status, account: bob)
second_status = Fabricate(:status, thread: first_status, account: alice) second_status = Fabricate(:status, thread: first_status, account: alice)
@ -192,8 +232,46 @@ RSpec.describe Status, type: :model do
end end
end end
describe '#filter_from_context?' do describe '#descendants' do
pending let!(:alice) { Fabricate(:account, username: 'alice') }
let!(:bob) { Fabricate(:account, username: 'bob', domain: 'example.com') }
let!(:jeff) { Fabricate(:account, username: 'jeff') }
let!(:status) { Fabricate(:status, account: alice) }
let!(:reply1) { Fabricate(:status, thread: status, account: alice) }
let!(:reply2) { Fabricate(:status, thread: status, account: bob) }
let!(:reply3) { Fabricate(:status, thread: reply1, account: jeff) }
let!(:viewer) { Fabricate(:account, username: 'viewer') }
it 'returns replies' do
expect(status.descendants).to include(reply1, reply2, reply3)
end
it 'does not return replies user is not allowed to see' do
reply1.update(visibility: :private)
reply3.update(visibility: :direct)
expect(status.descendants(viewer)).to_not include(reply1, reply3)
end
it 'does not return replies from blocked users' do
viewer.block!(jeff)
expect(status.descendants(viewer)).to_not include(reply3)
end
it 'does not return replies from muted users' do
viewer.mute!(jeff)
expect(status.descendants(viewer)).to_not include(reply3)
end
it 'does not return replies from silenced and not followed users' do
jeff.update(silenced: true)
expect(status.descendants(viewer)).to_not include(reply3)
end
it 'does not return replies from blocked domains' do
viewer.block_domain!('example.com')
expect(status.descendants(viewer)).to_not include(reply2)
end
end end
describe '.mutes_map' do describe '.mutes_map' do
@ -368,6 +446,15 @@ RSpec.describe Status, type: :model do
expect(results).not_to include(muted_status) expect(results).not_to include(muted_status)
end end
it 'excludes statuses from accounts from personally blocked domains' do
blocked = Fabricate(:account, domain: 'example.com')
@account.block_domain!(blocked.domain)
blocked_status = Fabricate(:status, account: blocked)
results = Status.as_public_timeline(@account)
expect(results).not_to include(blocked_status)
end
context 'with language preferences' do context 'with language preferences' do
it 'excludes statuses in languages not allowed by the account user' do it 'excludes statuses in languages not allowed by the account user' do
user = Fabricate(:user, allowed_languages: [:en, :es]) user = Fabricate(:user, allowed_languages: [:en, :es])

View file

@ -7,7 +7,7 @@ RSpec.describe NotifyService do
let(:user) { Fabricate(:user) } let(:user) { Fabricate(:user) }
let(:recipient) { user.account } let(:recipient) { user.account }
let(:sender) { Fabricate(:account) } let(:sender) { Fabricate(:account, domain: 'example.com') }
let(:activity) { Fabricate(:follow, account: sender, target_account: recipient) } let(:activity) { Fabricate(:follow, account: sender, target_account: recipient) }
it { is_expected.to change(Notification, :count).by(1) } it { is_expected.to change(Notification, :count).by(1) }
@ -17,6 +17,11 @@ RSpec.describe NotifyService do
is_expected.to_not change(Notification, :count) is_expected.to_not change(Notification, :count)
end end
it 'does not notify when sender\'s domain is blocked' do
recipient.block_domain!(sender.domain)
is_expected.to_not change(Notification, :count)
end
it 'does not notify when sender is silenced and not followed' do it 'does not notify when sender is silenced and not followed' do
sender.update(silenced: true) sender.update(silenced: true)
is_expected.to_not change(Notification, :count) is_expected.to_not change(Notification, :count)

View file

@ -229,20 +229,26 @@ if (cluster.isMaster) {
const unpackedPayload = JSON.parse(payload) const unpackedPayload = JSON.parse(payload)
const targetAccountIds = [unpackedPayload.account.id].concat(unpackedPayload.mentions.map(item => item.id)).concat(unpackedPayload.reblog ? [unpackedPayload.reblog.account.id] : []) const targetAccountIds = [unpackedPayload.account.id].concat(unpackedPayload.mentions.map(item => item.id)).concat(unpackedPayload.reblog ? [unpackedPayload.reblog.account.id] : [])
const accountDomain = unpackedPayload.account.acct.split('@')[1]
client.query(`SELECT target_account_id FROM blocks WHERE account_id = $1 AND target_account_id IN (${placeholders(targetAccountIds, 1)}) UNION SELECT target_account_id FROM mutes WHERE account_id = $1 AND target_account_id IN (${placeholders(targetAccountIds, 1)})`, [req.accountId].concat(targetAccountIds), (err, result) => { const queries = [
done() client.query(`SELECT 1 FROM blocks WHERE account_id = $1 AND target_account_id IN (${placeholders(targetAccountIds, 1)}) UNION SELECT 1 FROM mutes WHERE account_id = $1 AND target_account_id IN (${placeholders(targetAccountIds, 1)})`, [req.accountId].concat(targetAccountIds)),
]
if (err) { if (accountDomain) {
log.error(err) queries.push(client.query('SELECT 1 FROM account_domain_blocks WHERE account_id = $1 AND domain = $2', [req.accountId, accountDomain]))
return
} }
if (result.rows.length > 0) { Promise.all(queries).then(values => {
done()
if (values[0].rows.length > 0 || (values.length > 1 && values[1].rows.length > 0)) {
return return
} }
transmit() transmit()
}).catch(err => {
log.error(err)
}) })
}) })
} else { } else {