about summary refs log tree commit diff
path: root/app/services
diff options
context:
space:
mode:
Diffstat (limited to 'app/services')
-rw-r--r--app/services/account_search_service.rb2
-rw-r--r--app/services/activitypub/fetch_featured_collection_service.rb52
-rw-r--r--app/services/activitypub/process_account_service.rb22
-rw-r--r--app/services/backup_service.rb128
-rw-r--r--app/services/block_domain_service.rb37
-rw-r--r--app/services/concerns/author_extractor.rb2
-rw-r--r--app/services/fetch_link_card_service.rb26
-rw-r--r--app/services/follow_service.rb2
-rw-r--r--app/services/post_status_service.rb10
-rw-r--r--app/services/process_mentions_service.rb6
-rw-r--r--app/services/report_service.rb54
-rw-r--r--app/services/resolve_account_service.rb (renamed from app/services/resolve_remote_account_service.rb)2
-rw-r--r--app/services/resolve_url_service.rb (renamed from app/services/fetch_remote_resource_service.rb)2
-rw-r--r--app/services/search_service.rb59
14 files changed, 347 insertions, 57 deletions
diff --git a/app/services/account_search_service.rb b/app/services/account_search_service.rb
index 3be110665..3860a9cbd 100644
--- a/app/services/account_search_service.rb
+++ b/app/services/account_search_service.rb
@@ -18,7 +18,7 @@ class AccountSearchService < BaseService
     return [] if query_blank_or_hashtag? || limit < 1
 
     if resolving_non_matching_remote_account?
-      [ResolveRemoteAccountService.new.call("#{query_username}@#{query_domain}")].compact
+      [ResolveAccountService.new.call("#{query_username}@#{query_domain}")].compact
     else
       search_results_and_exact_match.compact.uniq.slice(0, limit)
     end
diff --git a/app/services/activitypub/fetch_featured_collection_service.rb b/app/services/activitypub/fetch_featured_collection_service.rb
new file mode 100644
index 000000000..40714e980
--- /dev/null
+++ b/app/services/activitypub/fetch_featured_collection_service.rb
@@ -0,0 +1,52 @@
+# frozen_string_literal: true
+
+class ActivityPub::FetchFeaturedCollectionService < BaseService
+  include JsonLdHelper
+
+  def call(account)
+    @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
diff --git a/app/services/activitypub/process_account_service.rb b/app/services/activitypub/process_account_service.rb
index f43edafe7..68e9db766 100644
--- a/app/services/activitypub/process_account_service.rb
+++ b/app/services/activitypub/process_account_service.rb
@@ -27,6 +27,7 @@ class ActivityPub::ProcessAccountService < BaseService
 
     after_protocol_change! if protocol_changed?
     after_key_change! if key_changed?
+    check_featured_collection! if @account.featured_collection_url.present?
 
     @account
   rescue Oj::ParseError
@@ -57,14 +58,15 @@ class ActivityPub::ProcessAccountService < BaseService
   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.url              = url || @uri
-    @account.display_name     = @json['name'] || ''
-    @account.note             = @json['summary'] || ''
-    @account.locked           = @json['manuallyApprovesFollowers'] || false
+    @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.display_name            = @json['name'] || ''
+    @account.note                    = @json['summary'] || ''
+    @account.locked                  = @json['manuallyApprovesFollowers'] || false
   end
 
   def set_fetchable_attributes!
@@ -85,6 +87,10 @@ class ActivityPub::ProcessAccountService < BaseService
     RefollowWorker.perform_async(@account.id)
   end
 
+  def check_featured_collection!
+    ActivityPub::SynchronizeFeaturedCollectionWorker.perform_async(@account.id)
+  end
+
   def image_url(key)
     value = first_of_value(@json[key])
 
diff --git a/app/services/backup_service.rb b/app/services/backup_service.rb
new file mode 100644
index 000000000..fadc24a82
--- /dev/null
+++ b/app/services/backup_service.rb
@@ -0,0 +1,128 @@
+# 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.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_actor!(tar)
+        end
+      end
+    end
+
+    archive_filename = ['archive', Time.now.utc.strftime('%Y%m%d%H%M%S'), SecureRandom.hex(2)].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).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]
+
+    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
+
+    tar.add_file_simple('key.pem', 0o444, account.private_key.bytesize) do |io|
+      io.write(account.private_key)
+    end
+  end
+
+  def collection_presenter
+    ActivityPub::CollectionPresenter.new(
+      id: account_outbox_url(account),
+      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
diff --git a/app/services/block_domain_service.rb b/app/services/block_domain_service.rb
index eefdc0dbf..d082de40b 100644
--- a/app/services/block_domain_service.rb
+++ b/app/services/block_domain_service.rb
@@ -5,13 +5,14 @@ class BlockDomainService < BaseService
 
   def call(domain_block)
     @domain_block = domain_block
-    process_domain_block
+    process_domain_block!
   end
 
   private
 
-  def process_domain_block
+  def process_domain_block!
     clear_media! if domain_block.reject_media?
+
     if domain_block.silence?
       silence_accounts!
     elsif domain_block.suspend?
@@ -19,14 +20,26 @@ class BlockDomainService < BaseService
     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.in_batches.update_all(silenced: true)
   end
 
   def clear_media!
-    clear_account_images
-    clear_account_attachments
-    clear_emojos
+    @affected_status_ids = []
+
+    clear_account_images!
+    clear_account_attachments!
+    clear_emojos!
+
+    invalidate_association_caches!
   end
 
   def suspend_accounts!
@@ -36,23 +49,25 @@ class BlockDomainService < BaseService
     end
   end
 
-  def clear_account_images
+  def clear_account_images!
     blocked_domain_accounts.find_each do |account|
-      account.avatar.destroy
-      account.header.destroy
+      account.avatar.destroy if account.avatar.exists?
+      account.header.destroy if account.header.exists?
       account.save
     end
   end
 
-  def clear_account_attachments
+  def clear_account_attachments!
     media_from_blocked_domain.find_each do |attachment|
-      attachment.file.destroy
+      @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
+  def clear_emojos!
     emojis_from_blocked_domains.destroy_all
   end
 
diff --git a/app/services/concerns/author_extractor.rb b/app/services/concerns/author_extractor.rb
index c2366188a..1e00eb803 100644
--- a/app/services/concerns/author_extractor.rb
+++ b/app/services/concerns/author_extractor.rb
@@ -18,6 +18,6 @@ module AuthorExtractor
       acct   = "#{username}@#{domain}"
     end
 
-    ResolveRemoteAccountService.new.call(acct, update_profile)
+    ResolveAccountService.new.call(acct, update_profile)
   end
 end
diff --git a/app/services/fetch_link_card_service.rb b/app/services/fetch_link_card_service.rb
index d0472a1d7..8f252e64c 100644
--- a/app/services/fetch_link_card_service.rb
+++ b/app/services/fetch_link_card_service.rb
@@ -91,17 +91,19 @@ class FetchLinkCardService < BaseService
 
     case @card.type
     when 'link'
-      @card.image = URI.parse(embed.thumbnail_url) if embed.respond_to?(:thumbnail_url)
+      @card.image_remote_url = embed.thumbnail_url if embed.respond_to?(:thumbnail_url)
     when 'photo'
       return false unless embed.respond_to?(:url)
-      @card.embed_url = embed.url
-      @card.image     = URI.parse(embed.url)
-      @card.width     = embed.width.presence  || 0
-      @card.height    = embed.height.presence || 0
+
+      @card.embed_url        = embed.url
+      @card.image_remote_url = embed.url
+      @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::MASTODON_OEMBED)
+      @card.width            = embed.width.presence  || 0
+      @card.height           = embed.height.presence || 0
+      @card.html             = Formatter.instance.sanitize(embed.html, Sanitize::Config::MASTODON_OEMBED)
+      @card.image_remote_url = embed.thumbnail_url if embed.respond_to?(:thumbnail_url)
     when 'rich'
       # Most providers rely on <script> tags, which is a no-no
       return false
@@ -130,12 +132,12 @@ class FetchLinkCardService < BaseService
                                                scrolling: 'no',
                                                frameborder: '0')
     else
-      @card.type             = :link
-      @card.image_remote_url = meta_property(page, 'og:image') if meta_property(page, 'og:image')
+      @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.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 = meta_property(page, 'og:image') if meta_property(page, 'og:image')
 
     return if @card.title.blank? && @card.html.blank?
 
diff --git a/app/services/follow_service.rb b/app/services/follow_service.rb
index ac0207a0a..60a389afd 100644
--- a/app/services/follow_service.rb
+++ b/app/services/follow_service.rb
@@ -9,7 +9,7 @@ class FollowService < BaseService
   # @param [true, false, nil] reblogs Whether or not to show reblogs, defaults to true
   def call(source_account, uri, reblogs: nil)
     reblogs = true if reblogs.nil?
-    target_account = uri.is_a?(Account) ? uri : ResolveRemoteAccountService.new.call(uri)
+    target_account = uri.is_a?(Account) ? uri : ResolveAccountService.new.call(uri)
 
     raise ActiveRecord::RecordNotFound if target_account.nil? || target_account.id == source_account.id || target_account.suspended?
     raise Mastodon::NotPermittedError  if target_account.blocking?(source_account) || source_account.blocking?(target_account)
diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb
index 6b6a37676..74b4cba0c 100644
--- a/app/services/post_status_service.rb
+++ b/app/services/post_status_service.rb
@@ -21,17 +21,18 @@ class PostStatusService < BaseService
 
     media  = validate_media!(options[:media_ids])
     status = nil
+    text   = options.delete(:spoiler_text) if text.blank? && options[:spoiler_text].present?
+    text   = '.' if text.blank? && !media.empty?
 
     ApplicationRecord.transaction do
       status = account.statuses.create!(text: text,
+                                        media_attachments: media || [],
                                         thread: in_reply_to,
                                         sensitive: options[:sensitive],
                                         spoiler_text: options[:spoiler_text] || '',
                                         visibility: options[:visibility] || account.user&.setting_default_privacy,
                                         language: LanguageDetector.instance.detect(text, account),
                                         application: options[:application])
-
-      attach_media(status, media)
     end
 
     process_mentions_service.call(status)
@@ -67,11 +68,6 @@ class PostStatusService < BaseService
     media
   end
 
-  def attach_media(status, media)
-    return if media.nil?
-    media.update(status_id: status.id)
-  end
-
   def process_mentions_service
     ProcessMentionsService.new
   end
diff --git a/app/services/process_mentions_service.rb b/app/services/process_mentions_service.rb
index 46401f298..8e285e1f7 100644
--- a/app/services/process_mentions_service.rb
+++ b/app/services/process_mentions_service.rb
@@ -16,7 +16,7 @@ class ProcessMentionsService < BaseService
 
       if mention_undeliverable?(status, mentioned_account)
         begin
-          mentioned_account = resolve_remote_account_service.call($1)
+          mentioned_account = resolve_account_service.call($1)
         rescue Goldfinger::Error, HTTP::Error
           mentioned_account = nil
         end
@@ -63,7 +63,7 @@ class ProcessMentionsService < BaseService
     ).as_json).sign!(status.account))
   end
 
-  def resolve_remote_account_service
-    ResolveRemoteAccountService.new
+  def resolve_account_service
+    ResolveAccountService.new
   end
 end
diff --git a/app/services/report_service.rb b/app/services/report_service.rb
new file mode 100644
index 000000000..c06488a6d
--- /dev/null
+++ b/app/services/report_service.rb
@@ -0,0 +1,54 @@
+# 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
+    )
+  end
+
+  def notify_staff!
+    User.staff.includes(:account).each do |u|
+      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.local.where(suspended: false).first
+  end
+end
diff --git a/app/services/resolve_remote_account_service.rb b/app/services/resolve_account_service.rb
index d7d0be210..fd6d30605 100644
--- a/app/services/resolve_remote_account_service.rb
+++ b/app/services/resolve_account_service.rb
@@ -1,6 +1,6 @@
 # frozen_string_literal: true
 
-class ResolveRemoteAccountService < BaseService
+class ResolveAccountService < BaseService
   include OStatus2::MagicKey
   include JsonLdHelper
 
diff --git a/app/services/fetch_remote_resource_service.rb b/app/services/resolve_url_service.rb
index 6d40796f2..1f2b24524 100644
--- a/app/services/fetch_remote_resource_service.rb
+++ b/app/services/resolve_url_service.rb
@@ -1,6 +1,6 @@
 # frozen_string_literal: true
 
-class FetchRemoteResourceService < BaseService
+class ResolveURLService < BaseService
   include JsonLdHelper
 
   attr_reader :url
diff --git a/app/services/search_service.rb b/app/services/search_service.rb
index 85ad94463..00a8b3dd7 100644
--- a/app/services/search_service.rb
+++ b/app/services/search_service.rb
@@ -1,21 +1,45 @@
 # frozen_string_literal: true
 
 class SearchService < BaseService
-  attr_accessor :query
+  attr_accessor :query, :account, :limit, :resolve
 
   def call(query, limit, resolve = false, account = nil)
-    @query = query
+    @query   = query
+    @account = account
+    @limit   = limit
+    @resolve = resolve
 
     default_results.tap do |results|
       if url_query?
-        results.merge!(remote_resource_results) unless remote_resource.nil?
+        results.merge!(url_resource_results) unless url_resource.nil?
       elsif query.present?
-        results[:accounts] = AccountSearchService.new.call(query, limit, account, resolve: resolve)
-        results[:hashtags] = Tag.search_for(query.gsub(/\A#/, ''), limit) unless query.start_with?('@')
+        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, limit, account, resolve: resolve)
+  end
+
+  def perform_statuses_search!
+    statuses = StatusesIndex.filter(term: { searchable_by: account.id })
+                            .query(multi_match: { type: 'most_fields', query: query, operator: 'and', fields: %w(text text.stemmed) })
+                            .limit(limit)
+                            .objects
+                            .compact
+
+    statuses.reject { |status| StatusFilter.new(status, account).filtered? }
+  end
+
+  def perform_hashtags_search!
+    Tag.search_for(query.gsub(/\A#/, ''), limit)
+  end
+
   def default_results
     { accounts: [], hashtags: [], statuses: [] }
   end
@@ -24,15 +48,28 @@ class SearchService < BaseService
     query =~ /\Ahttps?:\/\//
   end
 
-  def remote_resource_results
-    { remote_resource_symbol => [remote_resource] }
+  def url_resource_results
+    { url_resource_symbol => [url_resource] }
+  end
+
+  def url_resource
+    @_url_resource ||= ResolveURLService.new.call(query)
+  end
+
+  def url_resource_symbol
+    url_resource.class.name.downcase.pluralize.to_sym
+  end
+
+  def full_text_searchable?
+    return false unless Chewy.enabled?
+    !account.nil? && !((query.start_with?('#') || query.include?('@')) && !query.include?(' '))
   end
 
-  def remote_resource
-    @_remote_resource ||= FetchRemoteResourceService.new.call(query)
+  def account_searchable?
+    !(query.include?('@') && query.include?(' '))
   end
 
-  def remote_resource_symbol
-    remote_resource.class.name.downcase.pluralize.to_sym
+  def hashtag_searchable?
+    !query.include?('@')
   end
 end