--- /dev/null
+# 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
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?
return true if Block.where(account_id: receiver_id, target_account_id: check_for_blocks).any?
should_filter &&= !(status.account_id == status.in_reply_to_account_id) # and it's not a self-reply
return should_filter
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
false
include AccountAvatar
include AccountHeader
+ include AccountInteractions
include Attachmentable
include Remotable
include Targetable
has_many :mentions, 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
has_many :media_attachments, dependent: :destroy
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?
domain.nil?
end
followers.reorder(nil).pluck('distinct accounts.domain')
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
OpenSSL::PKey::RSA.new(private_key || public_key)
end
Rails.cache.fetch("exclude_account_ids_for:#{id}") { blocking.pluck(:target_account_id) + blocked_by.pluck(:account_id) + muting.pluck(:target_account_id) }
end
+ def excluded_from_timeline_domains
+ Rails.cache.fetch("exclude_domains_for:#{id}") { domain_blocks.pluck(:domain) }
+ end
+
class << self
def find_local!(username)
find_remote!(username, nil)
find_by_sql([sql, account.id, account.id, limit])
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
def generate_query_for_search(terms)
--- /dev/null
+# 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
module AccountAvatar
extend ActiveSupport::Concern
+
IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif'].freeze
class_methods do
styles[:static] = { format: 'png' } if file.content_type == 'image/gif'
styles
end
+
private :avatar_styles
end
module AccountHeader
extend ActiveSupport::Concern
+
IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif'].freeze
class_methods do
styles[:static] = { format: 'png' } if file.content_type == 'image/gif'
styles
end
+
private :header_styles
end
--- /dev/null
+# 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
scope :local_only, -> { left_outer_joins(:account).where(accounts: { domain: nil }) }
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 :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
end
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) }
# Order ancestors/descendants by tree path
end
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
def blocked?
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.domain_blocking?(@notification.from_account.domain) # Skip for domain blocked accounts
blocked ||= @recipient.blocking?(@notification.from_account) # Skip for blocked accounts
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
resources :favourites, only: [:index]
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
member do
--- /dev/null
+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
#
# 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
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|
t.string "username", default: "", null: false
t.string "domain"
--- /dev/null
+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
end
end
-
context 'video/webm' do
before do
post :create, params: { file: fixture_file_upload('files/attachment.webm', 'video/webm') }
--- /dev/null
+Fabricator(:account_domain_block) do
+ account_id 1
+ domain "MyString"
+end
describe '#filter?' do
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') }
context 'for home feed' do
status = PostStatusService.new.call(alice, 'Hey @jeff')
expect(FeedManager.instance.filter?(:home, status, bob.id)).to be true
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
context 'for mentions feed' do
--- /dev/null
+require 'rails_helper'
+
+RSpec.describe AccountDomainBlock, type: :model do
+
+end
end
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
- first_status = Fabricate(:status, account: bob)
+ first_status = Fabricate(:status, account: bob)
second_status = Fabricate(:status, thread: first_status, account: alice)
# Create cache and delete cached record
end
end
- describe '#filter_from_context?' do
- pending
+ describe '#descendants' 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: 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
describe '.mutes_map' do
expect(results).not_to include(muted_status)
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
it 'excludes statuses in languages not allowed by the account user' do
user = Fabricate(:user, allowed_languages: [:en, :es])
let(:user) { Fabricate(:user) }
let(:recipient) { user.account }
- let(:sender) { Fabricate(:account) }
+ let(:sender) { Fabricate(:account, domain: 'example.com') }
let(:activity) { Fabricate(:follow, account: sender, target_account: recipient) }
it { is_expected.to change(Notification, :count).by(1) }
is_expected.to_not change(Notification, :count)
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
sender.update(silenced: true)
is_expected.to_not change(Notification, :count)
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 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) => {
- done()
+ const queries = [
+ 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) {
- log.error(err)
- return
- }
+ if (accountDomain) {
+ queries.push(client.query('SELECT 1 FROM account_domain_blocks WHERE account_id = $1 AND domain = $2', [req.accountId, accountDomain]))
+ }
- 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
}
transmit()
+ }).catch(err => {
+ log.error(err)
})
})
} else {