diff --git a/app/controllers/api/v2/search_controller.rb b/app/controllers/api/v2/search_controller.rb index 3cfc6e7919..3ca13cc427 100644 --- a/app/controllers/api/v2/search_controller.rb +++ b/app/controllers/api/v2/search_controller.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true class Api::V2::SearchController < Api::BaseController + include AsyncRefreshesConcern include Authorization RESULTS_LIMIT = 20 @@ -13,6 +14,7 @@ class Api::V2::SearchController < Api::BaseController before_action :remote_resolve_error, if: :remote_resolve_requested? end before_action :require_valid_pagination_options! + before_action :handle_fasp_requests def index @search = Search.new(search_results) @@ -37,6 +39,21 @@ class Api::V2::SearchController < Api::BaseController render json: { error: 'Search queries that resolve remote resources are not supported without authentication' }, status: 401 end + def handle_fasp_requests + return unless Mastodon::Feature.fasp_enabled? + return if params[:q].blank? + + # Do not schedule a new retrieval if the request is a follow-up + # to an earlier retrieval + return if request.headers['Mastodon-Async-Refresh-Id'].present? + + refresh_key = "fasp:account_search:#{Digest::MD5.base64digest(params[:q])}" + return if AsyncRefresh.new(refresh_key).running? + + add_async_refresh_header(AsyncRefresh.create(refresh_key)) + @query_fasp = true + end + def remote_resolve_requested? truthy_param?(:resolve) end @@ -58,7 +75,8 @@ class Api::V2::SearchController < Api::BaseController search_params.merge( resolve: truthy_param?(:resolve), exclude_unreviewed: truthy_param?(:exclude_unreviewed), - following: truthy_param?(:following) + following: truthy_param?(:following), + query_fasp: @query_fasp ) end diff --git a/app/services/account_search_service.rb b/app/services/account_search_service.rb index 5261040884..6f70d530b2 100644 --- a/app/services/account_search_service.rb +++ b/app/services/account_search_service.rb @@ -178,6 +178,12 @@ class AccountSearchService < BaseService 'search.backend' => Chewy.enabled? ? 'elasticsearch' : 'database' ) + # Trigger searching accounts using providers. + # This will not return any immediate results but has the + # potential to fill the local database with relevant + # accounts for the next time the search is performed. + Fasp::AccountSearchWorker.perform_async(@query) if options[:query_fasp] + search_service_results.compact.uniq.tap do |results| span.set_attribute('search.results.count', results.size) end diff --git a/app/services/search_service.rb b/app/services/search_service.rb index 9a40d7bdd5..ffe380c2e0 100644 --- a/app/services/search_service.rb +++ b/app/services/search_service.rb @@ -11,6 +11,7 @@ class SearchService < BaseService @offset = options[:type].blank? ? 0 : options[:offset].to_i @resolve = options[:resolve] || false @following = options[:following] || false + @query_fasp = options[:query_fasp] || false default_results.tap do |results| next if @query.blank? || @limit.zero? @@ -36,7 +37,8 @@ class SearchService < BaseService offset: @offset, use_searchable_text: true, following: @following, - start_with_hashtag: @query.start_with?('#') + start_with_hashtag: @query.start_with?('#'), + query_fasp: @options[:query_fasp] ) end diff --git a/app/workers/fasp/account_search_worker.rb b/app/workers/fasp/account_search_worker.rb new file mode 100644 index 0000000000..745285c44d --- /dev/null +++ b/app/workers/fasp/account_search_worker.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +class Fasp::AccountSearchWorker + include Sidekiq::Worker + + sidekiq_options queue: 'fasp', retry: 0 + + def perform(query) + return unless Mastodon::Feature.fasp_enabled? + + async_refresh = AsyncRefresh.new("fasp:account_search:#{Digest::MD5.base64digest(query)}") + + account_search_providers = Fasp::Provider.with_capability('account_search') + return if account_search_providers.none? + + params = { term: query, limit: 10 }.to_query + fetch_service = ActivityPub::FetchRemoteActorService.new + + account_search_providers.each do |provider| + Fasp::Request.new(provider).get("/account_search/v0/search?#{params}").each do |uri| + next if Account.where(uri:).any? + + account = fetch_service.call(uri) + async_refresh.increment_result_count(by: 1) if account.present? + end + end + ensure + async_refresh&.finish! + end +end diff --git a/spec/fabricators/fasp/provider_fabricator.rb b/spec/fabricators/fasp/provider_fabricator.rb index 8700ebb8cf..e2ceb753ea 100644 --- a/spec/fabricators/fasp/provider_fabricator.rb +++ b/spec/fabricators/fasp/provider_fabricator.rb @@ -41,3 +41,15 @@ Fabricator(:follow_recommendation_fasp, from: :fasp_provider) do def fasp.update_remote_capabilities = true end end + +Fabricator(:account_search_fasp, from: :fasp_provider) do + confirmed true + capabilities [ + { id: 'account_search', version: '0.1', enabled: true }, + ] + + after_build do |fasp| + # Prevent fabrication from attempting an HTTP call to the provider + def fasp.update_remote_capabilities = true + end +end diff --git a/spec/requests/api/v2/search_spec.rb b/spec/requests/api/v2/search_spec.rb index 5a2346dc39..9c9f37e098 100644 --- a/spec/requests/api/v2/search_spec.rb +++ b/spec/requests/api/v2/search_spec.rb @@ -118,6 +118,15 @@ RSpec.describe 'Search API' do .to start_with('application/json') end end + + context 'when `account_search` FASP is enabled', feature: :fasp do + it 'enqueues a retrieval job and adds a header to inform the client' do + get '/api/v2/search', headers: headers, params: params + + expect(Fasp::AccountSearchWorker).to have_enqueued_sidekiq_job + expect(response.headers['Mastodon-Async-Refresh']).to be_present + end + end end end diff --git a/spec/services/search_service_spec.rb b/spec/services/search_service_spec.rb index cd4c424630..3260addb31 100644 --- a/spec/services/search_service_spec.rb +++ b/spec/services/search_service_spec.rb @@ -66,7 +66,7 @@ RSpec.describe SearchService do allow(AccountSearchService).to receive(:new).and_return(service) results = subject.call(query, nil, 10) - expect(service).to have_received(:call).with(query, nil, limit: 10, offset: 0, resolve: false, start_with_hashtag: false, use_searchable_text: true, following: false) + expect(service).to have_received(:call).with(query, nil, limit: 10, offset: 0, resolve: false, start_with_hashtag: false, use_searchable_text: true, following: false, query_fasp: nil) expect(results).to eq empty_results.merge(accounts: [account]) end end diff --git a/spec/workers/fasp/account_search_worker_spec.rb b/spec/workers/fasp/account_search_worker_spec.rb new file mode 100644 index 0000000000..a96ba0c23b --- /dev/null +++ b/spec/workers/fasp/account_search_worker_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Fasp::AccountSearchWorker, feature: :fasp do + include ProviderRequestHelper + + let(:provider) { Fabricate(:account_search_fasp) } + let(:account) { Fabricate(:account) } + let(:fetch_service) { instance_double(ActivityPub::FetchRemoteActorService, call: true) } + + let!(:stubbed_request) do + path = '/account_search/v0/search?term=cats&limit=10' + stub_provider_request(provider, + method: :get, + path:, + response_body: [ + 'https://fedi.example.com/accounts/2', + 'https://fedi.example.com/accounts/9', + ]) + end + + before do + allow(ActivityPub::FetchRemoteActorService).to receive(:new).and_return(fetch_service) + end + + it 'requests search results and fetches received account uris' do + described_class.new.perform('cats') + + expect(stubbed_request).to have_been_made + expect(fetch_service).to have_received(:call).with('https://fedi.example.com/accounts/2') + expect(fetch_service).to have_received(:call).with('https://fedi.example.com/accounts/9') + end + + it 'marks a running async refresh as finished' do + async_refresh = AsyncRefresh.create("fasp:account_search:#{Digest::MD5.base64digest('cats')}", count_results: true) + + described_class.new.perform('cats') + + expect(async_refresh.reload).to be_finished + end + + it 'tracks the number of fetched accounts in the async refresh' do + async_refresh = AsyncRefresh.create("fasp:account_search:#{Digest::MD5.base64digest('cats')}", count_results: true) + + described_class.new.perform('cats') + + expect(async_refresh.reload.result_count).to eq 2 + end +end