Gab Social. All are welcome.
This commit is contained in:
101
app/services/account_search_service.rb
Normal file
101
app/services/account_search_service.rb
Normal file
@@ -0,0 +1,101 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AccountSearchService < BaseService
|
||||
attr_reader :query, :limit, :offset, :options, :account
|
||||
|
||||
def call(query, account = nil, options = {})
|
||||
@query = query.strip
|
||||
@limit = options[:limit].to_i
|
||||
@offset = options[:offset].to_i
|
||||
@options = options
|
||||
@account = account
|
||||
|
||||
search_service_results
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def search_service_results
|
||||
return [] if query_blank_or_hashtag? || limit < 1
|
||||
|
||||
if resolving_non_matching_remote_account?
|
||||
[ResolveAccountService.new.call("#{query_username}@#{query_domain}")].compact
|
||||
else
|
||||
search_results_and_exact_match.compact.uniq.slice(0, limit)
|
||||
end
|
||||
end
|
||||
|
||||
def resolving_non_matching_remote_account?
|
||||
options[:resolve] && !exact_match && !domain_is_local?
|
||||
end
|
||||
|
||||
def search_results_and_exact_match
|
||||
exact = [exact_match]
|
||||
return exact if !exact[0].nil? && limit == 1
|
||||
exact + search_results.to_a
|
||||
end
|
||||
|
||||
def query_blank_or_hashtag?
|
||||
query.blank? || query.start_with?('#')
|
||||
end
|
||||
|
||||
def split_query_string
|
||||
@_split_query_string ||= query.gsub(/\A@/, '').split('@')
|
||||
end
|
||||
|
||||
def query_username
|
||||
@_query_username ||= split_query_string.first || ''
|
||||
end
|
||||
|
||||
def query_domain
|
||||
@_query_domain ||= query_without_split? ? nil : split_query_string.last
|
||||
end
|
||||
|
||||
def query_without_split?
|
||||
split_query_string.size == 1
|
||||
end
|
||||
|
||||
def domain_is_local?
|
||||
@_domain_is_local ||= TagManager.instance.local_domain?(query_domain)
|
||||
end
|
||||
|
||||
def search_from
|
||||
options[:following] && account ? account.following : Account
|
||||
end
|
||||
|
||||
def exact_match
|
||||
@_exact_match ||= begin
|
||||
if domain_is_local?
|
||||
search_from.without_suspended.find_local(query_username)
|
||||
else
|
||||
search_from.without_suspended.find_remote(query_username, query_domain)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def search_results
|
||||
@_search_results ||= begin
|
||||
if account
|
||||
advanced_search_results
|
||||
else
|
||||
simple_search_results
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def advanced_search_results
|
||||
Account.advanced_search_for(terms_for_query, account, limit, options[:following], offset)
|
||||
end
|
||||
|
||||
def simple_search_results
|
||||
Account.search_for(terms_for_query, limit, offset)
|
||||
end
|
||||
|
||||
def terms_for_query
|
||||
if domain_is_local?
|
||||
query_username
|
||||
else
|
||||
"#{query_username} #{query_domain}"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,54 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ActivityPub::FetchFeaturedCollectionService < BaseService
|
||||
include JsonLdHelper
|
||||
|
||||
def call(account)
|
||||
return if account.featured_collection_url.blank?
|
||||
|
||||
@account = account
|
||||
@json = fetch_resource(@account.featured_collection_url, true)
|
||||
|
||||
return unless supported_context?
|
||||
return if @account.suspended? || @account.local?
|
||||
|
||||
case @json['type']
|
||||
when 'Collection', 'CollectionPage'
|
||||
process_items @json['items']
|
||||
when 'OrderedCollection', 'OrderedCollectionPage'
|
||||
process_items @json['orderedItems']
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def process_items(items)
|
||||
status_ids = items.map { |item| value_or_id(item) }
|
||||
.reject { |uri| ActivityPub::TagManager.instance.local_uri?(uri) }
|
||||
.map { |uri| ActivityPub::FetchRemoteStatusService.new.call(uri) }
|
||||
.compact
|
||||
.select { |status| status.account_id == @account.id }
|
||||
.map(&:id)
|
||||
|
||||
to_remove = []
|
||||
to_add = status_ids
|
||||
|
||||
StatusPin.where(account: @account).pluck(:status_id).each do |status_id|
|
||||
if status_ids.include?(status_id)
|
||||
to_add.delete(status_id)
|
||||
else
|
||||
to_remove << status_id
|
||||
end
|
||||
end
|
||||
|
||||
StatusPin.where(account: @account, status_id: to_remove).delete_all unless to_remove.empty?
|
||||
|
||||
to_add.each do |status_id|
|
||||
StatusPin.create!(account: @account, status_id: status_id)
|
||||
end
|
||||
end
|
||||
|
||||
def supported_context?
|
||||
super(@json)
|
||||
end
|
||||
end
|
||||
62
app/services/activitypub/fetch_remote_account_service.rb
Normal file
62
app/services/activitypub/fetch_remote_account_service.rb
Normal file
@@ -0,0 +1,62 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ActivityPub::FetchRemoteAccountService < BaseService
|
||||
include JsonLdHelper
|
||||
|
||||
SUPPORTED_TYPES = %w(Application Group Organization Person Service).freeze
|
||||
|
||||
# Does a WebFinger roundtrip on each call, unless `only_key` is true
|
||||
def call(uri, id: true, prefetched_body: nil, break_on_redirect: false, only_key: false)
|
||||
return ActivityPub::TagManager.instance.uri_to_resource(uri, Account) if ActivityPub::TagManager.instance.local_uri?(uri)
|
||||
|
||||
@json = if prefetched_body.nil?
|
||||
fetch_resource(uri, id)
|
||||
else
|
||||
body_to_json(prefetched_body, compare_id: id ? uri : nil)
|
||||
end
|
||||
|
||||
return if !supported_context? || !expected_type? || (break_on_redirect && @json['movedTo'].present?)
|
||||
|
||||
@uri = @json['id']
|
||||
@username = @json['preferredUsername']
|
||||
@domain = Addressable::URI.parse(@uri).normalized_host
|
||||
|
||||
return unless only_key || verified_webfinger?
|
||||
|
||||
ActivityPub::ProcessAccountService.new.call(@username, @domain, @json, only_key: only_key)
|
||||
rescue Oj::ParseError
|
||||
nil
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def verified_webfinger?
|
||||
webfinger = Goldfinger.finger("acct:#{@username}@#{@domain}")
|
||||
confirmed_username, confirmed_domain = split_acct(webfinger.subject)
|
||||
|
||||
return webfinger.link('self')&.href == @uri if @username.casecmp(confirmed_username).zero? && @domain.casecmp(confirmed_domain).zero?
|
||||
|
||||
webfinger = Goldfinger.finger("acct:#{confirmed_username}@#{confirmed_domain}")
|
||||
@username, @domain = split_acct(webfinger.subject)
|
||||
self_reference = webfinger.link('self')
|
||||
|
||||
return false unless @username.casecmp(confirmed_username).zero? && @domain.casecmp(confirmed_domain).zero?
|
||||
return false if self_reference&.href != @uri
|
||||
|
||||
true
|
||||
rescue Goldfinger::Error
|
||||
false
|
||||
end
|
||||
|
||||
def split_acct(acct)
|
||||
acct.gsub(/\Aacct:/, '').split('@')
|
||||
end
|
||||
|
||||
def supported_context?
|
||||
super(@json)
|
||||
end
|
||||
|
||||
def expected_type?
|
||||
equals_or_includes_any?(@json['type'], SUPPORTED_TYPES)
|
||||
end
|
||||
end
|
||||
60
app/services/activitypub/fetch_remote_key_service.rb
Normal file
60
app/services/activitypub/fetch_remote_key_service.rb
Normal file
@@ -0,0 +1,60 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ActivityPub::FetchRemoteKeyService < BaseService
|
||||
include JsonLdHelper
|
||||
|
||||
# Returns account that owns the key
|
||||
def call(uri, id: true, prefetched_body: nil)
|
||||
if prefetched_body.nil?
|
||||
if id
|
||||
@json = fetch_resource_without_id_validation(uri)
|
||||
if person?
|
||||
@json = fetch_resource(@json['id'], true)
|
||||
elsif uri != @json['id']
|
||||
return
|
||||
end
|
||||
else
|
||||
@json = fetch_resource(uri, id)
|
||||
end
|
||||
else
|
||||
@json = body_to_json(prefetched_body, compare_id: id ? uri : nil)
|
||||
end
|
||||
|
||||
return unless supported_context?(@json) && expected_type?
|
||||
return find_account(@json['id'], @json) if person?
|
||||
|
||||
@owner = fetch_resource(owner_uri, true)
|
||||
|
||||
return unless supported_context?(@owner) && confirmed_owner?
|
||||
|
||||
find_account(owner_uri, @owner)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def find_account(uri, prefetched_body)
|
||||
account = ActivityPub::TagManager.instance.uri_to_resource(uri, Account)
|
||||
account ||= ActivityPub::FetchRemoteAccountService.new.call(uri, prefetched_body: prefetched_body)
|
||||
account
|
||||
end
|
||||
|
||||
def expected_type?
|
||||
person? || public_key?
|
||||
end
|
||||
|
||||
def person?
|
||||
equals_or_includes_any?(@json['type'], ActivityPub::FetchRemoteAccountService::SUPPORTED_TYPES)
|
||||
end
|
||||
|
||||
def public_key?
|
||||
@json['publicKeyPem'].present? && @json['owner'].present?
|
||||
end
|
||||
|
||||
def owner_uri
|
||||
@owner_uri ||= value_or_id(@json['owner'])
|
||||
end
|
||||
|
||||
def confirmed_owner?
|
||||
equals_or_includes_any?(@owner['type'], ActivityPub::FetchRemoteAccountService::SUPPORTED_TYPES) && value_or_id(@owner['publicKey']) == @json['id']
|
||||
end
|
||||
end
|
||||
11
app/services/activitypub/fetch_remote_poll_service.rb
Normal file
11
app/services/activitypub/fetch_remote_poll_service.rb
Normal file
@@ -0,0 +1,11 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ActivityPub::FetchRemotePollService < BaseService
|
||||
include JsonLdHelper
|
||||
|
||||
def call(poll, on_behalf_of = nil)
|
||||
json = fetch_resource(poll.status.uri, true, on_behalf_of)
|
||||
return unless supported_context?(json)
|
||||
ActivityPub::ProcessPollService.new.call(poll, json)
|
||||
end
|
||||
end
|
||||
52
app/services/activitypub/fetch_remote_status_service.rb
Normal file
52
app/services/activitypub/fetch_remote_status_service.rb
Normal file
@@ -0,0 +1,52 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ActivityPub::FetchRemoteStatusService < BaseService
|
||||
include JsonLdHelper
|
||||
|
||||
# Should be called when uri has already been checked for locality
|
||||
def call(uri, id: true, prefetched_body: nil, on_behalf_of: nil)
|
||||
@json = if prefetched_body.nil?
|
||||
fetch_resource(uri, id, on_behalf_of)
|
||||
else
|
||||
body_to_json(prefetched_body, compare_id: id ? uri : nil)
|
||||
end
|
||||
|
||||
return unless supported_context? && expected_type?
|
||||
|
||||
return if actor_id.nil? || !trustworthy_attribution?(@json['id'], actor_id)
|
||||
|
||||
actor = ActivityPub::TagManager.instance.uri_to_resource(actor_id, Account)
|
||||
actor = ActivityPub::FetchRemoteAccountService.new.call(actor_id, id: true) if actor.nil? || needs_update(actor)
|
||||
|
||||
return if actor.nil? || actor.suspended?
|
||||
|
||||
ActivityPub::Activity.factory(activity_json, actor).perform
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def activity_json
|
||||
{ 'type' => 'Create', 'actor' => actor_id, 'object' => @json }
|
||||
end
|
||||
|
||||
def actor_id
|
||||
value_or_id(first_of_value(@json['attributedTo']))
|
||||
end
|
||||
|
||||
def trustworthy_attribution?(uri, attributed_to)
|
||||
return false if uri.nil? || attributed_to.nil?
|
||||
Addressable::URI.parse(uri).normalized_host.casecmp(Addressable::URI.parse(attributed_to).normalized_host).zero?
|
||||
end
|
||||
|
||||
def supported_context?
|
||||
super(@json)
|
||||
end
|
||||
|
||||
def expected_type?
|
||||
equals_or_includes_any?(@json['type'], ActivityPub::Activity::Create::SUPPORTED_TYPES + ActivityPub::Activity::Create::CONVERTED_TYPES)
|
||||
end
|
||||
|
||||
def needs_update(actor)
|
||||
actor.possibly_stale?
|
||||
end
|
||||
end
|
||||
49
app/services/activitypub/fetch_replies_service.rb
Normal file
49
app/services/activitypub/fetch_replies_service.rb
Normal file
@@ -0,0 +1,49 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ActivityPub::FetchRepliesService < BaseService
|
||||
include JsonLdHelper
|
||||
|
||||
def call(parent_status, collection_or_uri, allow_synchronous_requests = true)
|
||||
@account = parent_status.account
|
||||
@allow_synchronous_requests = allow_synchronous_requests
|
||||
|
||||
@items = collection_items(collection_or_uri)
|
||||
return if @items.nil?
|
||||
|
||||
FetchReplyWorker.push_bulk(filtered_replies)
|
||||
|
||||
@items
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def collection_items(collection_or_uri)
|
||||
collection = fetch_collection(collection_or_uri)
|
||||
return unless collection.is_a?(Hash)
|
||||
|
||||
collection = fetch_collection(collection['first']) if collection['first'].present?
|
||||
return unless collection.is_a?(Hash)
|
||||
|
||||
case collection['type']
|
||||
when 'Collection', 'CollectionPage'
|
||||
collection['items']
|
||||
when 'OrderedCollection', 'OrderedCollectionPage'
|
||||
collection['orderedItems']
|
||||
end
|
||||
end
|
||||
|
||||
def fetch_collection(collection_or_uri)
|
||||
return collection_or_uri if collection_or_uri.is_a?(Hash)
|
||||
return unless @allow_synchronous_requests
|
||||
return if invalid_origin?(collection_or_uri)
|
||||
fetch_resource_without_id_validation(collection_or_uri, nil, true)
|
||||
end
|
||||
|
||||
def filtered_replies
|
||||
# Only fetch replies to the same server as the original status to avoid
|
||||
# amplification attacks.
|
||||
|
||||
# Also limit to 5 fetched replies to limit potential for DoS.
|
||||
@items.map { |item| value_or_id(item) }.reject { |uri| invalid_origin?(uri) }.take(5)
|
||||
end
|
||||
end
|
||||
276
app/services/activitypub/process_account_service.rb
Normal file
276
app/services/activitypub/process_account_service.rb
Normal file
@@ -0,0 +1,276 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ActivityPub::ProcessAccountService < BaseService
|
||||
include JsonLdHelper
|
||||
|
||||
# Should be called with confirmed valid JSON
|
||||
# and WebFinger-resolved username and domain
|
||||
def call(username, domain, json, options = {})
|
||||
return if json['inbox'].blank? || unsupported_uri_scheme?(json['id'])
|
||||
|
||||
@options = options
|
||||
@json = json
|
||||
@uri = @json['id']
|
||||
@username = username
|
||||
@domain = domain
|
||||
@collections = {}
|
||||
|
||||
RedisLock.acquire(lock_options) do |lock|
|
||||
if lock.acquired?
|
||||
@account = Account.find_remote(@username, @domain)
|
||||
@old_public_key = @account&.public_key
|
||||
@old_protocol = @account&.protocol
|
||||
|
||||
create_account if @account.nil?
|
||||
update_account
|
||||
process_tags
|
||||
process_attachments
|
||||
else
|
||||
raise GabSocial::RaceConditionError
|
||||
end
|
||||
end
|
||||
|
||||
return if @account.nil?
|
||||
|
||||
after_protocol_change! if protocol_changed?
|
||||
after_key_change! if key_changed? && !@options[:signed_with_known_key]
|
||||
clear_tombstones! if key_changed?
|
||||
|
||||
unless @options[:only_key]
|
||||
check_featured_collection! if @account.featured_collection_url.present?
|
||||
check_links! unless @account.fields.empty?
|
||||
end
|
||||
|
||||
@account
|
||||
rescue Oj::ParseError
|
||||
nil
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_account
|
||||
@account = Account.new
|
||||
@account.protocol = :activitypub
|
||||
@account.username = @username
|
||||
@account.domain = @domain
|
||||
@account.private_key = nil
|
||||
@account.suspended_at = domain_block.created_at if auto_suspend?
|
||||
@account.silenced_at = domain_block.created_at if auto_silence?
|
||||
end
|
||||
|
||||
def update_account
|
||||
@account.last_webfingered_at = Time.now.utc unless @options[:only_key]
|
||||
@account.protocol = :activitypub
|
||||
|
||||
set_immediate_attributes!
|
||||
set_fetchable_attributes! unless @options[:only_keys]
|
||||
|
||||
@account.save_with_optional_media!
|
||||
end
|
||||
|
||||
def set_immediate_attributes!
|
||||
@account.inbox_url = @json['inbox'] || ''
|
||||
@account.outbox_url = @json['outbox'] || ''
|
||||
@account.shared_inbox_url = (@json['endpoints'].is_a?(Hash) ? @json['endpoints']['sharedInbox'] : @json['sharedInbox']) || ''
|
||||
@account.followers_url = @json['followers'] || ''
|
||||
@account.featured_collection_url = @json['featured'] || ''
|
||||
@account.url = url || @uri
|
||||
@account.uri = @uri
|
||||
@account.display_name = @json['name'] || ''
|
||||
@account.note = @json['summary'] || ''
|
||||
@account.locked = @json['manuallyApprovesFollowers'] || false
|
||||
@account.fields = property_values || {}
|
||||
@account.also_known_as = as_array(@json['alsoKnownAs'] || []).map { |item| value_or_id(item) }
|
||||
@account.actor_type = actor_type
|
||||
end
|
||||
|
||||
def set_fetchable_attributes!
|
||||
@account.avatar_remote_url = image_url('icon') unless skip_download?
|
||||
@account.header_remote_url = image_url('image') unless skip_download?
|
||||
@account.public_key = public_key || ''
|
||||
@account.statuses_count = outbox_total_items if outbox_total_items.present?
|
||||
@account.following_count = following_total_items if following_total_items.present?
|
||||
@account.followers_count = followers_total_items if followers_total_items.present?
|
||||
@account.moved_to_account = @json['movedTo'].present? ? moved_account : nil
|
||||
end
|
||||
|
||||
def after_protocol_change!
|
||||
ActivityPub::PostUpgradeWorker.perform_async(@account.domain)
|
||||
end
|
||||
|
||||
def after_key_change!
|
||||
RefollowWorker.perform_async(@account.id)
|
||||
end
|
||||
|
||||
def check_featured_collection!
|
||||
ActivityPub::SynchronizeFeaturedCollectionWorker.perform_async(@account.id)
|
||||
end
|
||||
|
||||
def check_links!
|
||||
VerifyAccountLinksWorker.perform_async(@account.id)
|
||||
end
|
||||
|
||||
def actor_type
|
||||
if @json['type'].is_a?(Array)
|
||||
@json['type'].find { |type| ActivityPub::FetchRemoteAccountService::SUPPORTED_TYPES.include?(type) }
|
||||
else
|
||||
@json['type']
|
||||
end
|
||||
end
|
||||
|
||||
def image_url(key)
|
||||
value = first_of_value(@json[key])
|
||||
|
||||
return if value.nil?
|
||||
return value['url'] if value.is_a?(Hash)
|
||||
|
||||
image = fetch_resource_without_id_validation(value)
|
||||
image['url'] if image
|
||||
end
|
||||
|
||||
def public_key
|
||||
value = first_of_value(@json['publicKey'])
|
||||
|
||||
return if value.nil?
|
||||
return value['publicKeyPem'] if value.is_a?(Hash)
|
||||
|
||||
key = fetch_resource_without_id_validation(value)
|
||||
key['publicKeyPem'] if key
|
||||
end
|
||||
|
||||
def url
|
||||
return if @json['url'].blank?
|
||||
|
||||
url_candidate = url_to_href(@json['url'], 'text/html')
|
||||
|
||||
if unsupported_uri_scheme?(url_candidate) || mismatching_origin?(url_candidate)
|
||||
nil
|
||||
else
|
||||
url_candidate
|
||||
end
|
||||
end
|
||||
|
||||
def property_values
|
||||
return unless @json['attachment'].is_a?(Array)
|
||||
as_array(@json['attachment']).select { |attachment| attachment['type'] == 'PropertyValue' }.map { |attachment| attachment.slice('name', 'value') }
|
||||
end
|
||||
|
||||
def mismatching_origin?(url)
|
||||
needle = Addressable::URI.parse(url).host
|
||||
haystack = Addressable::URI.parse(@uri).host
|
||||
|
||||
!haystack.casecmp(needle).zero?
|
||||
end
|
||||
|
||||
def outbox_total_items
|
||||
collection_total_items('outbox')
|
||||
end
|
||||
|
||||
def following_total_items
|
||||
collection_total_items('following')
|
||||
end
|
||||
|
||||
def followers_total_items
|
||||
collection_total_items('followers')
|
||||
end
|
||||
|
||||
def collection_total_items(type)
|
||||
return if @json[type].blank?
|
||||
return @collections[type] if @collections.key?(type)
|
||||
|
||||
collection = fetch_resource_without_id_validation(@json[type])
|
||||
|
||||
@collections[type] = collection.is_a?(Hash) && collection['totalItems'].present? && collection['totalItems'].is_a?(Numeric) ? collection['totalItems'] : nil
|
||||
rescue HTTP::Error, OpenSSL::SSL::SSLError
|
||||
@collections[type] = nil
|
||||
end
|
||||
|
||||
def moved_account
|
||||
account = ActivityPub::TagManager.instance.uri_to_resource(@json['movedTo'], Account)
|
||||
account ||= ActivityPub::FetchRemoteAccountService.new.call(@json['movedTo'], id: true, break_on_redirect: true)
|
||||
account
|
||||
end
|
||||
|
||||
def skip_download?
|
||||
@account.suspended? || domain_block&.reject_media?
|
||||
end
|
||||
|
||||
def auto_suspend?
|
||||
domain_block&.suspend?
|
||||
end
|
||||
|
||||
def auto_silence?
|
||||
domain_block&.silence?
|
||||
end
|
||||
|
||||
def domain_block
|
||||
return @domain_block if defined?(@domain_block)
|
||||
@domain_block = DomainBlock.find_by(domain: @domain)
|
||||
end
|
||||
|
||||
def key_changed?
|
||||
!@old_public_key.nil? && @old_public_key != @account.public_key
|
||||
end
|
||||
|
||||
def clear_tombstones!
|
||||
Tombstone.where(account_id: @account.id).delete_all
|
||||
end
|
||||
|
||||
def protocol_changed?
|
||||
!@old_protocol.nil? && @old_protocol != @account.protocol
|
||||
end
|
||||
|
||||
def lock_options
|
||||
{ redis: Redis.current, key: "process_account:#{@uri}" }
|
||||
end
|
||||
|
||||
def process_tags
|
||||
return if @json['tag'].blank?
|
||||
|
||||
as_array(@json['tag']).each do |tag|
|
||||
process_emoji tag if equals_or_includes?(tag['type'], 'Emoji')
|
||||
end
|
||||
end
|
||||
|
||||
def process_attachments
|
||||
return if @json['attachment'].blank?
|
||||
|
||||
previous_proofs = @account.identity_proofs.to_a
|
||||
current_proofs = []
|
||||
|
||||
as_array(@json['attachment']).each do |attachment|
|
||||
next unless equals_or_includes?(attachment['type'], 'IdentityProof')
|
||||
current_proofs << process_identity_proof(attachment)
|
||||
end
|
||||
|
||||
previous_proofs.each do |previous_proof|
|
||||
next if current_proofs.any? { |current_proof| current_proof.id == previous_proof.id }
|
||||
previous_proof.delete
|
||||
end
|
||||
end
|
||||
|
||||
def process_emoji(tag)
|
||||
return if skip_download?
|
||||
return if tag['name'].blank? || tag['icon'].blank? || tag['icon']['url'].blank?
|
||||
|
||||
shortcode = tag['name'].delete(':')
|
||||
image_url = tag['icon']['url']
|
||||
uri = tag['id']
|
||||
updated = tag['updated']
|
||||
emoji = CustomEmoji.find_by(shortcode: shortcode, domain: @account.domain)
|
||||
|
||||
return unless emoji.nil? || image_url != emoji.image_remote_url || (updated && updated >= emoji.updated_at)
|
||||
|
||||
emoji ||= CustomEmoji.new(domain: @account.domain, shortcode: shortcode, uri: uri)
|
||||
emoji.image_remote_url = image_url
|
||||
emoji.save
|
||||
end
|
||||
|
||||
def process_identity_proof(attachment)
|
||||
provider = attachment['signatureAlgorithm']
|
||||
provider_username = attachment['name']
|
||||
token = attachment['signatureValue']
|
||||
|
||||
@account.identity_proofs.where(provider: provider, provider_username: provider_username).find_or_create_by(provider: provider, provider_username: provider_username, token: token)
|
||||
end
|
||||
end
|
||||
53
app/services/activitypub/process_collection_service.rb
Normal file
53
app/services/activitypub/process_collection_service.rb
Normal file
@@ -0,0 +1,53 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ActivityPub::ProcessCollectionService < BaseService
|
||||
include JsonLdHelper
|
||||
|
||||
def call(body, account, **options)
|
||||
@account = account
|
||||
@json = Oj.load(body, mode: :strict)
|
||||
@options = options
|
||||
|
||||
return unless supported_context?
|
||||
return if different_actor? && verify_account!.nil?
|
||||
return if @account.suspended? || @account.local?
|
||||
|
||||
case @json['type']
|
||||
when 'Collection', 'CollectionPage'
|
||||
process_items @json['items']
|
||||
when 'OrderedCollection', 'OrderedCollectionPage'
|
||||
process_items @json['orderedItems']
|
||||
else
|
||||
process_items [@json]
|
||||
end
|
||||
rescue Oj::ParseError
|
||||
nil
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def different_actor?
|
||||
@json['actor'].present? && value_or_id(@json['actor']) != @account.uri
|
||||
end
|
||||
|
||||
def process_items(items)
|
||||
items.reverse_each.map { |item| process_item(item) }.compact
|
||||
end
|
||||
|
||||
def supported_context?
|
||||
super(@json)
|
||||
end
|
||||
|
||||
def process_item(item)
|
||||
activity = ActivityPub::Activity.factory(item, @account, @options)
|
||||
activity&.perform
|
||||
end
|
||||
|
||||
def verify_account!
|
||||
@options[:relayed_through_account] = @account
|
||||
@account = ActivityPub::LinkedDataSignature.new(@json).verify_account!
|
||||
rescue JSON::LD::JsonLdError => e
|
||||
Rails.logger.debug "Could not verify LD-Signature for #{value_or_id(@json['actor'])}: #{e.message}"
|
||||
nil
|
||||
end
|
||||
end
|
||||
60
app/services/activitypub/process_poll_service.rb
Normal file
60
app/services/activitypub/process_poll_service.rb
Normal file
@@ -0,0 +1,60 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ActivityPub::ProcessPollService < BaseService
|
||||
include JsonLdHelper
|
||||
|
||||
def call(poll, json)
|
||||
@json = json
|
||||
return unless expected_type?
|
||||
|
||||
previous_expires_at = poll.expires_at
|
||||
|
||||
expires_at = begin
|
||||
if @json['closed'].is_a?(String)
|
||||
@json['closed']
|
||||
elsif !@json['closed'].nil? && !@json['closed'].is_a?(FalseClass)
|
||||
Time.now.utc
|
||||
else
|
||||
@json['endTime']
|
||||
end
|
||||
end
|
||||
|
||||
items = begin
|
||||
if @json['anyOf'].is_a?(Array)
|
||||
@json['anyOf']
|
||||
else
|
||||
@json['oneOf']
|
||||
end
|
||||
end
|
||||
|
||||
latest_options = items.map { |item| item['name'].presence || item['content'] }
|
||||
|
||||
# If for some reasons the options were changed, it invalidates all previous
|
||||
# votes, so we need to remove them
|
||||
poll.votes.delete_all if latest_options != poll.options
|
||||
|
||||
begin
|
||||
poll.update!(
|
||||
last_fetched_at: Time.now.utc,
|
||||
expires_at: expires_at,
|
||||
options: latest_options,
|
||||
cached_tallies: items.map { |item| item.dig('replies', 'totalItems') || 0 }
|
||||
)
|
||||
rescue ActiveRecord::StaleObjectError
|
||||
poll.reload
|
||||
retry
|
||||
end
|
||||
|
||||
# If the poll had no expiration date set but now has, and people have voted,
|
||||
# schedule a notification.
|
||||
if previous_expires_at.nil? && poll.expires_at.present? && poll.votes.exists?
|
||||
PollExpirationNotifyWorker.perform_at(poll.expires_at + 5.minutes, poll.id)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def expected_type?
|
||||
equals_or_includes_any?(@json['type'], %w(Question))
|
||||
end
|
||||
end
|
||||
42
app/services/after_block_domain_from_account_service.rb
Normal file
42
app/services/after_block_domain_from_account_service.rb
Normal file
@@ -0,0 +1,42 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AfterBlockDomainFromAccountService < BaseService
|
||||
# This service does not create an AccountDomainBlock record,
|
||||
# it's meant to be called after such a record has been created
|
||||
# synchronously, to "clean up"
|
||||
def call(account, domain)
|
||||
@account = account
|
||||
@domain = domain
|
||||
|
||||
reject_existing_followers!
|
||||
reject_pending_follow_requests!
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def reject_existing_followers!
|
||||
@account.passive_relationships.where(account: Account.where(domain: @domain)).includes(:account).reorder(nil).find_each do |follow|
|
||||
reject_follow!(follow)
|
||||
end
|
||||
end
|
||||
|
||||
def reject_pending_follow_requests!
|
||||
FollowRequest.where(target_account: @account).where(account: Account.where(domain: @domain)).includes(:account).reorder(nil).find_each do |follow_request|
|
||||
reject_follow!(follow_request)
|
||||
end
|
||||
end
|
||||
|
||||
def reject_follow!(follow)
|
||||
follow.destroy
|
||||
|
||||
return unless follow.account.activitypub?
|
||||
|
||||
json = ActiveModelSerializers::SerializableResource.new(
|
||||
follow,
|
||||
serializer: ActivityPub::RejectFollowSerializer,
|
||||
adapter: ActivityPub::Adapter
|
||||
).to_json
|
||||
|
||||
ActivityPub::DeliveryWorker.perform_async(json, @account.id, follow.account.inbox_url)
|
||||
end
|
||||
end
|
||||
44
app/services/after_block_service.rb
Normal file
44
app/services/after_block_service.rb
Normal file
@@ -0,0 +1,44 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AfterBlockService < BaseService
|
||||
def call(account, target_account)
|
||||
clear_home_feed(account, target_account)
|
||||
clear_notifications(account, target_account)
|
||||
clear_conversations(account, target_account)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def clear_home_feed(account, target_account)
|
||||
FeedManager.instance.clear_from_timeline(account, target_account)
|
||||
end
|
||||
|
||||
def clear_conversations(account, target_account)
|
||||
AccountConversation.where(account: account)
|
||||
.where('? = ANY(participant_account_ids)', target_account.id)
|
||||
.in_batches
|
||||
.destroy_all
|
||||
end
|
||||
|
||||
def clear_notifications(account, target_account)
|
||||
Notification.where(account: account)
|
||||
.joins(:follow)
|
||||
.where(activity_type: 'Follow', follows: { account_id: target_account.id })
|
||||
.delete_all
|
||||
|
||||
Notification.where(account: account)
|
||||
.joins(mention: :status)
|
||||
.where(activity_type: 'Mention', statuses: { account_id: target_account.id })
|
||||
.delete_all
|
||||
|
||||
Notification.where(account: account)
|
||||
.joins(:favourite)
|
||||
.where(activity_type: 'Favourite', favourites: { account_id: target_account.id })
|
||||
.delete_all
|
||||
|
||||
Notification.where(account: account)
|
||||
.joins(:status)
|
||||
.where(activity_type: 'Status', statuses: { account_id: target_account.id })
|
||||
.delete_all
|
||||
end
|
||||
end
|
||||
23
app/services/app_sign_up_service.rb
Normal file
23
app/services/app_sign_up_service.rb
Normal file
@@ -0,0 +1,23 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AppSignUpService < BaseService
|
||||
def call(app, params)
|
||||
return unless allowed_registrations?
|
||||
|
||||
user_params = params.slice(:email, :password, :agreement, :locale)
|
||||
account_params = params.slice(:username)
|
||||
user = User.create!(user_params.merge(created_by_application: app, password_confirmation: user_params[:password], account_attributes: account_params))
|
||||
|
||||
Doorkeeper::AccessToken.create!(application: app,
|
||||
resource_owner_id: user.id,
|
||||
scopes: app.scopes,
|
||||
expires_in: Doorkeeper.configuration.access_token_expires_in,
|
||||
use_refresh_token: Doorkeeper.configuration.refresh_token_enabled?)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def allowed_registrations?
|
||||
Setting.registrations_mode != 'none' && !Rails.configuration.x.single_user_mode
|
||||
end
|
||||
end
|
||||
37
app/services/authorize_follow_service.rb
Normal file
37
app/services/authorize_follow_service.rb
Normal file
@@ -0,0 +1,37 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AuthorizeFollowService < BaseService
|
||||
def call(source_account, target_account, **options)
|
||||
if options[:skip_follow_request]
|
||||
follow_request = FollowRequest.new(account: source_account, target_account: target_account, uri: options[:follow_request_uri])
|
||||
else
|
||||
follow_request = FollowRequest.find_by!(account: source_account, target_account: target_account)
|
||||
follow_request.authorize!
|
||||
end
|
||||
|
||||
create_notification(follow_request) unless source_account.local?
|
||||
follow_request
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_notification(follow_request)
|
||||
if follow_request.account.ostatus?
|
||||
NotificationWorker.perform_async(build_xml(follow_request), follow_request.target_account_id, follow_request.account_id)
|
||||
elsif follow_request.account.activitypub?
|
||||
ActivityPub::DeliveryWorker.perform_async(build_json(follow_request), follow_request.target_account_id, follow_request.account.inbox_url)
|
||||
end
|
||||
end
|
||||
|
||||
def build_json(follow_request)
|
||||
ActiveModelSerializers::SerializableResource.new(
|
||||
follow_request,
|
||||
serializer: ActivityPub::AcceptFollowSerializer,
|
||||
adapter: ActivityPub::Adapter
|
||||
).to_json
|
||||
end
|
||||
|
||||
def build_xml(follow_request)
|
||||
OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.authorize_follow_request_salmon(follow_request))
|
||||
end
|
||||
end
|
||||
146
app/services/backup_service.rb
Normal file
146
app/services/backup_service.rb
Normal file
@@ -0,0 +1,146 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rubygems/package'
|
||||
|
||||
class BackupService < BaseService
|
||||
attr_reader :account, :backup, :collection
|
||||
|
||||
def call(backup)
|
||||
@backup = backup
|
||||
@account = backup.user.account
|
||||
|
||||
build_json!
|
||||
build_archive!
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build_json!
|
||||
@collection = serialize(collection_presenter, ActivityPub::CollectionSerializer)
|
||||
|
||||
account.statuses.with_includes.reorder(nil).find_in_batches do |statuses|
|
||||
statuses.each do |status|
|
||||
item = serialize(status, ActivityPub::ActivitySerializer)
|
||||
item.delete(:'@context')
|
||||
|
||||
unless item[:type] == 'Announce' || item[:object][:attachment].blank?
|
||||
item[:object][:attachment].each do |attachment|
|
||||
attachment[:url] = Addressable::URI.parse(attachment[:url]).path.gsub(/\A\/system\//, '')
|
||||
end
|
||||
end
|
||||
|
||||
@collection[:orderedItems] << item
|
||||
end
|
||||
|
||||
GC.start
|
||||
end
|
||||
end
|
||||
|
||||
def build_archive!
|
||||
tmp_file = Tempfile.new(%w(archive .tar.gz))
|
||||
|
||||
File.open(tmp_file, 'wb') do |file|
|
||||
Zlib::GzipWriter.wrap(file) do |gz|
|
||||
Gem::Package::TarWriter.new(gz) do |tar|
|
||||
dump_media_attachments!(tar)
|
||||
dump_outbox!(tar)
|
||||
dump_likes!(tar)
|
||||
dump_actor!(tar)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
archive_filename = ['archive', Time.now.utc.strftime('%Y%m%d%H%M%S'), SecureRandom.hex(16)].join('-') + '.tar.gz'
|
||||
|
||||
@backup.dump = ActionDispatch::Http::UploadedFile.new(tempfile: tmp_file, filename: archive_filename)
|
||||
@backup.processed = true
|
||||
@backup.save!
|
||||
ensure
|
||||
tmp_file.close
|
||||
tmp_file.unlink
|
||||
end
|
||||
|
||||
def dump_media_attachments!(tar)
|
||||
MediaAttachment.attached.where(account: account).reorder(nil).find_in_batches do |media_attachments|
|
||||
media_attachments.each do |m|
|
||||
download_to_tar(tar, m.file, m.file.path)
|
||||
end
|
||||
|
||||
GC.start
|
||||
end
|
||||
end
|
||||
|
||||
def dump_outbox!(tar)
|
||||
json = Oj.dump(collection)
|
||||
|
||||
tar.add_file_simple('outbox.json', 0o444, json.bytesize) do |io|
|
||||
io.write(json)
|
||||
end
|
||||
end
|
||||
|
||||
def dump_actor!(tar)
|
||||
actor = serialize(account, ActivityPub::ActorSerializer)
|
||||
|
||||
actor[:icon][:url] = 'avatar' + File.extname(actor[:icon][:url]) if actor[:icon]
|
||||
actor[:image][:url] = 'header' + File.extname(actor[:image][:url]) if actor[:image]
|
||||
actor[:outbox] = 'outbox.json'
|
||||
actor[:likes] = 'likes.json'
|
||||
|
||||
download_to_tar(tar, account.avatar, 'avatar' + File.extname(account.avatar.path)) if account.avatar.exists?
|
||||
download_to_tar(tar, account.header, 'header' + File.extname(account.header.path)) if account.header.exists?
|
||||
|
||||
json = Oj.dump(actor)
|
||||
|
||||
tar.add_file_simple('actor.json', 0o444, json.bytesize) do |io|
|
||||
io.write(json)
|
||||
end
|
||||
end
|
||||
|
||||
def dump_likes!(tar)
|
||||
collection = serialize(ActivityPub::CollectionPresenter.new(id: 'likes.json', type: :ordered, size: 0, items: []), ActivityPub::CollectionSerializer)
|
||||
|
||||
Status.reorder(nil).joins(:favourites).includes(:account).merge(account.favourites).find_in_batches do |statuses|
|
||||
statuses.each do |status|
|
||||
collection[:totalItems] += 1
|
||||
collection[:orderedItems] << ActivityPub::TagManager.instance.uri_for(status)
|
||||
end
|
||||
|
||||
GC.start
|
||||
end
|
||||
|
||||
json = Oj.dump(collection)
|
||||
|
||||
tar.add_file_simple('likes.json', 0o444, json.bytesize) do |io|
|
||||
io.write(json)
|
||||
end
|
||||
end
|
||||
|
||||
def collection_presenter
|
||||
ActivityPub::CollectionPresenter.new(
|
||||
id: 'outbox.json',
|
||||
type: :ordered,
|
||||
size: account.statuses_count,
|
||||
items: []
|
||||
)
|
||||
end
|
||||
|
||||
def serialize(object, serializer)
|
||||
ActiveModelSerializers::SerializableResource.new(
|
||||
object,
|
||||
serializer: serializer,
|
||||
adapter: ActivityPub::Adapter
|
||||
).as_json
|
||||
end
|
||||
|
||||
CHUNK_SIZE = 1.megabyte
|
||||
|
||||
def download_to_tar(tar, attachment, filename)
|
||||
adapter = Paperclip.io_adapters.for(attachment)
|
||||
|
||||
tar.add_file_simple(filename, 0o444, adapter.size) do |io|
|
||||
while (buffer = adapter.read(CHUNK_SIZE))
|
||||
io.write(buffer)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
8
app/services/base_service.rb
Normal file
8
app/services/base_service.rb
Normal file
@@ -0,0 +1,8 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class BaseService
|
||||
include ActionView::Helpers::TextHelper
|
||||
include ActionView::Helpers::SanitizeHelper
|
||||
|
||||
include RoutingHelper
|
||||
end
|
||||
120
app/services/batched_remove_status_service.rb
Normal file
120
app/services/batched_remove_status_service.rb
Normal file
@@ -0,0 +1,120 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class BatchedRemoveStatusService < BaseService
|
||||
include StreamEntryRenderer
|
||||
include Redisable
|
||||
|
||||
# Delete given statuses and reblogs of them
|
||||
# Dispatch PuSH updates of the deleted statuses, but only local ones
|
||||
# Dispatch Salmon deletes, unique per domain, of the deleted statuses, but only local ones
|
||||
# Remove statuses from home feeds
|
||||
# Push delete events to streaming API for home feeds and public feeds
|
||||
# @param [Status] statuses A preferably batched array of statuses
|
||||
# @param [Hash] options
|
||||
# @option [Boolean] :skip_side_effects
|
||||
def call(statuses, **options)
|
||||
statuses = Status.where(id: statuses.map(&:id)).includes(:account, :stream_entry).flat_map { |status| [status] + status.reblogs.includes(:account, :stream_entry).to_a }
|
||||
|
||||
@mentions = statuses.each_with_object({}) { |s, h| h[s.id] = s.active_mentions.includes(:account).to_a }
|
||||
@tags = statuses.each_with_object({}) { |s, h| h[s.id] = s.tags.pluck(:name) }
|
||||
|
||||
@stream_entry_batches = []
|
||||
@salmon_batches = []
|
||||
@json_payloads = statuses.each_with_object({}) { |s, h| h[s.id] = Oj.dump(event: :delete, payload: s.id.to_s) }
|
||||
@activity_xml = {}
|
||||
|
||||
# Ensure that rendered XML reflects destroyed state
|
||||
statuses.each do |status|
|
||||
status.mark_for_mass_destruction!
|
||||
status.destroy
|
||||
end
|
||||
|
||||
return if options[:skip_side_effects]
|
||||
|
||||
# Batch by source account
|
||||
statuses.group_by(&:account_id).each_value do |account_statuses|
|
||||
account = account_statuses.first.account
|
||||
|
||||
next unless account
|
||||
|
||||
unpush_from_home_timelines(account, account_statuses)
|
||||
unpush_from_list_timelines(account, account_statuses)
|
||||
|
||||
batch_stream_entries(account, account_statuses) if account.local?
|
||||
end
|
||||
|
||||
# Cannot be batched
|
||||
statuses.each do |status|
|
||||
unpush_from_public_timelines(status)
|
||||
batch_salmon_slaps(status) if status.local?
|
||||
end
|
||||
|
||||
Pubsubhubbub::RawDistributionWorker.push_bulk(@stream_entry_batches) { |batch| batch }
|
||||
NotificationWorker.push_bulk(@salmon_batches) { |batch| batch }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def batch_stream_entries(account, statuses)
|
||||
statuses.each do |status|
|
||||
@stream_entry_batches << [build_xml(status.stream_entry), account.id]
|
||||
end
|
||||
end
|
||||
|
||||
def unpush_from_home_timelines(account, statuses)
|
||||
recipients = account.followers_for_local_distribution.to_a
|
||||
|
||||
recipients << account if account.local?
|
||||
|
||||
recipients.each do |follower|
|
||||
statuses.each do |status|
|
||||
FeedManager.instance.unpush_from_home(follower, status)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def unpush_from_list_timelines(account, statuses)
|
||||
account.lists_for_local_distribution.select(:id, :account_id).each do |list|
|
||||
statuses.each do |status|
|
||||
FeedManager.instance.unpush_from_list(list, status)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def unpush_from_public_timelines(status)
|
||||
return unless status.public_visibility?
|
||||
|
||||
payload = @json_payloads[status.id]
|
||||
|
||||
redis.pipelined do
|
||||
redis.publish('timeline:public', payload)
|
||||
redis.publish('timeline:public:local', payload) if status.local?
|
||||
|
||||
if status.media_attachments.any?
|
||||
redis.publish('timeline:public:media', payload)
|
||||
redis.publish('timeline:public:local:media', payload) if status.local?
|
||||
end
|
||||
|
||||
@tags[status.id].each do |hashtag|
|
||||
redis.publish("timeline:hashtag:#{hashtag}", payload)
|
||||
redis.publish("timeline:hashtag:#{hashtag}:local", payload) if status.local?
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def batch_salmon_slaps(status)
|
||||
return if @mentions[status.id].empty?
|
||||
|
||||
recipients = @mentions[status.id].map(&:account).reject(&:local?).select(&:ostatus?).uniq(&:domain).map(&:id)
|
||||
|
||||
recipients.each do |recipient_id|
|
||||
@salmon_batches << [build_xml(status.stream_entry), status.account_id, recipient_id]
|
||||
end
|
||||
end
|
||||
|
||||
def build_xml(stream_entry)
|
||||
return @activity_xml[stream_entry.id] if @activity_xml.key?(stream_entry.id)
|
||||
|
||||
@activity_xml[stream_entry.id] = stream_entry_to_xml(stream_entry)
|
||||
end
|
||||
end
|
||||
89
app/services/block_domain_service.rb
Normal file
89
app/services/block_domain_service.rb
Normal file
@@ -0,0 +1,89 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class BlockDomainService < BaseService
|
||||
attr_reader :domain_block
|
||||
|
||||
def call(domain_block)
|
||||
@domain_block = domain_block
|
||||
process_domain_block!
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def process_domain_block!
|
||||
clear_media! if domain_block.reject_media?
|
||||
|
||||
if domain_block.silence?
|
||||
silence_accounts!
|
||||
elsif domain_block.suspend?
|
||||
suspend_accounts!
|
||||
end
|
||||
end
|
||||
|
||||
def invalidate_association_caches!
|
||||
# Normally, associated models of a status are immutable (except for accounts)
|
||||
# so they are aggressively cached. After updating the media attachments to no
|
||||
# longer point to a local file, we need to clear the cache to make those
|
||||
# changes appear in the API and UI
|
||||
@affected_status_ids.each { |id| Rails.cache.delete_matched("statuses/#{id}-*") }
|
||||
end
|
||||
|
||||
def silence_accounts!
|
||||
blocked_domain_accounts.without_silenced.in_batches.update_all(silenced_at: @domain_block.created_at)
|
||||
end
|
||||
|
||||
def clear_media!
|
||||
@affected_status_ids = []
|
||||
|
||||
clear_account_images!
|
||||
clear_account_attachments!
|
||||
clear_emojos!
|
||||
|
||||
invalidate_association_caches!
|
||||
end
|
||||
|
||||
def suspend_accounts!
|
||||
blocked_domain_accounts.without_suspended.reorder(nil).find_each do |account|
|
||||
UnsubscribeService.new.call(account) if account.subscribed?
|
||||
SuspendAccountService.new.call(account, suspended_at: @domain_block.created_at)
|
||||
end
|
||||
end
|
||||
|
||||
def clear_account_images!
|
||||
blocked_domain_accounts.reorder(nil).find_each do |account|
|
||||
account.avatar.destroy if account.avatar.exists?
|
||||
account.header.destroy if account.header.exists?
|
||||
account.save
|
||||
end
|
||||
end
|
||||
|
||||
def clear_account_attachments!
|
||||
media_from_blocked_domain.reorder(nil).find_each do |attachment|
|
||||
@affected_status_ids << attachment.status_id if attachment.status_id.present?
|
||||
|
||||
attachment.file.destroy if attachment.file.exists?
|
||||
attachment.type = :unknown
|
||||
attachment.save
|
||||
end
|
||||
end
|
||||
|
||||
def clear_emojos!
|
||||
emojis_from_blocked_domains.destroy_all
|
||||
end
|
||||
|
||||
def blocked_domain
|
||||
domain_block.domain
|
||||
end
|
||||
|
||||
def blocked_domain_accounts
|
||||
Account.where(domain: blocked_domain)
|
||||
end
|
||||
|
||||
def media_from_blocked_domain
|
||||
MediaAttachment.joins(:account).merge(blocked_domain_accounts).reorder(nil)
|
||||
end
|
||||
|
||||
def emojis_from_blocked_domains
|
||||
CustomEmoji.where(domain: blocked_domain)
|
||||
end
|
||||
end
|
||||
39
app/services/block_service.rb
Normal file
39
app/services/block_service.rb
Normal file
@@ -0,0 +1,39 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class BlockService < BaseService
|
||||
def call(account, target_account)
|
||||
return if account.id == target_account.id
|
||||
|
||||
UnfollowService.new.call(account, target_account) if account.following?(target_account)
|
||||
UnfollowService.new.call(target_account, account) if target_account.following?(account)
|
||||
RejectFollowService.new.call(account, target_account) if target_account.requested?(account)
|
||||
|
||||
block = account.block!(target_account)
|
||||
|
||||
BlockWorker.perform_async(account.id, target_account.id)
|
||||
create_notification(block) unless target_account.local?
|
||||
block
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_notification(block)
|
||||
if block.target_account.ostatus?
|
||||
NotificationWorker.perform_async(build_xml(block), block.account_id, block.target_account_id)
|
||||
elsif block.target_account.activitypub?
|
||||
ActivityPub::DeliveryWorker.perform_async(build_json(block), block.account_id, block.target_account.inbox_url)
|
||||
end
|
||||
end
|
||||
|
||||
def build_json(block)
|
||||
ActiveModelSerializers::SerializableResource.new(
|
||||
block,
|
||||
serializer: ActivityPub::BlockSerializer,
|
||||
adapter: ActivityPub::Adapter
|
||||
).to_json
|
||||
end
|
||||
|
||||
def build_xml(block)
|
||||
OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.block_salmon(block))
|
||||
end
|
||||
end
|
||||
46
app/services/bootstrap_timeline_service.rb
Normal file
46
app/services/bootstrap_timeline_service.rb
Normal file
@@ -0,0 +1,46 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class BootstrapTimelineService < BaseService
|
||||
def call(source_account)
|
||||
@source_account = source_account
|
||||
|
||||
autofollow_inviter!
|
||||
autofollow_bootstrap_timeline_accounts!
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def autofollow_inviter!
|
||||
return unless @source_account&.user&.invite&.autofollow?
|
||||
FollowService.new.call(@source_account, @source_account.user.invite.user.account)
|
||||
end
|
||||
|
||||
def autofollow_bootstrap_timeline_accounts!
|
||||
bootstrap_timeline_accounts.each do |target_account|
|
||||
FollowService.new.call(@source_account, target_account)
|
||||
end
|
||||
end
|
||||
|
||||
def bootstrap_timeline_accounts
|
||||
return @bootstrap_timeline_accounts if defined?(@bootstrap_timeline_accounts)
|
||||
|
||||
@bootstrap_timeline_accounts = bootstrap_timeline_accounts_usernames.empty? ? admin_accounts : local_unlocked_accounts(bootstrap_timeline_accounts_usernames)
|
||||
end
|
||||
|
||||
def bootstrap_timeline_accounts_usernames
|
||||
@bootstrap_timeline_accounts_usernames ||= (Setting.bootstrap_timeline_accounts || '').split(',').map { |str| str.strip.gsub(/\A@/, '') }.reject(&:blank?)
|
||||
end
|
||||
|
||||
def admin_accounts
|
||||
User.admins
|
||||
.includes(:account)
|
||||
.where(accounts: { locked: false })
|
||||
.map(&:account)
|
||||
end
|
||||
|
||||
def local_unlocked_accounts(usernames)
|
||||
Account.local
|
||||
.where(username: usernames)
|
||||
.where(locked: false)
|
||||
end
|
||||
end
|
||||
23
app/services/concerns/author_extractor.rb
Normal file
23
app/services/concerns/author_extractor.rb
Normal file
@@ -0,0 +1,23 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module AuthorExtractor
|
||||
def author_from_xml(xml, update_profile = true)
|
||||
return nil if xml.nil?
|
||||
|
||||
# Try <email> for acct
|
||||
acct = xml.at_xpath('./xmlns:author/xmlns:email', xmlns: OStatus::TagManager::XMLNS)&.content
|
||||
|
||||
# Try <name> + <uri>
|
||||
if acct.blank?
|
||||
username = xml.at_xpath('./xmlns:author/xmlns:name', xmlns: OStatus::TagManager::XMLNS)&.content
|
||||
uri = xml.at_xpath('./xmlns:author/xmlns:uri', xmlns: OStatus::TagManager::XMLNS)&.content
|
||||
|
||||
return nil if username.blank? || uri.blank?
|
||||
|
||||
domain = Addressable::URI.parse(uri).normalized_host
|
||||
acct = "#{username}@#{domain}"
|
||||
end
|
||||
|
||||
ResolveAccountService.new.call(acct, update_profile: update_profile)
|
||||
end
|
||||
end
|
||||
7
app/services/concerns/stream_entry_renderer.rb
Normal file
7
app/services/concerns/stream_entry_renderer.rb
Normal file
@@ -0,0 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module StreamEntryRenderer
|
||||
def stream_entry_to_xml(stream_entry)
|
||||
OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.entry(stream_entry, true))
|
||||
end
|
||||
end
|
||||
106
app/services/fan_out_on_write_service.rb
Normal file
106
app/services/fan_out_on_write_service.rb
Normal file
@@ -0,0 +1,106 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class FanOutOnWriteService < BaseService
|
||||
# Push a status into home and mentions feeds
|
||||
# @param [Status] status
|
||||
def call(status)
|
||||
raise GabSocial::RaceConditionError if status.visibility.nil?
|
||||
|
||||
render_anonymous_payload(status)
|
||||
|
||||
if status.direct_visibility?
|
||||
deliver_to_own_conversation(status)
|
||||
elsif status.limited_visibility?
|
||||
deliver_to_mentioned_followers(status)
|
||||
else
|
||||
deliver_to_self(status) if status.account.local?
|
||||
deliver_to_followers(status)
|
||||
deliver_to_lists(status)
|
||||
deliver_to_group(status)
|
||||
end
|
||||
|
||||
return if status.account.silenced? || !status.public_visibility? || status.reblog?
|
||||
|
||||
deliver_to_hashtags(status)
|
||||
|
||||
return if status.reply? && status.in_reply_to_account_id != status.account_id
|
||||
|
||||
deliver_to_public(status)
|
||||
deliver_to_media(status) if status.media_attachments.any?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def deliver_to_self(status)
|
||||
Rails.logger.debug "Delivering status #{status.id} to author"
|
||||
FeedManager.instance.push_to_home(status.account, status)
|
||||
end
|
||||
|
||||
def deliver_to_followers(status)
|
||||
Rails.logger.debug "Delivering status #{status.id} to followers"
|
||||
|
||||
status.account.followers_for_local_distribution.select(:id).reorder(nil).find_in_batches do |followers|
|
||||
FeedInsertWorker.push_bulk(followers) do |follower|
|
||||
[status.id, follower.id, :home]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def deliver_to_lists(status)
|
||||
Rails.logger.debug "Delivering status #{status.id} to lists"
|
||||
|
||||
status.account.lists_for_local_distribution.select(:id).reorder(nil).find_in_batches do |lists|
|
||||
FeedInsertWorker.push_bulk(lists) do |list|
|
||||
[status.id, list.id, :list]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def deliver_to_group(status)
|
||||
return if status.group_id.nil?
|
||||
|
||||
Rails.logger.debug "Delivering status #{status.id} to group"
|
||||
|
||||
Redis.current.publish("timeline:group:#{status.group_id}", @payload)
|
||||
end
|
||||
|
||||
def deliver_to_mentioned_followers(status)
|
||||
Rails.logger.debug "Delivering status #{status.id} to limited followers"
|
||||
|
||||
FeedInsertWorker.push_bulk(status.mentions.includes(:account).map(&:account).select { |mentioned_account| mentioned_account.local? && mentioned_account.following?(status.account) }) do |follower|
|
||||
[status.id, follower.id, :home]
|
||||
end
|
||||
end
|
||||
|
||||
def render_anonymous_payload(status)
|
||||
@payload = InlineRenderer.render(status, nil, :status)
|
||||
@payload = Oj.dump(event: :update, payload: @payload)
|
||||
end
|
||||
|
||||
def deliver_to_hashtags(status)
|
||||
Rails.logger.debug "Delivering status #{status.id} to hashtags"
|
||||
|
||||
status.tags.pluck(:name).each do |hashtag|
|
||||
Redis.current.publish("timeline:hashtag:#{hashtag}", @payload)
|
||||
Redis.current.publish("timeline:hashtag:#{hashtag}:local", @payload) if status.local?
|
||||
end
|
||||
end
|
||||
|
||||
def deliver_to_public(status)
|
||||
Rails.logger.debug "Delivering status #{status.id} to public timeline"
|
||||
|
||||
Redis.current.publish('timeline:public', @payload)
|
||||
Redis.current.publish('timeline:public:local', @payload) if status.local?
|
||||
end
|
||||
|
||||
def deliver_to_media(status)
|
||||
Rails.logger.debug "Delivering status #{status.id} to media timeline"
|
||||
|
||||
Redis.current.publish('timeline:public:media', @payload)
|
||||
Redis.current.publish('timeline:public:local:media', @payload) if status.local?
|
||||
end
|
||||
|
||||
def deliver_to_own_conversation(status)
|
||||
AccountConversation.add_status(status.account, status)
|
||||
end
|
||||
end
|
||||
56
app/services/favourite_service.rb
Normal file
56
app/services/favourite_service.rb
Normal file
@@ -0,0 +1,56 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class FavouriteService < BaseService
|
||||
include Authorization
|
||||
|
||||
# Favourite a status and notify remote user
|
||||
# @param [Account] account
|
||||
# @param [Status] status
|
||||
# @return [Favourite]
|
||||
def call(account, status)
|
||||
authorize_with account, status, :favourite?
|
||||
|
||||
favourite = Favourite.find_by(account: account, status: status)
|
||||
|
||||
return favourite unless favourite.nil?
|
||||
|
||||
favourite = Favourite.create!(account: account, status: status)
|
||||
|
||||
create_notification(favourite)
|
||||
bump_potential_friendship(account, status)
|
||||
|
||||
favourite
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_notification(favourite)
|
||||
status = favourite.status
|
||||
|
||||
if status.account.local?
|
||||
NotifyService.new.call(status.account, favourite)
|
||||
elsif status.account.ostatus?
|
||||
NotificationWorker.perform_async(build_xml(favourite), favourite.account_id, status.account_id)
|
||||
elsif status.account.activitypub?
|
||||
ActivityPub::DeliveryWorker.perform_async(build_json(favourite), favourite.account_id, status.account.inbox_url)
|
||||
end
|
||||
end
|
||||
|
||||
def bump_potential_friendship(account, status)
|
||||
ActivityTracker.increment('activity:interactions')
|
||||
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,
|
||||
serializer: ActivityPub::LikeSerializer,
|
||||
adapter: ActivityPub::Adapter
|
||||
).as_json).sign!(favourite.account))
|
||||
end
|
||||
|
||||
def build_xml(favourite)
|
||||
OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.favourite_salmon(favourite))
|
||||
end
|
||||
end
|
||||
93
app/services/fetch_atom_service.rb
Normal file
93
app/services/fetch_atom_service.rb
Normal file
@@ -0,0 +1,93 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class FetchAtomService < BaseService
|
||||
include JsonLdHelper
|
||||
|
||||
def call(url)
|
||||
return if url.blank?
|
||||
|
||||
result = process(url)
|
||||
|
||||
# retry without ActivityPub
|
||||
result ||= process(url) if @unsupported_activity
|
||||
|
||||
result
|
||||
rescue OpenSSL::SSL::SSLError => e
|
||||
Rails.logger.debug "SSL error: #{e}"
|
||||
nil
|
||||
rescue HTTP::ConnectionError => e
|
||||
Rails.logger.debug "HTTP ConnectionError: #{e}"
|
||||
nil
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def process(url, terminal = false)
|
||||
@url = url
|
||||
perform_request { |response| process_response(response, terminal) }
|
||||
end
|
||||
|
||||
def perform_request(&block)
|
||||
accept = 'text/html'
|
||||
accept = 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams", application/atom+xml, ' + accept unless @unsupported_activity
|
||||
|
||||
Request.new(:get, @url).add_headers('Accept' => accept).perform(&block)
|
||||
end
|
||||
|
||||
def process_response(response, terminal = false)
|
||||
return nil if response.code != 200
|
||||
|
||||
if response.mime_type == 'application/atom+xml'
|
||||
[@url, { prefetched_body: response.body_with_limit }, :ostatus]
|
||||
elsif ['application/activity+json', 'application/ld+json'].include?(response.mime_type)
|
||||
body = response.body_with_limit
|
||||
json = body_to_json(body)
|
||||
if supported_context?(json) && equals_or_includes_any?(json['type'], ActivityPub::FetchRemoteAccountService::SUPPORTED_TYPES) && json['inbox'].present?
|
||||
[json['id'], { prefetched_body: body, id: true }, :activitypub]
|
||||
elsif supported_context?(json) && expected_type?(json)
|
||||
[json['id'], { prefetched_body: body, id: true }, :activitypub]
|
||||
else
|
||||
@unsupported_activity = true
|
||||
nil
|
||||
end
|
||||
elsif !terminal
|
||||
link_header = response['Link'] && parse_link_header(response)
|
||||
|
||||
if link_header&.find_link(%w(rel alternate))
|
||||
process_link_headers(link_header)
|
||||
elsif response.mime_type == 'text/html'
|
||||
process_html(response)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def expected_type?(json)
|
||||
equals_or_includes_any?(json['type'], ActivityPub::Activity::Create::SUPPORTED_TYPES + ActivityPub::Activity::Create::CONVERTED_TYPES)
|
||||
end
|
||||
|
||||
def process_html(response)
|
||||
page = Nokogiri::HTML(response.body_with_limit)
|
||||
|
||||
json_link = page.xpath('//link[@rel="alternate"]').find { |link| ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].include?(link['type']) }
|
||||
atom_link = page.xpath('//link[@rel="alternate"]').find { |link| link['type'] == 'application/atom+xml' }
|
||||
|
||||
result ||= process(json_link['href'], terminal: true) unless json_link.nil? || @unsupported_activity
|
||||
result ||= process(atom_link['href'], terminal: true) unless atom_link.nil?
|
||||
|
||||
result
|
||||
end
|
||||
|
||||
def process_link_headers(link_header)
|
||||
json_link = link_header.find_link(%w(rel alternate), %w(type application/activity+json)) || link_header.find_link(%w(rel alternate), ['type', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'])
|
||||
atom_link = link_header.find_link(%w(rel alternate), %w(type application/atom+xml))
|
||||
|
||||
result ||= process(json_link.href, terminal: true) unless json_link.nil? || @unsupported_activity
|
||||
result ||= process(atom_link.href, terminal: true) unless atom_link.nil?
|
||||
|
||||
result
|
||||
end
|
||||
|
||||
def parse_link_header(response)
|
||||
LinkHeader.parse(response['Link'].is_a?(Array) ? response['Link'].first : response['Link'])
|
||||
end
|
||||
end
|
||||
174
app/services/fetch_link_card_service.rb
Normal file
174
app/services/fetch_link_card_service.rb
Normal file
@@ -0,0 +1,174 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class FetchLinkCardService < BaseService
|
||||
URL_PATTERN = %r{
|
||||
( # $1 URL
|
||||
(https?:\/\/) # $2 Protocol (required)
|
||||
(#{Twitter::Regex[:valid_domain]}) # $3 Domain(s)
|
||||
(?::(#{Twitter::Regex[:valid_port_number]}))? # $4 Port number (optional)
|
||||
(/#{Twitter::Regex[:valid_url_path]}*)? # $5 URL Path and anchor
|
||||
(\?#{Twitter::Regex[:valid_url_query_chars]}*#{Twitter::Regex[:valid_url_query_ending_chars]})? # $6 Query String
|
||||
)
|
||||
}iox
|
||||
|
||||
def call(status)
|
||||
@status = status
|
||||
@url = parse_urls
|
||||
|
||||
return if @url.nil? || @status.preview_cards.any?
|
||||
|
||||
@url = @url.to_s
|
||||
|
||||
RedisLock.acquire(lock_options) do |lock|
|
||||
if lock.acquired?
|
||||
@card = PreviewCard.find_by(url: @url)
|
||||
process_url if @card.nil? || @card.updated_at <= 2.weeks.ago
|
||||
else
|
||||
raise GabSocial::RaceConditionError
|
||||
end
|
||||
end
|
||||
|
||||
attach_card if @card&.persisted?
|
||||
rescue HTTP::Error, Addressable::URI::InvalidURIError, GabSocial::HostValidationError, GabSocial::LengthValidationError => e
|
||||
Rails.logger.debug "Error fetching link #{@url}: #{e}"
|
||||
nil
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def process_url
|
||||
@card ||= PreviewCard.new(url: @url)
|
||||
|
||||
failed = Request.new(:head, @url).perform do |res|
|
||||
res.code != 405 && res.code != 501 && (res.code != 200 || res.mime_type != 'text/html')
|
||||
end
|
||||
|
||||
return if failed
|
||||
|
||||
Request.new(:get, @url).perform do |res|
|
||||
if res.code == 200 && res.mime_type == 'text/html'
|
||||
@html = res.body_with_limit
|
||||
@html_charset = res.charset
|
||||
else
|
||||
@html = nil
|
||||
@html_charset = nil
|
||||
end
|
||||
end
|
||||
|
||||
return if @html.nil?
|
||||
|
||||
attempt_oembed || attempt_opengraph
|
||||
end
|
||||
|
||||
def attach_card
|
||||
@status.preview_cards << @card
|
||||
Rails.cache.delete(@status)
|
||||
end
|
||||
|
||||
def parse_urls
|
||||
if @status.local?
|
||||
urls = @status.text.scan(URL_PATTERN).map { |array| Addressable::URI.parse(array[0]).normalize }
|
||||
else
|
||||
html = Nokogiri::HTML(@status.text)
|
||||
links = html.css('a')
|
||||
urls = links.map { |a| Addressable::URI.parse(a['href']).normalize unless skip_link?(a) }.compact
|
||||
end
|
||||
|
||||
urls.reject { |uri| bad_url?(uri) }.first
|
||||
end
|
||||
|
||||
def bad_url?(uri)
|
||||
# Avoid local instance URLs and invalid URLs
|
||||
uri.host.blank? || TagManager.instance.local_url?(uri.to_s) || !%w(http https).include?(uri.scheme)
|
||||
end
|
||||
|
||||
def mention_link?(a)
|
||||
@status.mentions.any? do |mention|
|
||||
a['href'] == TagManager.instance.url_for(mention.account)
|
||||
end
|
||||
end
|
||||
|
||||
def skip_link?(a)
|
||||
# Avoid links for hashtags and mentions (microformats)
|
||||
a['rel']&.include?('tag') || a['class']&.include?('u-url') || mention_link?(a)
|
||||
end
|
||||
|
||||
def attempt_oembed
|
||||
service = FetchOEmbedService.new
|
||||
embed = service.call(@url, html: @html)
|
||||
url = Addressable::URI.parse(service.endpoint_url)
|
||||
|
||||
return false if embed.nil?
|
||||
|
||||
@card.type = embed[:type]
|
||||
@card.title = embed[:title] || ''
|
||||
@card.author_name = embed[:author_name] || ''
|
||||
@card.author_url = embed[:author_url].present? ? (url + embed[:author_url]).to_s : ''
|
||||
@card.provider_name = embed[:provider_name] || ''
|
||||
@card.provider_url = embed[:provider_url].present? ? (url + embed[:provider_url]).to_s : ''
|
||||
@card.width = 0
|
||||
@card.height = 0
|
||||
|
||||
case @card.type
|
||||
when 'link'
|
||||
@card.image_remote_url = (url + embed[:thumbnail_url]).to_s if embed[:thumbnail_url].present?
|
||||
when 'photo'
|
||||
return false if embed[:url].blank?
|
||||
|
||||
@card.embed_url = (url + embed[:url]).to_s
|
||||
@card.image_remote_url = (url + embed[:url]).to_s
|
||||
@card.width = embed[:width].presence || 0
|
||||
@card.height = embed[:height].presence || 0
|
||||
when 'video'
|
||||
@card.width = embed[:width].presence || 0
|
||||
@card.height = embed[:height].presence || 0
|
||||
@card.html = Formatter.instance.sanitize(embed[:html], Sanitize::Config::GABSOCIAL_OEMBED)
|
||||
@card.image_remote_url = (url + embed[:thumbnail_url]).to_s if embed[:thumbnail_url].present?
|
||||
when 'rich'
|
||||
# Most providers rely on <script> tags, which is a no-no
|
||||
return false
|
||||
end
|
||||
|
||||
@card.save_with_optional_image!
|
||||
end
|
||||
|
||||
def attempt_opengraph
|
||||
detector = CharlockHolmes::EncodingDetector.new
|
||||
detector.strip_tags = true
|
||||
|
||||
guess = detector.detect(@html, @html_charset)
|
||||
encoding = guess&.fetch(:confidence, 0).to_i > 60 ? guess&.fetch(:encoding, nil) : nil
|
||||
page = Nokogiri::HTML(@html, nil, encoding)
|
||||
player_url = meta_property(page, 'twitter:player')
|
||||
|
||||
if player_url && !bad_url?(Addressable::URI.parse(player_url))
|
||||
@card.type = :video
|
||||
@card.width = meta_property(page, 'twitter:player:width') || 0
|
||||
@card.height = meta_property(page, 'twitter:player:height') || 0
|
||||
@card.html = content_tag(:iframe, nil, src: player_url,
|
||||
width: @card.width,
|
||||
height: @card.height,
|
||||
allowtransparency: 'true',
|
||||
scrolling: 'no',
|
||||
frameborder: '0')
|
||||
else
|
||||
@card.type = :link
|
||||
end
|
||||
|
||||
@card.title = meta_property(page, 'og:title').presence || page.at_xpath('//title')&.content || ''
|
||||
@card.description = meta_property(page, 'og:description').presence || meta_property(page, 'description') || ''
|
||||
@card.image_remote_url = (Addressable::URI.parse(@url) + meta_property(page, 'og:image')).to_s if meta_property(page, 'og:image')
|
||||
|
||||
return if @card.title.blank? && @card.html.blank?
|
||||
|
||||
@card.save_with_optional_image!
|
||||
end
|
||||
|
||||
def meta_property(page, property)
|
||||
page.at_xpath("//meta[contains(concat(' ', normalize-space(@property), ' '), ' #{property} ')]")&.attribute('content')&.value || page.at_xpath("//meta[@name=\"#{property}\"]")&.attribute('content')&.value
|
||||
end
|
||||
|
||||
def lock_options
|
||||
{ redis: Redis.current, key: "fetch:#{@url}" }
|
||||
end
|
||||
end
|
||||
71
app/services/fetch_oembed_service.rb
Normal file
71
app/services/fetch_oembed_service.rb
Normal file
@@ -0,0 +1,71 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class FetchOEmbedService
|
||||
attr_reader :url, :options, :format, :endpoint_url
|
||||
|
||||
def call(url, options = {})
|
||||
@url = url
|
||||
@options = options
|
||||
|
||||
discover_endpoint!
|
||||
fetch!
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def discover_endpoint!
|
||||
return if html.nil?
|
||||
|
||||
@format = @options[:format]
|
||||
page = Nokogiri::HTML(html)
|
||||
|
||||
if @format.nil? || @format == :json
|
||||
@endpoint_url ||= page.at_xpath('//link[@type="application/json+oembed"]')&.attribute('href')&.value
|
||||
@format ||= :json if @endpoint_url
|
||||
end
|
||||
|
||||
if @format.nil? || @format == :xml
|
||||
@endpoint_url ||= page.at_xpath('//link[@type="text/xml+oembed"]')&.attribute('href')&.value
|
||||
@format ||= :xml if @endpoint_url
|
||||
end
|
||||
|
||||
return if @endpoint_url.blank?
|
||||
|
||||
@endpoint_url = (Addressable::URI.parse(@url) + @endpoint_url).to_s
|
||||
rescue Addressable::URI::InvalidURIError
|
||||
@endpoint_url = nil
|
||||
end
|
||||
|
||||
def fetch!
|
||||
return if @endpoint_url.blank?
|
||||
|
||||
body = Request.new(:get, @endpoint_url).perform do |res|
|
||||
res.code != 200 ? nil : res.body_with_limit
|
||||
end
|
||||
|
||||
validate(parse_for_format(body)) if body.present?
|
||||
rescue Oj::ParseError, Ox::ParseError
|
||||
nil
|
||||
end
|
||||
|
||||
def parse_for_format(body)
|
||||
case @format
|
||||
when :json
|
||||
Oj.load(body, mode: :strict)&.with_indifferent_access
|
||||
when :xml
|
||||
Ox.load(body, mode: :hash_no_attrs)&.with_indifferent_access&.dig(:oembed)
|
||||
end
|
||||
end
|
||||
|
||||
def validate(oembed)
|
||||
oembed if oembed[:version] == '1.0' && oembed[:type].present?
|
||||
end
|
||||
|
||||
def html
|
||||
return @html if defined?(@html)
|
||||
|
||||
@html = @options[:html] || Request.new(:get, @url).perform do |res|
|
||||
res.code != 200 || res.mime_type != 'text/html' ? nil : res.body_with_limit
|
||||
end
|
||||
end
|
||||
end
|
||||
45
app/services/fetch_remote_account_service.rb
Normal file
45
app/services/fetch_remote_account_service.rb
Normal file
@@ -0,0 +1,45 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class FetchRemoteAccountService < BaseService
|
||||
include AuthorExtractor
|
||||
|
||||
def call(url, prefetched_body = nil, protocol = :ostatus)
|
||||
if prefetched_body.nil?
|
||||
resource_url, resource_options, protocol = FetchAtomService.new.call(url)
|
||||
else
|
||||
resource_url = url
|
||||
resource_options = { prefetched_body: prefetched_body }
|
||||
end
|
||||
|
||||
case protocol
|
||||
when :ostatus
|
||||
process_atom(resource_url, **resource_options)
|
||||
when :activitypub
|
||||
ActivityPub::FetchRemoteAccountService.new.call(resource_url, **resource_options)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def process_atom(url, prefetched_body:)
|
||||
xml = Nokogiri::XML(prefetched_body)
|
||||
xml.encoding = 'utf-8'
|
||||
|
||||
account = author_from_xml(xml.at_xpath('/xmlns:feed', xmlns: OStatus::TagManager::XMLNS), false)
|
||||
|
||||
UpdateRemoteProfileService.new.call(xml, account) if account.present? && trusted_domain?(url, account)
|
||||
|
||||
account
|
||||
rescue TypeError
|
||||
Rails.logger.debug "Unparseable URL given: #{url}"
|
||||
nil
|
||||
rescue Nokogiri::XML::XPath::SyntaxError
|
||||
Rails.logger.debug 'Invalid XML or missing namespace'
|
||||
nil
|
||||
end
|
||||
|
||||
def trusted_domain?(url, account)
|
||||
domain = Addressable::URI.parse(url).normalized_host
|
||||
domain.casecmp(account.domain).zero? || domain.casecmp(Addressable::URI.parse(account.remote_url.presence || account.uri).normalized_host).zero?
|
||||
end
|
||||
end
|
||||
45
app/services/fetch_remote_status_service.rb
Normal file
45
app/services/fetch_remote_status_service.rb
Normal file
@@ -0,0 +1,45 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class FetchRemoteStatusService < BaseService
|
||||
include AuthorExtractor
|
||||
|
||||
def call(url, prefetched_body = nil, protocol = :ostatus)
|
||||
if prefetched_body.nil?
|
||||
resource_url, resource_options, protocol = FetchAtomService.new.call(url)
|
||||
else
|
||||
resource_url = url
|
||||
resource_options = { prefetched_body: prefetched_body }
|
||||
end
|
||||
|
||||
case protocol
|
||||
when :ostatus
|
||||
process_atom(resource_url, **resource_options)
|
||||
when :activitypub
|
||||
ActivityPub::FetchRemoteStatusService.new.call(resource_url, **resource_options)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def process_atom(url, prefetched_body:)
|
||||
Rails.logger.debug "Processing Atom for remote status at #{url}"
|
||||
|
||||
xml = Nokogiri::XML(prefetched_body)
|
||||
xml.encoding = 'utf-8'
|
||||
|
||||
account = author_from_xml(xml.at_xpath('/xmlns:entry', xmlns: OStatus::TagManager::XMLNS))
|
||||
domain = Addressable::URI.parse(url).normalized_host
|
||||
|
||||
return nil unless !account.nil? && confirmed_domain?(domain, account)
|
||||
|
||||
statuses = ProcessFeedService.new.call(prefetched_body, account)
|
||||
statuses.first
|
||||
rescue Nokogiri::XML::XPath::SyntaxError
|
||||
Rails.logger.debug 'Invalid XML or missing namespace'
|
||||
nil
|
||||
end
|
||||
|
||||
def confirmed_domain?(domain, account)
|
||||
account.domain.nil? || domain.casecmp(account.domain).zero? || domain.casecmp(Addressable::URI.parse(account.remote_url.presence || account.uri).normalized_host).zero?
|
||||
end
|
||||
end
|
||||
87
app/services/follow_service.rb
Normal file
87
app/services/follow_service.rb
Normal file
@@ -0,0 +1,87 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class FollowService < BaseService
|
||||
include Redisable
|
||||
|
||||
# Follow a remote user, notify remote user about the follow
|
||||
# @param [Account] source_account From which to follow
|
||||
# @param [String, Account] uri User URI to follow in the form of username@domain (or account record)
|
||||
# @param [true, false, nil] reblogs Whether or not to show reblogs, defaults to true
|
||||
def call(source_account, target_account, reblogs: nil)
|
||||
reblogs = true if reblogs.nil?
|
||||
target_account = ResolveAccountService.new.call(target_account, skip_webfinger: true)
|
||||
|
||||
raise ActiveRecord::RecordNotFound if target_account.nil? || target_account.id == source_account.id || target_account.suspended?
|
||||
raise GabSocial::NotPermittedError if target_account.blocking?(source_account) || source_account.blocking?(target_account) || target_account.moved?
|
||||
|
||||
if source_account.following?(target_account)
|
||||
# We're already following this account, but we'll call follow! again to
|
||||
# make sure the reblogs status is set correctly.
|
||||
source_account.follow!(target_account, reblogs: reblogs)
|
||||
return
|
||||
elsif source_account.requested?(target_account)
|
||||
# This isn't managed by a method in AccountInteractions, so we modify it
|
||||
# ourselves if necessary.
|
||||
req = source_account.follow_requests.find_by(target_account: target_account)
|
||||
req.update!(show_reblogs: reblogs)
|
||||
return
|
||||
end
|
||||
|
||||
ActivityTracker.increment('activity:interactions')
|
||||
|
||||
if target_account.locked? || target_account.activitypub?
|
||||
request_follow(source_account, target_account, reblogs: reblogs)
|
||||
else
|
||||
direct_follow(source_account, target_account, reblogs: reblogs)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def request_follow(source_account, target_account, reblogs: true)
|
||||
follow_request = FollowRequest.create!(account: source_account, target_account: target_account, show_reblogs: reblogs)
|
||||
|
||||
if target_account.local?
|
||||
LocalNotificationWorker.perform_async(target_account.id, follow_request.id, follow_request.class.name)
|
||||
elsif target_account.ostatus?
|
||||
NotificationWorker.perform_async(build_follow_request_xml(follow_request), source_account.id, target_account.id)
|
||||
AfterRemoteFollowRequestWorker.perform_async(follow_request.id)
|
||||
elsif target_account.activitypub?
|
||||
ActivityPub::DeliveryWorker.perform_async(build_json(follow_request), source_account.id, target_account.inbox_url)
|
||||
end
|
||||
|
||||
follow_request
|
||||
end
|
||||
|
||||
def direct_follow(source_account, target_account, reblogs: true)
|
||||
follow = source_account.follow!(target_account, reblogs: reblogs)
|
||||
|
||||
if target_account.local?
|
||||
LocalNotificationWorker.perform_async(target_account.id, follow.id, follow.class.name)
|
||||
else
|
||||
Pubsubhubbub::SubscribeWorker.perform_async(target_account.id) unless target_account.subscribed?
|
||||
NotificationWorker.perform_async(build_follow_xml(follow), source_account.id, target_account.id)
|
||||
AfterRemoteFollowWorker.perform_async(follow.id)
|
||||
end
|
||||
|
||||
MergeWorker.perform_async(target_account.id, source_account.id)
|
||||
|
||||
follow
|
||||
end
|
||||
|
||||
def build_follow_request_xml(follow_request)
|
||||
OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.follow_request_salmon(follow_request))
|
||||
end
|
||||
|
||||
def build_follow_xml(follow)
|
||||
OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.follow_salmon(follow))
|
||||
end
|
||||
|
||||
def build_json(follow_request)
|
||||
ActiveModelSerializers::SerializableResource.new(
|
||||
follow_request,
|
||||
serializer: ActivityPub::FollowSerializer,
|
||||
adapter: ActivityPub::Adapter
|
||||
).to_json
|
||||
end
|
||||
end
|
||||
18
app/services/group_approve_status_service.rb
Normal file
18
app/services/group_approve_status_service.rb
Normal file
@@ -0,0 +1,18 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class GroupApproveStatusService < BaseService
|
||||
def call(account, group, status)
|
||||
@account = account
|
||||
@group = group
|
||||
@status = status
|
||||
|
||||
raise GabSocial::Error, "Record not found." if @group.id != @status.group_id
|
||||
|
||||
# Update status
|
||||
# @status.awaiting_moderation = false
|
||||
# @status.save!
|
||||
|
||||
# Grant write permissions
|
||||
GroupAccount.where(group: @group, account_id: @status.account_id).update write_permissions: true
|
||||
end
|
||||
end
|
||||
8
app/services/group_query_service.rb
Normal file
8
app/services/group_query_service.rb
Normal file
@@ -0,0 +1,8 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class GroupQueryService < BaseService
|
||||
def call(group)
|
||||
Status.distinct
|
||||
.as_group_timeline(group)
|
||||
end
|
||||
end
|
||||
16
app/services/group_unlink_status_service.rb
Normal file
16
app/services/group_unlink_status_service.rb
Normal file
@@ -0,0 +1,16 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class GroupUnlinkStatusService < BaseService
|
||||
def call(account, group, status)
|
||||
@account = account
|
||||
@group = group
|
||||
@status = status
|
||||
|
||||
raise GabSocial::Error, "Record not found." if @group.id != @status.group_id
|
||||
|
||||
# Update status
|
||||
@status.group_id = nil
|
||||
@status.thread = nil
|
||||
@status.save!(validate: false)
|
||||
end
|
||||
end
|
||||
20
app/services/hashtag_query_service.rb
Normal file
20
app/services/hashtag_query_service.rb
Normal file
@@ -0,0 +1,20 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class HashtagQueryService < BaseService
|
||||
def call(tag, params, account = nil, local = false)
|
||||
tags = tags_for(Array(tag.name) | Array(params[:any])).pluck(:id)
|
||||
all = tags_for(params[:all])
|
||||
none = tags_for(params[:none])
|
||||
|
||||
Status.distinct
|
||||
.as_tag_timeline(tags, account, local)
|
||||
.tagged_with_all(all)
|
||||
.tagged_with_none(none)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def tags_for(tags)
|
||||
Tag.where(name: tags.map(&:downcase)) if tags.presence
|
||||
end
|
||||
end
|
||||
101
app/services/import_service.rb
Normal file
101
app/services/import_service.rb
Normal file
@@ -0,0 +1,101 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'csv'
|
||||
|
||||
class ImportService < BaseService
|
||||
ROWS_PROCESSING_LIMIT = 20_000
|
||||
|
||||
def call(import)
|
||||
@import = import
|
||||
@account = @import.account
|
||||
|
||||
case @import.type
|
||||
when 'following'
|
||||
import_follows!
|
||||
when 'blocking'
|
||||
import_blocks!
|
||||
when 'muting'
|
||||
import_mutes!
|
||||
when 'domain_blocking'
|
||||
import_domain_blocks!
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def import_follows!
|
||||
parse_import_data!(['Account address'])
|
||||
import_relationships!('follow', 'unfollow', @account.following, follow_limit, reblogs: 'Show reposts')
|
||||
end
|
||||
|
||||
def import_blocks!
|
||||
parse_import_data!(['Account address'])
|
||||
import_relationships!('block', 'unblock', @account.blocking, ROWS_PROCESSING_LIMIT)
|
||||
end
|
||||
|
||||
def import_mutes!
|
||||
parse_import_data!(['Account address'])
|
||||
import_relationships!('mute', 'unmute', @account.muting, ROWS_PROCESSING_LIMIT, notifications: 'Hide notifications')
|
||||
end
|
||||
|
||||
def import_domain_blocks!
|
||||
parse_import_data!(['#domain'])
|
||||
items = @data.take(ROWS_PROCESSING_LIMIT).map { |row| row['#domain'].strip }
|
||||
|
||||
if @import.overwrite?
|
||||
presence_hash = items.each_with_object({}) { |id, mapping| mapping[id] = true }
|
||||
|
||||
@account.domain_blocks.find_each do |domain_block|
|
||||
if presence_hash[domain_block.domain]
|
||||
items.delete(domain_block.domain)
|
||||
else
|
||||
@account.unblock_domain!(domain_block.domain)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
items.each do |domain|
|
||||
@account.block_domain!(domain)
|
||||
end
|
||||
|
||||
AfterAccountDomainBlockWorker.push_bulk(items) do |domain|
|
||||
[@account.id, domain]
|
||||
end
|
||||
end
|
||||
|
||||
def import_relationships!(action, undo_action, overwrite_scope, limit, extra_fields = {})
|
||||
items = @data.take(limit).map { |row| [row['Account address']&.strip, Hash[extra_fields.map { |key, header| [key, row[header]&.strip] }]] }.reject { |(id, _)| id.blank? }
|
||||
|
||||
if @import.overwrite?
|
||||
presence_hash = items.each_with_object({}) { |(id, extra), mapping| mapping[id] = [true, extra] }
|
||||
|
||||
overwrite_scope.find_each do |target_account|
|
||||
if presence_hash[target_account.acct]
|
||||
items.delete(target_account.acct)
|
||||
extra = presence_hash[target_account.acct][1]
|
||||
Import::RelationshipWorker.perform_async(@account.id, target_account.acct, action, extra)
|
||||
else
|
||||
Import::RelationshipWorker.perform_async(@account.id, target_account.acct, undo_action)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Import::RelationshipWorker.push_bulk(items) do |acct, extra|
|
||||
[@account.id, acct, action, extra]
|
||||
end
|
||||
end
|
||||
|
||||
def parse_import_data!(default_headers)
|
||||
data = CSV.parse(import_data, headers: true)
|
||||
data = CSV.parse(import_data, headers: default_headers) unless data.headers&.first&.strip&.include?(' ')
|
||||
@data = data.reject(&:blank?)
|
||||
end
|
||||
|
||||
def import_data
|
||||
Paperclip.io_adapters.for(@import.data).read
|
||||
end
|
||||
|
||||
def follow_limit
|
||||
FollowLimitValidator.limit_for_account(@account)
|
||||
end
|
||||
end
|
||||
17
app/services/mute_service.rb
Normal file
17
app/services/mute_service.rb
Normal file
@@ -0,0 +1,17 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class MuteService < BaseService
|
||||
def call(account, target_account, notifications: nil)
|
||||
return if account.id == target_account.id
|
||||
|
||||
mute = account.mute!(target_account, notifications: notifications)
|
||||
|
||||
if mute.hide_notifications?
|
||||
BlockWorker.perform_async(account.id, target_account.id)
|
||||
else
|
||||
MuteWorker.perform_async(account.id, target_account.id)
|
||||
end
|
||||
|
||||
mute
|
||||
end
|
||||
end
|
||||
153
app/services/notify_service.rb
Normal file
153
app/services/notify_service.rb
Normal file
@@ -0,0 +1,153 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class NotifyService < BaseService
|
||||
def call(recipient, activity)
|
||||
@recipient = recipient
|
||||
@activity = activity
|
||||
@notification = Notification.new(account: @recipient, activity: @activity)
|
||||
|
||||
return if recipient.user.nil? || blocked?
|
||||
|
||||
create_notification!
|
||||
push_notification! if @notification.browserable?
|
||||
push_to_conversation! if direct_message?
|
||||
send_email! if email_enabled?
|
||||
rescue ActiveRecord::RecordInvalid
|
||||
return
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def blocked_mention?
|
||||
FeedManager.instance.filter?(:mentions, @notification.mention.status, @recipient.id)
|
||||
end
|
||||
|
||||
def blocked_favourite?
|
||||
false
|
||||
end
|
||||
|
||||
def blocked_follow?
|
||||
false
|
||||
end
|
||||
|
||||
def blocked_reblog?
|
||||
false
|
||||
end
|
||||
|
||||
def blocked_follow_request?
|
||||
false
|
||||
end
|
||||
|
||||
def blocked_poll?
|
||||
false
|
||||
end
|
||||
|
||||
def following_sender?
|
||||
return @following_sender if defined?(@following_sender)
|
||||
@following_sender = @recipient.following?(@notification.from_account) || @recipient.requested?(@notification.from_account)
|
||||
end
|
||||
|
||||
def optional_non_follower?
|
||||
@recipient.user.settings.interactions['must_be_follower'] && !@notification.from_account.following?(@recipient)
|
||||
end
|
||||
|
||||
def optional_non_following?
|
||||
@recipient.user.settings.interactions['must_be_following'] && !following_sender?
|
||||
end
|
||||
|
||||
def message?
|
||||
@notification.type == :mention
|
||||
end
|
||||
|
||||
def direct_message?
|
||||
message? && @notification.target_status.direct_visibility?
|
||||
end
|
||||
|
||||
def response_to_recipient?
|
||||
@notification.target_status.in_reply_to_account_id == @recipient.id && @notification.target_status.thread&.direct_visibility?
|
||||
end
|
||||
|
||||
def from_staff?
|
||||
@notification.from_account.local? && @notification.from_account.user.present? && @notification.from_account.user.staff?
|
||||
end
|
||||
|
||||
def optional_non_following_and_direct?
|
||||
direct_message? &&
|
||||
@recipient.user.settings.interactions['must_be_following_dm'] &&
|
||||
!following_sender? &&
|
||||
!response_to_recipient?
|
||||
end
|
||||
|
||||
def hellbanned?
|
||||
@notification.from_account.silenced? && !following_sender?
|
||||
end
|
||||
|
||||
def from_self?
|
||||
@recipient.id == @notification.from_account.id
|
||||
end
|
||||
|
||||
def domain_blocking?
|
||||
@recipient.domain_blocking?(@notification.from_account.domain) && !following_sender?
|
||||
end
|
||||
|
||||
def blocked?
|
||||
blocked = @recipient.suspended? # Skip if the recipient account is suspended anyway
|
||||
blocked ||= from_self? && @notification.type != :poll # Skip for interactions with self
|
||||
|
||||
return blocked if message? && from_staff?
|
||||
|
||||
blocked ||= domain_blocking? # Skip for domain blocked accounts
|
||||
blocked ||= @recipient.blocking?(@notification.from_account) # Skip for blocked accounts
|
||||
blocked ||= @recipient.muting_notifications?(@notification.from_account)
|
||||
blocked ||= hellbanned? # Hellban
|
||||
blocked ||= optional_non_follower? # Options
|
||||
blocked ||= optional_non_following? # Options
|
||||
blocked ||= optional_non_following_and_direct? # Options
|
||||
blocked ||= conversation_muted?
|
||||
blocked ||= send("blocked_#{@notification.type}?") # Type-dependent filters
|
||||
blocked
|
||||
end
|
||||
|
||||
def conversation_muted?
|
||||
if @notification.target_status
|
||||
@recipient.muting_conversation?(@notification.target_status.conversation)
|
||||
else
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
def create_notification!
|
||||
@notification.save!
|
||||
end
|
||||
|
||||
def push_notification!
|
||||
return if @notification.activity.nil?
|
||||
|
||||
Redis.current.publish("timeline:#{@recipient.id}", Oj.dump(event: :notification, payload: InlineRenderer.render(@notification, @recipient, :notification)))
|
||||
send_push_notifications!
|
||||
end
|
||||
|
||||
def push_to_conversation!
|
||||
return if @notification.activity.nil?
|
||||
AccountConversation.add_status(@recipient, @notification.target_status)
|
||||
end
|
||||
|
||||
def send_push_notifications!
|
||||
subscriptions_ids = ::Web::PushSubscription.where(user_id: @recipient.user.id)
|
||||
.select { |subscription| subscription.pushable?(@notification) }
|
||||
.map(&:id)
|
||||
|
||||
::Web::PushNotificationWorker.push_bulk(subscriptions_ids) do |subscription_id|
|
||||
[subscription_id, @notification.id]
|
||||
end
|
||||
end
|
||||
|
||||
def send_email!
|
||||
return if @notification.activity.nil?
|
||||
NotificationMailer.public_send(@notification.type, @recipient, @notification).deliver_later(wait: 2.minutes)
|
||||
end
|
||||
|
||||
def email_enabled?
|
||||
@recipient.user.settings.notification_emails[@notification.type.to_s]
|
||||
end
|
||||
end
|
||||
200
app/services/post_status_service.rb
Normal file
200
app/services/post_status_service.rb
Normal file
@@ -0,0 +1,200 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class PostStatusService < BaseService
|
||||
include Redisable
|
||||
|
||||
MIN_SCHEDULE_OFFSET = 5.minutes.freeze
|
||||
|
||||
# Post a text status update, fetch and notify remote users mentioned
|
||||
# @param [Account] account Account from which to post
|
||||
# @param [Hash] options
|
||||
# @option [String] :text Message
|
||||
# @option [Status] :thread Optional status to reply to
|
||||
# @option [Boolean] :sensitive
|
||||
# @option [String] :visibility
|
||||
# @option [String] :spoiler_text
|
||||
# @option [String] :language
|
||||
# @option [String] :scheduled_at
|
||||
# @option [Hash] :poll Optional poll to attach
|
||||
# @option [Enumerable] :media_ids Optional array of media IDs to attach
|
||||
# @option [Doorkeeper::Application] :application
|
||||
# @option [String] :idempotency Optional idempotency key
|
||||
# @option [String] :group Optional group id
|
||||
# @return [Status]
|
||||
def call(account, options = {})
|
||||
@account = account
|
||||
@options = options
|
||||
@text = @options[:text] || ''
|
||||
@in_reply_to = @options[:thread]
|
||||
|
||||
return idempotency_duplicate if idempotency_given? && idempotency_duplicate?
|
||||
|
||||
validate_media!
|
||||
validate_group!
|
||||
preprocess_attributes!
|
||||
|
||||
if scheduled?
|
||||
schedule_status!
|
||||
else
|
||||
process_status!
|
||||
postprocess_status!
|
||||
bump_potential_friendship!
|
||||
end
|
||||
|
||||
redis.setex(idempotency_key, 3_600, @status.id) if idempotency_given?
|
||||
|
||||
@status
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def preprocess_attributes!
|
||||
@text = @options.delete(:spoiler_text) if @text.blank? && @options[:spoiler_text].present?
|
||||
@visibility = @options[:visibility] || @account.user&.setting_default_privacy
|
||||
@visibility = :unlisted if @visibility == :public && @account.silenced?
|
||||
@scheduled_at = @options[:scheduled_at]&.to_datetime
|
||||
@scheduled_at = nil if scheduled_in_the_past?
|
||||
rescue ArgumentError
|
||||
raise ActiveRecord::RecordInvalid
|
||||
end
|
||||
|
||||
def process_status!
|
||||
# The following transaction block is needed to wrap the UPDATEs to
|
||||
# the media attachments when the status is created
|
||||
|
||||
ApplicationRecord.transaction do
|
||||
@status = @account.statuses.create!(status_attributes)
|
||||
end
|
||||
|
||||
process_hashtags_service.call(@status)
|
||||
process_mentions_service.call(@status)
|
||||
end
|
||||
|
||||
def schedule_status!
|
||||
status_for_validation = @account.statuses.build(status_attributes)
|
||||
|
||||
if status_for_validation.valid?
|
||||
status_for_validation.destroy
|
||||
|
||||
# The following transaction block is needed to wrap the UPDATEs to
|
||||
# the media attachments when the scheduled status is created
|
||||
|
||||
ApplicationRecord.transaction do
|
||||
@status = @account.scheduled_statuses.create!(scheduled_status_attributes)
|
||||
end
|
||||
else
|
||||
raise ActiveRecord::RecordInvalid
|
||||
end
|
||||
end
|
||||
|
||||
def postprocess_status!
|
||||
LinkCrawlWorker.perform_async(@status.id) unless @status.spoiler_text?
|
||||
DistributionWorker.perform_async(@status.id)
|
||||
Pubsubhubbub::DistributionWorker.perform_async(@status.stream_entry.id)
|
||||
ActivityPub::DistributionWorker.perform_async(@status.id)
|
||||
PollExpirationNotifyWorker.perform_at(@status.poll.expires_at, @status.poll.id) if @status.poll
|
||||
end
|
||||
|
||||
def validate_group!
|
||||
group_id = @options[:group_id]
|
||||
|
||||
return if group_id.blank?
|
||||
|
||||
raise GabSocial::ValidationError, I18n.t('statuses.not_a_member_of_group') if not GroupAccount.where(account: @account, group_id: group_id).exists?
|
||||
end
|
||||
|
||||
def validate_media!
|
||||
return if @options[:media_ids].blank? || !@options[:media_ids].is_a?(Enumerable)
|
||||
|
||||
raise GabSocial::ValidationError, I18n.t('media_attachments.validations.too_many') if @options[:media_ids].size > 4 || @options[:poll].present?
|
||||
|
||||
@media = @account.media_attachments.where(status_id: nil).where(id: @options[:media_ids].take(4).map(&:to_i))
|
||||
|
||||
raise GabSocial::ValidationError, I18n.t('media_attachments.validations.images_and_video') if @media.size > 1 && @media.find(&:video?)
|
||||
end
|
||||
|
||||
def language_from_option(str)
|
||||
ISO_639.find(str)&.alpha2
|
||||
end
|
||||
|
||||
def process_mentions_service
|
||||
ProcessMentionsService.new
|
||||
end
|
||||
|
||||
def process_hashtags_service
|
||||
ProcessHashtagsService.new
|
||||
end
|
||||
|
||||
def scheduled?
|
||||
@scheduled_at.present?
|
||||
end
|
||||
|
||||
def idempotency_key
|
||||
"idempotency:status:#{@account.id}:#{@options[:idempotency]}"
|
||||
end
|
||||
|
||||
def idempotency_given?
|
||||
@options[:idempotency].present?
|
||||
end
|
||||
|
||||
def idempotency_duplicate
|
||||
if scheduled?
|
||||
@account.schedule_statuses.find(@idempotency_duplicate)
|
||||
else
|
||||
@account.statuses.find(@idempotency_duplicate)
|
||||
end
|
||||
end
|
||||
|
||||
def idempotency_duplicate?
|
||||
@idempotency_duplicate = redis.get(idempotency_key)
|
||||
end
|
||||
|
||||
def scheduled_in_the_past?
|
||||
@scheduled_at.present? && @scheduled_at <= Time.now.utc + MIN_SCHEDULE_OFFSET
|
||||
end
|
||||
|
||||
def bump_potential_friendship!
|
||||
return if !@status.reply? || @account.id == @status.in_reply_to_account_id
|
||||
ActivityTracker.increment('activity:interactions')
|
||||
return if @account.following?(@status.in_reply_to_account_id)
|
||||
PotentialFriendshipTracker.record(@account.id, @status.in_reply_to_account_id, :reply)
|
||||
end
|
||||
|
||||
def status_attributes
|
||||
{
|
||||
text: @text,
|
||||
group_id: @options[:group_id],
|
||||
media_attachments: @media || [],
|
||||
thread: @in_reply_to,
|
||||
poll_attributes: poll_attributes,
|
||||
sensitive: (@options[:sensitive].nil? ? @account.user&.setting_default_sensitive : @options[:sensitive]) || @options[:spoiler_text].present?,
|
||||
spoiler_text: @options[:spoiler_text] || '',
|
||||
visibility: @visibility,
|
||||
language: language_from_option(@options[:language]) || @account.user&.setting_default_language&.presence || LanguageDetector.instance.detect(@text, @account),
|
||||
application: @options[:application],
|
||||
}.compact
|
||||
end
|
||||
|
||||
def scheduled_status_attributes
|
||||
{
|
||||
scheduled_at: @scheduled_at,
|
||||
media_attachments: @media || [],
|
||||
params: scheduled_options,
|
||||
}
|
||||
end
|
||||
|
||||
def poll_attributes
|
||||
return if @options[:poll].blank?
|
||||
|
||||
@options[:poll].merge(account: @account)
|
||||
end
|
||||
|
||||
def scheduled_options
|
||||
@options.tap do |options_hash|
|
||||
options_hash[:in_reply_to_id] = options_hash.delete(:thread)&.id
|
||||
options_hash[:application_id] = options_hash.delete(:application)&.id
|
||||
options_hash[:scheduled_at] = nil
|
||||
options_hash[:idempotency] = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
9
app/services/precompute_feed_service.rb
Normal file
9
app/services/precompute_feed_service.rb
Normal file
@@ -0,0 +1,9 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class PrecomputeFeedService < BaseService
|
||||
def call(account)
|
||||
FeedManager.instance.populate_feed(account)
|
||||
ensure
|
||||
Redis.current.del("account:#{account.id}:regeneration")
|
||||
end
|
||||
end
|
||||
31
app/services/process_feed_service.rb
Normal file
31
app/services/process_feed_service.rb
Normal file
@@ -0,0 +1,31 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ProcessFeedService < BaseService
|
||||
def call(body, account, **options)
|
||||
@options = options
|
||||
|
||||
xml = Nokogiri::XML(body)
|
||||
xml.encoding = 'utf-8'
|
||||
|
||||
update_author(body, account)
|
||||
process_entries(xml, account)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def update_author(body, account)
|
||||
RemoteProfileUpdateWorker.perform_async(account.id, body.force_encoding('UTF-8'), true)
|
||||
end
|
||||
|
||||
def process_entries(xml, account)
|
||||
xml.xpath('//xmlns:entry', xmlns: OStatus::TagManager::XMLNS).reverse_each.map { |entry| process_entry(entry, account) }.compact
|
||||
end
|
||||
|
||||
def process_entry(xml, account)
|
||||
activity = OStatus::Activity::General.new(xml, account, @options)
|
||||
activity.specialize&.perform if activity.status?
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
Rails.logger.debug "Nothing was saved for #{activity.id} because: #{e}"
|
||||
nil
|
||||
end
|
||||
end
|
||||
23
app/services/process_hashtags_service.rb
Normal file
23
app/services/process_hashtags_service.rb
Normal file
@@ -0,0 +1,23 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ProcessHashtagsService < BaseService
|
||||
def call(status, tags = [])
|
||||
tags = Extractor.extract_hashtags(status.text) if status.local?
|
||||
records = []
|
||||
|
||||
tags.map { |str| str.mb_chars.downcase }.uniq(&:to_s).each do |name|
|
||||
tag = Tag.where(name: name).first_or_create(name: name)
|
||||
|
||||
status.tags << tag
|
||||
records << tag
|
||||
|
||||
TrendingTags.record_use!(tag, status.account, status.created_at) if status.public_visibility?
|
||||
end
|
||||
|
||||
return unless status.public_visibility? || status.unlisted_visibility?
|
||||
|
||||
status.account.featured_tags.where(tag_id: records.map(&:id)).each do |featured_tag|
|
||||
featured_tag.increment(status.created_at)
|
||||
end
|
||||
end
|
||||
end
|
||||
151
app/services/process_interaction_service.rb
Normal file
151
app/services/process_interaction_service.rb
Normal file
@@ -0,0 +1,151 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ProcessInteractionService < BaseService
|
||||
include AuthorExtractor
|
||||
include Authorization
|
||||
|
||||
# Record locally the remote interaction with our user
|
||||
# @param [String] envelope Salmon envelope
|
||||
# @param [Account] target_account Account the Salmon was addressed to
|
||||
def call(envelope, target_account)
|
||||
body = salmon.unpack(envelope)
|
||||
|
||||
xml = Nokogiri::XML(body)
|
||||
xml.encoding = 'utf-8'
|
||||
|
||||
account = author_from_xml(xml.at_xpath('/xmlns:entry', xmlns: OStatus::TagManager::XMLNS))
|
||||
|
||||
return if account.nil? || account.suspended?
|
||||
|
||||
if salmon.verify(envelope, account.keypair)
|
||||
RemoteProfileUpdateWorker.perform_async(account.id, body.force_encoding('UTF-8'), true)
|
||||
|
||||
case verb(xml)
|
||||
when :follow
|
||||
follow!(account, target_account) unless target_account.locked? || target_account.blocking?(account) || target_account.domain_blocking?(account.domain)
|
||||
when :request_friend
|
||||
follow_request!(account, target_account) unless !target_account.locked? || target_account.blocking?(account) || target_account.domain_blocking?(account.domain)
|
||||
when :authorize
|
||||
authorize_follow_request!(account, target_account)
|
||||
when :reject
|
||||
reject_follow_request!(account, target_account)
|
||||
when :unfollow
|
||||
unfollow!(account, target_account)
|
||||
when :favorite
|
||||
favourite!(xml, account)
|
||||
when :unfavorite
|
||||
unfavourite!(xml, account)
|
||||
when :post
|
||||
add_post!(body, account) if mentions_account?(xml, target_account)
|
||||
when :share
|
||||
add_post!(body, account) unless status(xml).nil?
|
||||
when :delete
|
||||
delete_post!(xml, account)
|
||||
when :block
|
||||
reflect_block!(account, target_account)
|
||||
when :unblock
|
||||
reflect_unblock!(account, target_account)
|
||||
end
|
||||
end
|
||||
rescue HTTP::Error, OStatus2::BadSalmonError, GabSocial::NotPermittedError
|
||||
nil
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def mentions_account?(xml, account)
|
||||
xml.xpath('/xmlns:entry/xmlns:link[@rel="mentioned"]', xmlns: OStatus::TagManager::XMLNS).each { |mention_link| return true if [OStatus::TagManager.instance.uri_for(account), OStatus::TagManager.instance.url_for(account)].include?(mention_link.attribute('href').value) }
|
||||
false
|
||||
end
|
||||
|
||||
def verb(xml)
|
||||
raw = xml.at_xpath('//activity:verb', activity: OStatus::TagManager::AS_XMLNS).content
|
||||
OStatus::TagManager::VERBS.key(raw)
|
||||
rescue
|
||||
:post
|
||||
end
|
||||
|
||||
def follow!(account, target_account)
|
||||
follow = account.follow!(target_account)
|
||||
FollowRequest.find_by(account: account, target_account: target_account)&.destroy
|
||||
NotifyService.new.call(target_account, follow)
|
||||
end
|
||||
|
||||
def follow_request!(account, target_account)
|
||||
return if account.requested?(target_account)
|
||||
|
||||
follow_request = FollowRequest.create!(account: account, target_account: target_account)
|
||||
NotifyService.new.call(target_account, follow_request)
|
||||
end
|
||||
|
||||
def authorize_follow_request!(account, target_account)
|
||||
follow_request = FollowRequest.find_by(account: target_account, target_account: account)
|
||||
follow_request&.authorize!
|
||||
Pubsubhubbub::SubscribeWorker.perform_async(account.id) unless account.subscribed?
|
||||
end
|
||||
|
||||
def reject_follow_request!(account, target_account)
|
||||
follow_request = FollowRequest.find_by(account: target_account, target_account: account)
|
||||
follow_request&.reject!
|
||||
end
|
||||
|
||||
def unfollow!(account, target_account)
|
||||
account.unfollow!(target_account)
|
||||
FollowRequest.find_by(account: account, target_account: target_account)&.destroy
|
||||
end
|
||||
|
||||
def reflect_block!(account, target_account)
|
||||
UnfollowService.new.call(target_account, account) if target_account.following?(account)
|
||||
account.block!(target_account)
|
||||
end
|
||||
|
||||
def reflect_unblock!(account, target_account)
|
||||
UnblockService.new.call(account, target_account)
|
||||
end
|
||||
|
||||
def delete_post!(xml, account)
|
||||
status = Status.find(xml.at_xpath('//xmlns:id', xmlns: OStatus::TagManager::XMLNS).content)
|
||||
|
||||
return if status.nil?
|
||||
|
||||
authorize_with account, status, :destroy?
|
||||
|
||||
RemovalWorker.perform_async(status.id)
|
||||
end
|
||||
|
||||
def favourite!(xml, from_account)
|
||||
current_status = status(xml)
|
||||
|
||||
return if current_status.nil?
|
||||
|
||||
favourite = current_status.favourites.where(account: from_account).first_or_create!(account: from_account)
|
||||
NotifyService.new.call(current_status.account, favourite)
|
||||
end
|
||||
|
||||
def unfavourite!(xml, from_account)
|
||||
current_status = status(xml)
|
||||
|
||||
return if current_status.nil?
|
||||
|
||||
favourite = current_status.favourites.where(account: from_account).first
|
||||
favourite&.destroy
|
||||
end
|
||||
|
||||
def add_post!(body, account)
|
||||
ProcessingWorker.perform_async(account.id, body.force_encoding('UTF-8'))
|
||||
end
|
||||
|
||||
def status(xml)
|
||||
uri = activity_id(xml)
|
||||
return nil unless OStatus::TagManager.instance.local_id?(uri)
|
||||
Status.find(OStatus::TagManager.instance.unique_tag_to_local_id(uri, 'Status'))
|
||||
end
|
||||
|
||||
def activity_id(xml)
|
||||
xml.at_xpath('//activity:object', activity: OStatus::TagManager::AS_XMLNS).at_xpath('./xmlns:id', xmlns: OStatus::TagManager::XMLNS).content
|
||||
end
|
||||
|
||||
def salmon
|
||||
@salmon ||= OStatus2::Salmon.new
|
||||
end
|
||||
end
|
||||
75
app/services/process_mentions_service.rb
Normal file
75
app/services/process_mentions_service.rb
Normal file
@@ -0,0 +1,75 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ProcessMentionsService < BaseService
|
||||
include StreamEntryRenderer
|
||||
|
||||
# Scan status for mentions and fetch remote mentioned users, create
|
||||
# local mention pointers, send Salmon notifications to mentioned
|
||||
# remote users
|
||||
# @param [Status] status
|
||||
def call(status)
|
||||
return unless status.local?
|
||||
|
||||
@status = status
|
||||
mentions = []
|
||||
|
||||
status.text = status.text.gsub(Account::MENTION_RE) do |match|
|
||||
username, domain = Regexp.last_match(1).split('@')
|
||||
mentioned_account = Account.find_remote(username, domain)
|
||||
|
||||
if mention_undeliverable?(mentioned_account)
|
||||
begin
|
||||
mentioned_account = resolve_account_service.call(Regexp.last_match(1))
|
||||
rescue Goldfinger::Error, HTTP::Error, OpenSSL::SSL::SSLError, GabSocial::UnexpectedResponseError
|
||||
mentioned_account = nil
|
||||
end
|
||||
end
|
||||
|
||||
next match if mention_undeliverable?(mentioned_account) || mentioned_account&.suspended?
|
||||
|
||||
mentions << mentioned_account.mentions.where(status: status).first_or_create(status: status)
|
||||
|
||||
"@#{mentioned_account.acct}"
|
||||
end
|
||||
|
||||
status.save!
|
||||
|
||||
mentions.each { |mention| create_notification(mention) }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def mention_undeliverable?(mentioned_account)
|
||||
mentioned_account.nil? || (!mentioned_account.local? && mentioned_account.ostatus? && @status.stream_entry.hidden?)
|
||||
end
|
||||
|
||||
def create_notification(mention)
|
||||
mentioned_account = mention.account
|
||||
|
||||
if mentioned_account.local?
|
||||
LocalNotificationWorker.perform_async(mentioned_account.id, mention.id, mention.class.name)
|
||||
elsif mentioned_account.ostatus? && !@status.stream_entry.hidden?
|
||||
NotificationWorker.perform_async(ostatus_xml, @status.account_id, mentioned_account.id)
|
||||
elsif mentioned_account.activitypub?
|
||||
ActivityPub::DeliveryWorker.perform_async(activitypub_json, mention.status.account_id, mentioned_account.inbox_url)
|
||||
end
|
||||
end
|
||||
|
||||
def ostatus_xml
|
||||
@ostatus_xml ||= stream_entry_to_xml(@status.stream_entry)
|
||||
end
|
||||
|
||||
def activitypub_json
|
||||
return @activitypub_json if defined?(@activitypub_json)
|
||||
payload = ActiveModelSerializers::SerializableResource.new(
|
||||
@status,
|
||||
serializer: ActivityPub::ActivitySerializer,
|
||||
adapter: ActivityPub::Adapter
|
||||
).as_json
|
||||
@activitypub_json = Oj.dump(@status.distributable? ? ActivityPub::LinkedDataSignature.new(payload).sign!(@status.account) : payload)
|
||||
end
|
||||
|
||||
def resolve_account_service
|
||||
ResolveAccountService.new
|
||||
end
|
||||
end
|
||||
53
app/services/pubsubhubbub/subscribe_service.rb
Normal file
53
app/services/pubsubhubbub/subscribe_service.rb
Normal file
@@ -0,0 +1,53 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Pubsubhubbub::SubscribeService < BaseService
|
||||
URL_PATTERN = /\A#{URI.regexp(%w(http https))}\z/
|
||||
|
||||
attr_reader :account, :callback, :secret,
|
||||
:lease_seconds, :domain
|
||||
|
||||
def call(account, callback, secret, lease_seconds, verified_domain = nil)
|
||||
@account = account
|
||||
@callback = Addressable::URI.parse(callback).normalize.to_s
|
||||
@secret = secret
|
||||
@lease_seconds = lease_seconds
|
||||
@domain = verified_domain
|
||||
|
||||
process_subscribe
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def process_subscribe
|
||||
if account.nil?
|
||||
['Invalid topic URL', 422]
|
||||
elsif !valid_callback?
|
||||
['Invalid callback URL', 422]
|
||||
elsif blocked_domain?
|
||||
['Callback URL not allowed', 403]
|
||||
else
|
||||
confirm_subscription
|
||||
['', 202]
|
||||
end
|
||||
end
|
||||
|
||||
def confirm_subscription
|
||||
subscription = locate_subscription
|
||||
Pubsubhubbub::ConfirmationWorker.perform_async(subscription.id, 'subscribe', secret, lease_seconds)
|
||||
end
|
||||
|
||||
def valid_callback?
|
||||
callback.present? && callback =~ URL_PATTERN
|
||||
end
|
||||
|
||||
def blocked_domain?
|
||||
DomainBlock.blocked? Addressable::URI.parse(callback).host
|
||||
end
|
||||
|
||||
def locate_subscription
|
||||
subscription = Subscription.find_or_initialize_by(account: account, callback_url: callback)
|
||||
subscription.domain = domain
|
||||
subscription.save!
|
||||
subscription
|
||||
end
|
||||
end
|
||||
31
app/services/pubsubhubbub/unsubscribe_service.rb
Normal file
31
app/services/pubsubhubbub/unsubscribe_service.rb
Normal file
@@ -0,0 +1,31 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Pubsubhubbub::UnsubscribeService < BaseService
|
||||
attr_reader :account, :callback
|
||||
|
||||
def call(account, callback)
|
||||
@account = account
|
||||
@callback = Addressable::URI.parse(callback).normalize.to_s
|
||||
|
||||
process_unsubscribe
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def process_unsubscribe
|
||||
if account.nil?
|
||||
['Invalid topic URL', 422]
|
||||
else
|
||||
confirm_unsubscribe unless subscription.nil?
|
||||
['', 202]
|
||||
end
|
||||
end
|
||||
|
||||
def confirm_unsubscribe
|
||||
Pubsubhubbub::ConfirmationWorker.perform_async(subscription.id, 'unsubscribe')
|
||||
end
|
||||
|
||||
def subscription
|
||||
@_subscription ||= Subscription.find_by(account: account, callback_url: callback)
|
||||
end
|
||||
end
|
||||
63
app/services/reblog_service.rb
Normal file
63
app/services/reblog_service.rb
Normal file
@@ -0,0 +1,63 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ReblogService < BaseService
|
||||
include Authorization
|
||||
include StreamEntryRenderer
|
||||
|
||||
# Reblog a status and notify its remote author
|
||||
# @param [Account] account Account to reblog from
|
||||
# @param [Status] reblogged_status Status to be reblogged
|
||||
# @param [Hash] options
|
||||
# @return [Status]
|
||||
def call(account, reblogged_status, options = {})
|
||||
reblogged_status = reblogged_status.reblog if reblogged_status.reblog?
|
||||
|
||||
authorize_with account, reblogged_status, :reblog?
|
||||
|
||||
reblog = account.statuses.find_by(reblog: reblogged_status)
|
||||
|
||||
return reblog unless reblog.nil?
|
||||
|
||||
visibility = options[:visibility] || account.user&.setting_default_privacy
|
||||
visibility = reblogged_status.visibility if reblogged_status.hidden?
|
||||
text = options[:status] || ''
|
||||
reblog = account.statuses.create!(reblog: reblogged_status, text: text, visibility: visibility)
|
||||
|
||||
DistributionWorker.perform_async(reblog.id)
|
||||
Pubsubhubbub::DistributionWorker.perform_async(reblog.stream_entry.id)
|
||||
ActivityPub::DistributionWorker.perform_async(reblog.id)
|
||||
|
||||
create_notification(reblog)
|
||||
bump_potential_friendship(account, reblog)
|
||||
|
||||
reblog
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_notification(reblog)
|
||||
reblogged_status = reblog.reblog
|
||||
|
||||
if reblogged_status.account.local?
|
||||
LocalNotificationWorker.perform_async(reblogged_status.account_id, reblog.id, reblog.class.name)
|
||||
elsif reblogged_status.account.ostatus?
|
||||
NotificationWorker.perform_async(stream_entry_to_xml(reblog.stream_entry), reblog.account_id, reblogged_status.account_id)
|
||||
elsif reblogged_status.account.activitypub? && !reblogged_status.account.following?(reblog.account)
|
||||
ActivityPub::DeliveryWorker.perform_async(build_json(reblog), reblog.account_id, reblogged_status.account.inbox_url)
|
||||
end
|
||||
end
|
||||
|
||||
def bump_potential_friendship(account, reblog)
|
||||
ActivityTracker.increment('activity:interactions')
|
||||
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,
|
||||
serializer: ActivityPub::ActivitySerializer,
|
||||
adapter: ActivityPub::Adapter
|
||||
).as_json).sign!(reblog.account))
|
||||
end
|
||||
end
|
||||
32
app/services/reject_follow_service.rb
Normal file
32
app/services/reject_follow_service.rb
Normal file
@@ -0,0 +1,32 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class RejectFollowService < BaseService
|
||||
def call(source_account, target_account)
|
||||
follow_request = FollowRequest.find_by!(account: source_account, target_account: target_account)
|
||||
follow_request.reject!
|
||||
create_notification(follow_request) unless source_account.local?
|
||||
follow_request
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_notification(follow_request)
|
||||
if follow_request.account.ostatus?
|
||||
NotificationWorker.perform_async(build_xml(follow_request), follow_request.target_account_id, follow_request.account_id)
|
||||
elsif follow_request.account.activitypub?
|
||||
ActivityPub::DeliveryWorker.perform_async(build_json(follow_request), follow_request.target_account_id, follow_request.account.inbox_url)
|
||||
end
|
||||
end
|
||||
|
||||
def build_json(follow_request)
|
||||
ActiveModelSerializers::SerializableResource.new(
|
||||
follow_request,
|
||||
serializer: ActivityPub::RejectFollowSerializer,
|
||||
adapter: ActivityPub::Adapter
|
||||
).to_json
|
||||
end
|
||||
|
||||
def build_xml(follow_request)
|
||||
OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.reject_follow_request_salmon(follow_request))
|
||||
end
|
||||
end
|
||||
169
app/services/remove_status_service.rb
Normal file
169
app/services/remove_status_service.rb
Normal file
@@ -0,0 +1,169 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class RemoveStatusService < BaseService
|
||||
include StreamEntryRenderer
|
||||
include Redisable
|
||||
|
||||
def call(status, **options)
|
||||
@payload = Oj.dump(event: :delete, payload: status.id.to_s)
|
||||
@status = status
|
||||
@account = status.account
|
||||
@tags = status.tags.pluck(:name).to_a
|
||||
@mentions = status.active_mentions.includes(:account).to_a
|
||||
@reblogs = status.reblogs.includes(:account).to_a
|
||||
@stream_entry = status.stream_entry
|
||||
@options = options
|
||||
|
||||
RedisLock.acquire(lock_options) do |lock|
|
||||
if lock.acquired?
|
||||
remove_from_self if status.account.local?
|
||||
remove_from_followers
|
||||
remove_from_lists
|
||||
remove_from_affected
|
||||
remove_reblogs
|
||||
remove_from_hashtags
|
||||
remove_from_public
|
||||
remove_from_media if status.media_attachments.any?
|
||||
|
||||
@status.destroy!
|
||||
else
|
||||
raise GabSocial::RaceConditionError
|
||||
end
|
||||
end
|
||||
|
||||
# There is no reason to send out Undo activities when the
|
||||
# cause is that the original object has been removed, since
|
||||
# original object being removed implicitly removes reblogs
|
||||
# of it. The Delete activity of the original is forwarded
|
||||
# separately.
|
||||
return if !@account.local? || @options[:original_removed]
|
||||
|
||||
remove_from_remote_followers
|
||||
remove_from_remote_affected
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def remove_from_self
|
||||
FeedManager.instance.unpush_from_home(@account, @status)
|
||||
end
|
||||
|
||||
def remove_from_followers
|
||||
@account.followers_for_local_distribution.reorder(nil).find_each do |follower|
|
||||
FeedManager.instance.unpush_from_home(follower, @status)
|
||||
end
|
||||
end
|
||||
|
||||
def remove_from_lists
|
||||
@account.lists_for_local_distribution.select(:id, :account_id).reorder(nil).find_each do |list|
|
||||
FeedManager.instance.unpush_from_list(list, @status)
|
||||
end
|
||||
end
|
||||
|
||||
def remove_from_affected
|
||||
@mentions.map(&:account).select(&:local?).each do |account|
|
||||
redis.publish("timeline:#{account.id}", @payload)
|
||||
end
|
||||
end
|
||||
|
||||
def remove_from_remote_affected
|
||||
# People who got mentioned in the status, or who
|
||||
# reblogged it from someone else might not follow
|
||||
# the author and wouldn't normally receive the
|
||||
# delete notification - so here, we explicitly
|
||||
# send it to them
|
||||
|
||||
target_accounts = (@mentions.map(&:account).reject(&:local?) + @reblogs.map(&:account).reject(&:local?))
|
||||
target_accounts << @status.reblog.account if @status.reblog? && !@status.reblog.account.local?
|
||||
target_accounts.uniq!(&:id)
|
||||
|
||||
# Ostatus
|
||||
NotificationWorker.push_bulk(target_accounts.select(&:ostatus?).uniq(&:domain)) do |target_account|
|
||||
[salmon_xml, @account.id, target_account.id]
|
||||
end
|
||||
|
||||
# ActivityPub
|
||||
ActivityPub::DeliveryWorker.push_bulk(target_accounts.select(&:activitypub?).uniq(&:preferred_inbox_url)) do |target_account|
|
||||
[signed_activity_json, @account.id, target_account.preferred_inbox_url]
|
||||
end
|
||||
end
|
||||
|
||||
def remove_from_remote_followers
|
||||
# OStatus
|
||||
Pubsubhubbub::RawDistributionWorker.perform_async(salmon_xml, @account.id)
|
||||
|
||||
# ActivityPub
|
||||
ActivityPub::DeliveryWorker.push_bulk(@account.followers.inboxes) do |inbox_url|
|
||||
[signed_activity_json, @account.id, inbox_url]
|
||||
end
|
||||
|
||||
relay! if relayable?
|
||||
end
|
||||
|
||||
def relayable?
|
||||
@status.public_visibility?
|
||||
end
|
||||
|
||||
def relay!
|
||||
ActivityPub::DeliveryWorker.push_bulk(Relay.enabled.pluck(:inbox_url)) do |inbox_url|
|
||||
[signed_activity_json, @account.id, inbox_url]
|
||||
end
|
||||
end
|
||||
|
||||
def salmon_xml
|
||||
@salmon_xml ||= stream_entry_to_xml(@stream_entry)
|
||||
end
|
||||
|
||||
def signed_activity_json
|
||||
@signed_activity_json ||= Oj.dump(ActivityPub::LinkedDataSignature.new(activity_json).sign!(@account))
|
||||
end
|
||||
|
||||
def activity_json
|
||||
@activity_json ||= ActiveModelSerializers::SerializableResource.new(
|
||||
@status,
|
||||
serializer: @status.reblog? ? ActivityPub::UndoAnnounceSerializer : ActivityPub::DeleteSerializer,
|
||||
adapter: ActivityPub::Adapter
|
||||
).as_json
|
||||
end
|
||||
|
||||
def remove_reblogs
|
||||
# We delete reblogs of the status before the original status,
|
||||
# because once original status is gone, reblogs will disappear
|
||||
# without us being able to do all the fancy stuff
|
||||
|
||||
@reblogs.each do |reblog|
|
||||
RemoveStatusService.new.call(reblog, original_removed: true)
|
||||
end
|
||||
end
|
||||
|
||||
def remove_from_hashtags
|
||||
@account.featured_tags.where(tag_id: @status.tags.pluck(:id)).each do |featured_tag|
|
||||
featured_tag.decrement(@status.id)
|
||||
end
|
||||
|
||||
return unless @status.public_visibility?
|
||||
|
||||
@tags.each do |hashtag|
|
||||
redis.publish("timeline:hashtag:#{hashtag}", @payload)
|
||||
redis.publish("timeline:hashtag:#{hashtag}:local", @payload) if @status.local?
|
||||
end
|
||||
end
|
||||
|
||||
def remove_from_public
|
||||
return unless @status.public_visibility?
|
||||
|
||||
redis.publish('timeline:public', @payload)
|
||||
redis.publish('timeline:public:local', @payload) if @status.local?
|
||||
end
|
||||
|
||||
def remove_from_media
|
||||
return unless @status.public_visibility?
|
||||
|
||||
redis.publish('timeline:public:media', @payload)
|
||||
redis.publish('timeline:public:local:media', @payload) if @status.local?
|
||||
end
|
||||
|
||||
def lock_options
|
||||
{ redis: Redis.current, key: "distribute:#{@status.id}" }
|
||||
end
|
||||
end
|
||||
58
app/services/report_service.rb
Normal file
58
app/services/report_service.rb
Normal file
@@ -0,0 +1,58 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ReportService < BaseService
|
||||
def call(source_account, target_account, options = {})
|
||||
@source_account = source_account
|
||||
@target_account = target_account
|
||||
@status_ids = options.delete(:status_ids) || []
|
||||
@comment = options.delete(:comment) || ''
|
||||
@options = options
|
||||
|
||||
create_report!
|
||||
notify_staff!
|
||||
forward_to_origin! if !@target_account.local? && ActiveModel::Type::Boolean.new.cast(@options[:forward])
|
||||
|
||||
@report
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_report!
|
||||
@report = @source_account.reports.create!(
|
||||
target_account: @target_account,
|
||||
status_ids: @status_ids,
|
||||
comment: @comment,
|
||||
uri: @options[:uri]
|
||||
)
|
||||
end
|
||||
|
||||
def notify_staff!
|
||||
return if @report.unresolved_siblings?
|
||||
|
||||
User.staff.includes(:account).each do |u|
|
||||
next unless u.allows_report_emails?
|
||||
AdminMailer.new_report(u.account, @report).deliver_later
|
||||
end
|
||||
end
|
||||
|
||||
def forward_to_origin!
|
||||
ActivityPub::DeliveryWorker.perform_async(
|
||||
payload,
|
||||
some_local_account.id,
|
||||
@target_account.inbox_url
|
||||
)
|
||||
end
|
||||
|
||||
def payload
|
||||
Oj.dump(ActiveModelSerializers::SerializableResource.new(
|
||||
@report,
|
||||
serializer: ActivityPub::FlagSerializer,
|
||||
adapter: ActivityPub::Adapter,
|
||||
account: some_local_account
|
||||
).as_json)
|
||||
end
|
||||
|
||||
def some_local_account
|
||||
@some_local_account ||= Account.representative
|
||||
end
|
||||
end
|
||||
220
app/services/resolve_account_service.rb
Normal file
220
app/services/resolve_account_service.rb
Normal file
@@ -0,0 +1,220 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ResolveAccountService < BaseService
|
||||
include OStatus2::MagicKey
|
||||
include JsonLdHelper
|
||||
|
||||
DFRN_NS = 'http://purl.org/macgirvin/dfrn/1.0'
|
||||
|
||||
# Find or create a local account for a remote user.
|
||||
# When creating, look up the user's webfinger and fetch all
|
||||
# important information from their feed
|
||||
# @param [String, Account] uri User URI in the form of username@domain
|
||||
# @param [Hash] options
|
||||
# @return [Account]
|
||||
def call(uri, options = {})
|
||||
@options = options
|
||||
|
||||
if uri.is_a?(Account)
|
||||
@account = uri
|
||||
@username = @account.username
|
||||
@domain = @account.domain
|
||||
uri = "#{@username}@#{@domain}"
|
||||
|
||||
return @account if @account.local? || !webfinger_update_due?
|
||||
else
|
||||
@username, @domain = uri.split('@')
|
||||
|
||||
return Account.find_local(@username) if TagManager.instance.local_domain?(@domain)
|
||||
|
||||
@account = Account.find_remote(@username, @domain)
|
||||
|
||||
return @account unless webfinger_update_due?
|
||||
end
|
||||
|
||||
Rails.logger.debug "Looking up webfinger for #{uri}"
|
||||
|
||||
@webfinger = Goldfinger.finger("acct:#{uri}")
|
||||
|
||||
confirmed_username, confirmed_domain = @webfinger.subject.gsub(/\Aacct:/, '').split('@')
|
||||
|
||||
if confirmed_username.casecmp(@username).zero? && confirmed_domain.casecmp(@domain).zero?
|
||||
@username = confirmed_username
|
||||
@domain = confirmed_domain
|
||||
elsif options[:redirected].nil?
|
||||
return call("#{confirmed_username}@#{confirmed_domain}", options.merge(redirected: true))
|
||||
else
|
||||
Rails.logger.debug 'Requested and returned acct URIs do not match'
|
||||
return
|
||||
end
|
||||
|
||||
return if links_missing?
|
||||
return Account.find_local(@username) if TagManager.instance.local_domain?(@domain)
|
||||
|
||||
RedisLock.acquire(lock_options) do |lock|
|
||||
if lock.acquired?
|
||||
@account = Account.find_remote(@username, @domain)
|
||||
|
||||
if activitypub_ready? || @account&.activitypub?
|
||||
handle_activitypub
|
||||
else
|
||||
handle_ostatus
|
||||
end
|
||||
else
|
||||
raise GabSocial::RaceConditionError
|
||||
end
|
||||
end
|
||||
|
||||
@account
|
||||
rescue Goldfinger::Error => e
|
||||
Rails.logger.debug "Webfinger query for #{uri} unsuccessful: #{e}"
|
||||
nil
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def links_missing?
|
||||
!(activitypub_ready? || ostatus_ready?)
|
||||
end
|
||||
|
||||
def ostatus_ready?
|
||||
!(@webfinger.link('http://schemas.google.com/g/2010#updates-from').nil? ||
|
||||
@webfinger.link('salmon').nil? ||
|
||||
@webfinger.link('http://webfinger.net/rel/profile-page').nil? ||
|
||||
@webfinger.link('magic-public-key').nil? ||
|
||||
canonical_uri.nil? ||
|
||||
hub_url.nil?)
|
||||
end
|
||||
|
||||
def webfinger_update_due?
|
||||
@account.nil? || ((!@options[:skip_webfinger] || @account.ostatus?) && @account.possibly_stale?)
|
||||
end
|
||||
|
||||
def activitypub_ready?
|
||||
!@webfinger.link('self').nil? &&
|
||||
['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].include?(@webfinger.link('self').type) &&
|
||||
!actor_json.nil? &&
|
||||
actor_json['inbox'].present?
|
||||
end
|
||||
|
||||
def handle_ostatus
|
||||
create_account if @account.nil?
|
||||
update_account
|
||||
update_account_profile if update_profile?
|
||||
end
|
||||
|
||||
def update_profile?
|
||||
@options[:update_profile]
|
||||
end
|
||||
|
||||
def handle_activitypub
|
||||
return if actor_json.nil?
|
||||
|
||||
@account = ActivityPub::ProcessAccountService.new.call(@username, @domain, actor_json)
|
||||
rescue Oj::ParseError
|
||||
nil
|
||||
end
|
||||
|
||||
def create_account
|
||||
Rails.logger.debug "Creating new remote account for #{@username}@#{@domain}"
|
||||
|
||||
@account = Account.new(username: @username, domain: @domain)
|
||||
@account.suspended_at = domain_block.created_at if auto_suspend?
|
||||
@account.silenced_at = domain_block.created_at if auto_silence?
|
||||
@account.private_key = nil
|
||||
end
|
||||
|
||||
def update_account
|
||||
@account.last_webfingered_at = Time.now.utc
|
||||
@account.protocol = :ostatus
|
||||
@account.remote_url = atom_url
|
||||
@account.salmon_url = salmon_url
|
||||
@account.url = url
|
||||
@account.public_key = public_key
|
||||
@account.uri = canonical_uri
|
||||
@account.hub_url = hub_url
|
||||
@account.save!
|
||||
end
|
||||
|
||||
def auto_suspend?
|
||||
domain_block&.suspend?
|
||||
end
|
||||
|
||||
def auto_silence?
|
||||
domain_block&.silence?
|
||||
end
|
||||
|
||||
def domain_block
|
||||
return @domain_block if defined?(@domain_block)
|
||||
@domain_block = DomainBlock.find_by(domain: @domain)
|
||||
end
|
||||
|
||||
def atom_url
|
||||
@atom_url ||= @webfinger.link('http://schemas.google.com/g/2010#updates-from').href
|
||||
end
|
||||
|
||||
def salmon_url
|
||||
@salmon_url ||= @webfinger.link('salmon').href
|
||||
end
|
||||
|
||||
def actor_url
|
||||
@actor_url ||= @webfinger.link('self').href
|
||||
end
|
||||
|
||||
def url
|
||||
@url ||= @webfinger.link('http://webfinger.net/rel/profile-page').href
|
||||
end
|
||||
|
||||
def public_key
|
||||
@public_key ||= magic_key_to_pem(@webfinger.link('magic-public-key').href)
|
||||
end
|
||||
|
||||
def canonical_uri
|
||||
return @canonical_uri if defined?(@canonical_uri)
|
||||
|
||||
author_uri = atom.at_xpath('/xmlns:feed/xmlns:author/xmlns:uri')
|
||||
|
||||
if author_uri.nil?
|
||||
owner = atom.at_xpath('/xmlns:feed').at_xpath('./dfrn:owner', dfrn: DFRN_NS)
|
||||
author_uri = owner.at_xpath('./xmlns:uri') unless owner.nil?
|
||||
end
|
||||
|
||||
@canonical_uri = author_uri.nil? ? nil : author_uri.content
|
||||
end
|
||||
|
||||
def hub_url
|
||||
return @hub_url if defined?(@hub_url)
|
||||
|
||||
hubs = atom.xpath('//xmlns:link[@rel="hub"]')
|
||||
@hub_url = hubs.empty? || hubs.first['href'].nil? ? nil : hubs.first['href']
|
||||
end
|
||||
|
||||
def atom_body
|
||||
return @atom_body if defined?(@atom_body)
|
||||
|
||||
@atom_body = Request.new(:get, atom_url).perform do |response|
|
||||
raise GabSocial::UnexpectedResponseError, response unless response.code == 200
|
||||
response.body_with_limit
|
||||
end
|
||||
end
|
||||
|
||||
def actor_json
|
||||
return @actor_json if defined?(@actor_json)
|
||||
|
||||
json = fetch_resource(actor_url, false)
|
||||
@actor_json = supported_context?(json) && equals_or_includes_any?(json['type'], ActivityPub::FetchRemoteAccountService::SUPPORTED_TYPES) ? json : nil
|
||||
end
|
||||
|
||||
def atom
|
||||
return @atom if defined?(@atom)
|
||||
@atom = Nokogiri::XML(atom_body)
|
||||
end
|
||||
|
||||
def update_account_profile
|
||||
RemoteProfileUpdateWorker.perform_async(@account.id, atom_body.force_encoding('UTF-8'), false)
|
||||
end
|
||||
|
||||
def lock_options
|
||||
{ redis: Redis.current, key: "resolve:#{@username}@#{@domain}" }
|
||||
end
|
||||
end
|
||||
95
app/services/resolve_url_service.rb
Normal file
95
app/services/resolve_url_service.rb
Normal file
@@ -0,0 +1,95 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ResolveURLService < BaseService
|
||||
include JsonLdHelper
|
||||
include Authorization
|
||||
|
||||
attr_reader :url
|
||||
|
||||
def call(url, on_behalf_of: nil)
|
||||
@url = url
|
||||
@on_behalf_of = on_behalf_of
|
||||
|
||||
return process_local_url if local_url?
|
||||
|
||||
process_url unless fetched_atom_feed.nil?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def process_url
|
||||
if equals_or_includes_any?(type, %w(Application Group Organization Person Service))
|
||||
FetchRemoteAccountService.new.call(atom_url, body, protocol)
|
||||
elsif equals_or_includes_any?(type, %w(Note Article Image Video Page Question))
|
||||
FetchRemoteStatusService.new.call(atom_url, body, protocol)
|
||||
end
|
||||
end
|
||||
|
||||
def fetched_atom_feed
|
||||
@_fetched_atom_feed ||= FetchAtomService.new.call(url)
|
||||
end
|
||||
|
||||
def atom_url
|
||||
fetched_atom_feed.first
|
||||
end
|
||||
|
||||
def body
|
||||
fetched_atom_feed.second[:prefetched_body]
|
||||
end
|
||||
|
||||
def protocol
|
||||
fetched_atom_feed.third
|
||||
end
|
||||
|
||||
def type
|
||||
return json_data['type'] if protocol == :activitypub
|
||||
|
||||
case xml_root
|
||||
when 'feed'
|
||||
'Person'
|
||||
when 'entry'
|
||||
'Note'
|
||||
end
|
||||
end
|
||||
|
||||
def json_data
|
||||
@_json_data ||= body_to_json(body)
|
||||
end
|
||||
|
||||
def xml_root
|
||||
xml_data.root.name
|
||||
end
|
||||
|
||||
def xml_data
|
||||
@_xml_data ||= Nokogiri::XML(body, nil, 'utf-8')
|
||||
end
|
||||
|
||||
def local_url?
|
||||
TagManager.instance.local_url?(@url)
|
||||
end
|
||||
|
||||
def process_local_url
|
||||
recognized_params = Rails.application.routes.recognize_path(@url)
|
||||
|
||||
return unless recognized_params[:action] == 'show'
|
||||
|
||||
if recognized_params[:controller] == 'stream_entries'
|
||||
status = StreamEntry.find_by(id: recognized_params[:id])&.status
|
||||
check_local_status(status)
|
||||
elsif recognized_params[:controller] == 'statuses'
|
||||
status = Status.find_by(id: recognized_params[:id])
|
||||
check_local_status(status)
|
||||
elsif recognized_params[:controller] == 'accounts'
|
||||
Account.find_local(recognized_params[:username])
|
||||
end
|
||||
end
|
||||
|
||||
def check_local_status(status)
|
||||
return if status.nil?
|
||||
authorize_with @on_behalf_of, status, :show?
|
||||
status
|
||||
rescue GabSocial::NotPermittedError
|
||||
# Do not disclose the existence of status the user is not authorized to see
|
||||
nil
|
||||
end
|
||||
end
|
||||
123
app/services/search_service.rb
Normal file
123
app/services/search_service.rb
Normal file
@@ -0,0 +1,123 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SearchService < BaseService
|
||||
def call(query, account, limit, options = {})
|
||||
@query = query.strip
|
||||
@account = account
|
||||
@options = options
|
||||
@limit = limit.to_i
|
||||
@offset = options[:type].blank? ? 0 : options[:offset].to_i
|
||||
@resolve = options[:resolve] || false
|
||||
|
||||
default_results.tap do |results|
|
||||
if url_query?
|
||||
results.merge!(url_resource_results) unless url_resource.nil?
|
||||
elsif @query.present?
|
||||
results[:accounts] = perform_accounts_search! if account_searchable?
|
||||
results[:statuses] = perform_statuses_search! if full_text_searchable?
|
||||
results[:hashtags] = perform_hashtags_search! if hashtag_searchable?
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def perform_accounts_search!
|
||||
AccountSearchService.new.call(
|
||||
@query,
|
||||
@account,
|
||||
limit: @limit,
|
||||
resolve: @resolve,
|
||||
offset: @offset
|
||||
)
|
||||
end
|
||||
|
||||
def perform_statuses_search!
|
||||
definition = StatusesIndex.filter(term: { searchable_by: @account.id })
|
||||
.query(multi_match: { type: 'most_fields', query: @query, operator: 'and', fields: %w(text text.stemmed) })
|
||||
|
||||
if @options[:account_id].present?
|
||||
definition = definition.filter(term: { account_id: @options[:account_id] })
|
||||
end
|
||||
|
||||
if @options[:min_id].present? || @options[:max_id].present?
|
||||
range = {}
|
||||
range[:gt] = @options[:min_id].to_i if @options[:min_id].present?
|
||||
range[:lt] = @options[:max_id].to_i if @options[:max_id].present?
|
||||
definition = definition.filter(range: { id: range })
|
||||
end
|
||||
|
||||
results = definition.limit(@limit).offset(@offset).objects.compact
|
||||
account_ids = results.map(&:account_id)
|
||||
account_domains = results.map(&:account_domain)
|
||||
preloaded_relations = relations_map_for_account(@account, account_ids, account_domains)
|
||||
|
||||
results.reject { |status| StatusFilter.new(status, @account, preloaded_relations).filtered? }
|
||||
rescue Faraday::ConnectionFailed
|
||||
[]
|
||||
end
|
||||
|
||||
def perform_hashtags_search!
|
||||
Tag.search_for(
|
||||
@query.gsub(/\A#/, ''),
|
||||
@limit,
|
||||
@offset
|
||||
)
|
||||
end
|
||||
|
||||
def default_results
|
||||
{ accounts: [], hashtags: [], statuses: [] }
|
||||
end
|
||||
|
||||
def url_query?
|
||||
@options[:type].blank? && @query =~ /\Ahttps?:\/\//
|
||||
end
|
||||
|
||||
def url_resource_results
|
||||
{ url_resource_symbol => [url_resource] }
|
||||
end
|
||||
|
||||
def url_resource
|
||||
@_url_resource ||= ResolveURLService.new.call(@query, on_behalf_of: @account)
|
||||
end
|
||||
|
||||
def url_resource_symbol
|
||||
url_resource.class.name.downcase.pluralize.to_sym
|
||||
end
|
||||
|
||||
def full_text_searchable?
|
||||
return false unless Chewy.enabled?
|
||||
|
||||
statuses_search? && !@account.nil? && !((@query.start_with?('#') || @query.include?('@')) && !@query.include?(' '))
|
||||
end
|
||||
|
||||
def account_searchable?
|
||||
account_search? && !(@query.include?('@') && @query.include?(' '))
|
||||
end
|
||||
|
||||
def hashtag_searchable?
|
||||
hashtag_search? && !@query.include?('@')
|
||||
end
|
||||
|
||||
def account_search?
|
||||
@options[:type].blank? || @options[:type] == 'accounts'
|
||||
end
|
||||
|
||||
def hashtag_search?
|
||||
@options[:type].blank? || @options[:type] == 'hashtags'
|
||||
end
|
||||
|
||||
def statuses_search?
|
||||
@options[:type].blank? || @options[:type] == 'statuses'
|
||||
end
|
||||
|
||||
def relations_map_for_account(account, account_ids, domains)
|
||||
{
|
||||
blocking: Account.blocking_map(account_ids, account.id),
|
||||
blocked_by: Account.blocked_by_map(account_ids, account.id),
|
||||
muting: Account.muting_map(account_ids, account.id),
|
||||
following: Account.following_map(account_ids, account.id),
|
||||
domain_blocking_by_domain: Account.domain_blocking_map_by_domain(domains, account.id),
|
||||
}
|
||||
end
|
||||
end
|
||||
39
app/services/send_interaction_service.rb
Normal file
39
app/services/send_interaction_service.rb
Normal file
@@ -0,0 +1,39 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SendInteractionService < BaseService
|
||||
# Send an Atom representation of an interaction to a remote Salmon endpoint
|
||||
# @param [String] Entry XML
|
||||
# @param [Account] source_account
|
||||
# @param [Account] target_account
|
||||
def call(xml, source_account, target_account)
|
||||
@xml = xml
|
||||
@source_account = source_account
|
||||
@target_account = target_account
|
||||
|
||||
return if !target_account.ostatus? || block_notification?
|
||||
|
||||
build_request.perform do |delivery|
|
||||
raise GabSocial::UnexpectedResponseError, delivery unless delivery.code > 199 && delivery.code < 300
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build_request
|
||||
request = Request.new(:post, @target_account.salmon_url, body: envelope)
|
||||
request.add_headers('Content-Type' => 'application/magic-envelope+xml')
|
||||
request
|
||||
end
|
||||
|
||||
def envelope
|
||||
salmon.pack(@xml, @source_account.keypair)
|
||||
end
|
||||
|
||||
def block_notification?
|
||||
DomainBlock.blocked?(@target_account.domain)
|
||||
end
|
||||
|
||||
def salmon
|
||||
@salmon ||= OStatus2::Salmon.new
|
||||
end
|
||||
end
|
||||
58
app/services/subscribe_service.rb
Normal file
58
app/services/subscribe_service.rb
Normal file
@@ -0,0 +1,58 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SubscribeService < BaseService
|
||||
def call(account)
|
||||
return if account.hub_url.blank?
|
||||
|
||||
@account = account
|
||||
@account.secret = SecureRandom.hex
|
||||
|
||||
build_request.perform do |response|
|
||||
if response_failed_permanently? response
|
||||
# We're not allowed to subscribe. Fail and move on.
|
||||
@account.secret = ''
|
||||
@account.save!
|
||||
elsif response_successful? response
|
||||
# The subscription will be confirmed asynchronously.
|
||||
@account.save!
|
||||
else
|
||||
# The response was either a 429 rate limit, or a 5xx error.
|
||||
# We need to retry at a later time. Fail loudly!
|
||||
raise GabSocial::UnexpectedResponseError, response
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build_request
|
||||
request = Request.new(:post, @account.hub_url, form: subscription_params)
|
||||
request.on_behalf_of(some_local_account) if some_local_account
|
||||
request
|
||||
end
|
||||
|
||||
def subscription_params
|
||||
{
|
||||
'hub.topic': @account.remote_url,
|
||||
'hub.mode': 'subscribe',
|
||||
'hub.callback': api_subscription_url(@account.id),
|
||||
'hub.verify': 'async',
|
||||
'hub.secret': @account.secret,
|
||||
'hub.lease_seconds': 7.days.seconds,
|
||||
}
|
||||
end
|
||||
|
||||
def some_local_account
|
||||
@some_local_account ||= Account.local.without_suspended.first
|
||||
end
|
||||
|
||||
# Any response in the 3xx or 4xx range, except for 429 (rate limit)
|
||||
def response_failed_permanently?(response)
|
||||
(response.status.redirect? || response.status.client_error?) && !response.status.too_many_requests?
|
||||
end
|
||||
|
||||
# Any response in the 2xx range
|
||||
def response_successful?(response)
|
||||
response.status.success?
|
||||
end
|
||||
end
|
||||
155
app/services/suspend_account_service.rb
Normal file
155
app/services/suspend_account_service.rb
Normal file
@@ -0,0 +1,155 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SuspendAccountService < BaseService
|
||||
ASSOCIATIONS_ON_SUSPEND = %w(
|
||||
account_pins
|
||||
active_relationships
|
||||
block_relationships
|
||||
blocked_by_relationships
|
||||
conversation_mutes
|
||||
conversations
|
||||
custom_filters
|
||||
domain_blocks
|
||||
favourites
|
||||
follow_requests
|
||||
list_accounts
|
||||
media_attachments
|
||||
mute_relationships
|
||||
muted_by_relationships
|
||||
notifications
|
||||
owned_lists
|
||||
passive_relationships
|
||||
report_notes
|
||||
scheduled_statuses
|
||||
status_pins
|
||||
stream_entries
|
||||
subscriptions
|
||||
).freeze
|
||||
|
||||
ASSOCIATIONS_ON_DESTROY = %w(
|
||||
reports
|
||||
targeted_moderation_notes
|
||||
targeted_reports
|
||||
).freeze
|
||||
|
||||
# Suspend an account and remove as much of its data as possible
|
||||
# @param [Account]
|
||||
# @param [Hash] options
|
||||
# @option [Boolean] :including_user Remove the user record as well
|
||||
# @option [Boolean] :destroy Remove the account record instead of suspending
|
||||
def call(account, **options)
|
||||
@account = account
|
||||
@options = options
|
||||
|
||||
reject_follows!
|
||||
purge_user!
|
||||
purge_profile!
|
||||
purge_content!
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def reject_follows!
|
||||
return if @account.local? || !@account.activitypub?
|
||||
|
||||
ActivityPub::DeliveryWorker.push_bulk(Follow.where(account: @account)) do |follow|
|
||||
[build_reject_json(follow), follow.target_account_id, follow.account.inbox_url]
|
||||
end
|
||||
end
|
||||
|
||||
def purge_user!
|
||||
return if !@account.local? || @account.user.nil?
|
||||
|
||||
if @options[:including_user]
|
||||
@account.user.destroy
|
||||
else
|
||||
@account.user.disable!
|
||||
end
|
||||
end
|
||||
|
||||
def purge_content!
|
||||
distribute_delete_actor! if @account.local? && !@options[:skip_distribution]
|
||||
|
||||
@account.statuses.reorder(nil).find_in_batches do |statuses|
|
||||
BatchedRemoveStatusService.new.call(statuses, skip_side_effects: @options[:destroy])
|
||||
end
|
||||
|
||||
associations_for_destruction.each do |association_name|
|
||||
destroy_all(@account.public_send(association_name))
|
||||
end
|
||||
|
||||
@account.destroy if @options[:destroy]
|
||||
end
|
||||
|
||||
def purge_profile!
|
||||
# If the account is going to be destroyed
|
||||
# there is no point wasting time updating
|
||||
# its values first
|
||||
|
||||
return if @options[:destroy]
|
||||
|
||||
@account.silenced_at = nil
|
||||
@account.suspended_at = @options[:suspended_at] || Time.now.utc
|
||||
@account.locked = false
|
||||
@account.display_name = ''
|
||||
@account.note = ''
|
||||
@account.fields = []
|
||||
@account.statuses_count = 0
|
||||
@account.followers_count = 0
|
||||
@account.following_count = 0
|
||||
@account.moved_to_account = nil
|
||||
@account.avatar.destroy
|
||||
@account.header.destroy
|
||||
@account.save!
|
||||
end
|
||||
|
||||
def destroy_all(association)
|
||||
association.in_batches.destroy_all
|
||||
end
|
||||
|
||||
def distribute_delete_actor!
|
||||
ActivityPub::DeliveryWorker.push_bulk(delivery_inboxes) do |inbox_url|
|
||||
[delete_actor_json, @account.id, inbox_url]
|
||||
end
|
||||
|
||||
ActivityPub::LowPriorityDeliveryWorker.push_bulk(low_priority_delivery_inboxes) do |inbox_url|
|
||||
[delete_actor_json, @account.id, inbox_url]
|
||||
end
|
||||
end
|
||||
|
||||
def delete_actor_json
|
||||
return @delete_actor_json if defined?(@delete_actor_json)
|
||||
|
||||
payload = ActiveModelSerializers::SerializableResource.new(
|
||||
@account,
|
||||
serializer: ActivityPub::DeleteActorSerializer,
|
||||
adapter: ActivityPub::Adapter
|
||||
).as_json
|
||||
|
||||
@delete_actor_json = Oj.dump(ActivityPub::LinkedDataSignature.new(payload).sign!(@account))
|
||||
end
|
||||
|
||||
def build_reject_json(follow)
|
||||
ActiveModelSerializers::SerializableResource.new(
|
||||
follow,
|
||||
serializer: ActivityPub::RejectFollowSerializer,
|
||||
adapter: ActivityPub::Adapter
|
||||
).to_json
|
||||
end
|
||||
|
||||
def delivery_inboxes
|
||||
@delivery_inboxes ||= @account.followers.inboxes + Relay.enabled.pluck(:inbox_url)
|
||||
end
|
||||
|
||||
def low_priority_delivery_inboxes
|
||||
Account.inboxes - delivery_inboxes
|
||||
end
|
||||
|
||||
def associations_for_destruction
|
||||
if @options[:destroy]
|
||||
ASSOCIATIONS_ON_SUSPEND + ASSOCIATIONS_ON_DESTROY
|
||||
else
|
||||
ASSOCIATIONS_ON_SUSPEND
|
||||
end
|
||||
end
|
||||
end
|
||||
32
app/services/unblock_domain_service.rb
Normal file
32
app/services/unblock_domain_service.rb
Normal file
@@ -0,0 +1,32 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class UnblockDomainService < BaseService
|
||||
attr_accessor :domain_block
|
||||
|
||||
def call(domain_block)
|
||||
@domain_block = domain_block
|
||||
process_retroactive_updates
|
||||
domain_block.destroy
|
||||
end
|
||||
|
||||
def process_retroactive_updates
|
||||
blocked_accounts.in_batches.update_all(update_options) unless domain_block.noop?
|
||||
end
|
||||
|
||||
def blocked_accounts
|
||||
scope = Account.where(domain: domain_block.domain)
|
||||
if domain_block.silence?
|
||||
scope.where(silenced_at: @domain_block.created_at)
|
||||
else
|
||||
scope.where(suspended_at: @domain_block.created_at)
|
||||
end
|
||||
end
|
||||
|
||||
def update_options
|
||||
{ domain_block_impact => nil }
|
||||
end
|
||||
|
||||
def domain_block_impact
|
||||
domain_block.silence? ? :silenced_at : :suspended_at
|
||||
end
|
||||
end
|
||||
33
app/services/unblock_service.rb
Normal file
33
app/services/unblock_service.rb
Normal file
@@ -0,0 +1,33 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class UnblockService < BaseService
|
||||
def call(account, target_account)
|
||||
return unless account.blocking?(target_account)
|
||||
|
||||
unblock = account.unblock!(target_account)
|
||||
create_notification(unblock) unless target_account.local?
|
||||
unblock
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_notification(unblock)
|
||||
if unblock.target_account.ostatus?
|
||||
NotificationWorker.perform_async(build_xml(unblock), unblock.account_id, unblock.target_account_id)
|
||||
elsif unblock.target_account.activitypub?
|
||||
ActivityPub::DeliveryWorker.perform_async(build_json(unblock), unblock.account_id, unblock.target_account.inbox_url)
|
||||
end
|
||||
end
|
||||
|
||||
def build_json(unblock)
|
||||
ActiveModelSerializers::SerializableResource.new(
|
||||
unblock,
|
||||
serializer: ActivityPub::UndoBlockSerializer,
|
||||
adapter: ActivityPub::Adapter
|
||||
).to_json
|
||||
end
|
||||
|
||||
def build_xml(block)
|
||||
OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.unblock_salmon(block))
|
||||
end
|
||||
end
|
||||
34
app/services/unfavourite_service.rb
Normal file
34
app/services/unfavourite_service.rb
Normal file
@@ -0,0 +1,34 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class UnfavouriteService < BaseService
|
||||
def call(account, status)
|
||||
favourite = Favourite.find_by!(account: account, status: status)
|
||||
favourite.destroy!
|
||||
create_notification(favourite) unless status.local?
|
||||
favourite
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_notification(favourite)
|
||||
status = favourite.status
|
||||
|
||||
if status.account.ostatus?
|
||||
NotificationWorker.perform_async(build_xml(favourite), favourite.account_id, status.account_id)
|
||||
elsif status.account.activitypub?
|
||||
ActivityPub::DeliveryWorker.perform_async(build_json(favourite), favourite.account_id, status.account.inbox_url)
|
||||
end
|
||||
end
|
||||
|
||||
def build_json(favourite)
|
||||
Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new(
|
||||
favourite,
|
||||
serializer: ActivityPub::UndoLikeSerializer,
|
||||
adapter: ActivityPub::Adapter
|
||||
).as_json).sign!(favourite.account))
|
||||
end
|
||||
|
||||
def build_xml(favourite)
|
||||
OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.unfavourite_salmon(favourite))
|
||||
end
|
||||
end
|
||||
71
app/services/unfollow_service.rb
Normal file
71
app/services/unfollow_service.rb
Normal file
@@ -0,0 +1,71 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class UnfollowService < BaseService
|
||||
# Unfollow and notify the remote user
|
||||
# @param [Account] source_account Where to unfollow from
|
||||
# @param [Account] target_account Which to unfollow
|
||||
def call(source_account, target_account)
|
||||
@source_account = source_account
|
||||
@target_account = target_account
|
||||
|
||||
unfollow! || undo_follow_request!
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def unfollow!
|
||||
follow = Follow.find_by(account: @source_account, target_account: @target_account)
|
||||
|
||||
return unless follow
|
||||
|
||||
follow.destroy!
|
||||
create_notification(follow) unless @target_account.local?
|
||||
create_reject_notification(follow) if @target_account.local? && !@source_account.local?
|
||||
UnmergeWorker.perform_async(@target_account.id, @source_account.id)
|
||||
follow
|
||||
end
|
||||
|
||||
def undo_follow_request!
|
||||
follow_request = FollowRequest.find_by(account: @source_account, target_account: @target_account)
|
||||
|
||||
return unless follow_request
|
||||
|
||||
follow_request.destroy!
|
||||
create_notification(follow_request) unless @target_account.local?
|
||||
follow_request
|
||||
end
|
||||
|
||||
def create_notification(follow)
|
||||
if follow.target_account.ostatus?
|
||||
NotificationWorker.perform_async(build_xml(follow), follow.account_id, follow.target_account_id)
|
||||
elsif follow.target_account.activitypub?
|
||||
ActivityPub::DeliveryWorker.perform_async(build_json(follow), follow.account_id, follow.target_account.inbox_url)
|
||||
end
|
||||
end
|
||||
|
||||
def create_reject_notification(follow)
|
||||
# Rejecting an already-existing follow request
|
||||
return unless follow.account.activitypub?
|
||||
ActivityPub::DeliveryWorker.perform_async(build_reject_json(follow), follow.target_account_id, follow.account.inbox_url)
|
||||
end
|
||||
|
||||
def build_json(follow)
|
||||
ActiveModelSerializers::SerializableResource.new(
|
||||
follow,
|
||||
serializer: ActivityPub::UndoFollowSerializer,
|
||||
adapter: ActivityPub::Adapter
|
||||
).to_json
|
||||
end
|
||||
|
||||
def build_reject_json(follow)
|
||||
ActiveModelSerializers::SerializableResource.new(
|
||||
follow,
|
||||
serializer: ActivityPub::RejectFollowSerializer,
|
||||
adapter: ActivityPub::Adapter
|
||||
).to_json
|
||||
end
|
||||
|
||||
def build_xml(follow)
|
||||
OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.unfollow_salmon(follow))
|
||||
end
|
||||
end
|
||||
11
app/services/unmute_service.rb
Normal file
11
app/services/unmute_service.rb
Normal file
@@ -0,0 +1,11 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class UnmuteService < BaseService
|
||||
def call(account, target_account)
|
||||
return unless account.muting?(target_account)
|
||||
|
||||
account.unmute!(target_account)
|
||||
|
||||
MergeWorker.perform_async(target_account.id, account.id) if account.following?(target_account)
|
||||
end
|
||||
end
|
||||
36
app/services/unsubscribe_service.rb
Normal file
36
app/services/unsubscribe_service.rb
Normal file
@@ -0,0 +1,36 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class UnsubscribeService < BaseService
|
||||
def call(account)
|
||||
return if account.hub_url.blank?
|
||||
|
||||
@account = account
|
||||
|
||||
begin
|
||||
build_request.perform do |response|
|
||||
Rails.logger.debug "PuSH unsubscribe for #{@account.acct} failed: #{response.status}" unless response.status.success?
|
||||
end
|
||||
rescue HTTP::Error, OpenSSL::SSL::SSLError => e
|
||||
Rails.logger.debug "PuSH unsubscribe for #{@account.acct} failed: #{e}"
|
||||
end
|
||||
|
||||
@account.secret = ''
|
||||
@account.subscription_expires_at = nil
|
||||
@account.save!
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build_request
|
||||
Request.new(:post, @account.hub_url, form: subscription_params)
|
||||
end
|
||||
|
||||
def subscription_params
|
||||
{
|
||||
'hub.topic': @account.remote_url,
|
||||
'hub.mode': 'unsubscribe',
|
||||
'hub.callback': api_subscription_url(@account.id),
|
||||
'hub.verify': 'async',
|
||||
}
|
||||
end
|
||||
end
|
||||
35
app/services/update_account_service.rb
Normal file
35
app/services/update_account_service.rb
Normal file
@@ -0,0 +1,35 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class UpdateAccountService < BaseService
|
||||
def call(account, params, raise_error: false)
|
||||
was_locked = account.locked
|
||||
update_method = raise_error ? :update! : :update
|
||||
|
||||
account.send(update_method, params).tap do |ret|
|
||||
next unless ret
|
||||
|
||||
authorize_all_follow_requests(account) if was_locked && !account.locked
|
||||
check_links(account)
|
||||
process_hashtags(account)
|
||||
end
|
||||
rescue GabSocial::DimensionsValidationError => de
|
||||
account.errors.add(:avatar, de.message)
|
||||
false
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def authorize_all_follow_requests(account)
|
||||
AuthorizeFollowWorker.push_bulk(FollowRequest.where(target_account: account).select(:account_id, :target_account_id)) do |req|
|
||||
[req.account_id, req.target_account_id]
|
||||
end
|
||||
end
|
||||
|
||||
def check_links(account)
|
||||
VerifyAccountLinksWorker.perform_async(account.id)
|
||||
end
|
||||
|
||||
def process_hashtags(account)
|
||||
account.tags_as_strings = Extractor.extract_hashtags(account.note)
|
||||
end
|
||||
end
|
||||
66
app/services/update_remote_profile_service.rb
Normal file
66
app/services/update_remote_profile_service.rb
Normal file
@@ -0,0 +1,66 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class UpdateRemoteProfileService < BaseService
|
||||
attr_reader :account, :remote_profile
|
||||
|
||||
def call(body, account, resubscribe = false)
|
||||
@account = account
|
||||
@remote_profile = RemoteProfile.new(body)
|
||||
|
||||
return if remote_profile.root.nil?
|
||||
|
||||
update_account unless remote_profile.author.nil?
|
||||
|
||||
old_hub_url = account.hub_url
|
||||
account.hub_url = remote_profile.hub_link if remote_profile.hub_link.present? && remote_profile.hub_link != old_hub_url
|
||||
|
||||
account.save_with_optional_media!
|
||||
|
||||
Pubsubhubbub::SubscribeWorker.perform_async(account.id) if resubscribe && account.hub_url != old_hub_url
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def update_account
|
||||
account.display_name = remote_profile.display_name || ''
|
||||
account.note = remote_profile.note || ''
|
||||
account.locked = remote_profile.locked?
|
||||
|
||||
if !account.suspended? && !DomainBlock.find_by(domain: account.domain)&.reject_media?
|
||||
if remote_profile.avatar.present?
|
||||
account.avatar_remote_url = remote_profile.avatar
|
||||
else
|
||||
account.avatar_remote_url = ''
|
||||
account.avatar.destroy
|
||||
end
|
||||
|
||||
if remote_profile.header.present?
|
||||
account.header_remote_url = remote_profile.header
|
||||
else
|
||||
account.header_remote_url = ''
|
||||
account.header.destroy
|
||||
end
|
||||
|
||||
save_emojis if remote_profile.emojis.present?
|
||||
end
|
||||
end
|
||||
|
||||
def save_emojis
|
||||
do_not_download = DomainBlock.find_by(domain: account.domain)&.reject_media?
|
||||
|
||||
return if do_not_download
|
||||
|
||||
remote_profile.emojis.each do |link|
|
||||
next unless link['href'] && link['name']
|
||||
|
||||
shortcode = link['name'].delete(':')
|
||||
emoji = CustomEmoji.find_by(shortcode: shortcode, domain: account.domain)
|
||||
|
||||
next unless emoji.nil?
|
||||
|
||||
emoji = CustomEmoji.new(shortcode: shortcode, domain: account.domain)
|
||||
emoji.image_remote_url = link['href']
|
||||
emoji.save
|
||||
end
|
||||
end
|
||||
end
|
||||
47
app/services/verify_link_service.rb
Normal file
47
app/services/verify_link_service.rb
Normal file
@@ -0,0 +1,47 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class VerifyLinkService < BaseService
|
||||
def call(field)
|
||||
@link_back = ActivityPub::TagManager.instance.url_for(field.account)
|
||||
@url = field.value_for_verification
|
||||
|
||||
perform_request!
|
||||
|
||||
return unless link_back_present?
|
||||
|
||||
field.mark_verified!
|
||||
rescue OpenSSL::SSL::SSLError, HTTP::Error, Addressable::URI::InvalidURIError, GabSocial::HostValidationError, GabSocial::LengthValidationError => e
|
||||
Rails.logger.debug "Error fetching link #{@url}: #{e}"
|
||||
nil
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def perform_request!
|
||||
@body = Request.new(:get, @url).add_headers('Accept' => 'text/html').perform do |res|
|
||||
res.code != 200 ? nil : res.body_with_limit
|
||||
end
|
||||
end
|
||||
|
||||
def link_back_present?
|
||||
return false if @body.blank?
|
||||
|
||||
links = Nokogiri::HTML(@body).xpath('//a[contains(concat(" ", normalize-space(@rel), " "), " me ")]|//link[contains(concat(" ", normalize-space(@rel), " "), " me ")]')
|
||||
|
||||
if links.any? { |link| link['href'] == @link_back }
|
||||
true
|
||||
elsif links.empty?
|
||||
false
|
||||
else
|
||||
link_redirects_back?(links.first['href'])
|
||||
end
|
||||
end
|
||||
|
||||
def link_redirects_back?(test_url)
|
||||
redirect_to_url = Request.new(:head, test_url, follow: false).perform do |res|
|
||||
res.headers['Location']
|
||||
end
|
||||
|
||||
redirect_to_url == @link_back
|
||||
end
|
||||
end
|
||||
26
app/services/verify_salmon_service.rb
Normal file
26
app/services/verify_salmon_service.rb
Normal file
@@ -0,0 +1,26 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class VerifySalmonService < BaseService
|
||||
include AuthorExtractor
|
||||
|
||||
def call(payload)
|
||||
body = salmon.unpack(payload)
|
||||
|
||||
xml = Nokogiri::XML(body)
|
||||
xml.encoding = 'utf-8'
|
||||
|
||||
account = author_from_xml(xml.at_xpath('/xmlns:entry', xmlns: OStatus::TagManager::XMLNS))
|
||||
|
||||
if account.nil?
|
||||
false
|
||||
else
|
||||
salmon.verify(payload, account.keypair)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def salmon
|
||||
@salmon ||= OStatus2::Salmon.new
|
||||
end
|
||||
end
|
||||
59
app/services/vote_service.rb
Normal file
59
app/services/vote_service.rb
Normal file
@@ -0,0 +1,59 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class VoteService < BaseService
|
||||
include Authorization
|
||||
|
||||
def call(account, poll, choices)
|
||||
authorize_with account, poll, :vote?
|
||||
|
||||
@account = account
|
||||
@poll = poll
|
||||
@choices = choices
|
||||
@votes = []
|
||||
|
||||
ApplicationRecord.transaction do
|
||||
@choices.each do |choice|
|
||||
@votes << @poll.votes.create!(account: @account, choice: choice)
|
||||
end
|
||||
end
|
||||
|
||||
ActivityTracker.increment('activity:interactions')
|
||||
|
||||
if @poll.account.local?
|
||||
distribute_poll!
|
||||
else
|
||||
deliver_votes!
|
||||
queue_final_poll_check!
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def distribute_poll!
|
||||
return if @poll.hide_totals?
|
||||
ActivityPub::DistributePollUpdateWorker.perform_in(3.minutes, @poll.status.id)
|
||||
end
|
||||
|
||||
def queue_final_poll_check!
|
||||
return unless @poll.expires?
|
||||
PollExpirationNotifyWorker.perform_at(@poll.expires_at + 5.minutes, @poll.id)
|
||||
end
|
||||
|
||||
def deliver_votes!
|
||||
@votes.each do |vote|
|
||||
ActivityPub::DeliveryWorker.perform_async(
|
||||
build_json(vote),
|
||||
@account.id,
|
||||
@poll.account.inbox_url
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def build_json(vote)
|
||||
ActiveModelSerializers::SerializableResource.new(
|
||||
vote,
|
||||
serializer: ActivityPub::VoteSerializer,
|
||||
adapter: ActivityPub::Adapter
|
||||
).to_json
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user