Gab Social. All are welcome.
This commit is contained in:
29
app/lib/activity_tracker.rb
Normal file
29
app/lib/activity_tracker.rb
Normal file
@@ -0,0 +1,29 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ActivityTracker
|
||||
EXPIRE_AFTER = 90.days.seconds
|
||||
|
||||
class << self
|
||||
include Redisable
|
||||
|
||||
def increment(prefix)
|
||||
key = [prefix, current_week].join(':')
|
||||
|
||||
redis.incrby(key, 1)
|
||||
redis.expire(key, EXPIRE_AFTER)
|
||||
end
|
||||
|
||||
def record(prefix, value)
|
||||
key = [prefix, current_week].join(':')
|
||||
|
||||
redis.pfadd(key, value)
|
||||
redis.expire(key, EXPIRE_AFTER)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def current_week
|
||||
Time.zone.today.cweek
|
||||
end
|
||||
end
|
||||
end
|
||||
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
|
||||
9
app/lib/application_extension.rb
Normal file
9
app/lib/application_extension.rb
Normal file
@@ -0,0 +1,9 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module ApplicationExtension
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
validates :website, url: true, if: :website?
|
||||
end
|
||||
end
|
||||
253
app/lib/btcpay/client.rb
Normal file
253
app/lib/btcpay/client.rb
Normal file
@@ -0,0 +1,253 @@
|
||||
class Btcpay::Client
|
||||
# @return [Client]
|
||||
# @example
|
||||
# # Create a client with a pem file created by the bitpay client:
|
||||
# client = BitPay::SDK::Client.new
|
||||
def initialize(opts={})
|
||||
@auth_header = "Basic " + opts[:legacy_token]
|
||||
@pub_key = opts[:pub_key]
|
||||
@client_id = opts[:client_id]
|
||||
@uri = URI.parse opts[:api_uri] || API_URI
|
||||
@user_agent = 'ruby-bitpay-sdk'
|
||||
@https = Net::HTTP.new @uri.host, @uri.port
|
||||
@https.use_ssl = true
|
||||
@https.open_timeout = 10
|
||||
@https.read_timeout = 10
|
||||
|
||||
#@https.ca_file = File.join File.dirname(__FILE__), 'cacert.pem'
|
||||
@tokens = opts[:tokens] || {}
|
||||
|
||||
# Option to disable certificate validation in extraordinary circumstance. NOT recommended for production use
|
||||
@https.verify_mode = opts[:insecure] == true ? OpenSSL::SSL::VERIFY_NONE : OpenSSL::SSL::VERIFY_PEER
|
||||
|
||||
# Option to enable http request debugging
|
||||
@https.set_debug_output($stdout) if opts[:debug] == true
|
||||
end
|
||||
|
||||
## Pair client with BitPay service
|
||||
# => Pass empty hash {} to retreive client-initiated pairing code
|
||||
# => Pass {pairingCode: 'WfD01d2'} to claim a server-initiated pairing code
|
||||
#
|
||||
def pair_client(params={})
|
||||
tokens = post(path: 'tokens', params: params)
|
||||
return tokens["data"]
|
||||
end
|
||||
|
||||
## Compatibility method for pos pairing
|
||||
#
|
||||
def pair_pos_client(claimCode)
|
||||
raise BitPay::ArgumentError, "pairing code is not legal" unless verify_claim_code(claimCode)
|
||||
pair_client({pairingCode: claimCode})
|
||||
end
|
||||
|
||||
## Create bitcoin invoice
|
||||
#
|
||||
# Defaults to pos facade, also works with merchant facade
|
||||
#
|
||||
def create_invoice(price:, currency:, facade: 'pos', params:{})
|
||||
raise BitPay::ArgumentError, "Illegal Argument: Price must be formatted as a float" unless
|
||||
price.is_a?(Numeric) ||
|
||||
/^[[:digit:]]+(\.[[:digit:]]{2})?$/.match(price) ||
|
||||
currency == 'BTC' && /^[[:digit:]]+(\.[[:digit:]]{1,6})?$/.match(price)
|
||||
raise BitPay::ArgumentError, "Illegal Argument: Currency is invalid." unless /^[[:upper:]]{3}$/.match(currency)
|
||||
params.merge!({price: price, currency: currency})
|
||||
token = get_token(facade)
|
||||
invoice = post(path: "invoices", token: token, params: params)
|
||||
invoice["data"]
|
||||
end
|
||||
|
||||
## Gets the privileged merchant-version of the invoice
|
||||
# Requires merchant facade token
|
||||
#
|
||||
def get_invoice(id:)
|
||||
token = get_token('merchant')
|
||||
invoice = get(path: "invoices/#{id}", token: token)
|
||||
invoice["data"]
|
||||
end
|
||||
|
||||
## Gets the public version of the invoice
|
||||
#
|
||||
def get_public_invoice(id:)
|
||||
invoice = get(path: "invoices/#{id}", public: true)
|
||||
invoice["data"]
|
||||
end
|
||||
|
||||
|
||||
## Refund paid BitPay invoice
|
||||
#
|
||||
# If invoice["data"]["flags"]["refundable"] == true the a refund address was
|
||||
# provided with the payment and the refund_address parameter is an optional override
|
||||
#
|
||||
# Amount and Currency are required fields for fully paid invoices but optional
|
||||
# for under or overpaid invoices which will otherwise be completely refunded
|
||||
#
|
||||
# Requires merchant facade token
|
||||
#
|
||||
# @example
|
||||
# client.refund_invoice(id: 'JB49z2MsDH7FunczeyDS8j', params: {amount: 10, currency: 'USD', bitcoinAddress: '1Jtcygf8W3cEmtGgepggtjCxtmFFjrZwRV'})
|
||||
#
|
||||
def refund_invoice(id:, params:{})
|
||||
invoice = get_invoice(id: id)
|
||||
refund = post(path: "invoices/#{id}/refunds", token: invoice["token"], params: params)
|
||||
refund["data"]
|
||||
end
|
||||
|
||||
## Get All Refunds for Invoice
|
||||
# Returns an array of all refund requests for a specific invoice,
|
||||
#
|
||||
# Requires merchant facade token
|
||||
#
|
||||
# @example:
|
||||
# client.get_all_refunds_for_invoice(id: 'JB49z2MsDH7FunczeyDS8j')
|
||||
#
|
||||
def get_all_refunds_for_invoice(id:)
|
||||
urlpath = "invoices/#{id}/refunds"
|
||||
invoice = get_invoice(id: id)
|
||||
refunds = get(path: urlpath, token: invoice["token"])
|
||||
refunds["data"]
|
||||
end
|
||||
|
||||
## Get Refund
|
||||
# Requires merchant facade token
|
||||
#
|
||||
# @example:
|
||||
# client.get_refund(id: 'JB49z2MsDH7FunczeyDS8j', request_id: '4evCrXq4EDXk4oqDXdWQhX')
|
||||
#
|
||||
def get_refund(invoice_id:, request_id:)
|
||||
urlpath = "invoices/#{invoice_id}/refunds/#{request_id}"
|
||||
invoice = get_invoice(id: invoice_id)
|
||||
refund = get(path: urlpath, token: invoice["token"])
|
||||
refund["data"]
|
||||
end
|
||||
|
||||
## Cancel Refund
|
||||
# Requires merchant facade token
|
||||
#
|
||||
# @example:
|
||||
# client.cancel_refund(id: 'JB49z2MsDH7FunczeyDS8j', request_id: '4evCrXq4EDXk4oqDXdWQhX')
|
||||
#
|
||||
def cancel_refund(invoice_id:, request_id:)
|
||||
urlpath = "invoices/#{invoice_id}/refunds/#{request_id}"
|
||||
refund = get_refund(invoice_id: invoice_id, request_id: request_id)
|
||||
deletion = delete(path: urlpath, token: refund["token"])
|
||||
deletion["data"]
|
||||
end
|
||||
|
||||
## Checks that the passed tokens are valid by
|
||||
# comparing them to those that are authorized by the server
|
||||
#
|
||||
# Uses local @tokens variable if no tokens are passed
|
||||
# in order to validate the connector is properly paired
|
||||
#
|
||||
def verify_tokens(tokens: @tokens)
|
||||
server_tokens = refresh_tokens
|
||||
tokens.each{|key, value| return false if server_tokens[key] != value}
|
||||
return true
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def verify_claim_code(claim_code)
|
||||
regex = /^[[:alnum:]]{7}$/
|
||||
matches = regex.match(claim_code)
|
||||
!(matches.nil?)
|
||||
end
|
||||
|
||||
def send_request(verb, path, facade: 'merchant', params: {}, token: nil)
|
||||
token ||= get_token(facade)
|
||||
case verb.upcase
|
||||
when "GET"
|
||||
return get(path: path, token: token)
|
||||
when "POST"
|
||||
return post(path: path, token: token, params: params)
|
||||
else
|
||||
raise(StandardError, "Invalid HTTP verb: #{verb.upcase}")
|
||||
end
|
||||
end
|
||||
|
||||
def get(path:, token: nil, public: false)
|
||||
urlpath = '/' + path
|
||||
token_prefix = if urlpath.include? '?' then '&token=' else '?token=' end
|
||||
urlpath = urlpath + token_prefix + token if token
|
||||
request = Net::HTTP::Get.new urlpath
|
||||
unless public
|
||||
request['Authorization'] = @auth_header
|
||||
request['X-Identity'] = @pub_key
|
||||
end
|
||||
process_request(request)
|
||||
end
|
||||
|
||||
def post(path:, token: nil, params:)
|
||||
urlpath = '/' + path
|
||||
request = Net::HTTP::Post.new urlpath
|
||||
params[:token] = token if token
|
||||
params[:guid] = SecureRandom.uuid
|
||||
params[:id] = @client_id
|
||||
request.body = params.to_json
|
||||
if token
|
||||
request['Authorization'] = @auth_header
|
||||
request['X-Identity'] = @pub_key
|
||||
end
|
||||
process_request(request)
|
||||
end
|
||||
|
||||
def delete(path:, token: nil)
|
||||
urlpath = '/' + path
|
||||
urlpath = urlpath + '?token=' + token if token
|
||||
request = Net::HTTP::Delete.new urlpath
|
||||
request['Authorization'] = @auth_header
|
||||
request['X-Identity'] = @pub_key
|
||||
process_request(request)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
## Processes HTTP Request and returns parsed response
|
||||
# Otherwise throws error
|
||||
#
|
||||
def process_request(request)
|
||||
request['User-Agent'] = @user_agent
|
||||
request['Content-Type'] = 'application/json'
|
||||
request['X-BitPay-Plugin-Info'] = 'Rubylib'
|
||||
|
||||
begin
|
||||
response = @https.request request
|
||||
rescue => error
|
||||
raise StandardError, "Connection Error: #{error.message}"
|
||||
end
|
||||
|
||||
if response.kind_of? Net::HTTPSuccess
|
||||
return JSON.parse(response.body)
|
||||
elsif JSON.parse(response.body)["error"]
|
||||
raise(StandardError, "#{response.code}: #{JSON.parse(response.body)['error']}")
|
||||
else
|
||||
raise StandardError, "#{response.code}: #{JSON.parse(response.body)}"
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
## Fetches the tokens hash from the server and
|
||||
# updates @tokens
|
||||
#
|
||||
def refresh_tokens
|
||||
response = get(path: 'tokens')["data"]
|
||||
token_array = response || {}
|
||||
tokens = {}
|
||||
token_array.each do |t|
|
||||
tokens[t.keys.first] = t.values.first
|
||||
end
|
||||
@tokens = tokens
|
||||
return tokens
|
||||
end
|
||||
|
||||
## Makes a request to /tokens for pairing
|
||||
# Adds passed params as post parameters
|
||||
# If empty params, retrieves server-generated pairing code
|
||||
# If pairingCode key/value is passed, will pair client ID to this account
|
||||
# Returns response hash
|
||||
#
|
||||
|
||||
def get_token(facade)
|
||||
token = @tokens[facade] || refresh_tokens[facade] || raise(StandardError, "Not authorized for facade: #{facade}")
|
||||
end
|
||||
end
|
||||
56
app/lib/delivery_failure_tracker.rb
Normal file
56
app/lib/delivery_failure_tracker.rb
Normal file
@@ -0,0 +1,56 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class DeliveryFailureTracker
|
||||
FAILURE_DAYS_THRESHOLD = 7
|
||||
|
||||
def initialize(inbox_url)
|
||||
@inbox_url = inbox_url
|
||||
end
|
||||
|
||||
def track_failure!
|
||||
Redis.current.sadd(exhausted_deliveries_key, today)
|
||||
Redis.current.sadd('unavailable_inboxes', @inbox_url) if reached_failure_threshold?
|
||||
end
|
||||
|
||||
def track_success!
|
||||
Redis.current.del(exhausted_deliveries_key)
|
||||
Redis.current.srem('unavailable_inboxes', @inbox_url)
|
||||
end
|
||||
|
||||
def days
|
||||
Redis.current.scard(exhausted_deliveries_key) || 0
|
||||
end
|
||||
|
||||
class << self
|
||||
def filter(arr)
|
||||
arr.reject(&method(:unavailable?))
|
||||
end
|
||||
|
||||
def unavailable?(url)
|
||||
Redis.current.sismember('unavailable_inboxes', url)
|
||||
end
|
||||
|
||||
def available?(url)
|
||||
!unavailable?(url)
|
||||
end
|
||||
|
||||
def track_inverse_success!(from_account)
|
||||
new(from_account.inbox_url).track_success! if from_account.inbox_url.present?
|
||||
new(from_account.shared_inbox_url).track_success! if from_account.shared_inbox_url.present?
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def exhausted_deliveries_key
|
||||
"exhausted_deliveries:#{@inbox_url}"
|
||||
end
|
||||
|
||||
def today
|
||||
Time.now.utc.strftime('%Y%m%d')
|
||||
end
|
||||
|
||||
def reached_failure_threshold?
|
||||
days >= FAILURE_DAYS_THRESHOLD
|
||||
end
|
||||
end
|
||||
34
app/lib/entity_cache.rb
Normal file
34
app/lib/entity_cache.rb
Normal file
@@ -0,0 +1,34 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'singleton'
|
||||
|
||||
class EntityCache
|
||||
include Singleton
|
||||
|
||||
MAX_EXPIRATION = 7.days.freeze
|
||||
|
||||
def mention(username, domain)
|
||||
Rails.cache.fetch(to_key(:mention, username, domain), expires_in: MAX_EXPIRATION) { Account.select(:username, :domain, :url).find_remote(username, domain) }
|
||||
end
|
||||
|
||||
def emoji(shortcodes, domain)
|
||||
shortcodes = [shortcodes] unless shortcodes.is_a?(Array)
|
||||
cached = Rails.cache.read_multi(*shortcodes.map { |shortcode| to_key(:emoji, shortcode, domain) })
|
||||
uncached_ids = []
|
||||
|
||||
shortcodes.each do |shortcode|
|
||||
uncached_ids << shortcode unless cached.key?(to_key(:emoji, shortcode, domain))
|
||||
end
|
||||
|
||||
unless uncached_ids.empty?
|
||||
uncached = CustomEmoji.where(shortcode: shortcodes, domain: domain, disabled: false).each_with_object({}) { |item, h| h[item.shortcode] = item }
|
||||
uncached.each_value { |item| Rails.cache.write(to_key(:emoji, item.shortcode, domain), item, expires_in: MAX_EXPIRATION) }
|
||||
end
|
||||
|
||||
shortcodes.map { |shortcode| cached[to_key(:emoji, shortcode, domain)] || uncached[shortcode] }.compact
|
||||
end
|
||||
|
||||
def to_key(type, *ids)
|
||||
"#{type}:#{ids.compact.map(&:downcase).join(':')}"
|
||||
end
|
||||
end
|
||||
21
app/lib/exceptions.rb
Normal file
21
app/lib/exceptions.rb
Normal file
@@ -0,0 +1,21 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module GabSocial
|
||||
class Error < StandardError; end
|
||||
class NotPermittedError < Error; end
|
||||
class ValidationError < Error; end
|
||||
class HostValidationError < ValidationError; end
|
||||
class LengthValidationError < ValidationError; end
|
||||
class DimensionsValidationError < ValidationError; end
|
||||
class RaceConditionError < Error; end
|
||||
|
||||
class UnexpectedResponseError < Error
|
||||
def initialize(response = nil)
|
||||
if response.respond_to? :uri
|
||||
super("#{response.uri} returned code #{response.code}")
|
||||
else
|
||||
super
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
64
app/lib/extractor.rb
Normal file
64
app/lib/extractor.rb
Normal file
@@ -0,0 +1,64 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Extractor
|
||||
extend Twitter::Extractor
|
||||
|
||||
module_function
|
||||
|
||||
# :yields: username, list_slug, start, end
|
||||
def extract_mentions_or_lists_with_indices(text)
|
||||
return [] unless text =~ Twitter::Regex[:at_signs]
|
||||
|
||||
possible_entries = []
|
||||
|
||||
text.to_s.scan(Account::MENTION_RE) do |screen_name, _|
|
||||
match_data = $LAST_MATCH_INFO
|
||||
after = $'
|
||||
unless after =~ Twitter::Regex[:end_mention_match]
|
||||
start_position = match_data.char_begin(1) - 1
|
||||
end_position = match_data.char_end(1)
|
||||
possible_entries << {
|
||||
screen_name: screen_name,
|
||||
indices: [start_position, end_position],
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
if block_given?
|
||||
possible_entries.each do |mention|
|
||||
yield mention[:screen_name], mention[:indices].first, mention[:indices].last
|
||||
end
|
||||
end
|
||||
possible_entries
|
||||
end
|
||||
|
||||
def extract_hashtags_with_indices(text, **)
|
||||
return [] unless text =~ /#/
|
||||
|
||||
tags = []
|
||||
text.scan(Tag::HASHTAG_RE) do |hash_text, _|
|
||||
match_data = $LAST_MATCH_INFO
|
||||
start_position = match_data.char_begin(1) - 1
|
||||
end_position = match_data.char_end(1)
|
||||
after = $'
|
||||
if after =~ %r{\A://}
|
||||
hash_text.match(/(.+)(https?\Z)/) do |matched|
|
||||
hash_text = matched[1]
|
||||
end_position -= matched[2].char_length
|
||||
end
|
||||
end
|
||||
|
||||
tags << {
|
||||
hashtag: hash_text,
|
||||
indices: [start_position, end_position],
|
||||
}
|
||||
end
|
||||
|
||||
tags.each { |tag| yield tag[:hashtag], tag[:indices].first, tag[:indices].last } if block_given?
|
||||
tags
|
||||
end
|
||||
|
||||
def extract_cashtags_with_indices(_text)
|
||||
[] # always returns empty array
|
||||
end
|
||||
end
|
||||
11
app/lib/fast_geometry_parser.rb
Normal file
11
app/lib/fast_geometry_parser.rb
Normal file
@@ -0,0 +1,11 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class FastGeometryParser
|
||||
def self.from_file(file)
|
||||
width, height = FastImage.size(file.path)
|
||||
|
||||
raise Paperclip::Errors::NotIdentifiedByImageMagickError if width.nil?
|
||||
|
||||
Paperclip::Geometry.new(width, height)
|
||||
end
|
||||
end
|
||||
306
app/lib/feed_manager.rb
Normal file
306
app/lib/feed_manager.rb
Normal file
@@ -0,0 +1,306 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'singleton'
|
||||
|
||||
class FeedManager
|
||||
include Singleton
|
||||
include Redisable
|
||||
|
||||
MAX_ITEMS = 400
|
||||
|
||||
# Must be <= MAX_ITEMS or the tracking sets will grow forever
|
||||
REBLOG_FALLOFF = 40
|
||||
|
||||
def key(type, id, subtype = nil)
|
||||
return "feed:#{type}:#{id}" unless subtype
|
||||
|
||||
"feed:#{type}:#{id}:#{subtype}"
|
||||
end
|
||||
|
||||
def filter?(timeline_type, status, receiver_id)
|
||||
if timeline_type == :home
|
||||
filter_from_home?(status, receiver_id)
|
||||
elsif timeline_type == :mentions
|
||||
filter_from_mentions?(status, receiver_id)
|
||||
else
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
def push_to_home(account, status)
|
||||
return false unless add_to_feed(:home, account.id, status, account.user&.aggregates_reblogs?)
|
||||
trim(:home, account.id)
|
||||
PushUpdateWorker.perform_async(account.id, status.id, "timeline:#{account.id}") if push_update_required?("timeline:#{account.id}")
|
||||
true
|
||||
end
|
||||
|
||||
def unpush_from_home(account, status)
|
||||
return false unless remove_from_feed(:home, account.id, status)
|
||||
redis.publish("timeline:#{account.id}", Oj.dump(event: :delete, payload: status.id.to_s))
|
||||
true
|
||||
end
|
||||
|
||||
def push_to_list(list, status)
|
||||
if status.reply? && status.in_reply_to_account_id != status.account_id
|
||||
should_filter = status.in_reply_to_account_id != list.account_id
|
||||
should_filter &&= !ListAccount.where(list_id: list.id, account_id: status.in_reply_to_account_id).exists?
|
||||
return false if should_filter
|
||||
end
|
||||
return false unless add_to_feed(:list, list.id, status, list.account.user&.aggregates_reblogs?)
|
||||
trim(:list, list.id)
|
||||
PushUpdateWorker.perform_async(list.account_id, status.id, "timeline:list:#{list.id}") if push_update_required?("timeline:list:#{list.id}")
|
||||
true
|
||||
end
|
||||
|
||||
def unpush_from_list(list, status)
|
||||
return false unless remove_from_feed(:list, list.id, status)
|
||||
redis.publish("timeline:list:#{list.id}", Oj.dump(event: :delete, payload: status.id.to_s))
|
||||
true
|
||||
end
|
||||
|
||||
def trim(type, account_id)
|
||||
timeline_key = key(type, account_id)
|
||||
reblog_key = key(type, account_id, 'reblogs')
|
||||
|
||||
# Remove any items past the MAX_ITEMS'th entry in our feed
|
||||
redis.zremrangebyrank(timeline_key, '0', (-(FeedManager::MAX_ITEMS + 1)).to_s)
|
||||
|
||||
# Get the score of the REBLOG_FALLOFF'th item in our feed, and stop
|
||||
# tracking anything after it for deduplication purposes.
|
||||
falloff_rank = FeedManager::REBLOG_FALLOFF - 1
|
||||
falloff_range = redis.zrevrange(timeline_key, falloff_rank, falloff_rank, with_scores: true)
|
||||
falloff_score = falloff_range&.first&.last&.to_i || 0
|
||||
|
||||
# Get any reblogs we might have to clean up after.
|
||||
redis.zrangebyscore(reblog_key, 0, falloff_score).each do |reblogged_id|
|
||||
# Remove it from the set of reblogs we're tracking *first* to avoid races.
|
||||
redis.zrem(reblog_key, reblogged_id)
|
||||
# Just drop any set we might have created to track additional reblogs.
|
||||
# This means that if this reblog is deleted, we won't automatically insert
|
||||
# another reblog, but also that any new reblog can be inserted into the
|
||||
# feed.
|
||||
redis.del(key(type, account_id, "reblogs:#{reblogged_id}"))
|
||||
end
|
||||
end
|
||||
|
||||
def merge_into_timeline(from_account, into_account)
|
||||
timeline_key = key(:home, into_account.id)
|
||||
query = from_account.statuses.limit(FeedManager::MAX_ITEMS / 4)
|
||||
|
||||
if redis.zcard(timeline_key) >= FeedManager::MAX_ITEMS / 4
|
||||
oldest_home_score = redis.zrange(timeline_key, 0, 0, with_scores: true)&.first&.last&.to_i || 0
|
||||
query = query.where('id > ?', oldest_home_score)
|
||||
end
|
||||
|
||||
query.each do |status|
|
||||
next if status.direct_visibility? || status.limited_visibility? || filter?(:home, status, into_account)
|
||||
add_to_feed(:home, into_account.id, status, into_account.user&.aggregates_reblogs?)
|
||||
end
|
||||
|
||||
trim(:home, into_account.id)
|
||||
end
|
||||
|
||||
def unmerge_from_timeline(from_account, into_account)
|
||||
timeline_key = key(:home, into_account.id)
|
||||
oldest_home_score = redis.zrange(timeline_key, 0, 0, with_scores: true)&.first&.last&.to_i || 0
|
||||
|
||||
from_account.statuses.select('id, reblog_of_id').where('id > ?', oldest_home_score).reorder(nil).find_each do |status|
|
||||
remove_from_feed(:home, into_account.id, status)
|
||||
end
|
||||
end
|
||||
|
||||
def clear_from_timeline(account, target_account)
|
||||
timeline_key = key(:home, account.id)
|
||||
timeline_status_ids = redis.zrange(timeline_key, 0, -1)
|
||||
target_statuses = Status.where(id: timeline_status_ids, account: target_account)
|
||||
|
||||
target_statuses.each do |status|
|
||||
unpush_from_home(account, status)
|
||||
end
|
||||
end
|
||||
|
||||
def populate_feed(account)
|
||||
added = 0
|
||||
limit = FeedManager::MAX_ITEMS / 2
|
||||
max_id = nil
|
||||
|
||||
loop do
|
||||
statuses = Status.as_home_timeline(account)
|
||||
.paginate_by_max_id(limit, max_id)
|
||||
|
||||
break if statuses.empty?
|
||||
|
||||
statuses.each do |status|
|
||||
next if filter_from_home?(status, account)
|
||||
added += 1 if add_to_feed(:home, account.id, status, account.user&.aggregates_reblogs?)
|
||||
end
|
||||
|
||||
break unless added.zero?
|
||||
|
||||
max_id = statuses.last.id
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def push_update_required?(timeline_id)
|
||||
redis.exists("subscribed:#{timeline_id}")
|
||||
end
|
||||
|
||||
def blocks_or_mutes?(receiver_id, account_ids, context)
|
||||
Block.where(account_id: receiver_id, target_account_id: account_ids).any? ||
|
||||
(context == :home ? Mute.where(account_id: receiver_id, target_account_id: account_ids).any? : Mute.where(account_id: receiver_id, target_account_id: account_ids, hide_notifications: true).any?)
|
||||
end
|
||||
|
||||
def filter_from_home?(status, receiver_id)
|
||||
return false if receiver_id == status.account_id
|
||||
return true if status.reply? && (status.in_reply_to_id.nil? || status.in_reply_to_account_id.nil?)
|
||||
return true if phrase_filtered?(status, receiver_id, :home)
|
||||
|
||||
check_for_blocks = status.active_mentions.pluck(:account_id)
|
||||
check_for_blocks.concat([status.account_id])
|
||||
|
||||
if status.reblog?
|
||||
check_for_blocks.concat([status.reblog.account_id])
|
||||
check_for_blocks.concat(status.reblog.active_mentions.pluck(:account_id))
|
||||
end
|
||||
|
||||
return true if blocks_or_mutes?(receiver_id, check_for_blocks, :home)
|
||||
|
||||
if status.reply? && !status.in_reply_to_account_id.nil? # Filter out if it's a reply
|
||||
should_filter = !Follow.where(account_id: receiver_id, target_account_id: status.in_reply_to_account_id).exists? # and I'm not following the person it's a reply to
|
||||
should_filter &&= receiver_id != status.in_reply_to_account_id # and it's not a reply to me
|
||||
should_filter &&= status.account_id != status.in_reply_to_account_id # and it's not a self-reply
|
||||
return should_filter
|
||||
elsif status.reblog? # Filter out a reblog
|
||||
should_filter = Follow.where(account_id: receiver_id, target_account_id: status.account_id, show_reblogs: false).exists? # if the reblogger's reblogs are suppressed
|
||||
should_filter ||= Block.where(account_id: status.reblog.account_id, target_account_id: receiver_id).exists? # or if the author of the reblogged status is blocking me
|
||||
should_filter ||= AccountDomainBlock.where(account_id: receiver_id, domain: status.reblog.account.domain).exists? # or the author's domain is blocked
|
||||
return should_filter
|
||||
end
|
||||
|
||||
false
|
||||
end
|
||||
|
||||
def filter_from_mentions?(status, receiver_id)
|
||||
return true if receiver_id == status.account_id
|
||||
return true if phrase_filtered?(status, receiver_id, :notifications)
|
||||
|
||||
# This filter is called from NotifyService, but already after the sender of
|
||||
# the notification has been checked for mute/block. Therefore, it's not
|
||||
# necessary to check the author of the gab for mute/block again
|
||||
check_for_blocks = status.active_mentions.pluck(:account_id)
|
||||
check_for_blocks.concat([status.in_reply_to_account]) if status.reply? && !status.in_reply_to_account_id.nil?
|
||||
|
||||
should_filter = blocks_or_mutes?(receiver_id, check_for_blocks, :mentions) # Filter if it's from someone I blocked, in reply to someone I blocked, or mentioning someone I blocked (or muted)
|
||||
should_filter ||= (status.account.silenced? && !Follow.where(account_id: receiver_id, target_account_id: status.account_id).exists?) # of if the account is silenced and I'm not following them
|
||||
|
||||
should_filter
|
||||
end
|
||||
|
||||
def phrase_filtered?(status, receiver_id, context)
|
||||
active_filters = Rails.cache.fetch("filters:#{receiver_id}") { CustomFilter.where(account_id: receiver_id).active_irreversible.to_a }.to_a
|
||||
|
||||
active_filters.select! { |filter| filter.context.include?(context.to_s) && !filter.expired? }
|
||||
|
||||
active_filters.map! do |filter|
|
||||
if filter.whole_word
|
||||
sb = filter.phrase =~ /\A[[:word:]]/ ? '\b' : ''
|
||||
eb = filter.phrase =~ /[[:word:]]\z/ ? '\b' : ''
|
||||
|
||||
/(?mix:#{sb}#{Regexp.escape(filter.phrase)}#{eb})/
|
||||
else
|
||||
/#{Regexp.escape(filter.phrase)}/i
|
||||
end
|
||||
end
|
||||
|
||||
return false if active_filters.empty?
|
||||
|
||||
combined_regex = active_filters.reduce { |memo, obj| Regexp.union(memo, obj) }
|
||||
status = status.reblog if status.reblog?
|
||||
|
||||
!combined_regex.match(Formatter.instance.plaintext(status)).nil? ||
|
||||
(status.spoiler_text.present? && !combined_regex.match(status.spoiler_text).nil?)
|
||||
end
|
||||
|
||||
# Adds a status to an account's feed, returning true if a status was
|
||||
# added, and false if it was not added to the feed. Note that this is
|
||||
# an internal helper: callers must call trim or push updates if
|
||||
# either action is appropriate.
|
||||
def add_to_feed(timeline_type, account_id, status, aggregate_reblogs = true)
|
||||
timeline_key = key(timeline_type, account_id)
|
||||
reblog_key = key(timeline_type, account_id, 'reblogs')
|
||||
|
||||
if status.reblog? && (aggregate_reblogs.nil? || aggregate_reblogs)
|
||||
# If the original status or a reblog of it is within
|
||||
# REBLOG_FALLOFF statuses from the top, do not re-insert it into
|
||||
# the feed
|
||||
rank = redis.zrevrank(timeline_key, status.reblog_of_id)
|
||||
|
||||
return false if !rank.nil? && rank < FeedManager::REBLOG_FALLOFF
|
||||
|
||||
reblog_rank = redis.zrevrank(reblog_key, status.reblog_of_id)
|
||||
|
||||
if reblog_rank.nil?
|
||||
# This is not something we've already seen reblogged, so we
|
||||
# can just add it to the feed (and note that we're
|
||||
# reblogging it).
|
||||
redis.zadd(timeline_key, status.id, status.id)
|
||||
redis.zadd(reblog_key, status.id, status.reblog_of_id)
|
||||
else
|
||||
# Another reblog of the same status was already in the
|
||||
# REBLOG_FALLOFF most recent statuses, so we note that this
|
||||
# is an "extra" reblog, by storing it in reblog_set_key.
|
||||
reblog_set_key = key(timeline_type, account_id, "reblogs:#{status.reblog_of_id}")
|
||||
redis.sadd(reblog_set_key, status.id)
|
||||
return false
|
||||
end
|
||||
else
|
||||
# A reblog may reach earlier than the original status because of the
|
||||
# delay of the worker deliverying the original status, the late addition
|
||||
# by merging timelines, and other reasons.
|
||||
# If such a reblog already exists, just do not re-insert it into the feed.
|
||||
rank = redis.zrevrank(reblog_key, status.id)
|
||||
|
||||
return false unless rank.nil?
|
||||
|
||||
redis.zadd(timeline_key, status.id, status.id)
|
||||
end
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
# Removes an individual status from a feed, correctly handling cases
|
||||
# with reblogs, and returning true if a status was removed. As with
|
||||
# `add_to_feed`, this does not trigger push updates, so callers must
|
||||
# do so if appropriate.
|
||||
def remove_from_feed(timeline_type, account_id, status)
|
||||
timeline_key = key(timeline_type, account_id)
|
||||
|
||||
if status.reblog?
|
||||
# 1. If the reblogging status is not in the feed, stop.
|
||||
status_rank = redis.zrevrank(timeline_key, status.id)
|
||||
return false if status_rank.nil?
|
||||
|
||||
# 2. Remove reblog from set of this status's reblogs.
|
||||
reblog_set_key = key(timeline_type, account_id, "reblogs:#{status.reblog_of_id}")
|
||||
|
||||
redis.srem(reblog_set_key, status.id)
|
||||
# 3. Re-insert another reblog or original into the feed if one
|
||||
# remains in the set. We could pick a random element, but this
|
||||
# set should generally be small, and it seems ideal to show the
|
||||
# oldest potential such reblog.
|
||||
other_reblog = redis.smembers(reblog_set_key).map(&:to_i).min
|
||||
|
||||
redis.zadd(timeline_key, other_reblog, other_reblog) if other_reblog
|
||||
|
||||
# 4. Remove the reblogging status from the feed (as normal)
|
||||
# (outside conditional)
|
||||
else
|
||||
# If the original is getting deleted, no use for reblog references
|
||||
redis.del(key(timeline_type, account_id, "reblogs:#{status.id}"))
|
||||
end
|
||||
|
||||
redis.zrem(timeline_key, status.id)
|
||||
end
|
||||
end
|
||||
296
app/lib/formatter.rb
Normal file
296
app/lib/formatter.rb
Normal file
@@ -0,0 +1,296 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'singleton'
|
||||
require_relative './sanitize_config'
|
||||
|
||||
class Formatter
|
||||
include Singleton
|
||||
include RoutingHelper
|
||||
|
||||
include ActionView::Helpers::TextHelper
|
||||
|
||||
def format(status, **options)
|
||||
raw_content = status.text
|
||||
|
||||
if status.reblog?
|
||||
status = status.proper
|
||||
end
|
||||
|
||||
if options[:inline_poll_options] && status.preloadable_poll
|
||||
raw_content = raw_content + "\n\n" + status.preloadable_poll.options.map { |title| "[ ] #{title}" }.join("\n")
|
||||
end
|
||||
|
||||
return '' if raw_content.blank?
|
||||
|
||||
unless status.local?
|
||||
html = reformat(raw_content)
|
||||
html = encode_custom_emojis(html, status.emojis, options[:autoplay]) if options[:custom_emojify]
|
||||
return html.html_safe # rubocop:disable Rails/OutputSafety
|
||||
end
|
||||
|
||||
linkable_accounts = status.active_mentions.map(&:account)
|
||||
linkable_accounts << status.account
|
||||
|
||||
html = raw_content
|
||||
html = encode_and_link_urls(html, linkable_accounts)
|
||||
html = encode_custom_emojis(html, status.emojis, options[:autoplay]) if options[:custom_emojify]
|
||||
html = simple_format(html, {}, sanitize: false)
|
||||
html = html.delete("\n")
|
||||
|
||||
html.html_safe # rubocop:disable Rails/OutputSafety
|
||||
end
|
||||
|
||||
def reformat(html)
|
||||
sanitize(html, Sanitize::Config::GABSOCIAL_STRICT)
|
||||
end
|
||||
|
||||
def plaintext(status)
|
||||
return status.text if status.local?
|
||||
|
||||
text = status.text.gsub(/(<br \/>|<br>|<\/p>)+/) { |match| "#{match}\n" }
|
||||
strip_tags(text)
|
||||
end
|
||||
|
||||
def simplified_format(account, **options)
|
||||
html = account.local? ? linkify(account.note) : reformat(account.note)
|
||||
html = encode_custom_emojis(html, account.emojis, options[:autoplay]) if options[:custom_emojify]
|
||||
html.html_safe # rubocop:disable Rails/OutputSafety
|
||||
end
|
||||
|
||||
def sanitize(html, config)
|
||||
Sanitize.fragment(html, config)
|
||||
end
|
||||
|
||||
def format_spoiler(status, **options)
|
||||
html = encode(status.spoiler_text)
|
||||
html = encode_custom_emojis(html, status.emojis, options[:autoplay])
|
||||
html.html_safe # rubocop:disable Rails/OutputSafety
|
||||
end
|
||||
|
||||
def format_poll_option(status, option, **options)
|
||||
html = encode(option.title)
|
||||
html = encode_custom_emojis(html, status.emojis, options[:autoplay])
|
||||
html.html_safe # rubocop:disable Rails/OutputSafety
|
||||
end
|
||||
|
||||
def format_display_name(account, **options)
|
||||
html = encode(account.display_name.presence || account.username)
|
||||
html = encode_custom_emojis(html, account.emojis, options[:autoplay]) if options[:custom_emojify]
|
||||
html.html_safe # rubocop:disable Rails/OutputSafety
|
||||
end
|
||||
|
||||
def format_field(account, str, **options)
|
||||
return reformat(str).html_safe unless account.local? # rubocop:disable Rails/OutputSafety
|
||||
html = encode_and_link_urls(str, me: true)
|
||||
html = encode_custom_emojis(html, account.emojis, options[:autoplay]) if options[:custom_emojify]
|
||||
html.html_safe # rubocop:disable Rails/OutputSafety
|
||||
end
|
||||
|
||||
def linkify(text)
|
||||
html = encode_and_link_urls(text)
|
||||
html = simple_format(html, {}, sanitize: false)
|
||||
html = html.delete("\n")
|
||||
|
||||
html.html_safe # rubocop:disable Rails/OutputSafety
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def html_entities
|
||||
@html_entities ||= HTMLEntities.new
|
||||
end
|
||||
|
||||
def encode(html)
|
||||
html_entities.encode(html)
|
||||
end
|
||||
|
||||
def encode_and_link_urls(html, accounts = nil, options = {})
|
||||
entities = utf8_friendly_extractor(html, extract_url_without_protocol: false)
|
||||
|
||||
if accounts.is_a?(Hash)
|
||||
options = accounts
|
||||
accounts = nil
|
||||
end
|
||||
|
||||
rewrite(html.dup, entities) do |entity|
|
||||
if entity[:url]
|
||||
link_to_url(entity, options)
|
||||
elsif entity[:hashtag]
|
||||
link_to_hashtag(entity)
|
||||
elsif entity[:screen_name]
|
||||
link_to_mention(entity, accounts)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def count_tag_nesting(tag)
|
||||
if tag[1] == '/' then -1
|
||||
elsif tag[-2] == '/' then 0
|
||||
else 1
|
||||
end
|
||||
end
|
||||
|
||||
def encode_custom_emojis(html, emojis, animate = false)
|
||||
return html if emojis.empty?
|
||||
|
||||
emoji_map = if animate
|
||||
emojis.each_with_object({}) { |e, h| h[e.shortcode] = full_asset_url(e.image.url) }
|
||||
else
|
||||
emojis.each_with_object({}) { |e, h| h[e.shortcode] = full_asset_url(e.image.url(:static)) }
|
||||
end
|
||||
|
||||
i = -1
|
||||
tag_open_index = nil
|
||||
inside_shortname = false
|
||||
shortname_start_index = -1
|
||||
invisible_depth = 0
|
||||
|
||||
while i + 1 < html.size
|
||||
i += 1
|
||||
|
||||
if invisible_depth.zero? && 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=\":#{encode(shortcode)}:\" title=\":#{encode(shortcode)}:\" src=\"#{encode(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 tag_open_index && html[i] == '>'
|
||||
tag = html[tag_open_index..i]
|
||||
tag_open_index = nil
|
||||
if invisible_depth.positive?
|
||||
invisible_depth += count_tag_nesting(tag)
|
||||
elsif tag == '<span class="invisible">'
|
||||
invisible_depth = 1
|
||||
end
|
||||
elsif html[i] == '<'
|
||||
tag_open_index = i
|
||||
inside_shortname = false
|
||||
elsif !tag_open_index && html[i] == ':'
|
||||
inside_shortname = true
|
||||
shortname_start_index = i
|
||||
end
|
||||
end
|
||||
|
||||
html
|
||||
end
|
||||
|
||||
def rewrite(text, entities)
|
||||
text = text.to_s
|
||||
|
||||
# Sort by start index
|
||||
entities = entities.sort_by do |entity|
|
||||
indices = entity.respond_to?(:indices) ? entity.indices : entity[:indices]
|
||||
indices.first
|
||||
end
|
||||
|
||||
result = []
|
||||
|
||||
last_index = entities.reduce(0) do |index, entity|
|
||||
indices = entity.respond_to?(:indices) ? entity.indices : entity[:indices]
|
||||
result << encode(text[index...indices.first])
|
||||
result << yield(entity)
|
||||
indices.last
|
||||
end
|
||||
|
||||
result << encode(text[last_index..-1])
|
||||
|
||||
result.flatten.join
|
||||
end
|
||||
|
||||
UNICODE_ESCAPE_BLACKLIST_RE = /\p{Z}|\p{P}/
|
||||
|
||||
def utf8_friendly_extractor(text, options = {})
|
||||
old_to_new_index = [0]
|
||||
|
||||
escaped = text.chars.map do |c|
|
||||
output = begin
|
||||
if c.ord.to_s(16).length > 2 && UNICODE_ESCAPE_BLACKLIST_RE.match(c).nil?
|
||||
CGI.escape(c)
|
||||
else
|
||||
c
|
||||
end
|
||||
end
|
||||
|
||||
old_to_new_index << old_to_new_index.last + output.length
|
||||
|
||||
output
|
||||
end.join
|
||||
|
||||
# Note: I couldn't obtain list_slug with @user/list-name format
|
||||
# for mention so this requires additional check
|
||||
special = Extractor.extract_urls_with_indices(escaped, options).map do |extract|
|
||||
new_indices = [
|
||||
old_to_new_index.find_index(extract[:indices].first),
|
||||
old_to_new_index.find_index(extract[:indices].last),
|
||||
]
|
||||
|
||||
next extract.merge(
|
||||
indices: new_indices,
|
||||
url: text[new_indices.first..new_indices.last - 1]
|
||||
)
|
||||
end
|
||||
|
||||
standard = Extractor.extract_entities_with_indices(text, options)
|
||||
|
||||
Extractor.remove_overlapping_entities(special + standard)
|
||||
end
|
||||
|
||||
def link_to_url(entity, options = {})
|
||||
url = Addressable::URI.parse(entity[:url])
|
||||
html_attrs = { target: '_blank', rel: 'nofollow noopener' }
|
||||
|
||||
html_attrs[:rel] = "me #{html_attrs[:rel]}" if options[:me]
|
||||
|
||||
Twitter::Autolink.send(:link_to_text, entity, link_html(entity[:url]), url, html_attrs)
|
||||
rescue Addressable::URI::InvalidURIError, IDN::Idna::IdnaError
|
||||
encode(entity[:url])
|
||||
end
|
||||
|
||||
def link_to_mention(entity, linkable_accounts)
|
||||
acct = entity[:screen_name]
|
||||
|
||||
return link_to_account(acct) unless linkable_accounts
|
||||
|
||||
account = linkable_accounts.find { |item| TagManager.instance.same_acct?(item.acct, acct) }
|
||||
account ? mention_html(account) : "@#{encode(acct)}"
|
||||
end
|
||||
|
||||
def link_to_account(acct)
|
||||
username, domain = acct.split('@')
|
||||
|
||||
domain = nil if TagManager.instance.local_domain?(domain)
|
||||
account = EntityCache.instance.mention(username, domain)
|
||||
|
||||
account ? mention_html(account) : "@#{encode(acct)}"
|
||||
end
|
||||
|
||||
def link_to_hashtag(entity)
|
||||
hashtag_html(entity[:hashtag])
|
||||
end
|
||||
|
||||
def link_html(url)
|
||||
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\">#{encode(prefix)}</span><span class=\"#{cutoff ? 'ellipsis' : ''}\">#{encode(text)}</span><span class=\"invisible\">#{encode(suffix)}</span>"
|
||||
end
|
||||
|
||||
def hashtag_html(tag)
|
||||
"<a href=\"#{encode(tag_url(tag.downcase))}\" class=\"mention hashtag\" rel=\"tag\">#<span>#{encode(tag)}</span></a>"
|
||||
end
|
||||
|
||||
def mention_html(account)
|
||||
"<span class=\"h-card\"><a href=\"#{encode(TagManager.instance.url_for(account))}\" class=\"u-url mention\">@<span>#{encode(account.username)}</span></a></span>"
|
||||
end
|
||||
end
|
||||
10
app/lib/hash_object.rb
Normal file
10
app/lib/hash_object.rb
Normal file
@@ -0,0 +1,10 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class HashObject
|
||||
def initialize(hash)
|
||||
hash.each do |k, v|
|
||||
instance_variable_set("@#{k}", v)
|
||||
self.class.send(:define_method, k, proc { instance_variable_get("@#{k}") })
|
||||
end
|
||||
end
|
||||
end
|
||||
35
app/lib/inline_renderer.rb
Normal file
35
app/lib/inline_renderer.rb
Normal file
@@ -0,0 +1,35 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class InlineRenderer
|
||||
def initialize(object, current_account, template)
|
||||
@object = object
|
||||
@current_account = current_account
|
||||
@template = template
|
||||
end
|
||||
|
||||
def render
|
||||
case @template
|
||||
when :status
|
||||
serializer = REST::StatusSerializer
|
||||
when :notification
|
||||
serializer = REST::NotificationSerializer
|
||||
when :conversation
|
||||
serializer = REST::ConversationSerializer
|
||||
else
|
||||
return
|
||||
end
|
||||
|
||||
serializable_resource = ActiveModelSerializers::SerializableResource.new(@object, serializer: serializer, scope: current_user, scope_name: :current_user)
|
||||
serializable_resource.as_json
|
||||
end
|
||||
|
||||
def self.render(object, current_account, template)
|
||||
new(object, current_account, template).render
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def current_user
|
||||
@current_account&.user
|
||||
end
|
||||
end
|
||||
99
app/lib/language_detector.rb
Normal file
99
app/lib/language_detector.rb
Normal file
@@ -0,0 +1,99 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class LanguageDetector
|
||||
include Singleton
|
||||
|
||||
WORDS_THRESHOLD = 4
|
||||
RELIABLE_CHARACTERS_RE = /[\p{Hebrew}\p{Arabic}\p{Syriac}\p{Thaana}\p{Nko}\p{Han}\p{Katakana}\p{Hiragana}\p{Hangul}]+/m
|
||||
|
||||
def initialize
|
||||
@identifier = CLD3::NNetLanguageIdentifier.new(1, 2048)
|
||||
end
|
||||
|
||||
def detect(text, account)
|
||||
input_text = prepare_text(text)
|
||||
|
||||
return if input_text.blank?
|
||||
|
||||
detect_language_code(input_text) || default_locale(account)
|
||||
end
|
||||
|
||||
def language_names
|
||||
@language_names = CLD3::TaskContextParams::LANGUAGE_NAMES.map { |name| iso6391(name.to_s).to_sym }.uniq
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def prepare_text(text)
|
||||
simplify_text(text).strip
|
||||
end
|
||||
|
||||
def unreliable_input?(text)
|
||||
!reliable_input?(text)
|
||||
end
|
||||
|
||||
def reliable_input?(text)
|
||||
sufficient_text_length?(text) || language_specific_character_set?(text)
|
||||
end
|
||||
|
||||
def sufficient_text_length?(text)
|
||||
text.split(/\s+/).size >= WORDS_THRESHOLD
|
||||
end
|
||||
|
||||
def language_specific_character_set?(text)
|
||||
words = text.scan(RELIABLE_CHARACTERS_RE)
|
||||
|
||||
if words.present?
|
||||
words.reduce(0) { |acc, elem| acc + elem.size }.to_f / text.size.to_f > 0.3
|
||||
else
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
def detect_language_code(text)
|
||||
return if unreliable_input?(text)
|
||||
result = @identifier.find_language(text)
|
||||
iso6391(result.language.to_s).to_sym if result.reliable?
|
||||
end
|
||||
|
||||
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 simplify_text(text)
|
||||
new_text = remove_html(text)
|
||||
new_text.gsub!(FetchLinkCardService::URL_PATTERN, '')
|
||||
new_text.gsub!(Account::MENTION_RE, '')
|
||||
new_text.gsub!(Tag::HASHTAG_RE, '')
|
||||
new_text.gsub!(/:#{CustomEmoji::SHORTCODE_RE_FRAGMENT}:/, '')
|
||||
new_text.gsub!(/\s+/, ' ')
|
||||
new_text
|
||||
end
|
||||
|
||||
def new_scrubber
|
||||
scrubber = Rails::Html::PermitScrubber.new
|
||||
scrubber.tags = %w(br p)
|
||||
scrubber
|
||||
end
|
||||
|
||||
def scrubber
|
||||
@scrubber ||= new_scrubber
|
||||
end
|
||||
|
||||
def remove_html(text)
|
||||
text = Loofah.fragment(text).scrub!(scrubber).to_s
|
||||
text.gsub!('<br>', "\n")
|
||||
text.gsub!('</p><p>', "\n\n")
|
||||
text.gsub!(/(^<p>|<\/p>$)/, '')
|
||||
text
|
||||
end
|
||||
|
||||
def default_locale(account)
|
||||
account.user_locale&.to_sym || I18n.default_locale if account.local?
|
||||
end
|
||||
end
|
||||
71
app/lib/ostatus/activity/base.rb
Normal file
71
app/lib/ostatus/activity/base.rb
Normal file
@@ -0,0 +1,71 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class OStatus::Activity::Base
|
||||
include Redisable
|
||||
|
||||
def initialize(xml, account = nil, **options)
|
||||
@xml = xml
|
||||
@account = account
|
||||
@options = options
|
||||
end
|
||||
|
||||
def status?
|
||||
[:activity, :note, :comment].include?(type)
|
||||
end
|
||||
|
||||
def verb
|
||||
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: OStatus::TagManager::AS_XMLNS).content
|
||||
OStatus::TagManager::TYPES.key(raw)
|
||||
rescue
|
||||
:activity
|
||||
end
|
||||
|
||||
def id
|
||||
@xml.at_xpath('./xmlns:id', xmlns: OStatus::TagManager::XMLNS).content
|
||||
end
|
||||
|
||||
def url
|
||||
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 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
|
||||
end
|
||||
219
app/lib/ostatus/activity/creation.rb
Normal file
219
app/lib/ostatus/activity/creation.rb
Normal file
@@ -0,0 +1,219 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class OStatus::Activity::Creation < OStatus::Activity::Base
|
||||
def perform
|
||||
if redis.exists("delete_upon_arrival:#{@account.id}:#{id}")
|
||||
Rails.logger.debug "Delete for status #{id} was queued, ignoring"
|
||||
return [nil, false]
|
||||
end
|
||||
|
||||
return [nil, false] if @account.suspended? || invalid_origin?
|
||||
|
||||
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
|
||||
else
|
||||
raise GabSocial::RaceConditionError
|
||||
end
|
||||
end
|
||||
|
||||
[@status, true]
|
||||
end
|
||||
|
||||
def process_status
|
||||
Rails.logger.debug "Creating remote status #{id}"
|
||||
cached_reblog = reblog
|
||||
status = nil
|
||||
|
||||
# Skip if the reblogged status is not public
|
||||
return if cached_reblog && !(cached_reblog.public_visibility? || cached_reblog.unlisted_visibility?)
|
||||
|
||||
media_attachments = save_media.take(4)
|
||||
|
||||
ApplicationRecord.transaction do
|
||||
status = Status.create!(
|
||||
uri: id,
|
||||
url: url,
|
||||
account: @account,
|
||||
reblog: cached_reblog,
|
||||
text: content,
|
||||
spoiler_text: content_warning,
|
||||
created_at: published,
|
||||
override_timestamps: @options[:override_timestamps],
|
||||
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,
|
||||
media_attachment_ids: media_attachments.map(&:id),
|
||||
sensitive: sensitive?
|
||||
)
|
||||
|
||||
save_mentions(status)
|
||||
save_hashtags(status)
|
||||
save_emojis(status)
|
||||
end
|
||||
|
||||
if thread? && status.thread.nil? && Request.valid_url?(thread.second)
|
||||
Rails.logger.debug "Trying to attach #{status.id} (#{id}) to #{thread.first}"
|
||||
ThreadResolveWorker.perform_async(status.id, thread.second)
|
||||
end
|
||||
|
||||
Rails.logger.debug "Queuing remote status #{status.id} (#{id}) for distribution"
|
||||
|
||||
LinkCrawlWorker.perform_async(status.id) unless status.spoiler_text?
|
||||
|
||||
# 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 status unless @options[:override_timestamps] || status.within_realtime_window?
|
||||
|
||||
DistributionWorker.perform_async(status.id)
|
||||
|
||||
status
|
||||
end
|
||||
|
||||
def content
|
||||
@xml.at_xpath('./xmlns:content', xmlns: OStatus::TagManager::XMLNS).content
|
||||
end
|
||||
|
||||
def content_language
|
||||
@xml.at_xpath('./xmlns:content', xmlns: OStatus::TagManager::XMLNS)['xml:lang']&.presence || 'en'
|
||||
end
|
||||
|
||||
def content_warning
|
||||
@xml.at_xpath('./xmlns:summary', xmlns: OStatus::TagManager::XMLNS)&.content || ''
|
||||
end
|
||||
|
||||
def visibility_scope
|
||||
@xml.at_xpath('./gabsocial:scope', gabsocial: OStatus::TagManager::GABSCL_XMLNS)&.content&.to_sym || :public
|
||||
end
|
||||
|
||||
def published
|
||||
@xml.at_xpath('./xmlns:published', xmlns: OStatus::TagManager::XMLNS).content
|
||||
end
|
||||
|
||||
def thread?
|
||||
!@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: OStatus::TagManager::THR_XMLNS)
|
||||
[thr['ref'], thr['href']]
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def sensitive?
|
||||
# OStatus-specific convention (not standard)
|
||||
@xml.xpath('./xmlns:category', xmlns: OStatus::TagManager::XMLNS).any? { |category| category['term'] == 'nsfw' }
|
||||
end
|
||||
|
||||
def find_or_create_conversation
|
||||
uri = @xml.at_xpath('./ostatus:conversation', ostatus: OStatus::TagManager::OS_XMLNS)&.attribute('ref')&.content
|
||||
return if uri.nil?
|
||||
|
||||
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
|
||||
|
||||
Conversation.find_by(uri: uri) || Conversation.create!(uri: uri)
|
||||
end
|
||||
|
||||
def save_mentions(parent)
|
||||
processed_account_ids = []
|
||||
|
||||
@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'])
|
||||
|
||||
next if mentioned_account.nil? || processed_account_ids.include?(mentioned_account.id)
|
||||
|
||||
mentioned_account.mentions.where(status: parent).first_or_create(status: parent)
|
||||
|
||||
# So we can skip duplicate mentions
|
||||
processed_account_ids << mentioned_account.id
|
||||
end
|
||||
end
|
||||
|
||||
def save_hashtags(parent)
|
||||
tags = @xml.xpath('./xmlns:category', xmlns: OStatus::TagManager::XMLNS).map { |category| category['term'] }.select(&:present?)
|
||||
ProcessHashtagsService.new.call(parent, tags)
|
||||
end
|
||||
|
||||
def save_media
|
||||
do_not_download = DomainBlock.find_by(domain: @account.domain)&.reject_media?
|
||||
media_attachments = []
|
||||
|
||||
@xml.xpath('./xmlns:link[@rel="enclosure"]', xmlns: OStatus::TagManager::XMLNS).each do |link|
|
||||
next unless link['href']
|
||||
|
||||
media = MediaAttachment.where(status: nil, remote_url: link['href']).first_or_initialize(account: @account, status: nil, remote_url: link['href'])
|
||||
parsed_url = Addressable::URI.parse(link['href']).normalize
|
||||
|
||||
next if !%w(http https).include?(parsed_url.scheme) || parsed_url.host.empty?
|
||||
|
||||
media.save
|
||||
media_attachments << media
|
||||
|
||||
next if do_not_download
|
||||
|
||||
begin
|
||||
media.file_remote_url = link['href']
|
||||
media.save!
|
||||
rescue ActiveRecord::RecordInvalid
|
||||
next
|
||||
end
|
||||
end
|
||||
|
||||
media_attachments
|
||||
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
|
||||
|
||||
if TagManager.instance.web_domain?(url.host)
|
||||
Account.find_local(url.path.gsub('/users/', ''))
|
||||
else
|
||||
Account.where(uri: href).or(Account.where(url: href)).first || FetchRemoteAccountService.new.call(href)
|
||||
end
|
||||
end
|
||||
|
||||
def invalid_origin?
|
||||
return false unless id.start_with?('http') # Legacy IDs cannot be checked
|
||||
|
||||
needle = Addressable::URI.parse(id).normalized_host
|
||||
|
||||
!(needle.casecmp(@account.domain).zero? ||
|
||||
needle.casecmp(Addressable::URI.parse(@account.remote_url.presence || @account.uri).normalized_host).zero?)
|
||||
end
|
||||
|
||||
def lock_options
|
||||
{ redis: Redis.current, key: "create:#{id}" }
|
||||
end
|
||||
end
|
||||
16
app/lib/ostatus/activity/deletion.rb
Normal file
16
app/lib/ostatus/activity/deletion.rb
Normal file
@@ -0,0 +1,16 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
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: activitypub_uri, account: @account) if activitypub_uri?
|
||||
|
||||
if status.nil?
|
||||
redis.setex("delete_upon_arrival:#{@account.id}:#{id}", 6 * 3_600, id)
|
||||
else
|
||||
RemoveStatusService.new.call(status)
|
||||
end
|
||||
end
|
||||
end
|
||||
20
app/lib/ostatus/activity/general.rb
Normal file
20
app/lib/ostatus/activity/general.rb
Normal file
@@ -0,0 +1,20 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class OStatus::Activity::General < OStatus::Activity::Base
|
||||
def specialize
|
||||
special_class&.new(@xml, @account, @options)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def special_class
|
||||
case verb
|
||||
when :post
|
||||
OStatus::Activity::Post
|
||||
when :share
|
||||
OStatus::Activity::Share
|
||||
when :delete
|
||||
OStatus::Activity::Deletion
|
||||
end
|
||||
end
|
||||
end
|
||||
23
app/lib/ostatus/activity/post.rb
Normal file
23
app/lib/ostatus/activity/post.rb
Normal file
@@ -0,0 +1,23 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class OStatus::Activity::Post < OStatus::Activity::Creation
|
||||
def perform
|
||||
status, just_created = super
|
||||
|
||||
if just_created
|
||||
status.mentions.includes(:account).each do |mention|
|
||||
mentioned_account = mention.account
|
||||
next unless mentioned_account.local?
|
||||
NotifyService.new.call(mentioned_account, mention)
|
||||
end
|
||||
end
|
||||
|
||||
status
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def reblog
|
||||
nil
|
||||
end
|
||||
end
|
||||
11
app/lib/ostatus/activity/remote.rb
Normal file
11
app/lib/ostatus/activity/remote.rb
Normal file
@@ -0,0 +1,11 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class OStatus::Activity::Remote < OStatus::Activity::Base
|
||||
def perform
|
||||
if activitypub_uri?
|
||||
find_status(activitypub_uri) || FetchRemoteStatusService.new.call(url)
|
||||
else
|
||||
find_status(id) || FetchRemoteStatusService.new.call(url)
|
||||
end
|
||||
end
|
||||
end
|
||||
26
app/lib/ostatus/activity/share.rb
Normal file
26
app/lib/ostatus/activity/share.rb
Normal file
@@ -0,0 +1,26 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class OStatus::Activity::Share < OStatus::Activity::Creation
|
||||
def perform
|
||||
return if reblog.nil?
|
||||
|
||||
status, just_created = super
|
||||
NotifyService.new.call(reblog.account, status) if reblog.account.local? && just_created
|
||||
status
|
||||
end
|
||||
|
||||
def object
|
||||
@xml.at_xpath('.//activity:object', activity: OStatus::TagManager::AS_XMLNS)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def reblog
|
||||
return @reblog if defined? @reblog
|
||||
|
||||
original_status = OStatus::Activity::Remote.new(object).perform
|
||||
return if original_status.nil?
|
||||
|
||||
@reblog = original_status.reblog? ? original_status.reblog : original_status
|
||||
end
|
||||
end
|
||||
378
app/lib/ostatus/atom_serializer.rb
Normal file
378
app/lib/ostatus/atom_serializer.rb
Normal file
@@ -0,0 +1,378 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class OStatus::AtomSerializer
|
||||
include RoutingHelper
|
||||
include ActionView::Helpers::SanitizeHelper
|
||||
|
||||
class << self
|
||||
def render(element)
|
||||
document = Ox::Document.new(version: '1.0')
|
||||
document << element
|
||||
('<?xml version="1.0"?>' + Ox.dump(element, effort: :tolerant)).force_encoding('UTF-8')
|
||||
end
|
||||
end
|
||||
|
||||
def author(account)
|
||||
author = Ox::Element.new('author')
|
||||
|
||||
uri = OStatus::TagManager.instance.uri_for(account)
|
||||
|
||||
append_element(author, 'id', uri)
|
||||
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)
|
||||
append_element(author, 'summary', Formatter.instance.simplified_format(account).to_str, type: :html) if account.note?
|
||||
append_element(author, 'link', nil, rel: :alternate, type: 'text/html', href: ::TagManager.instance.url_for(account))
|
||||
append_element(author, 'link', nil, rel: :avatar, type: account.avatar_content_type, 'media:width': 120, 'media:height': 120, href: full_asset_url(account.avatar.url(:original))) if account.avatar?
|
||||
append_element(author, 'link', nil, rel: :header, type: account.header_content_type, 'media:width': 700, 'media:height': 335, href: full_asset_url(account.header.url(:original))) if account.header?
|
||||
account.emojis.each do |emoji|
|
||||
append_element(author, 'link', nil, rel: :emoji, href: full_asset_url(emoji.image.url), name: emoji.shortcode)
|
||||
end
|
||||
append_element(author, 'poco:preferredUsername', account.username)
|
||||
append_element(author, 'poco:displayName', account.display_name) if account.display_name?
|
||||
append_element(author, 'poco:note', account.local? ? account.note : strip_tags(account.note)) if account.note?
|
||||
append_element(author, 'gabsocial:scope', account.locked? ? :private : :public)
|
||||
|
||||
author
|
||||
end
|
||||
|
||||
def feed(account, stream_entries)
|
||||
feed = Ox::Element.new('feed')
|
||||
|
||||
add_namespaces(feed)
|
||||
|
||||
append_element(feed, 'id', account_url(account, format: 'atom'))
|
||||
append_element(feed, 'title', account.display_name.presence || account.username)
|
||||
append_element(feed, 'subtitle', account.note)
|
||||
append_element(feed, 'updated', account.updated_at.iso8601)
|
||||
append_element(feed, 'logo', full_asset_url(account.avatar.url(:original)))
|
||||
|
||||
feed << author(account)
|
||||
|
||||
append_element(feed, 'link', nil, rel: :alternate, type: 'text/html', href: ::TagManager.instance.url_for(account))
|
||||
append_element(feed, 'link', nil, rel: :self, type: 'application/atom+xml', href: account_url(account, format: 'atom'))
|
||||
append_element(feed, 'link', nil, rel: :next, type: 'application/atom+xml', href: account_url(account, format: 'atom', max_id: stream_entries.last.id)) if stream_entries.size == 20
|
||||
append_element(feed, 'link', nil, rel: :hub, href: api_push_url)
|
||||
append_element(feed, 'link', nil, rel: :salmon, href: api_salmon_url(account.id))
|
||||
|
||||
stream_entries.each do |stream_entry|
|
||||
feed << entry(stream_entry)
|
||||
end
|
||||
|
||||
feed
|
||||
end
|
||||
|
||||
def entry(stream_entry, root = false)
|
||||
entry = Ox::Element.new('entry')
|
||||
|
||||
add_namespaces(entry) if root
|
||||
|
||||
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', 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: ::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: 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
|
||||
end
|
||||
|
||||
def object(status)
|
||||
object = Ox::Element.new('activity:object')
|
||||
|
||||
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', 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: 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
|
||||
end
|
||||
|
||||
def follow_salmon(follow)
|
||||
entry = Ox::Element.new('entry')
|
||||
add_namespaces(entry)
|
||||
|
||||
description = "#{follow.account.acct} started following #{follow.target_account.acct}"
|
||||
|
||||
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', OStatus::TagManager::TYPES[:activity])
|
||||
append_element(entry, 'activity:verb', OStatus::TagManager::VERBS[:follow])
|
||||
|
||||
object = author(follow.target_account)
|
||||
object.value = 'activity:object'
|
||||
|
||||
entry << object
|
||||
entry
|
||||
end
|
||||
|
||||
def follow_request_salmon(follow_request)
|
||||
entry = Ox::Element.new('entry')
|
||||
add_namespaces(entry)
|
||||
|
||||
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', OStatus::TagManager::TYPES[:activity])
|
||||
append_element(entry, 'activity:verb', OStatus::TagManager::VERBS[:request_friend])
|
||||
|
||||
object = author(follow_request.target_account)
|
||||
object.value = 'activity:object'
|
||||
|
||||
entry << object
|
||||
entry
|
||||
end
|
||||
|
||||
def authorize_follow_request_salmon(follow_request)
|
||||
entry = Ox::Element.new('entry')
|
||||
add_namespaces(entry)
|
||||
|
||||
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', 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', 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'
|
||||
|
||||
object << inner_object
|
||||
entry << object
|
||||
entry
|
||||
end
|
||||
|
||||
def reject_follow_request_salmon(follow_request)
|
||||
entry = Ox::Element.new('entry')
|
||||
add_namespaces(entry)
|
||||
|
||||
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', 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', 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'
|
||||
|
||||
object << inner_object
|
||||
entry << object
|
||||
entry
|
||||
end
|
||||
|
||||
def unfollow_salmon(follow)
|
||||
entry = Ox::Element.new('entry')
|
||||
add_namespaces(entry)
|
||||
|
||||
description = "#{follow.account.acct} is no longer following #{follow.target_account.acct}"
|
||||
|
||||
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', OStatus::TagManager::TYPES[:activity])
|
||||
append_element(entry, 'activity:verb', OStatus::TagManager::VERBS[:unfollow])
|
||||
|
||||
object = author(follow.target_account)
|
||||
object.value = 'activity:object'
|
||||
|
||||
entry << object
|
||||
entry
|
||||
end
|
||||
|
||||
def block_salmon(block)
|
||||
entry = Ox::Element.new('entry')
|
||||
add_namespaces(entry)
|
||||
|
||||
description = "#{block.account.acct} no longer wishes to interact with #{block.target_account.acct}"
|
||||
|
||||
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', OStatus::TagManager::TYPES[:activity])
|
||||
append_element(entry, 'activity:verb', OStatus::TagManager::VERBS[:block])
|
||||
|
||||
object = author(block.target_account)
|
||||
object.value = 'activity:object'
|
||||
|
||||
entry << object
|
||||
entry
|
||||
end
|
||||
|
||||
def unblock_salmon(block)
|
||||
entry = Ox::Element.new('entry')
|
||||
add_namespaces(entry)
|
||||
|
||||
description = "#{block.account.acct} no longer blocks #{block.target_account.acct}"
|
||||
|
||||
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', OStatus::TagManager::TYPES[:activity])
|
||||
append_element(entry, 'activity:verb', OStatus::TagManager::VERBS[:unblock])
|
||||
|
||||
object = author(block.target_account)
|
||||
object.value = 'activity:object'
|
||||
|
||||
entry << object
|
||||
entry
|
||||
end
|
||||
|
||||
def favourite_salmon(favourite)
|
||||
entry = Ox::Element.new('entry')
|
||||
add_namespaces(entry)
|
||||
|
||||
description = "#{favourite.account.acct} favorited a status by #{favourite.status.account.acct}"
|
||||
|
||||
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', 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: OStatus::TagManager.instance.uri_for(favourite.status), href: ::TagManager.instance.url_for(favourite.status))
|
||||
|
||||
entry
|
||||
end
|
||||
|
||||
def unfavourite_salmon(favourite)
|
||||
entry = Ox::Element.new('entry')
|
||||
add_namespaces(entry)
|
||||
|
||||
description = "#{favourite.account.acct} no longer favorites a status by #{favourite.status.account.acct}"
|
||||
|
||||
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', 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: OStatus::TagManager.instance.uri_for(favourite.status), href: ::TagManager.instance.url_for(favourite.status))
|
||||
|
||||
entry
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def append_element(parent, name, content = nil, **attributes)
|
||||
element = Ox::Element.new(name)
|
||||
attributes.each { |k, v| element[k] = sanitize_str(v) }
|
||||
element << sanitize_str(content) unless content.nil?
|
||||
parent << element
|
||||
end
|
||||
|
||||
def sanitize_str(raw_str)
|
||||
raw_str.to_s
|
||||
end
|
||||
|
||||
def conversation_uri(conversation)
|
||||
return conversation.uri if conversation.uri?
|
||||
OStatus::TagManager.instance.unique_tag(conversation.created_at, conversation.id, 'Conversation')
|
||||
end
|
||||
|
||||
def add_namespaces(parent)
|
||||
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:gabsocial'] = OStatus::TagManager::GABSCL_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, inline_poll_options: true).to_str || '.', type: 'html', 'xml:lang': status.language)
|
||||
|
||||
status.active_mentions.sort_by(&:id).each do |mentioned|
|
||||
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': 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)
|
||||
end
|
||||
|
||||
status.media_attachments.each do |media|
|
||||
append_element(entry, 'link', nil, rel: :enclosure, type: media.file_content_type, length: media.file_file_size, href: full_asset_url(media.file.url(:original, false)))
|
||||
end
|
||||
|
||||
append_element(entry, 'category', nil, term: 'nsfw') if status.sensitive? && status.media_attachments.any?
|
||||
append_element(entry, 'gabsocial: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
|
||||
73
app/lib/ostatus/tag_manager.rb
Normal file
73
app/lib/ostatus/tag_manager.rb
Normal file
@@ -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://gab.com/schema/1.0/block',
|
||||
unblock: 'http://gab.com/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'
|
||||
GABSCL_XMLNS = 'http://gab.com/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
|
||||
37
app/lib/potential_friendship_tracker.rb
Normal file
37
app/lib/potential_friendship_tracker.rb
Normal file
@@ -0,0 +1,37 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class PotentialFriendshipTracker
|
||||
EXPIRE_AFTER = 90.days.seconds
|
||||
MAX_ITEMS = 80
|
||||
|
||||
WEIGHTS = {
|
||||
reply: 1,
|
||||
favourite: 10,
|
||||
reblog: 20,
|
||||
}.freeze
|
||||
|
||||
class << self
|
||||
include Redisable
|
||||
|
||||
def record(account_id, target_account_id, action)
|
||||
return if account_id == target_account_id
|
||||
|
||||
key = "interactions:#{account_id}"
|
||||
weight = WEIGHTS[action]
|
||||
|
||||
redis.zincrby(key, weight, target_account_id)
|
||||
redis.zremrangebyrank(key, 0, -MAX_ITEMS)
|
||||
redis.expire(key, EXPIRE_AFTER)
|
||||
end
|
||||
|
||||
def remove(account_id, target_account_id)
|
||||
redis.zrem("interactions:#{account_id}", target_account_id)
|
||||
end
|
||||
|
||||
def get(account_id, limit: 20, offset: 0)
|
||||
account_ids = redis.zrevrange("interactions:#{account_id}", offset, limit)
|
||||
return [] if account_ids.empty?
|
||||
Account.searchable.where(id: account_ids)
|
||||
end
|
||||
end
|
||||
end
|
||||
12
app/lib/proof_provider.rb
Normal file
12
app/lib/proof_provider.rb
Normal file
@@ -0,0 +1,12 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module ProofProvider
|
||||
SUPPORTED_PROVIDERS = %w(keybase).freeze
|
||||
|
||||
def self.find(identifier, proof = nil)
|
||||
case identifier
|
||||
when 'keybase'
|
||||
ProofProvider::Keybase.new(proof)
|
||||
end
|
||||
end
|
||||
end
|
||||
69
app/lib/proof_provider/keybase.rb
Normal file
69
app/lib/proof_provider/keybase.rb
Normal file
@@ -0,0 +1,69 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ProofProvider::Keybase
|
||||
BASE_URL = ENV.fetch('KEYBASE_BASE_URL', 'https://keybase.io')
|
||||
DOMAIN = ENV.fetch('KEYBASE_DOMAIN', Rails.configuration.x.web_domain)
|
||||
|
||||
class Error < StandardError; end
|
||||
|
||||
class ExpectedProofLiveError < Error; end
|
||||
|
||||
class UnexpectedResponseError < Error; end
|
||||
|
||||
def initialize(proof = nil)
|
||||
@proof = proof
|
||||
end
|
||||
|
||||
def serializer_class
|
||||
ProofProvider::Keybase::Serializer
|
||||
end
|
||||
|
||||
def worker_class
|
||||
ProofProvider::Keybase::Worker
|
||||
end
|
||||
|
||||
def validate!
|
||||
unless @proof.token&.size == 66
|
||||
@proof.errors.add(:base, I18n.t('identity_proofs.errors.keybase.invalid_token'))
|
||||
return
|
||||
end
|
||||
|
||||
# Do not perform synchronous validation for remote accounts
|
||||
return if @proof.provider_username.blank? || !@proof.account.local?
|
||||
|
||||
if verifier.valid?
|
||||
@proof.verified = true
|
||||
@proof.live = false
|
||||
else
|
||||
@proof.errors.add(:base, I18n.t('identity_proofs.errors.keybase.verification_failed', kb_username: @proof.provider_username))
|
||||
end
|
||||
end
|
||||
|
||||
def refresh!
|
||||
worker_class.new.perform(@proof)
|
||||
rescue ProofProvider::Keybase::Error
|
||||
nil
|
||||
end
|
||||
|
||||
def on_success_path(user_agent = nil)
|
||||
verifier.on_success_path(user_agent)
|
||||
end
|
||||
|
||||
def badge
|
||||
@badge ||= ProofProvider::Keybase::Badge.new(@proof.account.username, @proof.provider_username, @proof.token, domain)
|
||||
end
|
||||
|
||||
def verifier
|
||||
@verifier ||= ProofProvider::Keybase::Verifier.new(@proof.account.username, @proof.provider_username, @proof.token, domain)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def domain
|
||||
if @proof.account.local?
|
||||
DOMAIN
|
||||
else
|
||||
@proof.account.domain
|
||||
end
|
||||
end
|
||||
end
|
||||
45
app/lib/proof_provider/keybase/badge.rb
Normal file
45
app/lib/proof_provider/keybase/badge.rb
Normal file
@@ -0,0 +1,45 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ProofProvider::Keybase::Badge
|
||||
include RoutingHelper
|
||||
|
||||
def initialize(local_username, provider_username, token, domain)
|
||||
@local_username = local_username
|
||||
@provider_username = provider_username
|
||||
@token = token
|
||||
@domain = domain
|
||||
end
|
||||
|
||||
def proof_url
|
||||
"#{ProofProvider::Keybase::BASE_URL}/#{@provider_username}/sigchain\##{@token}"
|
||||
end
|
||||
|
||||
def profile_url
|
||||
"#{ProofProvider::Keybase::BASE_URL}/#{@provider_username}"
|
||||
end
|
||||
|
||||
def icon_url
|
||||
"#{ProofProvider::Keybase::BASE_URL}/#{@provider_username}/proof_badge/#{@token}?username=#{@local_username}&domain=#{@domain}"
|
||||
end
|
||||
|
||||
def avatar_url
|
||||
Rails.cache.fetch("proof_providers/keybase/#{@provider_username}/avatar_url", expires_in: 5.minutes) { remote_avatar_url } || default_avatar_url
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def remote_avatar_url
|
||||
request = Request.new(:get, "#{ProofProvider::Keybase::BASE_URL}/_/api/1.0/user/pic_url.json", params: { username: @provider_username })
|
||||
|
||||
request.perform do |res|
|
||||
json = Oj.load(res.body_with_limit, mode: :strict)
|
||||
json['pic_url'] if json.is_a?(Hash)
|
||||
end
|
||||
rescue Oj::ParseError, HTTP::Error, OpenSSL::SSL::SSLError
|
||||
nil
|
||||
end
|
||||
|
||||
def default_avatar_url
|
||||
asset_pack_path('media/images/proof_providers/keybase.png')
|
||||
end
|
||||
end
|
||||
71
app/lib/proof_provider/keybase/config_serializer.rb
Normal file
71
app/lib/proof_provider/keybase/config_serializer.rb
Normal file
@@ -0,0 +1,71 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ProofProvider::Keybase::ConfigSerializer < ActiveModel::Serializer
|
||||
include RoutingHelper
|
||||
include ActionView::Helpers::TextHelper
|
||||
|
||||
attributes :version, :domain, :display_name, :username,
|
||||
:brand_color, :logo, :description, :prefill_url,
|
||||
:profile_url, :check_url, :check_path, :avatar_path,
|
||||
:contact
|
||||
|
||||
def version
|
||||
1
|
||||
end
|
||||
|
||||
def domain
|
||||
ProofProvider::Keybase::DOMAIN
|
||||
end
|
||||
|
||||
def display_name
|
||||
Setting.site_title
|
||||
end
|
||||
|
||||
def logo
|
||||
{ svg_black: full_asset_url(asset_pack_path('media/images/logo_transparent_black.svg')), svg_full: full_asset_url(asset_pack_path('media/images/logo.png')) }
|
||||
end
|
||||
|
||||
def brand_color
|
||||
'#282c37'
|
||||
end
|
||||
|
||||
def description
|
||||
strip_tags(Setting.site_short_description.presence || I18n.t('about.about_gabsocial_html'))
|
||||
end
|
||||
|
||||
def username
|
||||
{ min: 1, max: 30, re: '[a-z0-9_]+([a-z0-9_.-]+[a-z0-9_]+)?' }
|
||||
end
|
||||
|
||||
def prefill_url
|
||||
params = {
|
||||
provider: 'keybase',
|
||||
token: '%{sig_hash}',
|
||||
provider_username: '%{kb_username}',
|
||||
username: '%{username}',
|
||||
user_agent: '%{kb_ua}',
|
||||
}
|
||||
|
||||
CGI.unescape(new_settings_identity_proof_url(params))
|
||||
end
|
||||
|
||||
def profile_url
|
||||
CGI.unescape(short_account_url('%{username}')) # rubocop:disable Style/FormatStringToken
|
||||
end
|
||||
|
||||
def check_url
|
||||
CGI.unescape(api_proofs_url(username: '%{username}', provider: 'keybase'))
|
||||
end
|
||||
|
||||
def check_path
|
||||
['signatures']
|
||||
end
|
||||
|
||||
def avatar_path
|
||||
['avatar']
|
||||
end
|
||||
|
||||
def contact
|
||||
[Setting.site_contact_email.presence || 'unknown'].compact
|
||||
end
|
||||
end
|
||||
25
app/lib/proof_provider/keybase/serializer.rb
Normal file
25
app/lib/proof_provider/keybase/serializer.rb
Normal file
@@ -0,0 +1,25 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ProofProvider::Keybase::Serializer < ActiveModel::Serializer
|
||||
include RoutingHelper
|
||||
|
||||
attribute :avatar
|
||||
|
||||
has_many :identity_proofs, key: :signatures
|
||||
|
||||
def avatar
|
||||
full_asset_url(object.avatar_original_url)
|
||||
end
|
||||
|
||||
class AccountIdentityProofSerializer < ActiveModel::Serializer
|
||||
attributes :sig_hash, :kb_username
|
||||
|
||||
def sig_hash
|
||||
object.token
|
||||
end
|
||||
|
||||
def kb_username
|
||||
object.provider_username
|
||||
end
|
||||
end
|
||||
end
|
||||
59
app/lib/proof_provider/keybase/verifier.rb
Normal file
59
app/lib/proof_provider/keybase/verifier.rb
Normal file
@@ -0,0 +1,59 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ProofProvider::Keybase::Verifier
|
||||
def initialize(local_username, provider_username, token, domain)
|
||||
@local_username = local_username
|
||||
@provider_username = provider_username
|
||||
@token = token
|
||||
@domain = domain
|
||||
end
|
||||
|
||||
def valid?
|
||||
request = Request.new(:get, "#{ProofProvider::Keybase::BASE_URL}/_/api/1.0/sig/proof_valid.json", params: query_params)
|
||||
|
||||
request.perform do |res|
|
||||
json = Oj.load(res.body_with_limit, mode: :strict)
|
||||
|
||||
if json.is_a?(Hash)
|
||||
json.fetch('proof_valid', false)
|
||||
else
|
||||
false
|
||||
end
|
||||
end
|
||||
rescue Oj::ParseError, HTTP::Error, OpenSSL::SSL::SSLError
|
||||
false
|
||||
end
|
||||
|
||||
def on_success_path(user_agent = nil)
|
||||
url = Addressable::URI.parse("#{ProofProvider::Keybase::BASE_URL}/_/proof_creation_success")
|
||||
url.query_values = query_params.merge(kb_ua: user_agent || 'unknown')
|
||||
url.to_s
|
||||
end
|
||||
|
||||
def status
|
||||
request = Request.new(:get, "#{ProofProvider::Keybase::BASE_URL}/_/api/1.0/sig/proof_live.json", params: query_params)
|
||||
|
||||
request.perform do |res|
|
||||
raise ProofProvider::Keybase::UnexpectedResponseError unless res.code == 200
|
||||
|
||||
json = Oj.load(res.body_with_limit, mode: :strict)
|
||||
|
||||
raise ProofProvider::Keybase::UnexpectedResponseError unless json.is_a?(Hash) && json.key?('proof_valid') && json.key?('proof_live')
|
||||
|
||||
json
|
||||
end
|
||||
rescue Oj::ParseError, HTTP::Error, OpenSSL::SSL::SSLError
|
||||
raise ProofProvider::Keybase::UnexpectedResponseError
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def query_params
|
||||
{
|
||||
domain: @domain,
|
||||
kb_username: @provider_username,
|
||||
username: @local_username,
|
||||
sig_hash: @token,
|
||||
}
|
||||
end
|
||||
end
|
||||
32
app/lib/proof_provider/keybase/worker.rb
Normal file
32
app/lib/proof_provider/keybase/worker.rb
Normal file
@@ -0,0 +1,32 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ProofProvider::Keybase::Worker
|
||||
include Sidekiq::Worker
|
||||
|
||||
sidekiq_options queue: 'pull', retry: 20, unique: :until_executed
|
||||
|
||||
sidekiq_retry_in do |count, exception|
|
||||
# Retry aggressively when the proof is valid but not live in Keybase.
|
||||
# This is likely because Keybase just hasn't noticed the proof being
|
||||
# served from here yet.
|
||||
|
||||
if exception.class == ProofProvider::Keybase::ExpectedProofLiveError
|
||||
case count
|
||||
when 0..2 then 0.seconds
|
||||
when 2..6 then 1.second
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def perform(proof_id)
|
||||
proof = proof_id.is_a?(AccountIdentityProof) ? proof_id : AccountIdentityProof.find(proof_id)
|
||||
status = proof.provider_instance.verifier.status
|
||||
|
||||
# If Keybase thinks the proof is valid, and it exists here in Gab Social,
|
||||
# then it should be live. Keybase just has to notice that it's here
|
||||
# and then update its state. That might take a couple seconds.
|
||||
raise ProofProvider::Keybase::ExpectedProofLiveError if status['proof_valid'] && !status['proof_live']
|
||||
|
||||
proof.update!(verified: status['proof_valid'], live: status['proof_live'])
|
||||
end
|
||||
end
|
||||
208
app/lib/request.rb
Normal file
208
app/lib/request.rb
Normal file
@@ -0,0 +1,208 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'ipaddr'
|
||||
require 'socket'
|
||||
require 'resolv'
|
||||
|
||||
# Monkey-patch the HTTP.rb timeout class to avoid using a timeout block
|
||||
# around the Socket#open method, since we use our own timeout blocks inside
|
||||
# that method
|
||||
class HTTP::Timeout::PerOperation
|
||||
def connect(socket_class, host, port, nodelay = false)
|
||||
@socket = socket_class.open(host, port)
|
||||
@socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1) if nodelay
|
||||
end
|
||||
end
|
||||
|
||||
class Request
|
||||
REQUEST_TARGET = '(request-target)'
|
||||
|
||||
include RoutingHelper
|
||||
|
||||
def initialize(verb, url, **options)
|
||||
raise ArgumentError if url.blank?
|
||||
|
||||
@verb = verb
|
||||
@url = Addressable::URI.parse(url).normalize
|
||||
@options = options.merge(use_proxy? ? Rails.configuration.x.http_client_proxy : { socket_class: Socket })
|
||||
@headers = {}
|
||||
|
||||
raise GabSocial::HostValidationError, 'Instance does not support hidden service connections' if block_hidden_service?
|
||||
|
||||
set_common_headers!
|
||||
set_digest! if options.key?(:body)
|
||||
end
|
||||
|
||||
def on_behalf_of(account, key_id_format = :acct, sign_with: nil)
|
||||
raise ArgumentError unless account.local?
|
||||
|
||||
@account = account
|
||||
@keypair = sign_with.present? ? OpenSSL::PKey::RSA.new(sign_with) : @account.keypair
|
||||
@key_id_format = key_id_format
|
||||
|
||||
self
|
||||
end
|
||||
|
||||
def add_headers(new_headers)
|
||||
@headers.merge!(new_headers)
|
||||
self
|
||||
end
|
||||
|
||||
def perform
|
||||
begin
|
||||
response = http_client.headers(headers).public_send(@verb, @url.to_s, @options)
|
||||
rescue => e
|
||||
raise e.class, "#{e.message} on #{@url}", e.backtrace[0]
|
||||
end
|
||||
|
||||
begin
|
||||
yield response.extend(ClientLimit) if block_given?
|
||||
ensure
|
||||
http_client.close
|
||||
end
|
||||
end
|
||||
|
||||
def headers
|
||||
(@account ? @headers.merge('Signature' => signature) : @headers).without(REQUEST_TARGET)
|
||||
end
|
||||
|
||||
class << self
|
||||
def valid_url?(url)
|
||||
begin
|
||||
parsed_url = Addressable::URI.parse(url)
|
||||
rescue Addressable::URI::InvalidURIError
|
||||
return false
|
||||
end
|
||||
|
||||
%w(http https).include?(parsed_url.scheme) && parsed_url.host.present?
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_common_headers!
|
||||
@headers[REQUEST_TARGET] = "#{@verb} #{@url.path}"
|
||||
@headers['User-Agent'] = GabSocial::Version.user_agent
|
||||
@headers['Host'] = @url.host
|
||||
@headers['Date'] = Time.now.utc.httpdate
|
||||
@headers['Accept-Encoding'] = 'gzip' if @verb != :head
|
||||
end
|
||||
|
||||
def set_digest!
|
||||
@headers['Digest'] = "SHA-256=#{Digest::SHA256.base64digest(@options[:body])}"
|
||||
end
|
||||
|
||||
def signature
|
||||
algorithm = 'rsa-sha256'
|
||||
signature = Base64.strict_encode64(@keypair.sign(OpenSSL::Digest::SHA256.new, signed_string))
|
||||
|
||||
"keyId=\"#{key_id}\",algorithm=\"#{algorithm}\",headers=\"#{signed_headers.keys.join(' ').downcase}\",signature=\"#{signature}\""
|
||||
end
|
||||
|
||||
def signed_string
|
||||
signed_headers.map { |key, value| "#{key.downcase}: #{value}" }.join("\n")
|
||||
end
|
||||
|
||||
def signed_headers
|
||||
@headers.without('User-Agent', 'Accept-Encoding')
|
||||
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
|
||||
# We enforce a 1s timeout on DNS resolving, 10s timeout on socket opening
|
||||
# and 5s timeout on the TLS handshake, meaning the worst case should take
|
||||
# about 16s in total
|
||||
|
||||
{ connect: 5, read: 10, write: 10 }
|
||||
end
|
||||
|
||||
def http_client
|
||||
@http_client ||= HTTP.use(:auto_inflate).timeout(:per_operation, timeout).follow(max_hops: 2)
|
||||
end
|
||||
|
||||
def use_proxy?
|
||||
Rails.configuration.x.http_client_proxy.present?
|
||||
end
|
||||
|
||||
def block_hidden_service?
|
||||
!Rails.configuration.x.access_to_hidden_service && /\.(onion|i2p)$/.match(@url.host)
|
||||
end
|
||||
|
||||
module ClientLimit
|
||||
def body_with_limit(limit = 1.megabyte)
|
||||
raise GabSocial::LengthValidationError if content_length.present? && content_length > limit
|
||||
|
||||
if charset.nil?
|
||||
encoding = Encoding::BINARY
|
||||
else
|
||||
begin
|
||||
encoding = Encoding.find(charset)
|
||||
rescue ArgumentError
|
||||
encoding = Encoding::BINARY
|
||||
end
|
||||
end
|
||||
|
||||
contents = String.new(encoding: encoding)
|
||||
|
||||
while (chunk = readpartial)
|
||||
contents << chunk
|
||||
chunk.clear
|
||||
|
||||
raise GabSocial::LengthValidationError if contents.bytesize > limit
|
||||
end
|
||||
|
||||
contents
|
||||
end
|
||||
end
|
||||
|
||||
class Socket < TCPSocket
|
||||
class << self
|
||||
def open(host, *args)
|
||||
return super(host, *args) if thru_hidden_service?(host)
|
||||
|
||||
outer_e = nil
|
||||
|
||||
Resolv::DNS.open do |dns|
|
||||
dns.timeouts = 5
|
||||
|
||||
addresses = dns.getaddresses(host).take(2)
|
||||
time_slot = 10.0 / addresses.size
|
||||
|
||||
addresses.each do |address|
|
||||
begin
|
||||
raise GabSocial::HostValidationError if PrivateAddressCheck.private_address?(IPAddr.new(address.to_s))
|
||||
|
||||
::Timeout.timeout(time_slot, HTTP::TimeoutError) do
|
||||
return super(address.to_s, *args)
|
||||
end
|
||||
rescue => e
|
||||
outer_e = e
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if outer_e
|
||||
raise outer_e
|
||||
else
|
||||
raise SocketError, "No address for #{host}"
|
||||
end
|
||||
end
|
||||
|
||||
alias new open
|
||||
|
||||
def thru_hidden_service?(host)
|
||||
Rails.configuration.x.access_to_hidden_service && /\.(onion|i2p)$/.match(host)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private_constant :ClientLimit, :Socket
|
||||
end
|
||||
130
app/lib/rss_builder.rb
Normal file
130
app/lib/rss_builder.rb
Normal file
@@ -0,0 +1,130 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class RSSBuilder
|
||||
class ItemBuilder
|
||||
def initialize
|
||||
@item = Ox::Element.new('item')
|
||||
end
|
||||
|
||||
def title(str)
|
||||
@item << (Ox::Element.new('title') << str)
|
||||
|
||||
self
|
||||
end
|
||||
|
||||
def link(str)
|
||||
@item << Ox::Element.new('guid').tap do |guid|
|
||||
guid['isPermalink'] = 'true'
|
||||
guid << str
|
||||
end
|
||||
|
||||
@item << (Ox::Element.new('link') << str)
|
||||
|
||||
self
|
||||
end
|
||||
|
||||
def pub_date(date)
|
||||
@item << (Ox::Element.new('pubDate') << date.to_formatted_s(:rfc822))
|
||||
|
||||
self
|
||||
end
|
||||
|
||||
def description(str)
|
||||
@item << (Ox::Element.new('description') << str)
|
||||
|
||||
self
|
||||
end
|
||||
|
||||
def enclosure(url, type, size)
|
||||
@item << Ox::Element.new('enclosure').tap do |enclosure|
|
||||
enclosure['url'] = url
|
||||
enclosure['length'] = size
|
||||
enclosure['type'] = type
|
||||
end
|
||||
|
||||
self
|
||||
end
|
||||
|
||||
def to_element
|
||||
@item
|
||||
end
|
||||
end
|
||||
|
||||
def initialize
|
||||
@document = Ox::Document.new(version: '1.0')
|
||||
@channel = Ox::Element.new('channel')
|
||||
|
||||
@document << (rss << @channel)
|
||||
end
|
||||
|
||||
def title(str)
|
||||
@channel << (Ox::Element.new('title') << str)
|
||||
|
||||
self
|
||||
end
|
||||
|
||||
def link(str)
|
||||
@channel << (Ox::Element.new('link') << str)
|
||||
|
||||
self
|
||||
end
|
||||
|
||||
def image(str)
|
||||
@channel << Ox::Element.new('image').tap do |image|
|
||||
image << (Ox::Element.new('url') << str)
|
||||
image << (Ox::Element.new('title') << '')
|
||||
image << (Ox::Element.new('link') << '')
|
||||
end
|
||||
|
||||
@channel << (Ox::Element.new('webfeeds:icon') << str)
|
||||
|
||||
self
|
||||
end
|
||||
|
||||
def cover(str)
|
||||
@channel << Ox::Element.new('webfeeds:cover').tap do |cover|
|
||||
cover['image'] = str
|
||||
end
|
||||
|
||||
self
|
||||
end
|
||||
|
||||
def logo(str)
|
||||
@channel << (Ox::Element.new('webfeeds:logo') << str)
|
||||
|
||||
self
|
||||
end
|
||||
|
||||
def accent_color(str)
|
||||
@channel << (Ox::Element.new('webfeeds:accentColor') << str)
|
||||
|
||||
self
|
||||
end
|
||||
|
||||
def description(str)
|
||||
@channel << (Ox::Element.new('description') << str)
|
||||
|
||||
self
|
||||
end
|
||||
|
||||
def item
|
||||
@channel << ItemBuilder.new.tap do |item|
|
||||
yield item
|
||||
end.to_element
|
||||
|
||||
self
|
||||
end
|
||||
|
||||
def to_xml
|
||||
('<?xml version="1.0" encoding="UTF-8"?>' + Ox.dump(@document, effort: :tolerant)).force_encoding('UTF-8')
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def rss
|
||||
Ox::Element.new('rss').tap do |rss|
|
||||
rss['version'] = '2.0'
|
||||
rss['xmlns:webfeeds'] = 'http://webfeeds.org/rss/1.0'
|
||||
end
|
||||
end
|
||||
end
|
||||
68
app/lib/sanitize_config.rb
Normal file
68
app/lib/sanitize_config.rb
Normal file
@@ -0,0 +1,68 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Sanitize
|
||||
module Config
|
||||
HTTP_PROTOCOLS ||= ['http', 'https', 'dat', 'dweb', 'ipfs', 'ipns', 'ssb', 'gopher', :relative].freeze
|
||||
|
||||
CLASS_WHITELIST_TRANSFORMER = lambda do |env|
|
||||
node = env[:node]
|
||||
class_list = node['class']&.split(/[\t\n\f\r ]/)
|
||||
|
||||
return unless class_list
|
||||
|
||||
class_list.keep_if do |e|
|
||||
next true if e =~ /^(h|p|u|dt|e)-/ # microformats classes
|
||||
next true if e =~ /^(mention|hashtag)$/ # semantic classes
|
||||
next true if e =~ /^(ellipsis|invisible)$/ # link formatting classes
|
||||
end
|
||||
|
||||
node['class'] = class_list.join(' ')
|
||||
end
|
||||
|
||||
GABSOCIAL_STRICT ||= freeze_config(
|
||||
elements: %w(p br span a),
|
||||
|
||||
attributes: {
|
||||
'a' => %w(href rel class),
|
||||
'span' => %w(class),
|
||||
},
|
||||
|
||||
add_attributes: {
|
||||
'a' => {
|
||||
'rel' => 'nofollow noopener',
|
||||
'target' => '_blank',
|
||||
},
|
||||
},
|
||||
|
||||
protocols: {
|
||||
'a' => { 'href' => HTTP_PROTOCOLS },
|
||||
},
|
||||
|
||||
transformers: [
|
||||
CLASS_WHITELIST_TRANSFORMER,
|
||||
]
|
||||
)
|
||||
|
||||
GABSOCIAL_OEMBED ||= freeze_config merge(
|
||||
RELAXED,
|
||||
elements: RELAXED[:elements] + %w(audio embed iframe source video),
|
||||
|
||||
attributes: merge(
|
||||
RELAXED[:attributes],
|
||||
'audio' => %w(controls),
|
||||
'embed' => %w(height src type width),
|
||||
'iframe' => %w(allowfullscreen frameborder height scrolling src width),
|
||||
'source' => %w(src type),
|
||||
'video' => %w(controls height loop width),
|
||||
'div' => [:data]
|
||||
),
|
||||
|
||||
protocols: merge(
|
||||
RELAXED[:protocols],
|
||||
'embed' => { 'src' => HTTP_PROTOCOLS },
|
||||
'iframe' => { 'src' => HTTP_PROTOCOLS },
|
||||
'source' => { 'src' => HTTP_PROTOCOLS }
|
||||
)
|
||||
)
|
||||
end
|
||||
end
|
||||
9
app/lib/settings/extend.rb
Normal file
9
app/lib/settings/extend.rb
Normal file
@@ -0,0 +1,9 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Settings
|
||||
module Extend
|
||||
def settings
|
||||
@settings ||= ScopedSettings.new(self)
|
||||
end
|
||||
end
|
||||
end
|
||||
79
app/lib/settings/scoped_settings.rb
Normal file
79
app/lib/settings/scoped_settings.rb
Normal file
@@ -0,0 +1,79 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Settings
|
||||
class ScopedSettings
|
||||
DEFAULTING_TO_UNSCOPED = %w(
|
||||
theme
|
||||
).freeze
|
||||
|
||||
def initialize(object)
|
||||
@object = object
|
||||
end
|
||||
|
||||
# rubocop:disable Style/MethodMissing
|
||||
def method_missing(method, *args)
|
||||
method_name = method.to_s
|
||||
# set a value for a variable
|
||||
if method_name[-1] == '='
|
||||
var_name = method_name.sub('=', '')
|
||||
value = args.first
|
||||
self[var_name] = value
|
||||
else
|
||||
# retrieve a value
|
||||
self[method_name]
|
||||
end
|
||||
end
|
||||
# rubocop:enable Style/MethodMissing
|
||||
|
||||
def respond_to_missing?(*)
|
||||
true
|
||||
end
|
||||
|
||||
def all_as_records
|
||||
vars = thing_scoped
|
||||
records = vars.each_with_object({}) { |r, h| h[r.var] = r }
|
||||
|
||||
Setting.default_settings.each do |key, default_value|
|
||||
next if records.key?(key) || default_value.is_a?(Hash)
|
||||
records[key] = Setting.new(var: key, value: default_value)
|
||||
end
|
||||
|
||||
records
|
||||
end
|
||||
|
||||
def []=(key, value)
|
||||
key = key.to_s
|
||||
record = thing_scoped.find_or_initialize_by(var: key)
|
||||
record.update!(value: value)
|
||||
|
||||
Rails.cache.write(Setting.cache_key(key, @object), value)
|
||||
value
|
||||
end
|
||||
|
||||
def [](key)
|
||||
Rails.cache.fetch(Setting.cache_key(key, @object)) do
|
||||
db_val = thing_scoped.find_by(var: key.to_s)
|
||||
if db_val
|
||||
default_value = ScopedSettings.default_settings[key]
|
||||
return default_value.with_indifferent_access.merge!(db_val.value) if default_value.is_a?(Hash)
|
||||
db_val.value
|
||||
else
|
||||
ScopedSettings.default_settings[key]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
class << self
|
||||
def default_settings
|
||||
defaulting = DEFAULTING_TO_UNSCOPED.each_with_object({}) { |k, h| h[k] = Setting[k] }
|
||||
Setting.default_settings.merge!(defaulting)
|
||||
end
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
def thing_scoped
|
||||
Setting.unscoped.where(thing_type: @object.class.base_class.to_s, thing_id: @object.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
11
app/lib/sidekiq_error_handler.rb
Normal file
11
app/lib/sidekiq_error_handler.rb
Normal file
@@ -0,0 +1,11 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SidekiqErrorHandler
|
||||
def call(*)
|
||||
yield
|
||||
rescue GabSocial::HostValidationError => e
|
||||
Rails.logger.error "#{e.class}: #{e.message}"
|
||||
Rails.logger.error e.backtrace.join("\n")
|
||||
# Do not retry
|
||||
end
|
||||
end
|
||||
58
app/lib/status_filter.rb
Normal file
58
app/lib/status_filter.rb
Normal file
@@ -0,0 +1,58 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class StatusFilter
|
||||
attr_reader :status, :account
|
||||
|
||||
def initialize(status, account, preloaded_relations = {})
|
||||
@status = status
|
||||
@account = account
|
||||
@preloaded_relations = preloaded_relations
|
||||
end
|
||||
|
||||
def filtered?
|
||||
return false if !account.nil? && account.id == status.account_id
|
||||
blocked_by_policy? || (account_present? && filtered_status?) || silenced_account?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def account_present?
|
||||
!account.nil?
|
||||
end
|
||||
|
||||
def filtered_status?
|
||||
blocking_account? || blocking_domain? || muting_account?
|
||||
end
|
||||
|
||||
def blocking_account?
|
||||
@preloaded_relations[:blocking] ? @preloaded_relations[:blocking][status.account_id] : account.blocking?(status.account_id)
|
||||
end
|
||||
|
||||
def blocking_domain?
|
||||
@preloaded_relations[:domain_blocking_by_domain] ? @preloaded_relations[:domain_blocking_by_domain][status.account_domain] : account.domain_blocking?(status.account_domain)
|
||||
end
|
||||
|
||||
def muting_account?
|
||||
@preloaded_relations[:muting] ? @preloaded_relations[:muting][status.account_id] : account.muting?(status.account_id)
|
||||
end
|
||||
|
||||
def silenced_account?
|
||||
!account&.silenced? && status_account_silenced? && !account_following_status_account?
|
||||
end
|
||||
|
||||
def status_account_silenced?
|
||||
status.account.silenced?
|
||||
end
|
||||
|
||||
def account_following_status_account?
|
||||
@preloaded_relations[:following] ? @preloaded_relations[:following][status.account_id] : account&.following?(status.account_id)
|
||||
end
|
||||
|
||||
def blocked_by_policy?
|
||||
!policy_allows_show?
|
||||
end
|
||||
|
||||
def policy_allows_show?
|
||||
StatusPolicy.new(account, status, @preloaded_relations).show?
|
||||
end
|
||||
end
|
||||
36
app/lib/status_finder.rb
Normal file
36
app/lib/status_finder.rb
Normal file
@@ -0,0 +1,36 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class StatusFinder
|
||||
attr_reader :url
|
||||
|
||||
def initialize(url)
|
||||
@url = url
|
||||
end
|
||||
|
||||
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]).status
|
||||
when 'statuses'
|
||||
Status.find(recognized_params[:id])
|
||||
else
|
||||
raise ActiveRecord::RecordNotFound
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def recognized_params
|
||||
Rails.application.routes.recognize_path(url)
|
||||
end
|
||||
|
||||
def verify_action!
|
||||
unless recognized_params[:action] == 'show'
|
||||
raise ActiveRecord::RecordNotFound
|
||||
end
|
||||
end
|
||||
end
|
||||
47
app/lib/tag_manager.rb
Normal file
47
app/lib/tag_manager.rb
Normal file
@@ -0,0 +1,47 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'singleton'
|
||||
|
||||
class TagManager
|
||||
include Singleton
|
||||
include RoutingHelper
|
||||
|
||||
def web_domain?(domain)
|
||||
domain.nil? || domain.gsub(/[\/]/, '').casecmp(Rails.configuration.x.web_domain).zero?
|
||||
end
|
||||
|
||||
def local_domain?(domain)
|
||||
domain.nil? || domain.gsub(/[\/]/, '').casecmp(Rails.configuration.x.local_domain).zero?
|
||||
end
|
||||
|
||||
def normalize_domain(domain)
|
||||
return if domain.nil?
|
||||
|
||||
uri = Addressable::URI.new
|
||||
uri.host = domain.gsub(/[\/]/, '')
|
||||
uri.normalized_host
|
||||
end
|
||||
|
||||
def same_acct?(canonical, needle)
|
||||
return true if canonical.casecmp(needle).zero?
|
||||
username, domain = needle.split('@')
|
||||
local_domain?(domain) && canonical.casecmp(username).zero?
|
||||
end
|
||||
|
||||
def local_url?(url)
|
||||
uri = Addressable::URI.parse(url).normalize
|
||||
domain = uri.host + (uri.port ? ":#{uri.port}" : '')
|
||||
TagManager.instance.web_domain?(domain)
|
||||
end
|
||||
|
||||
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
|
||||
short_account_status_url(target.account, target)
|
||||
end
|
||||
end
|
||||
end
|
||||
16
app/lib/themes.rb
Normal file
16
app/lib/themes.rb
Normal file
@@ -0,0 +1,16 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'singleton'
|
||||
require 'yaml'
|
||||
|
||||
class Themes
|
||||
include Singleton
|
||||
|
||||
def initialize
|
||||
@conf = YAML.load_file(Rails.root.join('config', 'themes.yml'))
|
||||
end
|
||||
|
||||
def names
|
||||
@conf.keys
|
||||
end
|
||||
end
|
||||
130
app/lib/user_settings_decorator.rb
Normal file
130
app/lib/user_settings_decorator.rb
Normal file
@@ -0,0 +1,130 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class UserSettingsDecorator
|
||||
attr_reader :user, :settings
|
||||
|
||||
def initialize(user)
|
||||
@user = user
|
||||
end
|
||||
|
||||
def update(settings)
|
||||
@settings = settings
|
||||
process_update
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def process_update
|
||||
user.settings['notification_emails'] = merged_notification_emails if change?('notification_emails')
|
||||
user.settings['interactions'] = merged_interactions if change?('interactions')
|
||||
user.settings['default_privacy'] = default_privacy_preference if change?('setting_default_privacy')
|
||||
user.settings['default_sensitive'] = default_sensitive_preference if change?('setting_default_sensitive')
|
||||
user.settings['default_language'] = default_language_preference if change?('setting_default_language')
|
||||
user.settings['unfollow_modal'] = unfollow_modal_preference if change?('setting_unfollow_modal')
|
||||
user.settings['boost_modal'] = boost_modal_preference if change?('setting_boost_modal')
|
||||
user.settings['delete_modal'] = delete_modal_preference if change?('setting_delete_modal')
|
||||
user.settings['auto_play_gif'] = auto_play_gif_preference if change?('setting_auto_play_gif')
|
||||
user.settings['display_media'] = display_media_preference if change?('setting_display_media')
|
||||
user.settings['expand_spoilers'] = expand_spoilers_preference if change?('setting_expand_spoilers')
|
||||
user.settings['reduce_motion'] = reduce_motion_preference if change?('setting_reduce_motion')
|
||||
user.settings['system_font_ui'] = system_font_ui_preference if change?('setting_system_font_ui')
|
||||
user.settings['noindex'] = noindex_preference if change?('setting_noindex')
|
||||
user.settings['theme'] = theme_preference if change?('setting_theme')
|
||||
user.settings['hide_network'] = hide_network_preference if change?('setting_hide_network')
|
||||
user.settings['aggregate_reblogs'] = aggregate_reblogs_preference if change?('setting_aggregate_reblogs')
|
||||
user.settings['show_application'] = show_application_preference if change?('setting_show_application')
|
||||
user.settings['advanced_layout'] = advanced_layout_preference if change?('setting_advanced_layout')
|
||||
end
|
||||
|
||||
def merged_notification_emails
|
||||
user.settings['notification_emails'].merge coerced_settings('notification_emails').to_h
|
||||
end
|
||||
|
||||
def merged_interactions
|
||||
user.settings['interactions'].merge coerced_settings('interactions').to_h
|
||||
end
|
||||
|
||||
def default_privacy_preference
|
||||
settings['setting_default_privacy']
|
||||
end
|
||||
|
||||
def default_sensitive_preference
|
||||
boolean_cast_setting 'setting_default_sensitive'
|
||||
end
|
||||
|
||||
def unfollow_modal_preference
|
||||
boolean_cast_setting 'setting_unfollow_modal'
|
||||
end
|
||||
|
||||
def boost_modal_preference
|
||||
boolean_cast_setting 'setting_boost_modal'
|
||||
end
|
||||
|
||||
def delete_modal_preference
|
||||
boolean_cast_setting 'setting_delete_modal'
|
||||
end
|
||||
|
||||
def system_font_ui_preference
|
||||
boolean_cast_setting 'setting_system_font_ui'
|
||||
end
|
||||
|
||||
def auto_play_gif_preference
|
||||
boolean_cast_setting 'setting_auto_play_gif'
|
||||
end
|
||||
|
||||
def display_media_preference
|
||||
settings['setting_display_media']
|
||||
end
|
||||
|
||||
def expand_spoilers_preference
|
||||
boolean_cast_setting 'setting_expand_spoilers'
|
||||
end
|
||||
|
||||
def reduce_motion_preference
|
||||
boolean_cast_setting 'setting_reduce_motion'
|
||||
end
|
||||
|
||||
def noindex_preference
|
||||
boolean_cast_setting 'setting_noindex'
|
||||
end
|
||||
|
||||
def hide_network_preference
|
||||
boolean_cast_setting 'setting_hide_network'
|
||||
end
|
||||
|
||||
def show_application_preference
|
||||
boolean_cast_setting 'setting_show_application'
|
||||
end
|
||||
|
||||
def theme_preference
|
||||
settings['setting_theme']
|
||||
end
|
||||
|
||||
def default_language_preference
|
||||
settings['setting_default_language']
|
||||
end
|
||||
|
||||
def aggregate_reblogs_preference
|
||||
boolean_cast_setting 'setting_aggregate_reblogs'
|
||||
end
|
||||
|
||||
def advanced_layout_preference
|
||||
boolean_cast_setting 'setting_advanced_layout'
|
||||
end
|
||||
|
||||
def boolean_cast_setting(key)
|
||||
ActiveModel::Type::Boolean.new.cast(settings[key])
|
||||
end
|
||||
|
||||
def coerced_settings(key)
|
||||
coerce_values settings.fetch(key, {})
|
||||
end
|
||||
|
||||
def coerce_values(params_hash)
|
||||
params_hash.transform_values { |x| ActiveModel::Type::Boolean.new.cast(x) }
|
||||
end
|
||||
|
||||
def change?(key)
|
||||
!settings[key].nil?
|
||||
end
|
||||
end
|
||||
66
app/lib/webfinger_resource.rb
Normal file
66
app/lib/webfinger_resource.rb
Normal file
@@ -0,0 +1,66 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class WebfingerResource
|
||||
attr_reader :resource
|
||||
|
||||
def initialize(resource)
|
||||
@resource = resource
|
||||
end
|
||||
|
||||
def username
|
||||
case resource
|
||||
when /\Ahttps?/i
|
||||
username_from_url
|
||||
when /\@/
|
||||
username_from_acct
|
||||
else
|
||||
raise(ActiveRecord::RecordNotFound)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def username_from_url
|
||||
if account_show_page?
|
||||
path_params[:username]
|
||||
else
|
||||
raise ActiveRecord::RecordNotFound
|
||||
end
|
||||
end
|
||||
|
||||
def account_show_page?
|
||||
path_params[:controller] == 'accounts' && path_params[:action] == 'show'
|
||||
end
|
||||
|
||||
def path_params
|
||||
Rails.application.routes.recognize_path(resource)
|
||||
end
|
||||
|
||||
def username_from_acct
|
||||
if domain_matches_local?
|
||||
local_username
|
||||
else
|
||||
raise ActiveRecord::RecordNotFound
|
||||
end
|
||||
end
|
||||
|
||||
def split_acct
|
||||
resource_without_acct_string.split('@')
|
||||
end
|
||||
|
||||
def resource_without_acct_string
|
||||
resource.gsub(/\Aacct:/, '')
|
||||
end
|
||||
|
||||
def local_username
|
||||
split_acct.first
|
||||
end
|
||||
|
||||
def local_domain
|
||||
split_acct.last
|
||||
end
|
||||
|
||||
def domain_matches_local?
|
||||
TagManager.instance.local_domain?(local_domain) || TagManager.instance.web_domain?(local_domain)
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user