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