Gab Social. All are welcome.

This commit is contained in:
robcolbert
2019-07-02 03:10:25 -04:00
commit bd0b5afc92
5366 changed files with 222812 additions and 0 deletions

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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
View 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

View 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
View 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
View 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
View 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

View 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
View 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
View 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
View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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
View 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

View 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

View 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

View 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

View 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

View 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

View 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
View 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
View 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

View 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

View File

@@ -0,0 +1,9 @@
# frozen_string_literal: true
module Settings
module Extend
def settings
@settings ||= ScopedSettings.new(self)
end
end
end

View 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

View 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
View 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
View 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
View 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
View 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

View 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

View 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