From cdb101340a20183a82889f811d9311c370c855e5 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Fri, 29 Jun 2018 15:34:36 +0200 Subject: Keyword/phrase filtering (#7905) * Add keyword filtering GET|POST /api/v1/filters GET|PUT|DELETE /api/v1/filters/:id - Irreversible filters can drop toots from home or notifications - Other filters can hide toots through the client app - Filters use a phrase valid in particular contexts, expiration * Make sure expired filters don't get applied client-side * Add missing API methods * Remove "regex filter" from column settings * Add tests * Add test for FeedManager * Add CustomFilter test * Add UI for managing filters * Add streaming API event to allow syncing filters * Fix tests --- app/models/account.rb | 1 + app/models/concerns/expireable.rb | 24 +++++++++++++++++ app/models/custom_filter.rb | 55 +++++++++++++++++++++++++++++++++++++++ app/models/invite.rb | 18 ++----------- 4 files changed, 82 insertions(+), 16 deletions(-) create mode 100644 app/models/concerns/expireable.rb create mode 100644 app/models/custom_filter.rb (limited to 'app/models') diff --git a/app/models/account.rb b/app/models/account.rb index c3eea79cc..40a45b1f8 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -99,6 +99,7 @@ class Account < ApplicationRecord has_many :targeted_reports, class_name: 'Report', foreign_key: :target_account_id has_many :report_notes, dependent: :destroy + has_many :custom_filters, inverse_of: :account, dependent: :destroy # Moderation notes has_many :account_moderation_notes, dependent: :destroy diff --git a/app/models/concerns/expireable.rb b/app/models/concerns/expireable.rb new file mode 100644 index 000000000..444ccdfdb --- /dev/null +++ b/app/models/concerns/expireable.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Expireable + extend ActiveSupport::Concern + + included do + scope :expired, -> { where.not(expires_at: nil).where('expires_at < ?', Time.now.utc) } + + attr_reader :expires_in + + def expires_in=(interval) + self.expires_at = interval.to_i.seconds.from_now unless interval.blank? + @expires_in = interval + end + + def expire! + touch(:expires_at) + end + + def expired? + !expires_at.nil? && expires_at < Time.now.utc + end + end +end diff --git a/app/models/custom_filter.rb b/app/models/custom_filter.rb new file mode 100644 index 000000000..2c1a54375 --- /dev/null +++ b/app/models/custom_filter.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true +# == Schema Information +# +# Table name: custom_filters +# +# id :bigint(8) not null, primary key +# account_id :bigint(8) +# expires_at :datetime +# phrase :text default(""), not null +# context :string default([]), not null, is an Array +# irreversible :boolean default(FALSE), not null +# created_at :datetime not null +# updated_at :datetime not null +# + +class CustomFilter < ApplicationRecord + VALID_CONTEXTS = %w( + home + notifications + public + thread + ).freeze + + include Expireable + + belongs_to :account + + validates :phrase, :context, presence: true + validate :context_must_be_valid + validate :irreversible_must_be_within_context + + scope :active_irreversible, -> { where(irreversible: true).where(Arel.sql('expires_at IS NULL OR expires_at > NOW()')) } + + before_validation :clean_up_contexts + after_commit :remove_cache + + private + + def clean_up_contexts + self.context = Array(context).map(&:strip).map(&:presence).compact + end + + def remove_cache + Rails.cache.delete("filters:#{account_id}") + Redis.current.publish("timeline:#{account_id}", Oj.dump(event: :filters_changed)) + end + + def context_must_be_valid + errors.add(:context, I18n.t('filters.errors.invalid_context')) if context.empty? || context.any? { |c| !VALID_CONTEXTS.include?(c) } + end + + def irreversible_must_be_within_context + errors.add(:irreversible, I18n.t('filters.errors.invalid_irreversible')) if irreversible? && !context.include?('home') && !context.include?('notifications') + end +end diff --git a/app/models/invite.rb b/app/models/invite.rb index d0cc427c4..fe2322462 100644 --- a/app/models/invite.rb +++ b/app/models/invite.rb @@ -15,33 +15,19 @@ # class Invite < ApplicationRecord + include Expireable + belongs_to :user has_many :users, inverse_of: :invite scope :available, -> { where(expires_at: nil).or(where('expires_at >= ?', Time.now.utc)) } - scope :expired, -> { where.not(expires_at: nil).where('expires_at < ?', Time.now.utc) } before_validation :set_code - attr_reader :expires_in - - def expires_in=(interval) - self.expires_at = interval.to_i.seconds.from_now unless interval.blank? - @expires_in = interval - end - def valid_for_use? (max_uses.nil? || uses < max_uses) && !expired? end - def expire! - touch(:expires_at) - end - - def expired? - !expires_at.nil? && expires_at < Time.now.utc - end - private def set_code -- cgit From da8fe8079e13758f45e5ba77cb8023c554ae193c Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Tue, 3 Jul 2018 01:47:56 +0200 Subject: Re-add follow recommendations API (#7918) * Re-add follow recommendations API GET /api/v1/suggestions Removed in 8efa081f210d72ed450c39ac4cde0fd84fb3d3fb due to Neo4J dependency. The algorithm uses triadic closures, takes into account suspensions, blocks, mutes, domain blocks, excludes locked and moved accounts, and prefers more recently updated accounts. * Track interactions with people you don't follow Replying to, favouriting and reblogging someone you're not following will make them show up in follow recommendations. The interactions have different weights: - Replying is 1 - Favouriting is 10 (decidedly positive interaction, but private) - Reblogging is 20 Following them, muting or blocking will remove them from the list, obviously. * Remove triadic closures, ensure potential friendships are trimmed --- app/controllers/api/v1/suggestions_controller.rb | 21 +++++++ app/lib/potential_friendship_tracker.rb | 39 ++++++++++++ app/models/account.rb | 29 +-------- app/models/concerns/account_interactions.rb | 12 ++++ app/services/favourite_service.rb | 8 +++ app/services/post_status_service.rb | 7 +++ app/services/reblog_service.rb | 7 +++ config/routes.rb | 1 + .../api/v1/suggestions_controller_spec.rb | 35 +++++++++++ spec/models/account_spec.rb | 71 ---------------------- 10 files changed, 131 insertions(+), 99 deletions(-) create mode 100644 app/controllers/api/v1/suggestions_controller.rb create mode 100644 app/lib/potential_friendship_tracker.rb create mode 100644 spec/controllers/api/v1/suggestions_controller_spec.rb (limited to 'app/models') diff --git a/app/controllers/api/v1/suggestions_controller.rb b/app/controllers/api/v1/suggestions_controller.rb new file mode 100644 index 000000000..3abccedd5 --- /dev/null +++ b/app/controllers/api/v1/suggestions_controller.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class Api::V1::SuggestionsController < Api::BaseController + include Authorization + + before_action -> { doorkeeper_authorize! :read } + before_action :require_user! + before_action :set_accounts + + respond_to :json + + def index + render json: @accounts, each_serializer: REST::AccountSerializer + end + + private + + def set_accounts + @accounts = PotentialFriendshipTracker.get(current_account.id, limit: limit_param(DEFAULT_ACCOUNTS_LIMIT)) + end +end diff --git a/app/lib/potential_friendship_tracker.rb b/app/lib/potential_friendship_tracker.rb new file mode 100644 index 000000000..362482669 --- /dev/null +++ b/app/lib/potential_friendship_tracker.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +class PotentialFriendshipTracker + EXPIRE_AFTER = 90.days.seconds + MAX_ITEMS = 80 + + WEIGHTS = { + reply: 1, + favourite: 10, + reblog: 20, + }.freeze + + class << self + def record(account_id, target_account_id, action) + key = "interactions:#{account_id}" + weight = WEIGHTS[action] + + redis.zincrby(key, weight, target_account_id) + redis.zremrangebyrank(key, 0, -MAX_ITEMS) + redis.expire(key, EXPIRE_AFTER) + end + + def remove(account_id, target_account_id) + redis.zrem("interactions:#{account_id}", target_account_id) + end + + def get(account_id, limit: 20, offset: 0) + account_ids = redis.zrevrange("interactions:#{account_id}", offset, limit) + return [] if account_ids.empty? + Account.searchable.where(id: account_ids) + end + + private + + def redis + Redis.current + end + end +end diff --git a/app/models/account.rb b/app/models/account.rb index 40a45b1f8..1f720bf88 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -127,6 +127,7 @@ class Account < ApplicationRecord scope :matches_username, ->(value) { where(arel_table[:username].matches("#{value}%")) } scope :matches_display_name, ->(value) { where(arel_table[:display_name].matches("#{value}%")) } scope :matches_domain, ->(value) { where(arel_table[:domain].matches("%#{value}%")) } + scope :searchable, -> { where(suspended: false).where(moved_to_account_id: nil) } delegate :email, :unconfirmed_email, @@ -309,34 +310,6 @@ class Account < ApplicationRecord DeliveryFailureTracker.filter(urls) end - def triadic_closures(account, limit: 5, offset: 0) - sql = <<-SQL.squish - WITH first_degree AS ( - SELECT target_account_id - FROM follows - WHERE account_id = :account_id - ) - SELECT accounts.* - FROM follows - INNER JOIN accounts ON follows.target_account_id = accounts.id - WHERE - account_id IN (SELECT * FROM first_degree) - AND target_account_id NOT IN (SELECT * FROM first_degree) - AND target_account_id NOT IN (:excluded_account_ids) - AND accounts.suspended = false - GROUP BY target_account_id, accounts.id - ORDER BY count(account_id) DESC - OFFSET :offset - LIMIT :limit - SQL - - excluded_account_ids = account.excluded_from_timeline_account_ids + [account.id] - - find_by_sql( - [sql, { account_id: account.id, excluded_account_ids: excluded_account_ids, limit: limit, offset: offset }] - ) - end - def search_for(terms, limit = 10) textsearch, query = generate_query_for_search(terms) diff --git a/app/models/concerns/account_interactions.rb b/app/models/concerns/account_interactions.rb index ef59f5d15..ee435f956 100644 --- a/app/models/concerns/account_interactions.rb +++ b/app/models/concerns/account_interactions.rb @@ -89,10 +89,13 @@ module AccountInteractions .find_or_create_by!(target_account: other_account) rel.update!(show_reblogs: reblogs) + remove_potential_friendship(other_account) + rel end def block!(other_account, uri: nil) + remove_potential_friendship(other_account) block_relationships.create_with(uri: uri) .find_or_create_by!(target_account: other_account) end @@ -100,10 +103,13 @@ module AccountInteractions def mute!(other_account, notifications: nil) notifications = true if notifications.nil? mute = mute_relationships.create_with(hide_notifications: notifications).find_or_create_by!(target_account: other_account) + remove_potential_friendship(other_account) + # When toggling a mute between hiding and allowing notifications, the mute will already exist, so the find_or_create_by! call will return the existing Mute without updating the hide_notifications attribute. Therefore, we check that hide_notifications? is what we want and set it if it isn't. if mute.hide_notifications? != notifications mute.update!(hide_notifications: notifications) end + mute end @@ -194,4 +200,10 @@ module AccountInteractions lists.joins(account: :user) .where('users.current_sign_in_at > ?', User::ACTIVE_DURATION.ago) end + + private + + def remove_potential_friendship(other_account) + PotentialFriendshipTracker.remove(id, other_account.id) + end end diff --git a/app/services/favourite_service.rb b/app/services/favourite_service.rb index bc2d1547a..6e1ac3ba9 100644 --- a/app/services/favourite_service.rb +++ b/app/services/favourite_service.rb @@ -15,7 +15,10 @@ class FavouriteService < BaseService return favourite unless favourite.nil? favourite = Favourite.create!(account: account, status: status) + create_notification(favourite) + bump_potential_friendship(account, status) + favourite end @@ -33,6 +36,11 @@ class FavouriteService < BaseService end end + def bump_potential_friendship(account, status) + return if account.following?(status.account_id) + PotentialFriendshipTracker.record(account.id, status.account_id, :favourite) + end + def build_json(favourite) Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new( favourite, diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb index 735985725..bad82051a 100644 --- a/app/services/post_status_service.rb +++ b/app/services/post_status_service.rb @@ -47,6 +47,8 @@ class PostStatusService < BaseService redis.setex("idempotency:status:#{account.id}:#{options[:idempotency]}", 3_600, status.id) end + bump_potential_friendship(account, status) + status end @@ -79,4 +81,9 @@ class PostStatusService < BaseService def redis Redis.current end + + def bump_potential_friendship(account, status) + return if !status.reply? || account.following?(status.account_id) + PotentialFriendshipTracker.record(account.id, status.in_reply_to_account_id, :reply) + end end diff --git a/app/services/reblog_service.rb b/app/services/reblog_service.rb index 3c4e5847f..0ee8bac2f 100644 --- a/app/services/reblog_service.rb +++ b/app/services/reblog_service.rb @@ -24,6 +24,8 @@ class ReblogService < BaseService ActivityPub::DistributionWorker.perform_async(reblog.id) create_notification(reblog) + bump_potential_friendship(account, reblog) + reblog end @@ -41,6 +43,11 @@ class ReblogService < BaseService end end + def bump_potential_friendship(account, reblog) + return if account.following?(reblog.reblog.account_id) + PotentialFriendshipTracker.record(account.id, reblog.reblog.account_id, :reblog) + end + def build_json(reblog) Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new( reblog, diff --git a/config/routes.rb b/config/routes.rb index 5fdd3b390..e59325964 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -246,6 +246,7 @@ Rails.application.routes.draw do resources :streaming, only: [:index] resources :custom_emojis, only: [:index] + resources :suggestions, only: [:index] get '/search', to: 'search#index', as: :search diff --git a/spec/controllers/api/v1/suggestions_controller_spec.rb b/spec/controllers/api/v1/suggestions_controller_spec.rb new file mode 100644 index 000000000..17f10b04f --- /dev/null +++ b/spec/controllers/api/v1/suggestions_controller_spec.rb @@ -0,0 +1,35 @@ +require 'rails_helper' + +RSpec.describe Api::V1::SuggestionsController, type: :controller do + render_views + + let(:user) { Fabricate(:user) } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read write') } + + before do + allow(controller).to receive(:doorkeeper_token) { token } + end + + describe 'GET #index' do + let(:bob) { Fabricate(:account) } + let(:jeff) { Fabricate(:account) } + + before do + PotentialFriendshipTracker.record(user.account_id, bob.id, :reblog) + PotentialFriendshipTracker.record(user.account_id, jeff.id, :favourite) + + get :index + end + + it 'returns http success' do + expect(response).to have_http_status(200) + end + + it 'returns accounts' do + json = body_as_json + + expect(json.size).to be >= 1 + expect(json.map { |i| i[:id] }).to include *[bob, jeff].map { |i| i.id.to_s } + end + end +end diff --git a/spec/models/account_spec.rb b/spec/models/account_spec.rb index cce659a8a..c50791bcd 100644 --- a/spec/models/account_spec.rb +++ b/spec/models/account_spec.rb @@ -454,77 +454,6 @@ RSpec.describe Account, type: :model do end end - describe '.triadic_closures' do - let!(:me) { Fabricate(:account) } - let!(:friend) { Fabricate(:account) } - let!(:friends_friend) { Fabricate(:account) } - let!(:both_follow) { Fabricate(:account) } - - before do - me.follow!(friend) - friend.follow!(friends_friend) - - me.follow!(both_follow) - friend.follow!(both_follow) - end - - it 'finds accounts you dont follow which are followed by accounts you do follow' do - expect(described_class.triadic_closures(me)).to eq [friends_friend] - end - - it 'limits by 5 with offset 0 by defualt' do - first_degree = 6.times.map { Fabricate(:account) } - matches = 5.times.map { Fabricate(:account) } - first_degree.each { |account| me.follow!(account) } - matches.each do |match| - first_degree.each { |account| account.follow!(match) } - first_degree.shift - end - - expect(described_class.triadic_closures(me)).to eq matches - end - - it 'accepts arbitrary limits' do - another_friend = Fabricate(:account) - higher_friends_friend = Fabricate(:account) - me.follow!(another_friend) - friend.follow!(higher_friends_friend) - another_friend.follow!(higher_friends_friend) - - expect(described_class.triadic_closures(me, limit: 1)).to eq [higher_friends_friend] - end - - it 'acceps arbitrary offset' do - another_friend = Fabricate(:account) - higher_friends_friend = Fabricate(:account) - me.follow!(another_friend) - friend.follow!(higher_friends_friend) - another_friend.follow!(higher_friends_friend) - - expect(described_class.triadic_closures(me, offset: 1)).to eq [friends_friend] - end - - context 'when you block account' do - before do - me.block!(friends_friend) - end - - it 'rejects blocked accounts' do - expect(described_class.triadic_closures(me)).to be_empty - end - end - - context 'when you mute account' do - before do - me.mute!(friends_friend) - end - - it 'rejects muted accounts' do - expect(described_class.triadic_closures(me)).to be_empty - end - end - end - describe '#statuses_count' do subject { Fabricate(:account) } -- cgit From 4045b50bd6f6194f41384c2426bd9b17831b1438 Mon Sep 17 00:00:00 2001 From: "Renato \"Lond\" Cerqueira" Date: Tue, 3 Jul 2018 19:47:09 +0200 Subject: Restore support to ruby 2.3, add ruby 2.3 to circle ci (#7935) This replace calls of String#match? with rails Regex#match? This follows the same idea used to keep Rails 5.2 compatible with Ruby 2.2.2 in https://github.com/rails/rails/pull/32973 --- .circleci/config.yml | 25 +++++++++++++++++++++++++ app/models/concerns/attachmentable.rb | 2 +- 2 files changed, 26 insertions(+), 1 deletion(-) (limited to 'app/models') diff --git a/.circleci/config.yml b/.circleci/config.yml index 8791965f0..2a1c84253 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -110,6 +110,13 @@ jobs: environment: *ruby_environment <<: *install_ruby_dependencies + install-ruby2.3: + <<: *defaults + docker: + - image: circleci/ruby:2.3.7-stretch-node + environment: *ruby_environment + <<: *install_ruby_dependencies + build: <<: *defaults steps: @@ -146,6 +153,17 @@ jobs: - image: circleci/redis:4.0.9-alpine <<: *test_steps + test-ruby2.3: + <<: *defaults + docker: + - image: circleci/ruby:2.3.7-stretch-node + environment: *ruby_environment + - image: circleci/postgres:10.3-alpine + environment: + POSTGRES_USER: root + - image: circleci/redis:4.0.9-alpine + <<: *test_steps + test-webui: <<: *defaults docker: @@ -174,6 +192,9 @@ workflows: - install-ruby2.4: requires: - install + - install-ruby2.3: + requires: + - install - build: requires: - install-ruby2.5 @@ -185,6 +206,10 @@ workflows: requires: - install-ruby2.4 - build + - test-ruby2.3: + requires: + - install-ruby2.3 + - build - test-webui: requires: - install diff --git a/app/models/concerns/attachmentable.rb b/app/models/concerns/attachmentable.rb index 44bdfa39a..de4cf8775 100644 --- a/app/models/concerns/attachmentable.rb +++ b/app/models/concerns/attachmentable.rb @@ -28,7 +28,7 @@ module Attachmentable self.class.attachment_definitions.each_key do |attachment_name| attachment = send(attachment_name) - next if attachment.blank? || !attachment.content_type.match?(/image.*/) || attachment.queued_for_write[:original].blank? + next if attachment.blank? || !/image.*/.match?(attachment.content_type) || attachment.queued_for_write[:original].blank? width, height = FastImage.size(attachment.queued_for_write[:original].path) -- cgit From f89c595ea070d2017adb868fb5e311e198d8e990 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Fri, 6 Jul 2018 02:15:56 +0200 Subject: Add admin setting to enable OG previews for sensitive media (#7962) --- app/controllers/admin/settings_controller.rb | 2 ++ app/models/form/admin_settings.rb | 2 ++ app/views/admin/settings/edit.html.haml | 6 ++++++ app/views/stream_entries/_og_image.html.haml | 2 +- config/locales/en.yml | 3 +++ config/settings.yml | 1 + 6 files changed, 15 insertions(+), 1 deletion(-) (limited to 'app/models') diff --git a/app/controllers/admin/settings_controller.rb b/app/controllers/admin/settings_controller.rb index ce3208209..75d00326c 100644 --- a/app/controllers/admin/settings_controller.rb +++ b/app/controllers/admin/settings_controller.rb @@ -21,6 +21,7 @@ module Admin activity_api_enabled peers_api_enabled show_known_fediverse_at_about_page + preview_sensitive_media ).freeze BOOLEAN_SETTINGS = %w( @@ -31,6 +32,7 @@ module Admin activity_api_enabled peers_api_enabled show_known_fediverse_at_about_page + preview_sensitive_media ).freeze UPLOAD_SETTINGS = %w( diff --git a/app/models/form/admin_settings.rb b/app/models/form/admin_settings.rb index 32922e7f1..723480bdd 100644 --- a/app/models/form/admin_settings.rb +++ b/app/models/form/admin_settings.rb @@ -36,6 +36,8 @@ class Form::AdminSettings :peers_api_enabled=, :show_known_fediverse_at_about_page, :show_known_fediverse_at_about_page=, + :preview_sensitive_media, + :preview_sensitive_media=, to: Setting ) end diff --git a/app/views/admin/settings/edit.html.haml b/app/views/admin/settings/edit.html.haml index 08d05d738..f5c5deca8 100644 --- a/app/views/admin/settings/edit.html.haml +++ b/app/views/admin/settings/edit.html.haml @@ -2,6 +2,9 @@ = t('admin.settings.title') = simple_form_for @admin_settings, url: admin_settings_path, html: { method: :patch } do |f| + .actions.actions--top + = f.button :button, t('generic.save_changes'), type: :submit + .fields-group = f.input :site_title, placeholder: t('admin.settings.site_title') = f.input :site_description, wrapper: :with_block_label, as: :text, label: t('admin.settings.site_description.title'), hint: t('admin.settings.site_description.desc_html'), input_html: { rows: 8 } @@ -58,5 +61,8 @@ .fields-group = f.input :peers_api_enabled, as: :boolean, wrapper: :with_label, label: t('admin.settings.peers_api_enabled.title'), hint: t('admin.settings.peers_api_enabled.desc_html') + .fields-group + = f.input :preview_sensitive_media, as: :boolean, wrapper: :with_label, label: t('admin.settings.preview_sensitive_media.title'), hint: t('admin.settings.preview_sensitive_media.desc_html') + .actions = f.button :button, t('generic.save_changes'), type: :submit diff --git a/app/views/stream_entries/_og_image.html.haml b/app/views/stream_entries/_og_image.html.haml index 40530f567..e1b977da3 100644 --- a/app/views/stream_entries/_og_image.html.haml +++ b/app/views/stream_entries/_og_image.html.haml @@ -1,4 +1,4 @@ -- if activity.is_a?(Status) && activity.non_sensitive_with_media? +- if activity.is_a?(Status) && (activity.non_sensitive_with_media? || (activity.with_media? && Setting.preview_sensitive_media)) - player_card = false - activity.media_attachments.each do |media| - if media.image? diff --git a/config/locales/en.yml b/config/locales/en.yml index 8ab652045..a03b12a39 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -316,6 +316,9 @@ en: peers_api_enabled: desc_html: Domain names this instance has encountered in the fediverse title: Publish list of discovered instances + preview_sensitive_media: + desc_html: Link previews on other websites will display a thumbnail even if the media is marked as sensitive + title: Show sensitive media in OpenGraph previews registrations: closed_message: desc_html: Displayed on frontpage when registrations are closed. You can use HTML tags diff --git a/config/settings.yml b/config/settings.yml index 3581d10a2..190f6afcd 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -26,6 +26,7 @@ defaults: &defaults delete_modal: true auto_play_gif: false display_sensitive_media: false + preview_sensitive_media: false reduce_motion: false system_font_ui: false noindex: false -- cgit From 6b9e03e002ed349fd8b7e2879d599bb75a698eb2 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Sat, 7 Jul 2018 21:09:54 +0200 Subject: Add API method to remove a suggestion (#7978) DELETE /api/v1/suggestions/:account_id When blocking, remove suggestion from both sides. Muting not affected, since muting is supposed to be invisible to the target. --- app/controllers/api/v1/suggestions_controller.rb | 5 +++++ app/models/concerns/account_interactions.rb | 3 ++- config/routes.rb | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) (limited to 'app/models') diff --git a/app/controllers/api/v1/suggestions_controller.rb b/app/controllers/api/v1/suggestions_controller.rb index 3abccedd5..9da2b60ae 100644 --- a/app/controllers/api/v1/suggestions_controller.rb +++ b/app/controllers/api/v1/suggestions_controller.rb @@ -13,6 +13,11 @@ class Api::V1::SuggestionsController < Api::BaseController render json: @accounts, each_serializer: REST::AccountSerializer end + def destroy + PotentialFriendshipTracker.remove(current_account.id, params[:id]) + render_empty + end + private def set_accounts diff --git a/app/models/concerns/account_interactions.rb b/app/models/concerns/account_interactions.rb index ee435f956..e14e041f6 100644 --- a/app/models/concerns/account_interactions.rb +++ b/app/models/concerns/account_interactions.rb @@ -203,7 +203,8 @@ module AccountInteractions private - def remove_potential_friendship(other_account) + def remove_potential_friendship(other_account, mutual = false) PotentialFriendshipTracker.remove(id, other_account.id) + PotentialFriendshipTracker.remove(other_account.id, id) if mutual end end diff --git a/config/routes.rb b/config/routes.rb index e59325964..fd26b4aa7 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -246,7 +246,7 @@ Rails.application.routes.draw do resources :streaming, only: [:index] resources :custom_emojis, only: [:index] - resources :suggestions, only: [:index] + resources :suggestions, only: [:index, :destroy] get '/search', to: 'search#index', as: :search -- cgit From 1ca4e51eb38de6de81cedf3ddcdaa626f1d1c569 Mon Sep 17 00:00:00 2001 From: ThibG Date: Mon, 9 Jul 2018 02:22:09 +0200 Subject: Add option to not consider word boundaries when processing keyword filtering (#7975) * Add option to not consider word boundaries when filtering phrases * Add a few tests for keyword/phrase filtering --- app/controllers/api/v1/filters_controller.rb | 2 +- app/javascript/mastodon/selectors/index.js | 5 +++- app/lib/feed_manager.rb | 11 ++++++++- app/models/custom_filter.rb | 1 + app/serializers/rest/filter_serializer.rb | 2 +- app/views/filters/_fields.html.haml | 3 +++ ...180707154237_add_whole_word_to_custom_filter.rb | 17 +++++++++++++ db/schema.rb | 3 ++- spec/lib/feed_manager_spec.rb | 28 +++++++++++++++++----- 9 files changed, 61 insertions(+), 11 deletions(-) create mode 100644 db/migrate/20180707154237_add_whole_word_to_custom_filter.rb (limited to 'app/models') diff --git a/app/controllers/api/v1/filters_controller.rb b/app/controllers/api/v1/filters_controller.rb index a98080d1d..e5ebaff4d 100644 --- a/app/controllers/api/v1/filters_controller.rb +++ b/app/controllers/api/v1/filters_controller.rb @@ -43,6 +43,6 @@ class Api::V1::FiltersController < Api::BaseController end def resource_params - params.permit(:phrase, :expires_in, :irreversible, context: []) + params.permit(:phrase, :expires_in, :irreversible, :whole_word, context: []) end end diff --git a/app/javascript/mastodon/selectors/index.js b/app/javascript/mastodon/selectors/index.js index 7aa7569a0..d0212c379 100644 --- a/app/javascript/mastodon/selectors/index.js +++ b/app/javascript/mastodon/selectors/index.js @@ -45,7 +45,10 @@ export const regexFromFilters = filters => { return null; } - return new RegExp(filters.map(filter => escapeRegExp(filter.get('phrase'))).map(expr => `\\b${expr}\\b`).join('|'), 'i'); + return new RegExp(filters.map(filter => { + let expr = escapeRegExp(filter.get('phrase')); + return filter.get('whole_word') ? `\\b${expr}\\b` : expr; + }).join('|'), 'i'); }; export const makeGetStatus = () => { diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb index 55c72d0ea..c247ab21d 100644 --- a/app/lib/feed_manager.rb +++ b/app/lib/feed_manager.rb @@ -200,7 +200,16 @@ class FeedManager active_filters = Rails.cache.fetch("filters:#{receiver_id}") { CustomFilter.where(account_id: receiver_id).active_irreversible.to_a }.to_a active_filters.select! { |filter| filter.context.include?(context.to_s) && !filter.expired? } - active_filters.map! { |filter| Regexp.new("\\b#{Regexp.escape(filter.phrase)}\\b", true) } + active_filters.map! do |filter| + if filter.whole_word + sb = filter.phrase =~ /\A[[:word:]]/ ? '\b' : '' + eb = filter.phrase =~ /[[:word:]]\Z/ ? '\b' : '' + + /(?mix:#{sb}#{Regexp.escape(filter.phrase)}#{eb})/ + else + /#{Regexp.escape(filter.phrase)}/i + end + end return false if active_filters.empty? diff --git a/app/models/custom_filter.rb b/app/models/custom_filter.rb index 2c1a54375..342207a55 100644 --- a/app/models/custom_filter.rb +++ b/app/models/custom_filter.rb @@ -8,6 +8,7 @@ # expires_at :datetime # phrase :text default(""), not null # context :string default([]), not null, is an Array +# whole_word :boolean default(TRUE), not null # irreversible :boolean default(FALSE), not null # created_at :datetime not null # updated_at :datetime not null diff --git a/app/serializers/rest/filter_serializer.rb b/app/serializers/rest/filter_serializer.rb index 51340aa79..3134be371 100644 --- a/app/serializers/rest/filter_serializer.rb +++ b/app/serializers/rest/filter_serializer.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true class REST::FilterSerializer < ActiveModel::Serializer - attributes :id, :phrase, :context, :expires_at, + attributes :id, :phrase, :context, :whole_word, :expires_at, :irreversible end diff --git a/app/views/filters/_fields.html.haml b/app/views/filters/_fields.html.haml index af5d648b8..a5a3f0337 100644 --- a/app/views/filters/_fields.html.haml +++ b/app/views/filters/_fields.html.haml @@ -7,5 +7,8 @@ .fields-group = f.input :irreversible, wrapper: :with_label +.fields-group + = f.input :whole_word, wrapper: :with_label + .fields-group = f.input :expires_in, wrapper: :with_label, collection: [30.minutes, 1.hour, 6.hours, 12.hours, 1.day, 1.week].map(&:to_i), label_method: lambda { |i| I18n.t("invites.expires_in.#{i}") }, prompt: I18n.t('invites.expires_in_prompt') diff --git a/db/migrate/20180707154237_add_whole_word_to_custom_filter.rb b/db/migrate/20180707154237_add_whole_word_to_custom_filter.rb new file mode 100644 index 000000000..63ecb8741 --- /dev/null +++ b/db/migrate/20180707154237_add_whole_word_to_custom_filter.rb @@ -0,0 +1,17 @@ +require Rails.root.join('lib', 'mastodon', 'migration_helpers') + +class AddWholeWordToCustomFilter < ActiveRecord::Migration[5.2] + include Mastodon::MigrationHelpers + + disable_ddl_transaction! + + def change + safety_assured do + add_column_with_default :custom_filters, :whole_word, :boolean, default: true, allow_null: false + end + end + + def down + remove_column :custom_filters, :whole_word + end +end diff --git a/db/schema.rb b/db/schema.rb index 661fc8179..02032c548 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2018_06_28_181026) do +ActiveRecord::Schema.define(version: 2018_07_07_154237) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -149,6 +149,7 @@ ActiveRecord::Schema.define(version: 2018_06_28_181026) do t.text "phrase", default: "", null: false t.string "context", default: [], null: false, array: true t.boolean "irreversible", default: false, null: false + t.boolean "whole_word", default: true, null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["account_id"], name: "index_custom_filters_on_account_id" diff --git a/spec/lib/feed_manager_spec.rb b/spec/lib/feed_manager_spec.rb index d1b847675..7535e144d 100644 --- a/spec/lib/feed_manager_spec.rb +++ b/spec/lib/feed_manager_spec.rb @@ -127,12 +127,28 @@ RSpec.describe FeedManager do expect(FeedManager.instance.filter?(:home, reblog, alice.id)).to be true end - it 'returns true if status contains irreversibly muted phrase' do - alice.custom_filters.create!(phrase: 'farts', context: %w(home public), irreversible: true) - alice.custom_filters.create!(phrase: 'pop tarts', context: %w(home), irreversible: true) - alice.follow!(jeff) - status = Fabricate(:status, text: 'i sure like POP TARts', account: jeff) - expect(FeedManager.instance.filter?(:home, status, alice.id)).to be true + context 'for irreversibly muted phrases' do + it 'considers word boundaries when matching' do + alice.custom_filters.create!(phrase: 'bob', context: %w(home), irreversible: true) + alice.follow!(jeff) + status = Fabricate(:status, text: 'bobcats', account: jeff) + expect(FeedManager.instance.filter?(:home, status, alice.id)).to be_falsy + end + + it 'returns true if phrase is contained' do + alice.custom_filters.create!(phrase: 'farts', context: %w(home public), irreversible: true) + alice.custom_filters.create!(phrase: 'pop tarts', context: %w(home), irreversible: true) + alice.follow!(jeff) + status = Fabricate(:status, text: 'i sure like POP TARts', account: jeff) + expect(FeedManager.instance.filter?(:home, status, alice.id)).to be true + end + + it 'matches substrings if whole_word is false' do + alice.custom_filters.create!(phrase: 'take', context: %w(home), whole_word: false, irreversible: true) + alice.follow!(jeff) + status = Fabricate(:status, text: 'shiitake', account: jeff) + expect(FeedManager.instance.filter?(:home, status, alice.id)).to be true + end end end -- cgit