about summary refs log tree commit diff
path: root/app/lib
diff options
context:
space:
mode:
Diffstat (limited to 'app/lib')
-rw-r--r--app/lib/activitypub/activity.rb106
-rw-r--r--app/lib/activitypub/activity/accept.rb25
-rw-r--r--app/lib/activitypub/activity/announce.rb35
-rw-r--r--app/lib/activitypub/activity/block.rb12
-rw-r--r--app/lib/activitypub/activity/create.rb215
-rw-r--r--app/lib/activitypub/activity/delete.rb45
-rw-r--r--app/lib/activitypub/activity/follow.rb24
-rw-r--r--app/lib/activitypub/activity/like.rb12
-rw-r--r--app/lib/activitypub/activity/reject.rb25
-rw-r--r--app/lib/activitypub/activity/undo.rb71
-rw-r--r--app/lib/activitypub/activity/update.rb17
-rw-r--r--app/lib/activitypub/adapter.rb27
-rw-r--r--app/lib/activitypub/case_transform.rb24
-rw-r--r--app/lib/activitypub/linked_data_signature.rb56
-rw-r--r--app/lib/activitypub/tag_manager.rb40
-rw-r--r--app/lib/emoji.rb40
-rw-r--r--app/lib/formatter.rb68
-rw-r--r--app/lib/language_detector.rb40
-rw-r--r--app/lib/ostatus/activity/base.rb38
-rw-r--r--app/lib/ostatus/activity/creation.rb123
-rw-r--r--app/lib/ostatus/activity/deletion.rb4
-rw-r--r--app/lib/ostatus/activity/remote.rb6
-rw-r--r--app/lib/ostatus/activity/share.rb2
-rw-r--r--app/lib/ostatus/atom_serializer.rb117
-rw-r--r--app/lib/ostatus/tag_manager.rb73
-rw-r--r--app/lib/request.rb26
-rw-r--r--app/lib/status_finder.rb (renamed from app/lib/stream_entry_finder.rb)10
-rw-r--r--app/lib/tag_manager.rb64
-rw-r--r--app/lib/themes.rb23
-rw-r--r--app/lib/user_settings_decorator.rb5
30 files changed, 1133 insertions, 240 deletions
diff --git a/app/lib/activitypub/activity.rb b/app/lib/activitypub/activity.rb
new file mode 100644
index 000000000..b06dd6194
--- /dev/null
+++ b/app/lib/activitypub/activity.rb
@@ -0,0 +1,106 @@
+# frozen_string_literal: true
+
+class ActivityPub::Activity
+  include JsonLdHelper
+
+  def initialize(json, account)
+    @json    = json
+    @account = account
+    @object  = @json['object']
+  end
+
+  def perform
+    raise NotImplementedError
+  end
+
+  class << self
+    def factory(json, account)
+      @json = json
+      klass&.new(json, account)
+    end
+
+    private
+
+    def klass
+      case @json['type']
+      when 'Create'
+        ActivityPub::Activity::Create
+      when 'Announce'
+        ActivityPub::Activity::Announce
+      when 'Delete'
+        ActivityPub::Activity::Delete
+      when 'Follow'
+        ActivityPub::Activity::Follow
+      when 'Like'
+        ActivityPub::Activity::Like
+      when 'Block'
+        ActivityPub::Activity::Block
+      when 'Update'
+        ActivityPub::Activity::Update
+      when 'Undo'
+        ActivityPub::Activity::Undo
+      when 'Accept'
+        ActivityPub::Activity::Accept
+      when 'Reject'
+        ActivityPub::Activity::Reject
+      end
+    end
+  end
+
+  protected
+
+  def status_from_uri(uri)
+    ActivityPub::TagManager.instance.uri_to_resource(uri, Status)
+  end
+
+  def account_from_uri(uri)
+    ActivityPub::TagManager.instance.uri_to_resource(uri, Account)
+  end
+
+  def object_uri
+    @object_uri ||= value_or_id(@object)
+  end
+
+  def redis
+    Redis.current
+  end
+
+  def distribute(status)
+    notify_about_reblog(status) if reblog_of_local_account?(status)
+    notify_about_mentions(status)
+    crawl_links(status)
+    distribute_to_followers(status)
+  end
+
+  def reblog_of_local_account?(status)
+    status.reblog? && status.reblog.account.local?
+  end
+
+  def notify_about_reblog(status)
+    NotifyService.new.call(status.reblog.account, status)
+  end
+
+  def notify_about_mentions(status)
+    status.mentions.includes(:account).each do |mention|
+      next unless mention.account.local? && audience_includes?(mention.account)
+      NotifyService.new.call(mention.account, mention)
+    end
+  end
+
+  def crawl_links(status)
+    return if status.spoiler_text?
+    LinkCrawlWorker.perform_async(status.id)
+  end
+
+  def distribute_to_followers(status)
+    ::DistributionWorker.perform_async(status.id)
+  end
+
+  def delete_arrived_first?(uri)
+    redis.exists("delete_upon_arrival:#{@account.id}:#{uri}")
+  end
+
+  def delete_later!(uri)
+    redis.setex("delete_upon_arrival:#{@account.id}:#{uri}", 6.hours.seconds, uri)
+  end
+end
diff --git a/app/lib/activitypub/activity/accept.rb b/app/lib/activitypub/activity/accept.rb
new file mode 100644
index 000000000..bd90c9019
--- /dev/null
+++ b/app/lib/activitypub/activity/accept.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+class ActivityPub::Activity::Accept < ActivityPub::Activity
+  def perform
+    case @object['type']
+    when 'Follow'
+      accept_follow
+    end
+  end
+
+  private
+
+  def accept_follow
+    target_account = account_from_uri(target_uri)
+
+    return if target_account.nil? || !target_account.local?
+
+    follow_request = FollowRequest.find_by(account: target_account, target_account: @account)
+    follow_request&.authorize!
+  end
+
+  def target_uri
+    @target_uri ||= value_or_id(@object['actor'])
+  end
+end
diff --git a/app/lib/activitypub/activity/announce.rb b/app/lib/activitypub/activity/announce.rb
new file mode 100644
index 000000000..4516454e1
--- /dev/null
+++ b/app/lib/activitypub/activity/announce.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+class ActivityPub::Activity::Announce < ActivityPub::Activity
+  def perform
+    original_status   = status_from_uri(object_uri)
+    original_status ||= fetch_remote_original_status
+
+    return if original_status.nil? || delete_arrived_first?(@json['id'])
+
+    status = Status.find_by(account: @account, reblog: original_status)
+
+    return status unless status.nil?
+
+    status = Status.create!(
+      account: @account,
+      reblog: original_status,
+      uri: @json['id'],
+      created_at: @json['published'] || Time.now.utc
+    )
+    distribute(status)
+    status
+  end
+
+  private
+
+  def fetch_remote_original_status
+    if object_uri.start_with?('http')
+      return if ActivityPub::TagManager.instance.local_uri?(object_uri)
+
+      ActivityPub::FetchRemoteStatusService.new.call(object_uri)
+    elsif @object['url'].present?
+      ::FetchRemoteStatusService.new.call(@object['url'])
+    end
+  end
+end
diff --git a/app/lib/activitypub/activity/block.rb b/app/lib/activitypub/activity/block.rb
new file mode 100644
index 000000000..f630d5db2
--- /dev/null
+++ b/app/lib/activitypub/activity/block.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+class ActivityPub::Activity::Block < ActivityPub::Activity
+  def perform
+    target_account = account_from_uri(object_uri)
+
+    return if target_account.nil? || !target_account.local? || delete_arrived_first?(@json['id']) || @account.blocking?(target_account)
+
+    UnfollowService.new.call(target_account, @account) if target_account.following?(@account)
+    @account.block!(target_account)
+  end
+end
diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb
new file mode 100644
index 000000000..4e19b3096
--- /dev/null
+++ b/app/lib/activitypub/activity/create.rb
@@ -0,0 +1,215 @@
+# frozen_string_literal: true
+
+class ActivityPub::Activity::Create < ActivityPub::Activity
+  def perform
+    return if delete_arrived_first?(object_uri) || unsupported_object_type?
+
+    RedisLock.acquire(lock_options) do |lock|
+      if lock.acquired?
+        @status = find_existing_status
+        process_status if @status.nil?
+      end
+    end
+
+    @status
+  end
+
+  private
+
+  def process_status
+    ApplicationRecord.transaction do
+      @status = Status.create!(status_params)
+
+      process_tags(@status)
+      process_attachments(@status)
+    end
+
+    resolve_thread(@status)
+    distribute(@status)
+    forward_for_reply if @status.public_visibility? || @status.unlisted_visibility?
+  end
+
+  def find_existing_status
+    status   = status_from_uri(object_uri)
+    status ||= Status.find_by(uri: @object['atomUri']) if @object['atomUri'].present?
+    status
+  end
+
+  def status_params
+    {
+      uri: @object['id'],
+      url: object_url || @object['id'],
+      account: @account,
+      text: text_from_content || '',
+      language: language_from_content,
+      spoiler_text: @object['summary'] || '',
+      created_at: @object['published'] || Time.now.utc,
+      reply: @object['inReplyTo'].present?,
+      sensitive: @object['sensitive'] || false,
+      visibility: visibility_from_audience,
+      thread: replied_to_status,
+      conversation: conversation_from_uri(@object['conversation']),
+    }
+  end
+
+  def process_tags(status)
+    return unless @object['tag'].is_a?(Array)
+
+    @object['tag'].each do |tag|
+      case tag['type']
+      when 'Hashtag'
+        process_hashtag tag, status
+      when 'Mention'
+        process_mention tag, status
+      when 'Emoji'
+        process_emoji tag, status
+      end
+    end
+  end
+
+  def process_hashtag(tag, status)
+    return if tag['name'].blank?
+
+    hashtag = tag['name'].gsub(/\A#/, '').mb_chars.downcase
+    hashtag = Tag.where(name: hashtag).first_or_initialize(name: hashtag)
+
+    status.tags << hashtag
+  end
+
+  def process_mention(tag, status)
+    return if tag['href'].blank?
+
+    account = account_from_uri(tag['href'])
+    account = FetchRemoteAccountService.new.call(tag['href']) if account.nil?
+    return if account.nil?
+    account.mentions.create(status: status)
+  end
+
+  def process_emoji(tag, _status)
+    return if tag['name'].blank? || tag['href'].blank?
+
+    shortcode = tag['name'].delete(':')
+    emoji     = CustomEmoji.find_by(shortcode: shortcode, domain: @account.domain)
+
+    return if !emoji.nil? || skip_download?
+
+    emoji = CustomEmoji.new(domain: @account.domain, shortcode: shortcode)
+    emoji.image_remote_url = tag['href']
+    emoji.save
+  end
+
+  def process_attachments(status)
+    return unless @object['attachment'].is_a?(Array)
+
+    @object['attachment'].each do |attachment|
+      next if unsupported_media_type?(attachment['mediaType']) || attachment['url'].blank?
+
+      href             = Addressable::URI.parse(attachment['url']).normalize.to_s
+      media_attachment = MediaAttachment.create(status: status, account: status.account, remote_url: href)
+
+      next if skip_download?
+
+      media_attachment.file_remote_url = href
+      media_attachment.save
+    end
+  rescue Addressable::URI::InvalidURIError => e
+    Rails.logger.debug e
+  end
+
+  def resolve_thread(status)
+    return unless status.reply? && status.thread.nil?
+    ThreadResolveWorker.perform_async(status.id, in_reply_to_uri)
+  end
+
+  def conversation_from_uri(uri)
+    return nil if uri.nil?
+    return Conversation.find_by(id: OStatus::TagManager.instance.unique_tag_to_local_id(uri, 'Conversation')) if OStatus::TagManager.instance.local_id?(uri)
+    Conversation.find_by(uri: uri) || Conversation.create(uri: uri)
+  end
+
+  def visibility_from_audience
+    if equals_or_includes?(@object['to'], ActivityPub::TagManager::COLLECTIONS[:public])
+      :public
+    elsif equals_or_includes?(@object['cc'], ActivityPub::TagManager::COLLECTIONS[:public])
+      :unlisted
+    elsif equals_or_includes?(@object['to'], @account.followers_url)
+      :private
+    else
+      :direct
+    end
+  end
+
+  def audience_includes?(account)
+    uri = ActivityPub::TagManager.instance.uri_for(account)
+    equals_or_includes?(@object['to'], uri) || equals_or_includes?(@object['cc'], uri)
+  end
+
+  def replied_to_status
+    return @replied_to_status if defined?(@replied_to_status)
+
+    if in_reply_to_uri.blank?
+      @replied_to_status = nil
+    else
+      @replied_to_status   = status_from_uri(in_reply_to_uri)
+      @replied_to_status ||= status_from_uri(@object['inReplyToAtomUri']) if @object['inReplyToAtomUri'].present?
+      @replied_to_status
+    end
+  end
+
+  def in_reply_to_uri
+    value_or_id(@object['inReplyTo'])
+  end
+
+  def text_from_content
+    if @object['content'].present?
+      @object['content']
+    elsif language_map?
+      @object['contentMap'].values.first
+    end
+  end
+
+  def language_from_content
+    return nil unless language_map?
+    @object['contentMap'].keys.first
+  end
+
+  def object_url
+    return if @object['url'].blank?
+
+    value = first_of_value(@object['url'])
+
+    return value if value.is_a?(String)
+
+    value['href']
+  end
+
+  def language_map?
+    @object['contentMap'].is_a?(Hash) && !@object['contentMap'].empty?
+  end
+
+  def unsupported_object_type?
+    @object.is_a?(String) || !%w(Article Note).include?(@object['type'])
+  end
+
+  def unsupported_media_type?(mime_type)
+    mime_type.present? && !(MediaAttachment::IMAGE_MIME_TYPES + MediaAttachment::VIDEO_MIME_TYPES).include?(mime_type)
+  end
+
+  def skip_download?
+    return @skip_download if defined?(@skip_download)
+    @skip_download ||= DomainBlock.find_by(domain: @account.domain)&.reject_media?
+  end
+
+  def reply_to_local?
+    !replied_to_status.nil? && replied_to_status.account.local?
+  end
+
+  def forward_for_reply
+    return unless @json['signature'].present? && reply_to_local?
+    ActivityPub::RawDistributionWorker.perform_async(Oj.dump(@json), replied_to_status.account_id)
+  end
+
+  def lock_options
+    { redis: Redis.current, key: "create:#{@object['id']}" }
+  end
+end
diff --git a/app/lib/activitypub/activity/delete.rb b/app/lib/activitypub/activity/delete.rb
new file mode 100644
index 000000000..4c6afb090
--- /dev/null
+++ b/app/lib/activitypub/activity/delete.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+class ActivityPub::Activity::Delete < ActivityPub::Activity
+  def perform
+    if @account.uri == object_uri
+      delete_person
+    else
+      delete_note
+    end
+  end
+
+  private
+
+  def delete_person
+    SuspendAccountService.new.call(@account)
+  end
+
+  def delete_note
+    status   = Status.find_by(uri: object_uri, account: @account)
+    status ||= Status.find_by(uri: @object['atomUri'], account: @account) if @object.is_a?(Hash) && @object['atomUri'].present?
+
+    delete_later!(object_uri)
+
+    return if status.nil?
+
+    forward_for_reblogs(status)
+    delete_now!(status)
+  end
+
+  def forward_for_reblogs(status)
+    return if @json['signature'].blank?
+
+    ActivityPub::RawDistributionWorker.push_bulk(status.reblogs.includes(:account).references(:account).merge(Account.local).pluck(:account_id)) do |account_id|
+      [payload, account_id]
+    end
+  end
+
+  def delete_now!(status)
+    RemoveStatusService.new.call(status)
+  end
+
+  def payload
+    @payload ||= Oj.dump(@json)
+  end
+end
diff --git a/app/lib/activitypub/activity/follow.rb b/app/lib/activitypub/activity/follow.rb
new file mode 100644
index 000000000..8adbbb9c3
--- /dev/null
+++ b/app/lib/activitypub/activity/follow.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+class ActivityPub::Activity::Follow < ActivityPub::Activity
+  def perform
+    target_account = account_from_uri(object_uri)
+
+    return if target_account.nil? || !target_account.local? || delete_arrived_first?(@json['id']) || @account.requested?(target_account)
+
+    # Fast-forward repeat follow requests
+    if @account.following?(target_account)
+      AuthorizeFollowService.new.call(@account, target_account, skip_follow_request: true)
+      return
+    end
+
+    follow_request = FollowRequest.create!(account: @account, target_account: target_account)
+
+    if target_account.locked?
+      NotifyService.new.call(target_account, follow_request)
+    else
+      AuthorizeFollowService.new.call(@account, target_account)
+      NotifyService.new.call(target_account, ::Follow.find_by(account: @account, target_account: target_account))
+    end
+  end
+end
diff --git a/app/lib/activitypub/activity/like.rb b/app/lib/activitypub/activity/like.rb
new file mode 100644
index 000000000..674d5fe47
--- /dev/null
+++ b/app/lib/activitypub/activity/like.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+class ActivityPub::Activity::Like < ActivityPub::Activity
+  def perform
+    original_status = status_from_uri(object_uri)
+
+    return if original_status.nil? || !original_status.account.local? || delete_arrived_first?(@json['id']) || @account.favourited?(original_status)
+
+    favourite = original_status.favourites.create!(account: @account)
+    NotifyService.new.call(original_status.account, favourite)
+  end
+end
diff --git a/app/lib/activitypub/activity/reject.rb b/app/lib/activitypub/activity/reject.rb
new file mode 100644
index 000000000..d815feeb6
--- /dev/null
+++ b/app/lib/activitypub/activity/reject.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+class ActivityPub::Activity::Reject < ActivityPub::Activity
+  def perform
+    case @object['type']
+    when 'Follow'
+      reject_follow
+    end
+  end
+
+  private
+
+  def reject_follow
+    target_account = account_from_uri(target_uri)
+
+    return if target_account.nil? || !target_account.local?
+
+    follow_request = FollowRequest.find_by(account: target_account, target_account: @account)
+    follow_request&.reject!
+  end
+
+  def target_uri
+    @target_uri ||= value_or_id(@object['actor'])
+  end
+end
diff --git a/app/lib/activitypub/activity/undo.rb b/app/lib/activitypub/activity/undo.rb
new file mode 100644
index 000000000..4b0905de2
--- /dev/null
+++ b/app/lib/activitypub/activity/undo.rb
@@ -0,0 +1,71 @@
+# frozen_string_literal: true
+
+class ActivityPub::Activity::Undo < ActivityPub::Activity
+  def perform
+    case @object['type']
+    when 'Announce'
+      undo_announce
+    when 'Follow'
+      undo_follow
+    when 'Like'
+      undo_like
+    when 'Block'
+      undo_block
+    end
+  end
+
+  private
+
+  def undo_announce
+    status = Status.find_by(uri: object_uri, account: @account)
+
+    if status.nil?
+      delete_later!(object_uri)
+    else
+      RemoveStatusService.new.call(status)
+    end
+  end
+
+  def undo_follow
+    target_account = account_from_uri(target_uri)
+
+    return if target_account.nil? || !target_account.local?
+
+    if @account.following?(target_account)
+      @account.unfollow!(target_account)
+    elsif @account.requested?(target_account)
+      FollowRequest.find_by(account: @account, target_account: target_account)&.destroy
+    else
+      delete_later!(object_uri)
+    end
+  end
+
+  def undo_like
+    status = status_from_uri(target_uri)
+
+    return if status.nil? || !status.account.local?
+
+    if @account.favourited?(status)
+      favourite = status.favourites.where(account: @account).first
+      favourite&.destroy
+    else
+      delete_later!(object_uri)
+    end
+  end
+
+  def undo_block
+    target_account = account_from_uri(target_uri)
+
+    return if target_account.nil? || !target_account.local?
+
+    if @account.blocking?(target_account)
+      UnblockService.new.call(@account, target_account)
+    else
+      delete_later!(object_uri)
+    end
+  end
+
+  def target_uri
+    @target_uri ||= value_or_id(@object['object'])
+  end
+end
diff --git a/app/lib/activitypub/activity/update.rb b/app/lib/activitypub/activity/update.rb
new file mode 100644
index 000000000..0134b4015
--- /dev/null
+++ b/app/lib/activitypub/activity/update.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class ActivityPub::Activity::Update < ActivityPub::Activity
+  def perform
+    case @object['type']
+    when 'Person'
+      update_account
+    end
+  end
+
+  private
+
+  def update_account
+    return if @account.uri != object_uri
+    ActivityPub::ProcessAccountService.new.call(@account.username, @account.domain, @object)
+  end
+end
diff --git a/app/lib/activitypub/adapter.rb b/app/lib/activitypub/adapter.rb
index 0a70207bc..790d2025c 100644
--- a/app/lib/activitypub/adapter.rb
+++ b/app/lib/activitypub/adapter.rb
@@ -1,13 +1,36 @@
 # frozen_string_literal: true
 
 class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base
+  CONTEXT = {
+    '@context': [
+      'https://www.w3.org/ns/activitystreams',
+      'https://w3id.org/security/v1',
+
+      {
+        'manuallyApprovesFollowers' => 'as:manuallyApprovesFollowers',
+        'sensitive'                 => 'as:sensitive',
+        'Hashtag'                   => 'as:Hashtag',
+        'ostatus'                   => 'http://ostatus.org#',
+        'atomUri'                   => 'ostatus:atomUri',
+        'inReplyToAtomUri'          => 'ostatus:inReplyToAtomUri',
+        'conversation'              => 'ostatus:conversation',
+        'toot'                      => 'http://joinmastodon.org/ns#',
+        'Emoji'                     => 'toot:Emoji',
+      },
+    ],
+  }.freeze
+
   def self.default_key_transform
     :camel_lower
   end
 
+  def self.transform_key_casing!(value, _options)
+    ActivityPub::CaseTransform.camel_lower(value)
+  end
+
   def serializable_hash(options = nil)
     options = serialization_options(options)
-    serialized_hash = { '@context': 'https://www.w3.org/ns/activitystreams' }.merge(ActiveModelSerializers::Adapter::Attributes.new(serializer, instance_options).serializable_hash(options))
-    self.class.transform_key_casing!(serialized_hash, instance_options)
+    serialized_hash = ActiveModelSerializers::Adapter::Attributes.new(serializer, instance_options).serializable_hash(options)
+    CONTEXT.merge(self.class.transform_key_casing!(serialized_hash, instance_options))
   end
 end
diff --git a/app/lib/activitypub/case_transform.rb b/app/lib/activitypub/case_transform.rb
new file mode 100644
index 000000000..7f716f862
--- /dev/null
+++ b/app/lib/activitypub/case_transform.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+module ActivityPub::CaseTransform
+  class << self
+    def camel_lower_cache
+      @camel_lower_cache ||= {}
+    end
+
+    def camel_lower(value)
+      case value
+      when Array then value.map { |item| camel_lower(item) }
+      when Hash then value.deep_transform_keys! { |key| camel_lower(key) }
+      when Symbol then camel_lower(value.to_s).to_sym
+      when String
+        camel_lower_cache[value] ||= if value.start_with?('_:')
+                                       '_:' + value.gsub(/\A_:/, '').underscore.camelize(:lower)
+                                     else
+                                       value.underscore.camelize(:lower)
+                                     end
+      else value
+      end
+    end
+  end
+end
diff --git a/app/lib/activitypub/linked_data_signature.rb b/app/lib/activitypub/linked_data_signature.rb
new file mode 100644
index 000000000..adb8b6cdf
--- /dev/null
+++ b/app/lib/activitypub/linked_data_signature.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+class ActivityPub::LinkedDataSignature
+  include JsonLdHelper
+
+  CONTEXT = 'https://w3id.org/identity/v1'
+
+  def initialize(json)
+    @json = json.with_indifferent_access
+  end
+
+  def verify_account!
+    return unless @json['signature'].is_a?(Hash)
+
+    type        = @json['signature']['type']
+    creator_uri = @json['signature']['creator']
+    signature   = @json['signature']['signatureValue']
+
+    return unless type == 'RsaSignature2017'
+
+    creator   = ActivityPub::TagManager.instance.uri_to_resource(creator_uri, Account)
+    creator ||= ActivityPub::FetchRemoteKeyService.new.call(creator_uri)
+
+    return if creator.nil?
+
+    options_hash   = hash(@json['signature'].without('type', 'id', 'signatureValue').merge('@context' => CONTEXT))
+    document_hash  = hash(@json.without('signature'))
+    to_be_verified = options_hash + document_hash
+
+    if creator.keypair.public_key.verify(OpenSSL::Digest::SHA256.new, Base64.decode64(signature), to_be_verified)
+      creator
+    end
+  end
+
+  def sign!(creator)
+    options = {
+      'type'    => 'RsaSignature2017',
+      'creator' => [ActivityPub::TagManager.instance.uri_for(creator), '#main-key'].join,
+      'created' => Time.now.utc.iso8601,
+    }
+
+    options_hash  = hash(options.without('type', 'id', 'signatureValue').merge('@context' => CONTEXT))
+    document_hash = hash(@json.without('signature'))
+    to_be_signed  = options_hash + document_hash
+
+    signature = Base64.strict_encode64(creator.keypair.sign(OpenSSL::Digest::SHA256.new, to_be_signed))
+
+    @json.merge('signature' => options.merge('signatureValue' => signature))
+  end
+
+  private
+
+  def hash(obj)
+    Digest::SHA256.hexdigest(canonicalize(obj))
+  end
+end
diff --git a/app/lib/activitypub/tag_manager.rb b/app/lib/activitypub/tag_manager.rb
index ec42bcad3..4ec3b8c56 100644
--- a/app/lib/activitypub/tag_manager.rb
+++ b/app/lib/activitypub/tag_manager.rb
@@ -6,6 +6,8 @@ class ActivityPub::TagManager
   include Singleton
   include RoutingHelper
 
+  CONTEXT = 'https://www.w3.org/ns/activitystreams'
+
   COLLECTIONS = {
     public: 'https://www.w3.org/ns/activitystreams#Public',
   }.freeze
@@ -17,6 +19,7 @@ class ActivityPub::TagManager
     when :person
       short_account_url(target)
     when :note, :comment, :activity
+      return activity_account_status_url(target.account, target) if target.reblog?
       short_account_status_url(target.account, target)
     end
   end
@@ -28,10 +31,17 @@ class ActivityPub::TagManager
     when :person
       account_url(target)
     when :note, :comment, :activity
+      return activity_account_status_url(target.account, target) if target.reblog?
       account_status_url(target.account, target)
     end
   end
 
+  def activity_uri_for(target)
+    raise ArgumentError, 'target must be a local activity' unless %i(note comment activity).include?(target.object_type) && target.local?
+
+    activity_account_status_url(target.account, target)
+  end
+
   # Primary audience of a status
   # Public statuses go out to primarily the public collection
   # Unlisted and private statuses go out primarily to the followers collection
@@ -66,4 +76,34 @@ class ActivityPub::TagManager
 
     cc
   end
+
+  def local_uri?(uri)
+    uri  = Addressable::URI.parse(uri)
+    host = uri.normalized_host
+    host = "#{host}:#{uri.port}" if uri.port
+
+    !host.nil? && (::TagManager.instance.local_domain?(host) || ::TagManager.instance.web_domain?(host))
+  end
+
+  def uri_to_local_id(uri, param = :id)
+    path_params = Rails.application.routes.recognize_path(uri)
+    path_params[param]
+  end
+
+  def uri_to_resource(uri, klass)
+    if local_uri?(uri)
+      case klass.name
+      when 'Account'
+        klass.find_local(uri_to_local_id(uri, :username))
+      else
+        StatusFinder.new(uri).status
+      end
+    elsif OStatus::TagManager.instance.local_id?(uri)
+      klass.find_by(id: OStatus::TagManager.instance.unique_tag_to_local_id(uri, klass.to_s))
+    else
+      klass.find_by(uri: uri.split('#').first)
+    end
+  rescue ActiveRecord::RecordNotFound
+    nil
+  end
 end
diff --git a/app/lib/emoji.rb b/app/lib/emoji.rb
deleted file mode 100644
index 45b7f53de..000000000
--- a/app/lib/emoji.rb
+++ /dev/null
@@ -1,40 +0,0 @@
-# frozen_string_literal: true
-
-require 'singleton'
-
-class Emoji
-  include Singleton
-
-  def initialize
-    data = Oj.load(File.open(Rails.root.join('lib', 'assets', 'emoji.json')))
-
-    @map = {}
-
-    data.each do |_, emoji|
-      keys    = [emoji['shortname']] + emoji['aliases']
-      unicode = codepoint_to_unicode(emoji['unicode'])
-
-      keys.each do |key|
-        @map[key] = unicode
-      end
-    end
-  end
-
-  def unicode(shortcode)
-    @map[shortcode]
-  end
-
-  def names
-    @map.keys
-  end
-
-  private
-
-  def codepoint_to_unicode(codepoint)
-    if codepoint.include?('-')
-      codepoint.split('-').map(&:hex).pack('U*')
-    else
-      [codepoint.hex].pack('U')
-    end
-  end
-end
diff --git a/app/lib/formatter.rb b/app/lib/formatter.rb
index 7b89305ac..42cd72990 100644
--- a/app/lib/formatter.rb
+++ b/app/lib/formatter.rb
@@ -9,7 +9,7 @@ class Formatter
 
   include ActionView::Helpers::TextHelper
 
-  def format(status)
+  def format(status, options = {})
     if status.reblog?
       prepend_reblog = status.reblog.account.acct
       status         = status.proper
@@ -19,7 +19,11 @@ class Formatter
 
     raw_content = status.text
 
-    return reformat(raw_content) unless status.local?
+    unless status.local?
+      html = reformat(raw_content)
+      html = encode_custom_emojis(html, status.emojis) if options[:custom_emojify]
+      return html.html_safe # rubocop:disable Rails/OutputSafety
+    end
 
     linkable_accounts = status.mentions.map(&:account)
     linkable_accounts << status.account
@@ -27,6 +31,7 @@ class Formatter
     html = raw_content
     html = "RT @#{prepend_reblog} #{html}" if prepend_reblog
     html = encode_and_link_urls(html, linkable_accounts)
+    html = encode_custom_emojis(html, status.emojis) if options[:custom_emojify]
     html = simple_format(html, {}, sanitize: false)
     html = html.delete("\n")
 
@@ -34,12 +39,14 @@ class Formatter
   end
 
   def reformat(html)
-    sanitize(html, Sanitize::Config::MASTODON_STRICT).html_safe # rubocop:disable Rails/OutputSafety
+    sanitize(html, Sanitize::Config::MASTODON_STRICT)
   end
 
   def plaintext(status)
     return status.text if status.local?
-    strip_tags(status.text)
+
+    text = status.text.gsub(/(<br \/>|<br>|<\/p>)+/) { |match| "#{match}\n" }
+    strip_tags(text)
   end
 
   def simplified_format(account)
@@ -56,6 +63,12 @@ class Formatter
     Sanitize.fragment(html, config)
   end
 
+  def format_spoiler(status)
+    html = encode(status.spoiler_text)
+    html = encode_custom_emojis(html, status.emojis)
+    html.html_safe # rubocop:disable Rails/OutputSafety
+  end
+
   private
 
   def encode(html)
@@ -76,6 +89,47 @@ class Formatter
     end
   end
 
+  def encode_custom_emojis(html, emojis)
+    return html if emojis.empty?
+
+    emoji_map = emojis.map { |e| [e.shortcode, full_asset_url(e.image.url)] }.to_h
+
+    i                     = -1
+    inside_tag            = false
+    inside_shortname      = false
+    shortname_start_index = -1
+
+    while i + 1 < html.size
+      i += 1
+
+      if inside_shortname && html[i] == ':'
+        shortcode = html[shortname_start_index + 1..i - 1]
+        emoji     = emoji_map[shortcode]
+
+        if emoji
+          replacement = "<img draggable=\"false\" class=\"emojione\" alt=\":#{shortcode}:\" title=\":#{shortcode}:\" src=\"#{emoji}\" />"
+          before_html = shortname_start_index.positive? ? html[0..shortname_start_index - 1] : ''
+          html        = before_html + replacement + html[i + 1..-1]
+          i          += replacement.size - (shortcode.size + 2) - 1
+        else
+          i -= 1
+        end
+
+        inside_shortname = false
+      elsif inside_tag && html[i] == '>'
+        inside_tag = false
+      elsif html[i] == '<'
+        inside_tag       = true
+        inside_shortname = false
+      elsif !inside_tag && html[i] == ':'
+        inside_shortname      = true
+        shortname_start_index = i
+      end
+    end
+
+    html
+  end
+
   def rewrite(text, entities)
     chars = text.to_s.to_char_a
 
@@ -104,7 +158,7 @@ class Formatter
     html_attrs     = { target: '_blank', rel: 'nofollow noopener' }
 
     Twitter::Autolink.send(:link_to_text, entity, link_html(entity[:url]), normalized_url, html_attrs)
-  rescue Addressable::URI::InvalidURIError
+  rescue Addressable::URI::InvalidURIError, IDN::Idna::IdnaError
     encode(entity[:url])
   end
 
@@ -131,13 +185,13 @@ class Formatter
   end
 
   def link_html(url)
-    url    = Addressable::URI.parse(url).display_uri.to_s
+    url    = Addressable::URI.parse(url).to_s
     prefix = url.match(/\Ahttps?:\/\/(www\.)?/).to_s
     text   = url[prefix.length, 30]
     suffix = url[prefix.length + 30..-1]
     cutoff = url[prefix.length..-1].length > 30
 
-    "<span class=\"invisible\">#{prefix}</span><span class=\"#{cutoff ? 'ellipsis' : ''}\">#{text}</span><span class=\"invisible\">#{suffix}</span>"
+    "<span class=\"invisible\">#{encode(prefix)}</span><span class=\"#{cutoff ? 'ellipsis' : ''}\">#{encode(text)}</span><span class=\"invisible\">#{encode(suffix)}</span>"
   end
 
   def hashtag_html(tag)
diff --git a/app/lib/language_detector.rb b/app/lib/language_detector.rb
index cc7509fdc..a42460e10 100644
--- a/app/lib/language_detector.rb
+++ b/app/lib/language_detector.rb
@@ -1,37 +1,43 @@
 # frozen_string_literal: true
 
 class LanguageDetector
-  attr_reader :text, :account
+  include Singleton
 
-  def initialize(text, account = nil)
-    @text = text
-    @account = account
+  def initialize
     @identifier = CLD3::NNetLanguageIdentifier.new(1, 2048)
   end
 
-  def to_iso_s
-    detected_language_code || default_locale
+  def detect(text, account)
+    detect_language_code(text) || default_locale(account)
   end
 
-  def prepared_text
-    simplified_text.strip
+  def language_names
+    @language_names =
+      CLD3::TaskContextParams::LANGUAGE_NAMES.map { |name| iso6391(name.to_s).to_sym }
+                                             .uniq
   end
 
   private
 
-  def detected_language_code
-    result.language.to_sym if detected_language_reliable?
+  def prepare_text(text)
+    simplify_text(text).strip
   end
 
-  def result
-    @result ||= @identifier.find_language(prepared_text)
+  def detect_language_code(text)
+    result = @identifier.find_language(prepare_text(text))
+    iso6391(result.language.to_s).to_sym if result.reliable?
   end
 
-  def detected_language_reliable?
-    result.reliable?
+  def iso6391(bcp47)
+    iso639 = bcp47.split('-').first
+
+    # CLD3 returns grandfathered language code for Hebrew
+    return 'he' if iso639 == 'iw'
+
+    ISO_639.find(iso639).alpha2
   end
 
-  def simplified_text
+  def simplify_text(text)
     text.dup.tap do |new_text|
       new_text.gsub!(FetchLinkCardService::URL_PATTERN, '')
       new_text.gsub!(Account::MENTION_RE, '')
@@ -40,7 +46,7 @@ class LanguageDetector
     end
   end
 
-  def default_locale
-    account&.user_locale&.to_sym || nil
+  def default_locale(account)
+    account.user_locale&.to_sym
   end
 end
diff --git a/app/lib/ostatus/activity/base.rb b/app/lib/ostatus/activity/base.rb
index e1477f0eb..039381397 100644
--- a/app/lib/ostatus/activity/base.rb
+++ b/app/lib/ostatus/activity/base.rb
@@ -11,39 +11,61 @@ class OStatus::Activity::Base
   end
 
   def verb
-    raw = @xml.at_xpath('./activity:verb', activity: TagManager::AS_XMLNS).content
-    TagManager::VERBS.key(raw)
+    raw = @xml.at_xpath('./activity:verb', activity: OStatus::TagManager::AS_XMLNS).content
+    OStatus::TagManager::VERBS.key(raw)
   rescue
     :post
   end
 
   def type
-    raw = @xml.at_xpath('./activity:object-type', activity: TagManager::AS_XMLNS).content
-    TagManager::TYPES.key(raw)
+    raw = @xml.at_xpath('./activity:object-type', activity: OStatus::TagManager::AS_XMLNS).content
+    OStatus::TagManager::TYPES.key(raw)
   rescue
     :activity
   end
 
   def id
-    @xml.at_xpath('./xmlns:id', xmlns: TagManager::XMLNS).content
+    @xml.at_xpath('./xmlns:id', xmlns: OStatus::TagManager::XMLNS).content
   end
 
   def url
-    link = @xml.at_xpath('./xmlns:link[@rel="alternate"]', xmlns: TagManager::XMLNS)
+    link = @xml.xpath('./xmlns:link[@rel="alternate"]', xmlns: OStatus::TagManager::XMLNS).find { |link_candidate| link_candidate['type'] == 'text/html' }
     link.nil? ? nil : link['href']
   end
 
+  def activitypub_uri
+    link = @xml.xpath('./xmlns:link[@rel="alternate"]', xmlns: OStatus::TagManager::XMLNS).find { |link_candidate| ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].include?(link_candidate['type']) }
+    link.nil? ? nil : link['href']
+  end
+
+  def activitypub_uri?
+    activitypub_uri.present?
+  end
+
   private
 
   def find_status(uri)
-    if TagManager.instance.local_id?(uri)
-      local_id = TagManager.instance.unique_tag_to_local_id(uri, 'Status')
+    if OStatus::TagManager.instance.local_id?(uri)
+      local_id = OStatus::TagManager.instance.unique_tag_to_local_id(uri, 'Status')
+      return Status.find_by(id: local_id)
+    elsif ActivityPub::TagManager.instance.local_uri?(uri)
+      local_id = ActivityPub::TagManager.instance.uri_to_local_id(uri)
       return Status.find_by(id: local_id)
     end
 
     Status.find_by(uri: uri)
   end
 
+  def find_activitypub_status(uri, href)
+    tag_matches = /tag:([^,:]+)[^:]*:objectId=([\d]+)/.match(uri)
+    href_matches = %r{/users/([^/]+)}.match(href)
+
+    unless tag_matches.nil? || href_matches.nil?
+      uri = "https://#{tag_matches[1]}/users/#{href_matches[1]}/statuses/#{tag_matches[2]}"
+      Status.find_by(uri: uri)
+    end
+  end
+
   def redis
     Redis.current
   end
diff --git a/app/lib/ostatus/activity/creation.rb b/app/lib/ostatus/activity/creation.rb
index e22f746f2..2687776f9 100644
--- a/app/lib/ostatus/activity/creation.rb
+++ b/app/lib/ostatus/activity/creation.rb
@@ -9,31 +9,49 @@ class OStatus::Activity::Creation < OStatus::Activity::Base
 
     return [nil, false] if @account.suspended?
 
-    Rails.logger.debug "Creating remote status #{id}"
+    if activitypub_uri? && [:public, :unlisted].include?(visibility_scope)
+      result = perform_via_activitypub
+      return result if result.first.present?
+    end
+
+    RedisLock.acquire(lock_options) do |lock|
+      if lock.acquired?
+        # Return early if status already exists in db
+        @status = find_status(id)
+        return [@status, false] unless @status.nil?
+        @status = process_status
+      end
+    end
 
-    # Return early if status already exists in db
-    status = find_status(id)
-
-    return [status, false] unless status.nil?
-
-    status = Status.create!(
-      uri: id,
-      url: url,
-      account: @account,
-      reblog: reblog,
-      text: content,
-      spoiler_text: content_warning,
-      created_at: published,
-      reply: thread?,
-      language: content_language,
-      visibility: visibility_scope,
-      conversation: find_or_create_conversation,
-      thread: thread? ? find_status(thread.first) : nil
-    )
-
-    save_mentions(status)
-    save_hashtags(status)
-    save_media(status)
+    [@status, true]
+  end
+
+  def process_status
+    Rails.logger.debug "Creating remote status #{id}"
+    cached_reblog = reblog
+    status = nil
+
+    ApplicationRecord.transaction do
+      status = Status.create!(
+        uri: id,
+        url: url,
+        account: @account,
+        reblog: cached_reblog,
+        text: content,
+        spoiler_text: content_warning,
+        created_at: published,
+        reply: thread?,
+        language: content_language,
+        visibility: visibility_scope,
+        conversation: find_or_create_conversation,
+        thread: thread? ? find_status(thread.first) || find_activitypub_status(thread.first, thread.second) : nil
+      )
+
+      save_mentions(status)
+      save_hashtags(status)
+      save_media(status)
+      save_emojis(status)
+    end
 
     if thread? && status.thread.nil?
       Rails.logger.debug "Trying to attach #{status.id} (#{id}) to #{thread.first}"
@@ -45,46 +63,50 @@ class OStatus::Activity::Creation < OStatus::Activity::Base
     LinkCrawlWorker.perform_async(status.id) unless status.spoiler_text?
     DistributionWorker.perform_async(status.id)
 
-    [status, true]
+    status
+  end
+
+  def perform_via_activitypub
+    [find_status(activitypub_uri) || ActivityPub::FetchRemoteStatusService.new.call(activitypub_uri), false]
   end
 
   def content
-    @xml.at_xpath('./xmlns:content', xmlns: TagManager::XMLNS).content
+    @xml.at_xpath('./xmlns:content', xmlns: OStatus::TagManager::XMLNS).content
   end
 
   def content_language
-    @xml.at_xpath('./xmlns:content', xmlns: TagManager::XMLNS)['xml:lang']&.presence || 'en'
+    @xml.at_xpath('./xmlns:content', xmlns: OStatus::TagManager::XMLNS)['xml:lang']&.presence || 'en'
   end
 
   def content_warning
-    @xml.at_xpath('./xmlns:summary', xmlns: TagManager::XMLNS)&.content || ''
+    @xml.at_xpath('./xmlns:summary', xmlns: OStatus::TagManager::XMLNS)&.content || ''
   end
 
   def visibility_scope
-    @xml.at_xpath('./mastodon:scope', mastodon: TagManager::MTDN_XMLNS)&.content&.to_sym || :public
+    @xml.at_xpath('./mastodon:scope', mastodon: OStatus::TagManager::MTDN_XMLNS)&.content&.to_sym || :public
   end
 
   def published
-    @xml.at_xpath('./xmlns:published', xmlns: TagManager::XMLNS).content
+    @xml.at_xpath('./xmlns:published', xmlns: OStatus::TagManager::XMLNS).content
   end
 
   def thread?
-    !@xml.at_xpath('./thr:in-reply-to', thr: TagManager::THR_XMLNS).nil?
+    !@xml.at_xpath('./thr:in-reply-to', thr: OStatus::TagManager::THR_XMLNS).nil?
   end
 
   def thread
-    thr = @xml.at_xpath('./thr:in-reply-to', thr: TagManager::THR_XMLNS)
+    thr = @xml.at_xpath('./thr:in-reply-to', thr: OStatus::TagManager::THR_XMLNS)
     [thr['ref'], thr['href']]
   end
 
   private
 
   def find_or_create_conversation
-    uri = @xml.at_xpath('./ostatus:conversation', ostatus: TagManager::OS_XMLNS)&.attribute('ref')&.content
+    uri = @xml.at_xpath('./ostatus:conversation', ostatus: OStatus::TagManager::OS_XMLNS)&.attribute('ref')&.content
     return if uri.nil?
 
-    if TagManager.instance.local_id?(uri)
-      local_id = TagManager.instance.unique_tag_to_local_id(uri, 'Conversation')
+    if OStatus::TagManager.instance.local_id?(uri)
+      local_id = OStatus::TagManager.instance.unique_tag_to_local_id(uri, 'Conversation')
       return Conversation.find_by(id: local_id)
     end
 
@@ -94,8 +116,8 @@ class OStatus::Activity::Creation < OStatus::Activity::Base
   def save_mentions(parent)
     processed_account_ids = []
 
-    @xml.xpath('./xmlns:link[@rel="mentioned"]', xmlns: TagManager::XMLNS).each do |link|
-      next if [TagManager::TYPES[:group], TagManager::TYPES[:collection]].include? link['ostatus:object-type']
+    @xml.xpath('./xmlns:link[@rel="mentioned"]', xmlns: OStatus::TagManager::XMLNS).each do |link|
+      next if [OStatus::TagManager::TYPES[:group], OStatus::TagManager::TYPES[:collection]].include? link['ostatus:object-type']
 
       mentioned_account = account_from_href(link['href'])
 
@@ -109,14 +131,14 @@ class OStatus::Activity::Creation < OStatus::Activity::Base
   end
 
   def save_hashtags(parent)
-    tags = @xml.xpath('./xmlns:category', xmlns: TagManager::XMLNS).map { |category| category['term'] }.select(&:present?)
+    tags = @xml.xpath('./xmlns:category', xmlns: OStatus::TagManager::XMLNS).map { |category| category['term'] }.select(&:present?)
     ProcessHashtagsService.new.call(parent, tags)
   end
 
   def save_media(parent)
     do_not_download = DomainBlock.find_by(domain: parent.account.domain)&.reject_media?
 
-    @xml.xpath('./xmlns:link[@rel="enclosure"]', xmlns: TagManager::XMLNS).each do |link|
+    @xml.xpath('./xmlns:link[@rel="enclosure"]', xmlns: OStatus::TagManager::XMLNS).each do |link|
       next unless link['href']
 
       media = MediaAttachment.where(status: parent, remote_url: link['href']).first_or_initialize(account: parent.account, status: parent, remote_url: link['href'])
@@ -137,6 +159,25 @@ class OStatus::Activity::Creation < OStatus::Activity::Base
     end
   end
 
+  def save_emojis(parent)
+    do_not_download = DomainBlock.find_by(domain: parent.account.domain)&.reject_media?
+
+    return if do_not_download
+
+    @xml.xpath('./xmlns:link[@rel="emoji"]', xmlns: OStatus::TagManager::XMLNS).each do |link|
+      next unless link['href'] && link['name']
+
+      shortcode = link['name'].delete(':')
+      emoji     = CustomEmoji.find_by(shortcode: shortcode, domain: parent.account.domain)
+
+      next unless emoji.nil?
+
+      emoji = CustomEmoji.new(shortcode: shortcode, domain: parent.account.domain)
+      emoji.image_remote_url = link['href']
+      emoji.save
+    end
+  end
+
   def account_from_href(href)
     url = Addressable::URI.parse(href).normalize
 
@@ -146,4 +187,8 @@ class OStatus::Activity::Creation < OStatus::Activity::Base
       Account.where(uri: href).or(Account.where(url: href)).first || FetchRemoteAccountService.new.call(href)
     end
   end
+
+  def lock_options
+    { redis: Redis.current, key: "create:#{id}" }
+  end
 end
diff --git a/app/lib/ostatus/activity/deletion.rb b/app/lib/ostatus/activity/deletion.rb
index 860faf501..c98f5ee0a 100644
--- a/app/lib/ostatus/activity/deletion.rb
+++ b/app/lib/ostatus/activity/deletion.rb
@@ -3,7 +3,9 @@
 class OStatus::Activity::Deletion < OStatus::Activity::Base
   def perform
     Rails.logger.debug "Deleting remote status #{id}"
-    status = Status.find_by(uri: id, account: @account)
+
+    status   = Status.find_by(uri: id, account: @account)
+    status ||= Status.find_by(uri: activitypub_uri, account: @account) if activitypub_uri?
 
     if status.nil?
       redis.setex("delete_upon_arrival:#{@account.id}:#{id}", 6 * 3_600, id)
diff --git a/app/lib/ostatus/activity/remote.rb b/app/lib/ostatus/activity/remote.rb
index ecec6886c..5b204b6d8 100644
--- a/app/lib/ostatus/activity/remote.rb
+++ b/app/lib/ostatus/activity/remote.rb
@@ -2,6 +2,10 @@
 
 class OStatus::Activity::Remote < OStatus::Activity::Base
   def perform
-    find_status(id) || FetchRemoteStatusService.new.call(url)
+    if activitypub_uri?
+      find_status(activitypub_uri) || FetchRemoteStatusService.new.call(url)
+    else
+      find_status(id) || FetchRemoteStatusService.new.call(url)
+    end
   end
 end
diff --git a/app/lib/ostatus/activity/share.rb b/app/lib/ostatus/activity/share.rb
index 290008021..5ca601415 100644
--- a/app/lib/ostatus/activity/share.rb
+++ b/app/lib/ostatus/activity/share.rb
@@ -10,7 +10,7 @@ class OStatus::Activity::Share < OStatus::Activity::Creation
   end
 
   def object
-    @xml.at_xpath('.//activity:object', activity: TagManager::AS_XMLNS)
+    @xml.at_xpath('.//activity:object', activity: OStatus::TagManager::AS_XMLNS)
   end
 
   private
diff --git a/app/lib/ostatus/atom_serializer.rb b/app/lib/ostatus/atom_serializer.rb
index 0d62361be..a1ac11a51 100644
--- a/app/lib/ostatus/atom_serializer.rb
+++ b/app/lib/ostatus/atom_serializer.rb
@@ -15,10 +15,10 @@ class OStatus::AtomSerializer
   def author(account)
     author = Ox::Element.new('author')
 
-    uri = TagManager.instance.uri_for(account)
+    uri = OStatus::TagManager.instance.uri_for(account)
 
     append_element(author, 'id', uri)
-    append_element(author, 'activity:object-type', TagManager::TYPES[:person])
+    append_element(author, 'activity:object-type', OStatus::TagManager::TYPES[:person])
     append_element(author, 'uri', uri)
     append_element(author, 'name', account.username)
     append_element(author, 'email', account.local? ? account.local_username_and_domain : account.acct)
@@ -65,27 +65,30 @@ class OStatus::AtomSerializer
 
     add_namespaces(entry) if root
 
-    append_element(entry, 'id', TagManager.instance.unique_tag(stream_entry.created_at, stream_entry.activity_id, stream_entry.activity_type))
+    append_element(entry, 'id', OStatus::TagManager.instance.uri_for(stream_entry.status))
     append_element(entry, 'published', stream_entry.created_at.iso8601)
     append_element(entry, 'updated', stream_entry.updated_at.iso8601)
     append_element(entry, 'title', stream_entry&.status&.title || "#{stream_entry.account.acct} deleted status")
 
     entry << author(stream_entry.account) if root
 
-    append_element(entry, 'activity:object-type', TagManager::TYPES[stream_entry.object_type])
-    append_element(entry, 'activity:verb', TagManager::VERBS[stream_entry.verb])
+    append_element(entry, 'activity:object-type', OStatus::TagManager::TYPES[stream_entry.object_type])
+    append_element(entry, 'activity:verb', OStatus::TagManager::VERBS[stream_entry.verb])
 
     entry << object(stream_entry.target) if stream_entry.targeted?
 
     if stream_entry.status.nil?
       append_element(entry, 'content', 'Deleted status')
+    elsif stream_entry.status.destroyed?
+      append_element(entry, 'content', 'Deleted status')
+      append_element(entry, 'link', nil, rel: :alternate, type: 'application/activity+json', href: ActivityPub::TagManager.instance.uri_for(stream_entry.status)) if stream_entry.account.local?
     else
       serialize_status_attributes(entry, stream_entry.status)
     end
 
-    append_element(entry, 'link', nil, rel: :alternate, type: 'text/html', href: account_stream_entry_url(stream_entry.account, stream_entry))
+    append_element(entry, 'link', nil, rel: :alternate, type: 'text/html', href: TagManager.instance.url_for(stream_entry.status))
     append_element(entry, 'link', nil, rel: :self, type: 'application/atom+xml', href: account_stream_entry_url(stream_entry.account, stream_entry, format: 'atom'))
-    append_element(entry, 'thr:in-reply-to', nil, ref: TagManager.instance.uri_for(stream_entry.thread), href: TagManager.instance.url_for(stream_entry.thread)) if stream_entry.threaded?
+    append_element(entry, 'thr:in-reply-to', nil, ref: OStatus::TagManager.instance.uri_for(stream_entry.thread), href: TagManager.instance.url_for(stream_entry.thread)) if stream_entry.threaded?
     append_element(entry, 'ostatus:conversation', nil, ref: conversation_uri(stream_entry.status.conversation)) unless stream_entry&.status&.conversation_id.nil?
 
     entry
@@ -94,20 +97,20 @@ class OStatus::AtomSerializer
   def object(status)
     object = Ox::Element.new('activity:object')
 
-    append_element(object, 'id', TagManager.instance.uri_for(status))
+    append_element(object, 'id', OStatus::TagManager.instance.uri_for(status))
     append_element(object, 'published', status.created_at.iso8601)
     append_element(object, 'updated', status.updated_at.iso8601)
     append_element(object, 'title', status.title)
 
     object << author(status.account)
 
-    append_element(object, 'activity:object-type', TagManager::TYPES[status.object_type])
-    append_element(object, 'activity:verb', TagManager::VERBS[status.verb])
+    append_element(object, 'activity:object-type', OStatus::TagManager::TYPES[status.object_type])
+    append_element(object, 'activity:verb', OStatus::TagManager::VERBS[status.verb])
 
     serialize_status_attributes(object, status)
 
     append_element(object, 'link', nil, rel: :alternate, type: 'text/html', href: TagManager.instance.url_for(status))
-    append_element(object, 'thr:in-reply-to', nil, ref: TagManager.instance.uri_for(status.thread), href: TagManager.instance.url_for(status.thread)) unless status.thread.nil?
+    append_element(object, 'thr:in-reply-to', nil, ref: OStatus::TagManager.instance.uri_for(status.thread), href: TagManager.instance.url_for(status.thread)) unless status.thread.nil?
     append_element(object, 'ostatus:conversation', nil, ref: conversation_uri(status.conversation)) unless status.conversation_id.nil?
 
     object
@@ -119,14 +122,14 @@ class OStatus::AtomSerializer
 
     description = "#{follow.account.acct} started following #{follow.target_account.acct}"
 
-    append_element(entry, 'id', TagManager.instance.unique_tag(follow.created_at, follow.id, 'Follow'))
+    append_element(entry, 'id', OStatus::TagManager.instance.unique_tag(follow.created_at, follow.id, 'Follow'))
     append_element(entry, 'title', description)
     append_element(entry, 'content', description, type: :html)
 
     entry << author(follow.account)
 
-    append_element(entry, 'activity:object-type', TagManager::TYPES[:activity])
-    append_element(entry, 'activity:verb', TagManager::VERBS[:follow])
+    append_element(entry, 'activity:object-type', OStatus::TagManager::TYPES[:activity])
+    append_element(entry, 'activity:verb', OStatus::TagManager::VERBS[:follow])
 
     object = author(follow.target_account)
     object.value = 'activity:object'
@@ -139,13 +142,13 @@ class OStatus::AtomSerializer
     entry = Ox::Element.new('entry')
     add_namespaces(entry)
 
-    append_element(entry, 'id', TagManager.instance.unique_tag(follow_request.created_at, follow_request.id, 'FollowRequest'))
+    append_element(entry, 'id', OStatus::TagManager.instance.unique_tag(follow_request.created_at, follow_request.id, 'FollowRequest'))
     append_element(entry, 'title', "#{follow_request.account.acct} requested to follow #{follow_request.target_account.acct}")
 
     entry << author(follow_request.account)
 
-    append_element(entry, 'activity:object-type', TagManager::TYPES[:activity])
-    append_element(entry, 'activity:verb', TagManager::VERBS[:request_friend])
+    append_element(entry, 'activity:object-type', OStatus::TagManager::TYPES[:activity])
+    append_element(entry, 'activity:verb', OStatus::TagManager::VERBS[:request_friend])
 
     object = author(follow_request.target_account)
     object.value = 'activity:object'
@@ -158,19 +161,19 @@ class OStatus::AtomSerializer
     entry = Ox::Element.new('entry')
     add_namespaces(entry)
 
-    append_element(entry, 'id', TagManager.instance.unique_tag(Time.now.utc, follow_request.id, 'FollowRequest'))
+    append_element(entry, 'id', OStatus::TagManager.instance.unique_tag(Time.now.utc, follow_request.id, 'FollowRequest'))
     append_element(entry, 'title', "#{follow_request.target_account.acct} authorizes follow request by #{follow_request.account.acct}")
 
     entry << author(follow_request.target_account)
 
-    append_element(entry, 'activity:object-type', TagManager::TYPES[:activity])
-    append_element(entry, 'activity:verb', TagManager::VERBS[:authorize])
+    append_element(entry, 'activity:object-type', OStatus::TagManager::TYPES[:activity])
+    append_element(entry, 'activity:verb', OStatus::TagManager::VERBS[:authorize])
 
     object = Ox::Element.new('activity:object')
     object << author(follow_request.account)
 
-    append_element(object, 'activity:object-type', TagManager::TYPES[:activity])
-    append_element(object, 'activity:verb', TagManager::VERBS[:request_friend])
+    append_element(object, 'activity:object-type', OStatus::TagManager::TYPES[:activity])
+    append_element(object, 'activity:verb', OStatus::TagManager::VERBS[:request_friend])
 
     inner_object = author(follow_request.target_account)
     inner_object.value = 'activity:object'
@@ -184,19 +187,19 @@ class OStatus::AtomSerializer
     entry = Ox::Element.new('entry')
     add_namespaces(entry)
 
-    append_element(entry, 'id', TagManager.instance.unique_tag(Time.now.utc, follow_request.id, 'FollowRequest'))
+    append_element(entry, 'id', OStatus::TagManager.instance.unique_tag(Time.now.utc, follow_request.id, 'FollowRequest'))
     append_element(entry, 'title', "#{follow_request.target_account.acct} rejects follow request by #{follow_request.account.acct}")
 
     entry << author(follow_request.target_account)
 
-    append_element(entry, 'activity:object-type', TagManager::TYPES[:activity])
-    append_element(entry, 'activity:verb', TagManager::VERBS[:reject])
+    append_element(entry, 'activity:object-type', OStatus::TagManager::TYPES[:activity])
+    append_element(entry, 'activity:verb', OStatus::TagManager::VERBS[:reject])
 
     object = Ox::Element.new('activity:object')
     object << author(follow_request.account)
 
-    append_element(object, 'activity:object-type', TagManager::TYPES[:activity])
-    append_element(object, 'activity:verb', TagManager::VERBS[:request_friend])
+    append_element(object, 'activity:object-type', OStatus::TagManager::TYPES[:activity])
+    append_element(object, 'activity:verb', OStatus::TagManager::VERBS[:request_friend])
 
     inner_object = author(follow_request.target_account)
     inner_object.value = 'activity:object'
@@ -212,14 +215,14 @@ class OStatus::AtomSerializer
 
     description = "#{follow.account.acct} is no longer following #{follow.target_account.acct}"
 
-    append_element(entry, 'id', TagManager.instance.unique_tag(Time.now.utc, follow.id, 'Follow'))
+    append_element(entry, 'id', OStatus::TagManager.instance.unique_tag(Time.now.utc, follow.id, 'Follow'))
     append_element(entry, 'title', description)
     append_element(entry, 'content', description, type: :html)
 
     entry << author(follow.account)
 
-    append_element(entry, 'activity:object-type', TagManager::TYPES[:activity])
-    append_element(entry, 'activity:verb', TagManager::VERBS[:unfollow])
+    append_element(entry, 'activity:object-type', OStatus::TagManager::TYPES[:activity])
+    append_element(entry, 'activity:verb', OStatus::TagManager::VERBS[:unfollow])
 
     object = author(follow.target_account)
     object.value = 'activity:object'
@@ -234,13 +237,13 @@ class OStatus::AtomSerializer
 
     description = "#{block.account.acct} no longer wishes to interact with #{block.target_account.acct}"
 
-    append_element(entry, 'id', TagManager.instance.unique_tag(Time.now.utc, block.id, 'Block'))
+    append_element(entry, 'id', OStatus::TagManager.instance.unique_tag(Time.now.utc, block.id, 'Block'))
     append_element(entry, 'title', description)
 
     entry << author(block.account)
 
-    append_element(entry, 'activity:object-type', TagManager::TYPES[:activity])
-    append_element(entry, 'activity:verb', TagManager::VERBS[:block])
+    append_element(entry, 'activity:object-type', OStatus::TagManager::TYPES[:activity])
+    append_element(entry, 'activity:verb', OStatus::TagManager::VERBS[:block])
 
     object = author(block.target_account)
     object.value = 'activity:object'
@@ -255,13 +258,13 @@ class OStatus::AtomSerializer
 
     description = "#{block.account.acct} no longer blocks #{block.target_account.acct}"
 
-    append_element(entry, 'id', TagManager.instance.unique_tag(Time.now.utc, block.id, 'Block'))
+    append_element(entry, 'id', OStatus::TagManager.instance.unique_tag(Time.now.utc, block.id, 'Block'))
     append_element(entry, 'title', description)
 
     entry << author(block.account)
 
-    append_element(entry, 'activity:object-type', TagManager::TYPES[:activity])
-    append_element(entry, 'activity:verb', TagManager::VERBS[:unblock])
+    append_element(entry, 'activity:object-type', OStatus::TagManager::TYPES[:activity])
+    append_element(entry, 'activity:verb', OStatus::TagManager::VERBS[:unblock])
 
     object = author(block.target_account)
     object.value = 'activity:object'
@@ -276,18 +279,18 @@ class OStatus::AtomSerializer
 
     description = "#{favourite.account.acct} favourited a status by #{favourite.status.account.acct}"
 
-    append_element(entry, 'id', TagManager.instance.unique_tag(favourite.created_at, favourite.id, 'Favourite'))
+    append_element(entry, 'id', OStatus::TagManager.instance.unique_tag(favourite.created_at, favourite.id, 'Favourite'))
     append_element(entry, 'title', description)
     append_element(entry, 'content', description, type: :html)
 
     entry << author(favourite.account)
 
-    append_element(entry, 'activity:object-type', TagManager::TYPES[:activity])
-    append_element(entry, 'activity:verb', TagManager::VERBS[:favorite])
+    append_element(entry, 'activity:object-type', OStatus::TagManager::TYPES[:activity])
+    append_element(entry, 'activity:verb', OStatus::TagManager::VERBS[:favorite])
 
     entry << object(favourite.status)
 
-    append_element(entry, 'thr:in-reply-to', nil, ref: TagManager.instance.uri_for(favourite.status), href: TagManager.instance.url_for(favourite.status))
+    append_element(entry, 'thr:in-reply-to', nil, ref: OStatus::TagManager.instance.uri_for(favourite.status), href: TagManager.instance.url_for(favourite.status))
 
     entry
   end
@@ -298,18 +301,18 @@ class OStatus::AtomSerializer
 
     description = "#{favourite.account.acct} no longer favourites a status by #{favourite.status.account.acct}"
 
-    append_element(entry, 'id', TagManager.instance.unique_tag(Time.now.utc, favourite.id, 'Favourite'))
+    append_element(entry, 'id', OStatus::TagManager.instance.unique_tag(Time.now.utc, favourite.id, 'Favourite'))
     append_element(entry, 'title', description)
     append_element(entry, 'content', description, type: :html)
 
     entry << author(favourite.account)
 
-    append_element(entry, 'activity:object-type', TagManager::TYPES[:activity])
-    append_element(entry, 'activity:verb', TagManager::VERBS[:unfavorite])
+    append_element(entry, 'activity:object-type', OStatus::TagManager::TYPES[:activity])
+    append_element(entry, 'activity:verb', OStatus::TagManager::VERBS[:unfavorite])
 
     entry << object(favourite.status)
 
-    append_element(entry, 'thr:in-reply-to', nil, ref: TagManager.instance.uri_for(favourite.status), href: TagManager.instance.url_for(favourite.status))
+    append_element(entry, 'thr:in-reply-to', nil, ref: OStatus::TagManager.instance.uri_for(favourite.status), href: TagManager.instance.url_for(favourite.status))
 
     entry
   end
@@ -329,28 +332,30 @@ class OStatus::AtomSerializer
 
   def conversation_uri(conversation)
     return conversation.uri if conversation.uri?
-    TagManager.instance.unique_tag(conversation.created_at, conversation.id, 'Conversation')
+    OStatus::TagManager.instance.unique_tag(conversation.created_at, conversation.id, 'Conversation')
   end
 
   def add_namespaces(parent)
-    parent['xmlns']          = TagManager::XMLNS
-    parent['xmlns:thr']      = TagManager::THR_XMLNS
-    parent['xmlns:activity'] = TagManager::AS_XMLNS
-    parent['xmlns:poco']     = TagManager::POCO_XMLNS
-    parent['xmlns:media']    = TagManager::MEDIA_XMLNS
-    parent['xmlns:ostatus']  = TagManager::OS_XMLNS
-    parent['xmlns:mastodon'] = TagManager::MTDN_XMLNS
+    parent['xmlns']          = OStatus::TagManager::XMLNS
+    parent['xmlns:thr']      = OStatus::TagManager::THR_XMLNS
+    parent['xmlns:activity'] = OStatus::TagManager::AS_XMLNS
+    parent['xmlns:poco']     = OStatus::TagManager::POCO_XMLNS
+    parent['xmlns:media']    = OStatus::TagManager::MEDIA_XMLNS
+    parent['xmlns:ostatus']  = OStatus::TagManager::OS_XMLNS
+    parent['xmlns:mastodon'] = OStatus::TagManager::MTDN_XMLNS
   end
 
   def serialize_status_attributes(entry, status)
+    append_element(entry, 'link', nil, rel: :alternate, type: 'application/activity+json', href: ActivityPub::TagManager.instance.uri_for(status)) if status.account.local?
+
     append_element(entry, 'summary', status.spoiler_text, 'xml:lang': status.language) if status.spoiler_text?
     append_element(entry, 'content', Formatter.instance.format(status).to_str, type: 'html', 'xml:lang': status.language)
 
     status.mentions.each do |mentioned|
-      append_element(entry, 'link', nil, rel: :mentioned, 'ostatus:object-type': TagManager::TYPES[:person], href: TagManager.instance.uri_for(mentioned.account))
+      append_element(entry, 'link', nil, rel: :mentioned, 'ostatus:object-type': OStatus::TagManager::TYPES[:person], href: OStatus::TagManager.instance.uri_for(mentioned.account))
     end
 
-    append_element(entry, 'link', nil, rel: :mentioned, 'ostatus:object-type': TagManager::TYPES[:collection], href: TagManager::COLLECTIONS[:public]) if status.public_visibility?
+    append_element(entry, 'link', nil, rel: :mentioned, 'ostatus:object-type': OStatus::TagManager::TYPES[:collection], href: OStatus::TagManager::COLLECTIONS[:public]) if status.public_visibility?
 
     status.tags.each do |tag|
       append_element(entry, 'category', nil, term: tag.name)
@@ -363,5 +368,9 @@ class OStatus::AtomSerializer
     end
 
     append_element(entry, 'mastodon:scope', status.visibility)
+
+    status.emojis.each do |emoji|
+      append_element(entry, 'link', nil, rel: :emoji, href: full_asset_url(emoji.image.url), name: emoji.shortcode)
+    end
   end
 end
diff --git a/app/lib/ostatus/tag_manager.rb b/app/lib/ostatus/tag_manager.rb
new file mode 100644
index 000000000..4f4501312
--- /dev/null
+++ b/app/lib/ostatus/tag_manager.rb
@@ -0,0 +1,73 @@
+# frozen_string_literal: true
+
+class OStatus::TagManager
+  include Singleton
+  include RoutingHelper
+
+  VERBS = {
+    post:           'http://activitystrea.ms/schema/1.0/post',
+    share:          'http://activitystrea.ms/schema/1.0/share',
+    favorite:       'http://activitystrea.ms/schema/1.0/favorite',
+    unfavorite:     'http://activitystrea.ms/schema/1.0/unfavorite',
+    delete:         'http://activitystrea.ms/schema/1.0/delete',
+    follow:         'http://activitystrea.ms/schema/1.0/follow',
+    request_friend: 'http://activitystrea.ms/schema/1.0/request-friend',
+    authorize:      'http://activitystrea.ms/schema/1.0/authorize',
+    reject:         'http://activitystrea.ms/schema/1.0/reject',
+    unfollow:       'http://ostatus.org/schema/1.0/unfollow',
+    block:          'http://mastodon.social/schema/1.0/block',
+    unblock:        'http://mastodon.social/schema/1.0/unblock',
+  }.freeze
+
+  TYPES = {
+    activity:   'http://activitystrea.ms/schema/1.0/activity',
+    note:       'http://activitystrea.ms/schema/1.0/note',
+    comment:    'http://activitystrea.ms/schema/1.0/comment',
+    person:     'http://activitystrea.ms/schema/1.0/person',
+    collection: 'http://activitystrea.ms/schema/1.0/collection',
+    group:      'http://activitystrea.ms/schema/1.0/group',
+  }.freeze
+
+  COLLECTIONS = {
+    public: 'http://activityschema.org/collection/public',
+  }.freeze
+
+  XMLNS       = 'http://www.w3.org/2005/Atom'
+  MEDIA_XMLNS = 'http://purl.org/syndication/atommedia'
+  AS_XMLNS    = 'http://activitystrea.ms/spec/1.0/'
+  THR_XMLNS   = 'http://purl.org/syndication/thread/1.0'
+  POCO_XMLNS  = 'http://portablecontacts.net/spec/1.0'
+  DFRN_XMLNS  = 'http://purl.org/macgirvin/dfrn/1.0'
+  OS_XMLNS    = 'http://ostatus.org/schema/1.0'
+  MTDN_XMLNS  = 'http://mastodon.social/schema/1.0'
+
+  def unique_tag(date, id, type)
+    "tag:#{Rails.configuration.x.local_domain},#{date.strftime('%Y-%m-%d')}:objectId=#{id}:objectType=#{type}"
+  end
+
+  def unique_tag_to_local_id(tag, expected_type)
+    return nil unless local_id?(tag)
+
+    if ActivityPub::TagManager.instance.local_uri?(tag)
+      ActivityPub::TagManager.instance.uri_to_local_id(tag)
+    else
+      matches = Regexp.new("objectId=([\\d]+):objectType=#{expected_type}").match(tag)
+      return matches[1] unless matches.nil?
+    end
+  end
+
+  def local_id?(id)
+    id.start_with?("tag:#{Rails.configuration.x.local_domain}") || ActivityPub::TagManager.instance.local_uri?(id)
+  end
+
+  def uri_for(target)
+    return target.uri if target.respond_to?(:local?) && !target.local?
+
+    case target.object_type
+    when :person
+      account_url(target)
+    when :note, :comment, :activity
+      target.uri || unique_tag(target.created_at, target.id, 'Status')
+    end
+  end
+end
diff --git a/app/lib/request.rb b/app/lib/request.rb
index e73c5ac20..b083edaf7 100644
--- a/app/lib/request.rb
+++ b/app/lib/request.rb
@@ -12,19 +12,27 @@ class Request
     @headers = {}
 
     set_common_headers!
+    set_digest! if options.key?(:body)
   end
 
-  def on_behalf_of(account)
+  def on_behalf_of(account, key_id_format = :acct)
     raise ArgumentError unless account.local?
-    @account = account
+
+    @account       = account
+    @key_id_format = key_id_format
+
+    self
   end
 
   def add_headers(new_headers)
     @headers.merge!(new_headers)
+    self
   end
 
   def perform
     http_client.headers(headers).public_send(@verb, @url.to_s, @options)
+  rescue => e
+    raise e.class, "#{e.message} on #{@url}"
   end
 
   def headers
@@ -40,8 +48,11 @@ class Request
     @headers['Date']         = Time.now.utc.httpdate
   end
 
+  def set_digest!
+    @headers['Digest'] = "SHA-256=#{Digest::SHA256.base64digest(@options[:body])}"
+  end
+
   def signature
-    key_id    = @account.to_webfinger_s
     algorithm = 'rsa-sha256'
     signature = Base64.strict_encode64(@account.keypair.sign(OpenSSL::Digest::SHA256.new, signed_string))
 
@@ -60,6 +71,15 @@ class Request
     @user_agent ||= "#{HTTP::Request::USER_AGENT} (Mastodon/#{Mastodon::Version}; +#{root_url})"
   end
 
+  def key_id
+    case @key_id_format
+    when :acct
+      @account.to_webfinger_s
+    when :uri
+      [ActivityPub::TagManager.instance.uri_for(@account), '#main-key'].join
+    end
+  end
+
   def timeout
     { write: 10, connect: 10, read: 10 }
   end
diff --git a/app/lib/stream_entry_finder.rb b/app/lib/status_finder.rb
index 0ea33229c..4d1aed297 100644
--- a/app/lib/stream_entry_finder.rb
+++ b/app/lib/status_finder.rb
@@ -1,20 +1,22 @@
 # frozen_string_literal: true
 
-class StreamEntryFinder
+class StatusFinder
   attr_reader :url
 
   def initialize(url)
     @url = url
   end
 
-  def stream_entry
+  def status
     verify_action!
 
+    raise ActiveRecord::RecordNotFound unless TagManager.instance.local_url?(url)
+
     case recognized_params[:controller]
     when 'stream_entries'
-      StreamEntry.find(recognized_params[:id])
+      StreamEntry.find(recognized_params[:id]).status
     when 'statuses'
-      Status.find(recognized_params[:id]).stream_entry
+      Status.find(recognized_params[:id])
     else
       raise ActiveRecord::RecordNotFound
     end
diff --git a/app/lib/tag_manager.rb b/app/lib/tag_manager.rb
index 5f87a2a48..fb364cb98 100644
--- a/app/lib/tag_manager.rb
+++ b/app/lib/tag_manager.rb
@@ -6,57 +6,6 @@ class TagManager
   include Singleton
   include RoutingHelper
 
-  VERBS = {
-    post:           'http://activitystrea.ms/schema/1.0/post',
-    share:          'http://activitystrea.ms/schema/1.0/share',
-    favorite:       'http://activitystrea.ms/schema/1.0/favorite',
-    unfavorite:     'http://activitystrea.ms/schema/1.0/unfavorite',
-    delete:         'http://activitystrea.ms/schema/1.0/delete',
-    follow:         'http://activitystrea.ms/schema/1.0/follow',
-    request_friend: 'http://activitystrea.ms/schema/1.0/request-friend',
-    authorize:      'http://activitystrea.ms/schema/1.0/authorize',
-    reject:         'http://activitystrea.ms/schema/1.0/reject',
-    unfollow:       'http://ostatus.org/schema/1.0/unfollow',
-    block:          'http://mastodon.social/schema/1.0/block',
-    unblock:        'http://mastodon.social/schema/1.0/unblock',
-  }.freeze
-
-  TYPES = {
-    activity:   'http://activitystrea.ms/schema/1.0/activity',
-    note:       'http://activitystrea.ms/schema/1.0/note',
-    comment:    'http://activitystrea.ms/schema/1.0/comment',
-    person:     'http://activitystrea.ms/schema/1.0/person',
-    collection: 'http://activitystrea.ms/schema/1.0/collection',
-    group:      'http://activitystrea.ms/schema/1.0/group',
-  }.freeze
-
-  COLLECTIONS = {
-    public: 'http://activityschema.org/collection/public',
-  }.freeze
-
-  XMLNS       = 'http://www.w3.org/2005/Atom'
-  MEDIA_XMLNS = 'http://purl.org/syndication/atommedia'
-  AS_XMLNS    = 'http://activitystrea.ms/spec/1.0/'
-  THR_XMLNS   = 'http://purl.org/syndication/thread/1.0'
-  POCO_XMLNS  = 'http://portablecontacts.net/spec/1.0'
-  DFRN_XMLNS  = 'http://purl.org/macgirvin/dfrn/1.0'
-  OS_XMLNS    = 'http://ostatus.org/schema/1.0'
-  MTDN_XMLNS  = 'http://mastodon.social/schema/1.0'
-
-  def unique_tag(date, id, type)
-    "tag:#{Rails.configuration.x.local_domain},#{date.strftime('%Y-%m-%d')}:objectId=#{id}:objectType=#{type}"
-  end
-
-  def unique_tag_to_local_id(tag, expected_type)
-    return nil unless local_id?(tag)
-    matches = Regexp.new("objectId=([\\d]+):objectType=#{expected_type}").match(tag)
-    return matches[1] unless matches.nil?
-  end
-
-  def local_id?(id)
-    id.start_with?("tag:#{Rails.configuration.x.local_domain}")
-  end
-
   def web_domain?(domain)
     domain.nil? || domain.gsub(/[\/]/, '').casecmp(Rails.configuration.x.web_domain).zero?
   end
@@ -82,18 +31,7 @@ class TagManager
   def local_url?(url)
     uri    = Addressable::URI.parse(url).normalize
     domain = uri.host + (uri.port ? ":#{uri.port}" : '')
-    TagManager.instance.local_domain?(domain)
-  end
-
-  def uri_for(target)
-    return target.uri if target.respond_to?(:local?) && !target.local?
-
-    case target.object_type
-    when :person
-      account_url(target)
-    when :note, :comment, :activity
-      unique_tag(target.created_at, target.id, 'Status')
-    end
+    TagManager.instance.web_domain?(domain)
   end
 
   def url_for(target)
diff --git a/app/lib/themes.rb b/app/lib/themes.rb
new file mode 100644
index 000000000..2dd188297
--- /dev/null
+++ b/app/lib/themes.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+require 'singleton'
+require 'yaml'
+
+class Themes
+  include Singleton
+
+  def initialize
+    result = Hash.new
+    Dir.glob(Rails.root.join('app', 'javascript', 'themes', '*', 'theme.yml')) do |path|
+      data = YAML.load_file(path)
+      if data['pack'] && data['name']
+        result[data['name']] = data
+      end
+    end
+    @conf = result
+  end
+
+  def names
+    @conf.keys
+  end
+end
diff --git a/app/lib/user_settings_decorator.rb b/app/lib/user_settings_decorator.rb
index 62046ed72..3b156b98c 100644
--- a/app/lib/user_settings_decorator.rb
+++ b/app/lib/user_settings_decorator.rb
@@ -25,6 +25,7 @@ class UserSettingsDecorator
     user.settings['auto_play_gif'] = auto_play_gif_preference
     user.settings['system_font_ui'] = system_font_ui_preference
     user.settings['noindex'] = noindex_preference
+    user.settings['theme'] = theme_preference
   end
 
   def merged_notification_emails
@@ -67,6 +68,10 @@ class UserSettingsDecorator
     boolean_cast_setting 'setting_noindex'
   end
 
+  def theme_preference
+    settings['setting_theme']
+  end
+  
   def boolean_cast_setting(key)
     settings[key] == '1'
   end