Gab Social. All are welcome.
This commit is contained in:
187
app/lib/activitypub/activity.rb
Normal file
187
app/lib/activitypub/activity.rb
Normal file
@@ -0,0 +1,187 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ActivityPub::Activity
|
||||
include JsonLdHelper
|
||||
include Redisable
|
||||
|
||||
SUPPORTED_TYPES = %w(Note Question).freeze
|
||||
CONVERTED_TYPES = %w(Image Video Article Page).freeze
|
||||
|
||||
def initialize(json, account, **options)
|
||||
@json = json
|
||||
@account = account
|
||||
@object = @json['object']
|
||||
@options = options
|
||||
end
|
||||
|
||||
def perform
|
||||
raise NotImplementedError
|
||||
end
|
||||
|
||||
class << self
|
||||
def factory(json, account, **options)
|
||||
@json = json
|
||||
klass&.new(json, account, options)
|
||||
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
|
||||
when 'Flag'
|
||||
ActivityPub::Activity::Flag
|
||||
when 'Add'
|
||||
ActivityPub::Activity::Add
|
||||
when 'Remove'
|
||||
ActivityPub::Activity::Remove
|
||||
when 'Move'
|
||||
ActivityPub::Activity::Move
|
||||
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 unsupported_object_type?
|
||||
@object.is_a?(String) || !(supported_object_type? || converted_object_type?)
|
||||
end
|
||||
|
||||
def supported_object_type?
|
||||
equals_or_includes_any?(@object['type'], SUPPORTED_TYPES)
|
||||
end
|
||||
|
||||
def converted_object_type?
|
||||
equals_or_includes_any?(@object['type'], CONVERTED_TYPES)
|
||||
end
|
||||
|
||||
def distribute(status)
|
||||
crawl_links(status)
|
||||
|
||||
notify_about_reblog(status) if reblog_of_local_account?(status)
|
||||
notify_about_mentions(status)
|
||||
|
||||
# Only continue if the status is supposed to have arrived in real-time.
|
||||
# Note that if @options[:override_timestamps] isn't set, the status
|
||||
# may have a lower snowflake id than other existing statuses, potentially
|
||||
# "hiding" it from paginated API calls
|
||||
return unless @options[:override_timestamps] || status.within_realtime_window?
|
||||
|
||||
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.active_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?
|
||||
|
||||
# Spread out crawling randomly to avoid DDoSing the link
|
||||
LinkCrawlWorker.perform_in(rand(1..59).seconds, 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
|
||||
|
||||
def status_from_object
|
||||
# If the status is already known, return it
|
||||
status = status_from_uri(object_uri)
|
||||
|
||||
return status unless status.nil?
|
||||
|
||||
# If the reposted gab is embedded and it is a self-repost, handle it like a Create
|
||||
unless unsupported_object_type?
|
||||
actor_id = value_or_id(first_of_value(@object['attributedTo'])) || @account.uri
|
||||
|
||||
if actor_id == @account.uri
|
||||
return ActivityPub::Activity.factory({ 'type' => 'Create', 'actor' => actor_id, 'object' => @object }, @account).perform
|
||||
end
|
||||
end
|
||||
|
||||
fetch_remote_original_status
|
||||
end
|
||||
|
||||
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, id: true, on_behalf_of: @account.followers.local.first)
|
||||
elsif @object['url'].present?
|
||||
::FetchRemoteStatusService.new.call(@object['url'])
|
||||
end
|
||||
end
|
||||
|
||||
def lock_or_return(key, expire_after = 7.days.seconds)
|
||||
yield if redis.set(key, true, nx: true, ex: expire_after)
|
||||
ensure
|
||||
redis.del(key)
|
||||
end
|
||||
|
||||
def fetch?
|
||||
!@options[:delivery]
|
||||
end
|
||||
|
||||
def followed_by_local_accounts?
|
||||
@account.passive_relationships.exists?
|
||||
end
|
||||
|
||||
def requested_through_relay?
|
||||
@options[:relayed_through_account] && Relay.find_by(inbox_url: @options[:relayed_through_account].inbox_url)&.enabled?
|
||||
end
|
||||
|
||||
def reject_payload!
|
||||
Rails.logger.info("Rejected #{@json['type']} activity #{@json['id']} from #{@account.uri}#{@options[:relayed_through_account] && "via #{@options[:relayed_through_account].uri}"}")
|
||||
nil
|
||||
end
|
||||
end
|
||||
39
app/lib/activitypub/activity/accept.rb
Normal file
39
app/lib/activitypub/activity/accept.rb
Normal file
@@ -0,0 +1,39 @@
|
||||
# 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
|
||||
return accept_follow_for_relay if relay_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 accept_follow_for_relay
|
||||
relay.update!(state: :accepted)
|
||||
end
|
||||
|
||||
def relay
|
||||
@relay ||= Relay.find_by(follow_activity_id: object_uri) unless object_uri.nil?
|
||||
end
|
||||
|
||||
def relay_follow?
|
||||
relay.present?
|
||||
end
|
||||
|
||||
def target_uri
|
||||
@target_uri ||= value_or_id(@object['actor'])
|
||||
end
|
||||
end
|
||||
14
app/lib/activitypub/activity/add.rb
Normal file
14
app/lib/activitypub/activity/add.rb
Normal file
@@ -0,0 +1,14 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ActivityPub::Activity::Add < ActivityPub::Activity
|
||||
def perform
|
||||
return unless @json['target'].present? && value_or_id(@json['target']) == @account.featured_collection_url
|
||||
|
||||
status = status_from_uri(object_uri)
|
||||
status ||= fetch_remote_original_status
|
||||
|
||||
return unless !status.nil? && status.account_id == @account.id && !@account.pinned?(status)
|
||||
|
||||
StatusPin.create!(account: @account, status: status)
|
||||
end
|
||||
end
|
||||
57
app/lib/activitypub/activity/announce.rb
Normal file
57
app/lib/activitypub/activity/announce.rb
Normal file
@@ -0,0 +1,57 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ActivityPub::Activity::Announce < ActivityPub::Activity
|
||||
def perform
|
||||
return reject_payload! if delete_arrived_first?(@json['id']) || !related_to_local_activity?
|
||||
|
||||
original_status = status_from_object
|
||||
|
||||
return reject_payload! if original_status.nil? || !announceable?(original_status)
|
||||
|
||||
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'],
|
||||
override_timestamps: @options[:override_timestamps],
|
||||
visibility: visibility_from_audience
|
||||
)
|
||||
|
||||
distribute(status)
|
||||
status
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def visibility_from_audience
|
||||
if equals_or_includes?(@json['to'], ActivityPub::TagManager::COLLECTIONS[:public])
|
||||
:public
|
||||
elsif equals_or_includes?(@json['cc'], ActivityPub::TagManager::COLLECTIONS[:public])
|
||||
:unlisted
|
||||
elsif equals_or_includes?(@json['to'], @account.followers_url)
|
||||
:private
|
||||
else
|
||||
:direct
|
||||
end
|
||||
end
|
||||
|
||||
def announceable?(status)
|
||||
status.account_id == @account.id || status.public_visibility? || status.unlisted_visibility?
|
||||
end
|
||||
|
||||
def related_to_local_activity?
|
||||
followed_by_local_accounts? || requested_through_relay? || reblog_of_local_status?
|
||||
end
|
||||
|
||||
def requested_through_relay?
|
||||
super || Relay.find_by(inbox_url: @account.inbox_url)&.enabled?
|
||||
end
|
||||
|
||||
def reblog_of_local_status?
|
||||
status_from_uri(object_uri)&.account&.local?
|
||||
end
|
||||
end
|
||||
13
app/lib/activitypub/activity/block.rb
Normal file
13
app/lib/activitypub/activity/block.rb
Normal file
@@ -0,0 +1,13 @@
|
||||
# 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? || @account.blocking?(target_account)
|
||||
|
||||
UnfollowService.new.call(target_account, @account) if target_account.following?(@account)
|
||||
|
||||
@account.block!(target_account, uri: @json['id']) unless delete_arrived_first?(@json['id'])
|
||||
end
|
||||
end
|
||||
417
app/lib/activitypub/activity/create.rb
Normal file
417
app/lib/activitypub/activity/create.rb
Normal file
@@ -0,0 +1,417 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ActivityPub::Activity::Create < ActivityPub::Activity
|
||||
def perform
|
||||
return reject_payload! if unsupported_object_type? || invalid_origin?(@object['id']) || Tombstone.exists?(uri: @object['id']) || !related_to_local_activity?
|
||||
|
||||
RedisLock.acquire(lock_options) do |lock|
|
||||
if lock.acquired?
|
||||
return if delete_arrived_first?(object_uri) || poll_vote?
|
||||
|
||||
@status = find_existing_status
|
||||
|
||||
if @status.nil?
|
||||
process_status
|
||||
elsif @options[:delivered_to_account_id].present?
|
||||
postprocess_audience_and_deliver
|
||||
end
|
||||
else
|
||||
raise GabSocial::RaceConditionError
|
||||
end
|
||||
end
|
||||
|
||||
@status
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def process_status
|
||||
@tags = []
|
||||
@mentions = []
|
||||
@params = {}
|
||||
|
||||
process_status_params
|
||||
process_tags
|
||||
process_audience
|
||||
|
||||
ApplicationRecord.transaction do
|
||||
@status = Status.create!(@params)
|
||||
attach_tags(@status)
|
||||
end
|
||||
|
||||
resolve_thread(@status)
|
||||
fetch_replies(@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 process_status_params
|
||||
@params = begin
|
||||
{
|
||||
uri: @object['id'],
|
||||
url: object_url || @object['id'],
|
||||
account: @account,
|
||||
text: text_from_content || '',
|
||||
language: detected_language,
|
||||
spoiler_text: converted_object_type? ? '' : (text_from_summary || ''),
|
||||
created_at: @object['published'],
|
||||
override_timestamps: @options[:override_timestamps],
|
||||
reply: @object['inReplyTo'].present?,
|
||||
sensitive: @object['sensitive'] || false,
|
||||
visibility: visibility_from_audience,
|
||||
thread: replied_to_status,
|
||||
conversation: conversation_from_uri(@object['conversation']),
|
||||
media_attachment_ids: process_attachments.take(4).map(&:id),
|
||||
poll: process_poll,
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def process_audience
|
||||
(as_array(@object['to']) + as_array(@object['cc'])).uniq.each do |audience|
|
||||
next if audience == ActivityPub::TagManager::COLLECTIONS[:public]
|
||||
|
||||
# Unlike with tags, there is no point in resolving accounts we don't already
|
||||
# know here, because silent mentions would only be used for local access
|
||||
# control anyway
|
||||
account = account_from_uri(audience)
|
||||
|
||||
next if account.nil? || @mentions.any? { |mention| mention.account_id == account.id }
|
||||
|
||||
@mentions << Mention.new(account: account, silent: true)
|
||||
|
||||
# If there is at least one silent mention, then the status can be considered
|
||||
# as a limited-audience status, and not strictly a direct message, but only
|
||||
# if we considered a direct message in the first place
|
||||
next unless @params[:visibility] == :direct
|
||||
|
||||
@params[:visibility] = :limited
|
||||
end
|
||||
|
||||
# If the payload was delivered to a specific inbox, the inbox owner must have
|
||||
# access to it, unless they already have access to it anyway
|
||||
return if @options[:delivered_to_account_id].nil? || @mentions.any? { |mention| mention.account_id == @options[:delivered_to_account_id] }
|
||||
|
||||
@mentions << Mention.new(account_id: @options[:delivered_to_account_id], silent: true)
|
||||
|
||||
return unless @params[:visibility] == :direct
|
||||
|
||||
@params[:visibility] = :limited
|
||||
end
|
||||
|
||||
def postprocess_audience_and_deliver
|
||||
return if @status.mentions.find_by(account_id: @options[:delivered_to_account_id])
|
||||
|
||||
delivered_to_account = Account.find(@options[:delivered_to_account_id])
|
||||
|
||||
@status.mentions.create(account: delivered_to_account, silent: true)
|
||||
@status.update(visibility: :limited) if @status.direct_visibility?
|
||||
|
||||
return unless delivered_to_account.following?(@account)
|
||||
|
||||
FeedInsertWorker.perform_async(@status.id, delivered_to_account.id, :home)
|
||||
end
|
||||
|
||||
def attach_tags(status)
|
||||
@tags.each do |tag|
|
||||
status.tags << tag
|
||||
TrendingTags.record_use!(tag, status.account, status.created_at) if status.public_visibility?
|
||||
end
|
||||
|
||||
@mentions.each do |mention|
|
||||
mention.status = status
|
||||
mention.save
|
||||
end
|
||||
end
|
||||
|
||||
def process_tags
|
||||
return if @object['tag'].nil?
|
||||
|
||||
as_array(@object['tag']).each do |tag|
|
||||
if equals_or_includes?(tag['type'], 'Hashtag')
|
||||
process_hashtag tag
|
||||
elsif equals_or_includes?(tag['type'], 'Mention')
|
||||
process_mention tag
|
||||
elsif equals_or_includes?(tag['type'], 'Emoji')
|
||||
process_emoji tag
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def process_hashtag(tag)
|
||||
return if tag['name'].blank?
|
||||
|
||||
hashtag = tag['name'].gsub(/\A#/, '').mb_chars.downcase
|
||||
hashtag = Tag.where(name: hashtag).first_or_create!(name: hashtag)
|
||||
|
||||
return if @tags.include?(hashtag)
|
||||
|
||||
@tags << hashtag
|
||||
rescue ActiveRecord::RecordInvalid
|
||||
nil
|
||||
end
|
||||
|
||||
def process_mention(tag)
|
||||
return if tag['href'].blank?
|
||||
|
||||
account = account_from_uri(tag['href'])
|
||||
account = ::FetchRemoteAccountService.new.call(tag['href']) if account.nil?
|
||||
|
||||
return if account.nil?
|
||||
|
||||
@mentions << Mention.new(account: account, silent: false)
|
||||
end
|
||||
|
||||
def process_emoji(tag)
|
||||
return if skip_download?
|
||||
return if tag['name'].blank? || tag['icon'].blank? || tag['icon']['url'].blank?
|
||||
|
||||
shortcode = tag['name'].delete(':')
|
||||
image_url = tag['icon']['url']
|
||||
uri = tag['id']
|
||||
updated = tag['updated']
|
||||
emoji = CustomEmoji.find_by(shortcode: shortcode, domain: @account.domain)
|
||||
|
||||
return unless emoji.nil? || image_url != emoji.image_remote_url || (updated && updated >= emoji.updated_at)
|
||||
|
||||
emoji ||= CustomEmoji.new(domain: @account.domain, shortcode: shortcode, uri: uri)
|
||||
emoji.image_remote_url = image_url
|
||||
emoji.save
|
||||
end
|
||||
|
||||
def process_attachments
|
||||
return [] if @object['attachment'].nil?
|
||||
|
||||
media_attachments = []
|
||||
|
||||
as_array(@object['attachment']).each do |attachment|
|
||||
next if attachment['url'].blank?
|
||||
|
||||
href = Addressable::URI.parse(attachment['url']).normalize.to_s
|
||||
media_attachment = MediaAttachment.create(account: @account, remote_url: href, description: attachment['name'].presence, focus: attachment['focalPoint'], blurhash: supported_blurhash?(attachment['blurhash']) ? attachment['blurhash'] : nil)
|
||||
media_attachments << media_attachment
|
||||
|
||||
next if unsupported_media_type?(attachment['mediaType']) || skip_download?
|
||||
|
||||
media_attachment.file_remote_url = href
|
||||
media_attachment.save
|
||||
end
|
||||
|
||||
media_attachments
|
||||
rescue Addressable::URI::InvalidURIError => e
|
||||
Rails.logger.debug e
|
||||
|
||||
media_attachments
|
||||
end
|
||||
|
||||
def process_poll
|
||||
return unless @object['type'] == 'Question' && (@object['anyOf'].is_a?(Array) || @object['oneOf'].is_a?(Array))
|
||||
|
||||
expires_at = begin
|
||||
if @object['closed'].is_a?(String)
|
||||
@object['closed']
|
||||
elsif !@object['closed'].nil? && !@object['closed'].is_a?(FalseClass)
|
||||
Time.now.utc
|
||||
else
|
||||
@object['endTime']
|
||||
end
|
||||
end
|
||||
|
||||
if @object['anyOf'].is_a?(Array)
|
||||
multiple = true
|
||||
items = @object['anyOf']
|
||||
else
|
||||
multiple = false
|
||||
items = @object['oneOf']
|
||||
end
|
||||
|
||||
@account.polls.new(
|
||||
multiple: multiple,
|
||||
expires_at: expires_at,
|
||||
options: items.map { |item| item['name'].presence || item['content'] }.compact,
|
||||
cached_tallies: items.map { |item| item.dig('replies', 'totalItems') || 0 }
|
||||
)
|
||||
end
|
||||
|
||||
def poll_vote?
|
||||
return false if replied_to_status.nil? || replied_to_status.preloadable_poll.nil? || !replied_to_status.local? || !replied_to_status.preloadable_poll.options.include?(@object['name'])
|
||||
|
||||
unless replied_to_status.preloadable_poll.expired?
|
||||
replied_to_status.preloadable_poll.votes.create!(account: @account, choice: replied_to_status.preloadable_poll.options.index(@object['name']), uri: @object['id'])
|
||||
ActivityPub::DistributePollUpdateWorker.perform_in(3.minutes, replied_to_status.id) unless replied_to_status.preloadable_poll.hide_totals?
|
||||
end
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
def resolve_thread(status)
|
||||
return unless status.reply? && status.thread.nil? && Request.valid_url?(in_reply_to_uri)
|
||||
ThreadResolveWorker.perform_async(status.id, in_reply_to_uri)
|
||||
end
|
||||
|
||||
def fetch_replies(status)
|
||||
collection = @object['replies']
|
||||
return if collection.nil?
|
||||
replies = ActivityPub::FetchRepliesService.new.call(status, collection, false)
|
||||
return unless replies.nil?
|
||||
uri = value_or_id(collection)
|
||||
ActivityPub::FetchRepliesWorker.perform_async(status.id, uri) unless uri.nil?
|
||||
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)
|
||||
begin
|
||||
Conversation.find_or_create_by!(uri: uri)
|
||||
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique
|
||||
retry
|
||||
end
|
||||
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
|
||||
return Formatter.instance.linkify([[text_from_name, text_from_summary.presence].compact.join("\n\n"), object_url || @object['id']].join(' ')) if converted_object_type?
|
||||
|
||||
if @object['content'].present?
|
||||
@object['content']
|
||||
elsif content_language_map?
|
||||
@object['contentMap'].values.first
|
||||
end
|
||||
end
|
||||
|
||||
def text_from_summary
|
||||
if @object['summary'].present?
|
||||
@object['summary']
|
||||
elsif summary_language_map?
|
||||
@object['summaryMap'].values.first
|
||||
end
|
||||
end
|
||||
|
||||
def text_from_name
|
||||
if @object['name'].present?
|
||||
@object['name']
|
||||
elsif name_language_map?
|
||||
@object['nameMap'].values.first
|
||||
end
|
||||
end
|
||||
|
||||
def detected_language
|
||||
if content_language_map?
|
||||
@object['contentMap'].keys.first
|
||||
elsif name_language_map?
|
||||
@object['nameMap'].keys.first
|
||||
elsif summary_language_map?
|
||||
@object['summaryMap'].keys.first
|
||||
elsif supported_object_type?
|
||||
LanguageDetector.instance.detect(text_from_content, @account)
|
||||
end
|
||||
end
|
||||
|
||||
def object_url
|
||||
return if @object['url'].blank?
|
||||
|
||||
url_candidate = url_to_href(@object['url'], 'text/html')
|
||||
|
||||
if invalid_origin?(url_candidate)
|
||||
nil
|
||||
else
|
||||
url_candidate
|
||||
end
|
||||
end
|
||||
|
||||
def summary_language_map?
|
||||
@object['summaryMap'].is_a?(Hash) && !@object['summaryMap'].empty?
|
||||
end
|
||||
|
||||
def content_language_map?
|
||||
@object['contentMap'].is_a?(Hash) && !@object['contentMap'].empty?
|
||||
end
|
||||
|
||||
def name_language_map?
|
||||
@object['nameMap'].is_a?(Hash) && !@object['nameMap'].empty?
|
||||
end
|
||||
|
||||
def unsupported_media_type?(mime_type)
|
||||
mime_type.present? && !(MediaAttachment::IMAGE_MIME_TYPES + MediaAttachment::VIDEO_MIME_TYPES).include?(mime_type)
|
||||
end
|
||||
|
||||
def supported_blurhash?(blurhash)
|
||||
components = blurhash.blank? ? nil : Blurhash.components(blurhash)
|
||||
components.present? && components.none? { |comp| comp > 5 }
|
||||
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 related_to_local_activity?
|
||||
fetch? || followed_by_local_accounts? || requested_through_relay? ||
|
||||
responds_to_followed_account? || addresses_local_accounts?
|
||||
end
|
||||
|
||||
def responds_to_followed_account?
|
||||
!replied_to_status.nil? && (replied_to_status.account.local? || replied_to_status.account.passive_relationships.exists?)
|
||||
end
|
||||
|
||||
def addresses_local_accounts?
|
||||
return true if @options[:delivered_to_account_id]
|
||||
|
||||
local_usernames = (as_array(@object['to']) + as_array(@object['cc'])).uniq.select { |uri| ActivityPub::TagManager.instance.local_uri?(uri) }.map { |uri| ActivityPub::TagManager.instance.uri_to_local_id(uri, :username) }
|
||||
|
||||
return false if local_usernames.empty?
|
||||
|
||||
Account.local.where(username: local_usernames).exists?
|
||||
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, [@account.preferred_inbox_url])
|
||||
end
|
||||
|
||||
def lock_options
|
||||
{ redis: Redis.current, key: "create:#{@object['id']}" }
|
||||
end
|
||||
end
|
||||
78
app/lib/activitypub/activity/delete.rb
Normal file
78
app/lib/activitypub/activity/delete.rb
Normal file
@@ -0,0 +1,78 @@
|
||||
# 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
|
||||
lock_or_return("delete_in_progress:#{@account.id}") do
|
||||
SuspendAccountService.new.call(@account)
|
||||
@account.destroy!
|
||||
end
|
||||
end
|
||||
|
||||
def delete_note
|
||||
return if object_uri.nil?
|
||||
|
||||
unless invalid_origin?(object_uri)
|
||||
RedisLock.acquire(lock_options) { |_lock| delete_later!(object_uri) }
|
||||
Tombstone.find_or_create_by(uri: object_uri, account: @account)
|
||||
end
|
||||
|
||||
@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?
|
||||
|
||||
return if @status.nil?
|
||||
|
||||
if @status.public_visibility? || @status.unlisted_visibility?
|
||||
forward_for_reply
|
||||
forward_for_reblogs
|
||||
end
|
||||
|
||||
delete_now!
|
||||
end
|
||||
|
||||
def forward_for_reblogs
|
||||
return if @json['signature'].blank?
|
||||
|
||||
rebloggers_ids = @status.reblogs.includes(:account).references(:account).merge(Account.local).pluck(:account_id)
|
||||
inboxes = Account.where(id: ::Follow.where(target_account_id: rebloggers_ids).select(:account_id)).inboxes - [@account.preferred_inbox_url]
|
||||
|
||||
ActivityPub::DeliveryWorker.push_bulk(inboxes) do |inbox_url|
|
||||
[payload, rebloggers_ids.first, inbox_url]
|
||||
end
|
||||
end
|
||||
|
||||
def replied_to_status
|
||||
return @replied_to_status if defined?(@replied_to_status)
|
||||
@replied_to_status = @status.thread
|
||||
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, [@account.preferred_inbox_url])
|
||||
end
|
||||
|
||||
def delete_now!
|
||||
RemoveStatusService.new.call(@status)
|
||||
end
|
||||
|
||||
def payload
|
||||
@payload ||= Oj.dump(@json)
|
||||
end
|
||||
|
||||
def lock_options
|
||||
{ redis: Redis.current, key: "create:#{object_uri}" }
|
||||
end
|
||||
end
|
||||
36
app/lib/activitypub/activity/flag.rb
Normal file
36
app/lib/activitypub/activity/flag.rb
Normal file
@@ -0,0 +1,36 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ActivityPub::Activity::Flag < ActivityPub::Activity
|
||||
def perform
|
||||
return if skip_reports?
|
||||
|
||||
target_accounts = object_uris.map { |uri| account_from_uri(uri) }.compact.select(&:local?)
|
||||
target_statuses_by_account = object_uris.map { |uri| status_from_uri(uri) }.compact.select(&:local?).group_by(&:account_id)
|
||||
|
||||
target_accounts.each do |target_account|
|
||||
target_statuses = target_statuses_by_account[target_account.id]
|
||||
|
||||
ReportService.new.call(
|
||||
@account,
|
||||
target_account,
|
||||
status_ids: target_statuses.nil? ? [] : target_statuses.map(&:id),
|
||||
comment: @json['content'] || '',
|
||||
uri: report_uri
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def skip_reports?
|
||||
DomainBlock.find_by(domain: @account.domain)&.reject_reports?
|
||||
end
|
||||
|
||||
def object_uris
|
||||
@object_uris ||= Array(@object.is_a?(Array) ? @object.map { |item| value_or_id(item) } : value_or_id(@object))
|
||||
end
|
||||
|
||||
def report_uri
|
||||
@json['id'] unless @json['id'].nil? || invalid_origin?(@json['id'])
|
||||
end
|
||||
end
|
||||
34
app/lib/activitypub/activity/follow.rb
Normal file
34
app/lib/activitypub/activity/follow.rb
Normal file
@@ -0,0 +1,34 @@
|
||||
# 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)
|
||||
|
||||
if target_account.blocking?(@account) || target_account.domain_blocking?(@account.domain) || target_account.moved?
|
||||
reject_follow_request!(target_account)
|
||||
return
|
||||
end
|
||||
|
||||
# Fast-forward repeat follow requests
|
||||
if @account.following?(target_account)
|
||||
AuthorizeFollowService.new.call(@account, target_account, skip_follow_request: true, follow_request_uri: @json['id'])
|
||||
return
|
||||
end
|
||||
|
||||
follow_request = FollowRequest.create!(account: @account, target_account: target_account, uri: @json['id'])
|
||||
|
||||
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
|
||||
|
||||
def reject_follow_request!(target_account)
|
||||
json = ActiveModelSerializers::SerializableResource.new(FollowRequest.new(account: @account, target_account: target_account, uri: @json['id']), serializer: ActivityPub::RejectFollowSerializer, adapter: ActivityPub::Adapter).to_json
|
||||
ActivityPub::DeliveryWorker.perform_async(json, target_account.id, @account.inbox_url)
|
||||
end
|
||||
end
|
||||
12
app/lib/activitypub/activity/like.rb
Normal file
12
app/lib/activitypub/activity/like.rb
Normal file
@@ -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
|
||||
43
app/lib/activitypub/activity/move.rb
Normal file
43
app/lib/activitypub/activity/move.rb
Normal file
@@ -0,0 +1,43 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ActivityPub::Activity::Move < ActivityPub::Activity
|
||||
PROCESSING_COOLDOWN = 7.days.seconds
|
||||
|
||||
def perform
|
||||
return if origin_account.uri != object_uri || processed?
|
||||
|
||||
mark_as_processing!
|
||||
|
||||
target_account = ActivityPub::FetchRemoteAccountService.new.call(target_uri)
|
||||
|
||||
return if target_account.nil? || !target_account.also_known_as.include?(origin_account.uri)
|
||||
|
||||
# In case for some reason we didn't have a redirect for the profile already, set it
|
||||
origin_account.update(moved_to_account: target_account) if origin_account.moved_to_account_id.nil?
|
||||
|
||||
# Initiate a re-follow for each follower
|
||||
origin_account.followers.local.select(:id).find_in_batches do |follower_accounts|
|
||||
UnfollowFollowWorker.push_bulk(follower_accounts.map(&:id)) do |follower_account_id|
|
||||
[follower_account_id, origin_account.id, target_account.id]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def origin_account
|
||||
@account
|
||||
end
|
||||
|
||||
def target_uri
|
||||
value_or_id(@json['target'])
|
||||
end
|
||||
|
||||
def processed?
|
||||
redis.exists("move_in_progress:#{@account.id}")
|
||||
end
|
||||
|
||||
def mark_as_processing!
|
||||
redis.setex("move_in_progress:#{@account.id}", PROCESSING_COOLDOWN, true)
|
||||
end
|
||||
end
|
||||
41
app/lib/activitypub/activity/reject.rb
Normal file
41
app/lib/activitypub/activity/reject.rb
Normal file
@@ -0,0 +1,41 @@
|
||||
# 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
|
||||
return reject_follow_for_relay if relay_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!
|
||||
|
||||
UnfollowService.new.call(target_account, @account) if target_account.following?(@account)
|
||||
end
|
||||
|
||||
def reject_follow_for_relay
|
||||
relay.update!(state: :rejected)
|
||||
end
|
||||
|
||||
def relay
|
||||
@relay ||= Relay.find_by(follow_activity_id: object_uri) unless object_uri.nil?
|
||||
end
|
||||
|
||||
def relay_follow?
|
||||
relay.present?
|
||||
end
|
||||
|
||||
def target_uri
|
||||
@target_uri ||= value_or_id(@object['actor'])
|
||||
end
|
||||
end
|
||||
14
app/lib/activitypub/activity/remove.rb
Normal file
14
app/lib/activitypub/activity/remove.rb
Normal file
@@ -0,0 +1,14 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ActivityPub::Activity::Remove < ActivityPub::Activity
|
||||
def perform
|
||||
return unless @json['target'].present? && value_or_id(@json['target']) == @account.featured_collection_url
|
||||
|
||||
status = status_from_uri(object_uri)
|
||||
|
||||
return unless !status.nil? && status.account_id == @account.id
|
||||
|
||||
pin = StatusPin.find_by(account: @account, status: status)
|
||||
pin&.destroy!
|
||||
end
|
||||
end
|
||||
80
app/lib/activitypub/activity/undo.rb
Normal file
80
app/lib/activitypub/activity/undo.rb
Normal file
@@ -0,0 +1,80 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ActivityPub::Activity::Undo < ActivityPub::Activity
|
||||
def perform
|
||||
case @object['type']
|
||||
when 'Announce'
|
||||
undo_announce
|
||||
when 'Accept'
|
||||
undo_accept
|
||||
when 'Follow'
|
||||
undo_follow
|
||||
when 'Like'
|
||||
undo_like
|
||||
when 'Block'
|
||||
undo_block
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def undo_announce
|
||||
return if object_uri.nil?
|
||||
|
||||
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?
|
||||
|
||||
if status.nil?
|
||||
delete_later!(object_uri)
|
||||
else
|
||||
RemoveStatusService.new.call(status)
|
||||
end
|
||||
end
|
||||
|
||||
def undo_accept
|
||||
::Follow.find_by(target_account: @account, uri: target_uri)&.revoke_request!
|
||||
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
|
||||
30
app/lib/activitypub/activity/update.rb
Normal file
30
app/lib/activitypub/activity/update.rb
Normal file
@@ -0,0 +1,30 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ActivityPub::Activity::Update < ActivityPub::Activity
|
||||
SUPPORTED_TYPES = %w(Application Group Organization Person Service).freeze
|
||||
|
||||
def perform
|
||||
if equals_or_includes_any?(@object['type'], SUPPORTED_TYPES)
|
||||
update_account
|
||||
elsif equals_or_includes_any?(@object['type'], %w(Question))
|
||||
update_poll
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def update_account
|
||||
return if @account.uri != object_uri
|
||||
|
||||
ActivityPub::ProcessAccountService.new.call(@account.username, @account.domain, @object, signed_with_known_key: true)
|
||||
end
|
||||
|
||||
def update_poll
|
||||
return reject_payload! if invalid_origin?(@object['id'])
|
||||
|
||||
status = Status.find_by(uri: object_uri, account_id: @account.id)
|
||||
return if status.nil? || status.preloadable_poll.nil?
|
||||
|
||||
ActivityPub::ProcessPollService.new.call(status.preloadable_poll, @object)
|
||||
end
|
||||
end
|
||||
66
app/lib/activitypub/adapter.rb
Normal file
66
app/lib/activitypub/adapter.rb
Normal file
@@ -0,0 +1,66 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base
|
||||
NAMED_CONTEXT_MAP = {
|
||||
activitystreams: 'https://www.w3.org/ns/activitystreams',
|
||||
security: 'https://w3id.org/security/v1',
|
||||
}.freeze
|
||||
|
||||
CONTEXT_EXTENSION_MAP = {
|
||||
manually_approves_followers: { 'manuallyApprovesFollowers' => 'as:manuallyApprovesFollowers' },
|
||||
sensitive: { 'sensitive' => 'as:sensitive' },
|
||||
hashtag: { 'Hashtag' => 'as:Hashtag' },
|
||||
moved_to: { 'movedTo' => { '@id' => 'as:movedTo', '@type' => '@id' } },
|
||||
also_known_as: { 'alsoKnownAs' => { '@id' => 'as:alsoKnownAs', '@type' => '@id' } },
|
||||
emoji: { 'toot' => 'http://joinmastodon.org/ns#', 'Emoji' => 'toot:Emoji' },
|
||||
featured: { 'toot' => 'http://joinmastodon.org/ns#', 'featured' => { '@id' => 'toot:featured', '@type' => '@id' } },
|
||||
property_value: { 'schema' => 'http://schema.org#', 'PropertyValue' => 'schema:PropertyValue', 'value' => 'schema:value' },
|
||||
atom_uri: { 'ostatus' => 'http://ostatus.org#', 'atomUri' => 'ostatus:atomUri' },
|
||||
conversation: { 'ostatus' => 'http://ostatus.org#', 'inReplyToAtomUri' => 'ostatus:inReplyToAtomUri', 'conversation' => 'ostatus:conversation' },
|
||||
focal_point: { 'toot' => 'http://joinmastodon.org/ns#', 'focalPoint' => { '@container' => '@list', '@id' => 'toot:focalPoint' } },
|
||||
identity_proof: { 'toot' => 'http://joinmastodon.org/ns#', 'IdentityProof' => 'toot:IdentityProof' },
|
||||
blurhash: { 'toot' => 'http://joinmastodon.org/ns#', 'blurhash' => 'toot:blurhash' },
|
||||
}.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 = serializer.serializable_hash(options)
|
||||
serialized_hash = self.class.transform_key_casing!(serialized_hash, instance_options)
|
||||
|
||||
{ '@context' => serialized_context }.merge(serialized_hash)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def serialized_context
|
||||
context_array = []
|
||||
|
||||
serializer_options = serializer.send(:instance_options) || {}
|
||||
named_contexts = [:activitystreams] + serializer._named_contexts.keys + serializer_options.fetch(:named_contexts, {}).keys
|
||||
context_extensions = serializer._context_extensions.keys + serializer_options.fetch(:context_extensions, {}).keys
|
||||
|
||||
named_contexts.each do |key|
|
||||
context_array << NAMED_CONTEXT_MAP[key]
|
||||
end
|
||||
|
||||
extensions = context_extensions.each_with_object({}) do |key, h|
|
||||
h.merge!(CONTEXT_EXTENSION_MAP[key])
|
||||
end
|
||||
|
||||
context_array << extensions unless extensions.empty?
|
||||
|
||||
if context_array.size == 1
|
||||
context_array.first
|
||||
else
|
||||
context_array
|
||||
end
|
||||
end
|
||||
end
|
||||
24
app/lib/activitypub/case_transform.rb
Normal file
24
app/lib/activitypub/case_transform.rb
Normal file
@@ -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
|
||||
57
app/lib/activitypub/linked_data_signature.rb
Normal file
57
app/lib/activitypub/linked_data_signature.rb
Normal file
@@ -0,0 +1,57 @@
|
||||
# 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, id: false)
|
||||
|
||||
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, sign_with: nil)
|
||||
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
|
||||
keypair = sign_with.present? ? OpenSSL::PKey::RSA.new(sign_with) : creator.keypair
|
||||
|
||||
signature = Base64.strict_encode64(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
|
||||
30
app/lib/activitypub/serializer.rb
Normal file
30
app/lib/activitypub/serializer.rb
Normal file
@@ -0,0 +1,30 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ActivityPub::Serializer < ActiveModel::Serializer
|
||||
with_options instance_writer: false, instance_reader: true do |serializer|
|
||||
serializer.class_attribute :_named_contexts
|
||||
serializer.class_attribute :_context_extensions
|
||||
|
||||
self._named_contexts ||= {}
|
||||
self._context_extensions ||= {}
|
||||
end
|
||||
|
||||
def self.inherited(base)
|
||||
super
|
||||
|
||||
base._named_contexts = _named_contexts.dup
|
||||
base._context_extensions = _context_extensions.dup
|
||||
end
|
||||
|
||||
def self.context(*named_contexts)
|
||||
named_contexts.each do |context|
|
||||
_named_contexts[context] = true
|
||||
end
|
||||
end
|
||||
|
||||
def self.context_extensions(*extension_names)
|
||||
extension_names.each do |extension_name|
|
||||
_context_extensions[extension_name] = true
|
||||
end
|
||||
end
|
||||
end
|
||||
143
app/lib/activitypub/tag_manager.rb
Normal file
143
app/lib/activitypub/tag_manager.rb
Normal file
@@ -0,0 +1,143 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'singleton'
|
||||
|
||||
class ActivityPub::TagManager
|
||||
include Singleton
|
||||
include RoutingHelper
|
||||
|
||||
CONTEXT = 'https://www.w3.org/ns/activitystreams'
|
||||
|
||||
COLLECTIONS = {
|
||||
public: 'https://www.w3.org/ns/activitystreams#Public',
|
||||
}.freeze
|
||||
|
||||
def url_for(target)
|
||||
return target.url if target.respond_to?(:local?) && !target.local?
|
||||
|
||||
case target.object_type
|
||||
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
|
||||
|
||||
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
|
||||
return activity_account_status_url(target.account, target) if target.reblog?
|
||||
account_status_url(target.account, target)
|
||||
when :emoji
|
||||
emoji_url(target)
|
||||
end
|
||||
end
|
||||
|
||||
def generate_uri_for(_target)
|
||||
URI.join(root_url, 'payloads', SecureRandom.uuid)
|
||||
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
|
||||
|
||||
def replies_uri_for(target, page_params = nil)
|
||||
raise ArgumentError, 'target must be a local activity' unless %i(note comment activity).include?(target.object_type) && target.local?
|
||||
|
||||
replies_account_status_url(target.account, target, page_params)
|
||||
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
|
||||
# Others go out only to the people they mention
|
||||
def to(status)
|
||||
case status.visibility
|
||||
when 'public'
|
||||
[COLLECTIONS[:public]]
|
||||
when 'unlisted', 'private'
|
||||
[account_followers_url(status.account)]
|
||||
when 'direct', 'limited'
|
||||
if status.account.silenced?
|
||||
# Only notify followers if the account is locally silenced
|
||||
account_ids = status.active_mentions.pluck(:account_id)
|
||||
to = status.account.followers.where(id: account_ids).map { |account| uri_for(account) }
|
||||
to.concat(FollowRequest.where(target_account_id: status.account_id, account_id: account_ids).map { |request| uri_for(request.account) })
|
||||
else
|
||||
status.active_mentions.map { |mention| uri_for(mention.account) }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Secondary audience of a status
|
||||
# Public statuses go out to followers as well
|
||||
# Unlisted statuses go to the public as well
|
||||
# Both of those and private statuses also go to the people mentioned in them
|
||||
# Direct ones don't have a secondary audience
|
||||
def cc(status)
|
||||
cc = []
|
||||
|
||||
cc << uri_for(status.reblog.account) if status.reblog?
|
||||
|
||||
case status.visibility
|
||||
when 'public'
|
||||
cc << account_followers_url(status.account)
|
||||
when 'unlisted'
|
||||
cc << COLLECTIONS[:public]
|
||||
end
|
||||
|
||||
unless status.direct_visibility? || status.limited_visibility?
|
||||
if status.account.silenced?
|
||||
# Only notify followers if the account is locally silenced
|
||||
account_ids = status.active_mentions.pluck(:account_id)
|
||||
cc.concat(status.account.followers.where(id: account_ids).map { |account| uri_for(account) })
|
||||
cc.concat(FollowRequest.where(target_account_id: status.account_id, account_id: account_ids).map { |request| uri_for(request.account) })
|
||||
else
|
||||
cc.concat(status.active_mentions.map { |mention| uri_for(mention.account) })
|
||||
end
|
||||
end
|
||||
|
||||
cc
|
||||
end
|
||||
|
||||
def local_uri?(uri)
|
||||
return false if uri.nil?
|
||||
|
||||
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)
|
||||
return if uri.nil?
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user