Add support for editing for published statuses (#16697)
* Add support for editing for published statuses * Fix references to stripped-out code * Various fixes and improvements * Further fixes and improvements * Fix updates being potentially sent to unauthorized recipients * Various fixes and improvements * Fix wrong words in test * Fix notifying accounts that were tagged but were not in the audience * Fix mistakemain
parent
2d1f082bb6
commit
1060666c58
@ -0,0 +1,21 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::Statuses::HistoriesController < Api::BaseController
|
||||
include Authorization
|
||||
|
||||
before_action -> { authorize_if_got_token! :read, :'read:statuses' }
|
||||
before_action :set_status
|
||||
|
||||
def show
|
||||
render json: @status.edits, each_serializer: REST::StatusEditSerializer
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_status
|
||||
@status = Status.find(params[:status_id])
|
||||
authorize @status, :show?
|
||||
rescue Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
end
|
@ -0,0 +1,21 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::Statuses::SourcesController < Api::BaseController
|
||||
include Authorization
|
||||
|
||||
before_action -> { doorkeeper_authorize! :read, :'read:statuses' }
|
||||
before_action :set_status
|
||||
|
||||
def show
|
||||
render json: @status, serializer: REST::StatusSourceSerializer
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_status
|
||||
@status = Status.find(params[:status_id])
|
||||
authorize @status, :show?
|
||||
rescue Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
end
|
@ -1,32 +1,31 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ActivityPub::Activity::Update < ActivityPub::Activity
|
||||
SUPPORTED_TYPES = %w(Application Group Organization Person Service).freeze
|
||||
|
||||
def perform
|
||||
dereference_object!
|
||||
|
||||
if equals_or_includes_any?(@object['type'], SUPPORTED_TYPES)
|
||||
if equals_or_includes_any?(@object['type'], %w(Application Group Organization Person Service))
|
||||
update_account
|
||||
elsif equals_or_includes_any?(@object['type'], %w(Question))
|
||||
update_poll
|
||||
elsif equals_or_includes_any?(@object['type'], %w(Note Question))
|
||||
update_status
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def update_account
|
||||
return if @account.uri != object_uri
|
||||
return reject_payload! if @account.uri != object_uri
|
||||
|
||||
ActivityPub::ProcessAccountService.new.call(@account.username, @account.domain, @object, signed_with_known_key: true)
|
||||
end
|
||||
|
||||
def update_poll
|
||||
def update_status
|
||||
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)
|
||||
return if status.nil?
|
||||
|
||||
ActivityPub::ProcessStatusUpdateService.new.call(status, @object)
|
||||
end
|
||||
end
|
||||
|
@ -0,0 +1,27 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ActivityPub::Parser::CustomEmojiParser
|
||||
include JsonLdHelper
|
||||
|
||||
def initialize(json)
|
||||
@json = json
|
||||
end
|
||||
|
||||
def uri
|
||||
@json['id']
|
||||
end
|
||||
|
||||
def shortcode
|
||||
@json['name']&.delete(':')
|
||||
end
|
||||
|
||||
def image_remote_url
|
||||
@json.dig('icon', 'url')
|
||||
end
|
||||
|
||||
def updated_at
|
||||
@json['updated']&.to_datetime
|
||||
rescue ArgumentError
|
||||
nil
|
||||
end
|
||||
end
|
@ -0,0 +1,58 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ActivityPub::Parser::MediaAttachmentParser
|
||||
include JsonLdHelper
|
||||
|
||||
def initialize(json)
|
||||
@json = json
|
||||
end
|
||||
|
||||
# @param [MediaAttachment] previous_record
|
||||
def significantly_changes?(previous_record)
|
||||
remote_url != previous_record.remote_url ||
|
||||
thumbnail_remote_url != previous_record.thumbnail_remote_url ||
|
||||
description != previous_record.description
|
||||
end
|
||||
|
||||
def remote_url
|
||||
Addressable::URI.parse(@json['url'])&.normalize&.to_s
|
||||
rescue Addressable::URI::InvalidURIError
|
||||
nil
|
||||
end
|
||||
|
||||
def thumbnail_remote_url
|
||||
Addressable::URI.parse(@json['icon'].is_a?(Hash) ? @json['icon']['url'] : @json['icon'])&.normalize&.to_s
|
||||
rescue Addressable::URI::InvalidURIError
|
||||
nil
|
||||
end
|
||||
|
||||
def description
|
||||
@json['summary'].presence || @json['name'].presence
|
||||
end
|
||||
|
||||
def focus
|
||||
@json['focalPoint']
|
||||
end
|
||||
|
||||
def blurhash
|
||||
supported_blurhash? ? @json['blurhash'] : nil
|
||||
end
|
||||
|
||||
def file_content_type
|
||||
@json['mediaType']
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def supported_blurhash?
|
||||
components = begin
|
||||
blurhash = @json['blurhash']
|
||||
|
||||
if blurhash.present? && /^[\w#$%*+-.:;=?@\[\]^{|}~]+$/.match?(blurhash)
|
||||
Blurhash.components(blurhash)
|
||||
end
|
||||
end
|
||||
|
||||
components.present? && components.none? { |comp| comp > 5 }
|
||||
end
|
||||
end
|
@ -0,0 +1,53 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ActivityPub::Parser::PollParser
|
||||
include JsonLdHelper
|
||||
|
||||
def initialize(json)
|
||||
@json = json
|
||||
end
|
||||
|
||||
def valid?
|
||||
equals_or_includes?(@json['type'], 'Question') && items.is_a?(Array)
|
||||
end
|
||||
|
||||
# @param [Poll] previous_record
|
||||
def significantly_changes?(previous_record)
|
||||
options != previous_record.options ||
|
||||
multiple != previous_record.multiple
|
||||
end
|
||||
|
||||
def options
|
||||
items.filter_map { |item| item['name'].presence || item['content'] }
|
||||
end
|
||||
|
||||
def multiple
|
||||
@json['anyOf'].is_a?(Array)
|
||||
end
|
||||
|
||||
def expires_at
|
||||
if @json['closed'].is_a?(String)
|
||||
@json['closed'].to_datetime
|
||||
elsif !@json['closed'].nil? && !@json['closed'].is_a?(FalseClass)
|
||||
Time.now.utc
|
||||
else
|
||||
@json['endTime']&.to_datetime
|
||||
end
|
||||
rescue ArgumentError
|
||||
nil
|
||||
end
|
||||
|
||||
def voters_count
|
||||
@json['votersCount']
|
||||
end
|
||||
|
||||
def cached_tallies
|
||||
items.map { |item| item.dig('replies', 'totalItems') || 0 }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def items
|
||||
@json['anyOf'] || @json['oneOf']
|
||||
end
|
||||
end
|
@ -0,0 +1,118 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ActivityPub::Parser::StatusParser
|
||||
include JsonLdHelper
|
||||
|
||||
# @param [Hash] json
|
||||
# @param [Hash] magic_values
|
||||
# @option magic_values [String] :followers_collection
|
||||
def initialize(json, magic_values = {})
|
||||
@json = json
|
||||
@object = json['object'] || json
|
||||
@magic_values = magic_values
|
||||
end
|
||||
|
||||
def uri
|
||||
id = @object['id']
|
||||
|
||||
if id&.start_with?('bear:')
|
||||
Addressable::URI.parse(id).query_values['u']
|
||||
else
|
||||
id
|
||||
end
|
||||
rescue Addressable::URI::InvalidURIError
|
||||
id
|
||||
end
|
||||
|
||||
def url
|
||||
url_to_href(@object['url'], 'text/html') if @object['url'].present?
|
||||
end
|
||||
|
||||
def text
|
||||
if @object['content'].present?
|
||||
@object['content']
|
||||
elsif content_language_map?
|
||||
@object['contentMap'].values.first
|
||||
end
|
||||
end
|
||||
|
||||
def spoiler_text
|
||||
if @object['summary'].present?
|
||||
@object['summary']
|
||||
elsif summary_language_map?
|
||||
@object['summaryMap'].values.first
|
||||
end
|
||||
end
|
||||
|
||||
def title
|
||||
if @object['name'].present?
|
||||
@object['name']
|
||||
elsif name_language_map?
|
||||
@object['nameMap'].values.first
|
||||
end
|
||||
end
|
||||
|
||||
def created_at
|
||||
@object['published']&.to_datetime
|
||||
rescue ArgumentError
|
||||
nil
|
||||
end
|
||||
|
||||
def edited_at
|
||||
@object['updated']&.to_datetime
|
||||
rescue ArgumentError
|
||||
nil
|
||||
end
|
||||
|
||||
def reply
|
||||
@object['inReplyTo'].present?
|
||||
end
|
||||
|
||||
def sensitive
|
||||
@object['sensitive']
|
||||
end
|
||||
|
||||
def visibility
|
||||
if audience_to.any? { |to| ActivityPub::TagManager.instance.public_collection?(to) }
|
||||
:public
|
||||
elsif audience_cc.any? { |cc| ActivityPub::TagManager.instance.public_collection?(cc) }
|
||||
:unlisted
|
||||
elsif audience_to.include?(@magic_values[:followers_collection])
|
||||
:private
|
||||
else
|
||||
:direct
|
||||
end
|
||||
end
|
||||
|
||||
def 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
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def audience_to
|
||||
as_array(@object['to'] || @json['to']).map { |x| value_or_id(x) }
|
||||
end
|
||||
|
||||
def audience_cc
|
||||
as_array(@object['cc'] || @json['cc']).map { |x| value_or_id(x) }
|
||||
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
|
||||
end
|
@ -0,0 +1,23 @@
|
||||
# frozen_string_literal: true
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: status_edits
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# status_id :bigint(8) not null
|
||||
# account_id :bigint(8)
|
||||
# text :text default(""), not null
|
||||
# spoiler_text :text default(""), not null
|
||||
# media_attachments_changed :boolean default(FALSE), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
|
||||
class StatusEdit < ApplicationRecord
|
||||
belongs_to :status
|
||||
belongs_to :account, optional: true
|
||||
|
||||
default_scope { order(id: :asc) }
|
||||
|
||||
delegate :local?, to: :status
|
||||
end
|
@ -0,0 +1,6 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class REST::StatusEditSerializer < ActiveModel::Serializer
|
||||
attributes :text, :spoiler_text, :media_attachments_changed,
|
||||
:created_at
|
||||
end
|
@ -0,0 +1,9 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class REST::StatusSourceSerializer < ActiveModel::Serializer
|
||||
attributes :id, :text, :spoiler_text
|
||||
|
||||
def id
|
||||
object.id.to_s
|
||||
end
|
||||
end
|
@ -1,64 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ActivityPub::ProcessPollService < BaseService
|
||||
include JsonLdHelper
|
||||
|
||||
def call(poll, json)
|
||||
@json = json
|
||||
|
||||
return unless expected_type?
|
||||
|
||||
previous_expires_at = poll.expires_at
|
||||
|
||||
expires_at = begin
|
||||
if @json['closed'].is_a?(String)
|
||||
@json['closed']
|
||||
elsif !@json['closed'].nil? && !@json['closed'].is_a?(FalseClass)
|
||||
Time.now.utc
|
||||
else
|
||||
@json['endTime']
|
||||
end
|
||||
end
|
||||
|
||||
items = begin
|
||||
if @json['anyOf'].is_a?(Array)
|
||||
@json['anyOf']
|
||||
else
|
||||
@json['oneOf']
|
||||
end
|
||||
end
|
||||
|
||||
voters_count = @json['votersCount']
|
||||
|
||||
latest_options = items.filter_map { |item| item['name'].presence || item['content'] }
|
||||
|
||||
# If for some reasons the options were changed, it invalidates all previous
|
||||
# votes, so we need to remove them
|
||||
poll.votes.delete_all if latest_options != poll.options
|
||||
|
||||
begin
|
||||
poll.update!(
|
||||
last_fetched_at: Time.now.utc,
|
||||
expires_at: expires_at,
|
||||
options: latest_options,
|
||||
cached_tallies: items.map { |item| item.dig('replies', 'totalItems') || 0 },
|
||||
voters_count: voters_count
|
||||
)
|
||||
rescue ActiveRecord::StaleObjectError
|
||||
poll.reload
|
||||
retry
|
||||
end
|
||||
|
||||
# If the poll had no expiration date set but now has, and people have voted,
|
||||
# schedule a notification.
|
||||
if previous_expires_at.nil? && poll.expires_at.present? && poll.votes.exists?
|
||||
PollExpirationNotifyWorker.perform_at(poll.expires_at + 5.minutes, poll.id)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def expected_type?
|
||||
equals_or_includes_any?(@json['type'], %w(Question))
|
||||
end
|
||||
end
|
@ -0,0 +1,275 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ActivityPub::ProcessStatusUpdateService < BaseService
|
||||
include JsonLdHelper
|
||||
|
||||
def call(status, json)
|
||||
@json = json
|
||||
@status_parser = ActivityPub::Parser::StatusParser.new(@json)
|
||||
@uri = @status_parser.uri
|
||||
@status = status
|
||||
@account = status.account
|
||||
@media_attachments_changed = false
|
||||
|
||||
# Only native types can be updated at the moment
|
||||
return if !expected_type? || already_updated_more_recently?
|
||||
|
||||
# Only allow processing one create/update per status at a time
|
||||
RedisLock.acquire(lock_options) do |lock|
|
||||
if lock.acquired?
|
||||
Status.transaction do
|
||||
create_previous_edit!
|
||||
update_media_attachments!
|
||||
update_poll!
|
||||
update_immediate_attributes!
|
||||
update_metadata!
|
||||
create_edit!
|
||||
end
|
||||
|
||||
queue_poll_notifications!
|
||||
reset_preview_card!
|
||||
broadcast_updates!
|
||||
else
|
||||
raise Mastodon::RaceConditionError
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def update_media_attachments!
|
||||
previous_media_attachments = @status.media_attachments.to_a
|
||||
next_media_attachments = []
|
||||
|
||||
as_array(@json['attachment']).each do |attachment|
|
||||
media_attachment_parser = ActivityPub::Parser::MediaAttachmentParser.new(attachment)
|
||||
|
||||
next if media_attachment_parser.remote_url.blank? || next_media_attachments.size > 4
|
||||
|
||||
begin
|
||||
media_attachment = previous_media_attachments.find { |previous_media_attachment| previous_media_attachment.remote_url == media_attachment_parser.remote_url }
|
||||
media_attachment ||= MediaAttachment.new(account: @account, remote_url: media_attachment_parser.remote_url)
|
||||
|
||||
# If a previously existing media attachment was significantly updated, mark
|
||||
# media attachments as changed even if none were added or removed
|
||||
if media_attachment_parser.significantly_changes?(media_attachment)
|
||||
@media_attachments_changed = true
|
||||
end
|
||||
|
||||
media_attachment.description = media_attachment_parser.description
|
||||
media_attachment.focus = media_attachment_parser.focus
|
||||
media_attachment.thumbnail_remote_url = media_attachment_parser.thumbnail_remote_url
|
||||
media_attachment.blurhash = media_attachment_parser.blurhash
|
||||
media_attachment.save!
|
||||
|
||||
next_media_attachments << media_attachment
|
||||
|
||||
next if unsupported_media_type?(media_attachment_parser.file_content_type) || skip_download?
|
||||
|
||||
RedownloadMediaWorker.perform_async(media_attachment.id) if media_attachment.remote_url_previously_changed? || media_attachment.thumbnail_remote_url_previously_changed?
|
||||
rescue Addressable::URI::InvalidURIError => e
|
||||
Rails.logger.debug "Invalid URL in attachment: #{e}"
|
||||
end
|
||||
end
|
||||
|
||||
removed_media_attachments = previous_media_attachments - next_media_attachments
|
||||
added_media_attachments = next_media_attachments - previous_media_attachments
|
||||
|
||||
MediaAttachment.where(id: removed_media_attachments.map(&:id)).update_all(status_id: nil)
|
||||
MediaAttachment.where(id: added_media_attachments.map(&:id)).update_all(status_id: @status.id)
|
||||
|
||||
@media_attachments_changed = true if removed_media_attachments.positive? || added_media_attachments.positive?
|
||||
end
|
||||
|
||||
def update_poll!
|
||||
previous_poll = @status.preloadable_poll
|
||||
@previous_expires_at = previous_poll&.expires_at
|
||||
poll_parser = ActivityPub::Parser::PollParser.new(@json)
|
||||
|
||||
if poll_parser.valid?
|
||||
poll = previous_poll || @account.polls.new(status: @status)
|
||||
|
||||
# If for some reasons the options were changed, it invalidates all previous
|
||||
# votes, so we need to remove them
|
||||
if poll_parser.significantly_changes?(poll)
|
||||
@media_attachments_changed = true
|
||||
poll.votes.delete_all unless poll.new_record?
|
||||
end
|
||||
|
||||
poll.last_fetched_at = Time.now.utc
|
||||
poll.options = poll_parser.options
|
||||
poll.multiple = poll_parser.multiple
|
||||
poll.expires_at = poll_parser.expires_at
|
||||
poll.voters_count = poll_parser.voters_count
|
||||
poll.cached_tallies = poll_parser.cached_tallies
|
||||
poll.save!
|
||||
|
||||
@status.poll_id = poll.id
|
||||
elsif previous_poll.present?
|
||||
previous_poll.destroy!
|
||||
@media_attachments_changed = true
|
||||
@status.poll_id = nil
|
||||
end
|
||||
end
|
||||
|
||||
def update_immediate_attributes!
|
||||
@status.text = @status_parser.text || ''
|
||||
@status.spoiler_text = @status_parser.spoiler_text || ''
|
||||
@status.sensitive = @account.sensitized? || @status_parser.sensitive || false
|
||||
@status.language = @status_parser.language || detected_language
|
||||
@status.edited_at = @status_parser.edited_at || Time.now.utc
|
||||
|
||||
@status.save!
|
||||
end
|
||||
|
||||
def update_metadata!
|
||||
@raw_tags = []
|
||||
@raw_mentions = []
|
||||
@raw_emojis = []
|
||||
|
||||
as_array(@json['tag']).each do |tag|
|
||||
if equals_or_includes?(tag['type'], 'Hashtag')
|
||||
@raw_tags << tag['name']
|
||||
elsif equals_or_includes?(tag['type'], 'Mention')
|
||||
@raw_mentions << tag['href']
|
||||
elsif equals_or_includes?(tag['type'], 'Emoji')
|
||||
@raw_emojis << tag
|
||||
end
|
||||
end
|
||||
|
||||
update_tags!
|
||||
update_mentions!
|
||||
update_emojis!
|
||||
end
|
||||
|
||||
def update_tags!
|
||||
@status.tags = Tag.find_or_create_by_names(@raw_tags)
|
||||
end
|
||||
|
||||
def update_mentions!
|
||||
previous_mentions = @status.active_mentions.includes(:account).to_a
|
||||
current_mentions = []
|
||||
|
||||
@raw_mentions.each do |href|
|
||||
next if href.blank?
|
||||
|
||||
account = ActivityPub::TagManager.instance.uri_to_resource(href, Account)
|
||||
account ||= ActivityPub::FetchRemoteAccountService.new.call(href)
|
||||
|
||||
next if account.nil?
|
||||
|
||||
mention = previous_mentions.find { |x| x.account_id == account.id }
|
||||
mention ||= account.mentions.new(status: @status)
|
||||
|
||||
current_mentions << mention
|
||||
end
|
||||
|
||||
current_mentions.each do |mention|
|
||||
mention.save if mention.new_record?
|
||||
end
|
||||
|
||||
# If previous mentions are no longer contained in the text, convert them
|
||||
# to silent mentions, since withdrawing access from someone who already
|
||||
# received a notification might be more confusing
|
||||
removed_mentions = previous_mentions - current_mentions
|
||||
|
||||
Mention.where(id: removed_mentions.map(&:id)).update_all(silent: true) unless removed_mentions.empty?
|
||||
end
|
||||
|
||||
def update_emojis!
|
||||
return if skip_download?
|
||||
|
||||
@raw_emojis.each do |raw_emoji|
|
||||
custom_emoji_parser = ActivityPub::Parser::CustomEmojiParser.new(raw_emoji)
|
||||
|
||||
next if custom_emoji_parser.shortcode.blank? || custom_emoji_parser.image_remote_url.blank?
|
||||
|
||||
emoji = CustomEmoji.find_by(shortcode: custom_emoji_parser.shortcode, domain: @account.domain)
|
||||
|
||||
next unless emoji.nil? || custom_emoji_parser.image_remote_url != emoji.image_remote_url || (custom_emoji_parser.updated_at && custom_emoji_parser.updated_at >= emoji.updated_at)
|
||||
|
||||
begin
|
||||
emoji ||= CustomEmoji.new(domain: @account.domain, shortcode: custom_emoji_parser.shortcode, uri: custom_emoji_parser.uri)
|
||||
emoji.image_remote_url = custom_emoji_parser.image_remote_url
|
||||
emoji.save
|
||||
rescue Seahorse::Client::NetworkingError => e
|
||||
Rails.logger.warn "Error storing emoji: #{e}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def expected_type?
|
||||
equals_or_includes_any?(@json['type'], %w(Note Question))
|
||||
end
|
||||
|
||||
def lock_options
|
||||
{ redis: Redis.current, key: "create:#{@uri}", autorelease: 15.minutes.seconds }
|
||||
end
|
||||
|
||||
def detected_language
|
||||
LanguageDetector.instance.detect(@status_parser.text, @account)
|
||||
end
|
||||
|
||||
def create_previous_edit!
|
||||
# We only need to create a previous edit when no previous edits exist, e.g.
|
||||
# when the status has never been edited. For other cases, we always create
|
||||
# an edit, so the step can be skipped
|
||||
|
||||
return if @status.edits.any?
|
||||
|
||||
@status.edits.create(
|
||||
text: @status.text,
|
||||
spoiler_text: @status.spoiler_text,
|
||||
media_attachments_changed: false,
|
||||
account_id: @account.id,
|
||||
created_at: @status.created_at
|
||||
)
|
||||
end
|
||||
|
||||
def create_edit!
|
||||
return unless @status.text_previously_changed? || @status.spoiler_text_previously_changed? || @media_attachments_changed
|
||||
|
||||
@status_edit = @status.edits.create(
|
||||
text: @status.text,
|
||||
spoiler_text: @status.spoiler_text,
|
||||
media_attachments_changed: @media_attachments_changed,
|
||||
account_id: @account.id,
|
||||
created_at: @status.edited_at
|
||||
)
|
||||
end
|
||||
|
||||
def skip_download?
|
||||
return @skip_download if defined?(@skip_download)
|
||||
|
||||
@skip_download ||= DomainBlock.reject_media?(@account.domain)
|
||||
end
|
||||
|
||||
def unsupported_media_type?(mime_type)
|
||||
mime_type.present? && !MediaAttachment.supported_mime_types.include?(mime_type)
|
||||
end
|
||||
|
||||
def already_updated_more_recently?
|
||||
@status.edited_at.present? && @status_parser.edited_at.present? && @status.edited_at > @status_parser.edited_at
|
||||
end
|
||||
|
||||
def reset_preview_card!
|
||||
@status.preview_cards.clear if @status.text_previously_changed? || @status.spoiler_text.present?
|
||||
LinkCrawlWorker.perform_in(rand(1..59).seconds, @status.id) if @status.spoiler_text.blank?
|
||||
end
|
||||
|
||||
def broadcast_updates!
|
||||
::DistributionWorker.perform_async(@status.id, update: true)
|
||||
end
|
||||
|
||||
def queue_poll_notifications!
|
||||
poll = @status.preloadable_poll
|
||||
|
||||
# If the poll had no expiration date set but now has, or now has a sooner
|
||||
# expiration date, and people have voted, schedule a notification
|
||||
|
||||
return unless poll.present? && poll.expires_at.present? && poll.votes.exists?
|
||||
|
||||
PollExpirationNotifyWorker.remove_from_scheduled(poll.id) if @previous_expires_at.present? && @previous_expires_at > poll.expires_at
|
||||
PollExpirationNotifyWorker.perform_at(poll.expires_at + 5.minutes, poll.id)
|
||||
end
|
||||
end
|
@ -1,54 +1,32 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ActivityPub::DistributionWorker
|
||||
include Sidekiq::Worker
|
||||
include Payloadable
|
||||
|
||||
sidekiq_options queue: 'push'
|
||||
|
||||
class ActivityPub::DistributionWorker < ActivityPub::RawDistributionWorker
|
||||
# Distribute a new status or an edit of a status to all the places
|
||||
# where the status is supposed to go or where it was interacted with
|
||||
def perform(status_id)
|
||||
@status = Status.find(status_id)
|
||||
@account = @status.account
|
||||
|
||||
return if skip_distribution?
|
||||
|
||||
ActivityPub::DeliveryWorker.push_bulk(inboxes) do |inbox_url|
|
||||
[payload, @account.id, inbox_url, { synchronize_followers: !@status.distributable? }]
|
||||
end
|
||||
|
||||
relay! if relayable?
|
||||
distribute!
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
true
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def skip_distribution?
|
||||
@status.direct_visibility? || @status.limited_visibility?
|
||||
end
|
||||
|
||||
def relayable?
|
||||
@status.public_visibility?
|
||||
end
|
||||
protected
|
||||
|
||||
def inboxes
|
||||
# Deliver the status to all followers.
|
||||
# If the status is a reply to another local status, also forward it to that
|
||||
# status' authors' followers.
|
||||
@inboxes ||= if @status.in_reply_to_local_account? && @status.distributable?
|
||||
@account.followers.or(@status.thread.account.followers).inboxes
|
||||
else
|
||||
@account.followers.inboxes
|
||||
end
|
||||
@inboxes ||= StatusReachFinder.new(@status).inboxes
|
||||
end
|
||||
|
||||
def payload
|
||||
@payload ||= Oj.dump(serialize_payload(ActivityPub::ActivityPresenter.from_status(@status), ActivityPub::ActivitySerializer, signer: @account))
|
||||
@payload ||= Oj.dump(serialize_payload(activity, ActivityPub::ActivitySerializer, signer: @account))
|
||||
end
|
||||
|
||||
def activity
|
||||
ActivityPub::ActivityPresenter.from_status(@status)
|
||||
end
|
||||
|
||||
def relay!
|
||||
ActivityPub::DeliveryWorker.push_bulk(Relay.enabled.pluck(:inbox_url)) do |inbox_url|
|
||||
[payload, @account.id, inbox_url]
|
||||
end
|
||||
def options
|
||||
{ synchronize_followers: @status.private_visibility? }
|
||||
end
|
||||
end
|
||||
|
@ -1,34 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Obsolete but kept around to make sure existing jobs do not fail after upgrade.
|
||||
# Should be removed in a subsequent release.
|
||||
|
||||
class ActivityPub::ReplyDistributionWorker
|
||||
include Sidekiq::Worker
|
||||
include Payloadable
|
||||
|
||||
sidekiq_options queue: 'push'
|
||||
|
||||
def perform(status_id)
|
||||
@status = Status.find(status_id)
|
||||
@account = @status.thread&.account
|
||||
|
||||
return unless @account.present? && @status.distributable?
|
||||
|
||||
ActivityPub::DeliveryWorker.push_bulk(inboxes) do |inbox_url|
|
||||
[payload, @status.account_id, inbox_url]
|
||||
end
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
true
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def inboxes
|
||||
@inboxes ||= @account.followers.inboxes
|
||||
end
|
||||
|
||||
def payload
|
||||
@payload ||= Oj.dump(serialize_payload(ActivityPub::ActivityPresenter.from_status(@status), ActivityPub::ActivitySerializer, signer: @status.account))
|
||||
end
|
||||
end
|
@ -1,33 +1,24 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ActivityPub::UpdateDistributionWorker
|
||||
include Sidekiq::Worker
|
||||
include Payloadable
|
||||
|
||||
sidekiq_options queue: 'push'
|
||||
|
||||
class ActivityPub::UpdateDistributionWorker < ActivityPub::RawDistributionWorker
|
||||
# Distribute an profile update to servers that might have a copy
|
||||
# of the account in question
|
||||
def perform(account_id, options = {})
|
||||
@options = options.with_indifferent_access
|
||||
@account = Account.find(account_id)
|
||||
|
||||
ActivityPub::DeliveryWorker.push_bulk(inboxes) do |inbox_url|
|
||||
[signed_payload, @account.id, inbox_url]
|
||||
end
|
||||
|
||||
ActivityPub::DeliveryWorker.push_bulk(Relay.enabled.pluck(:inbox_url)) do |inbox_url|
|
||||
[signed_payload, @account.id, inbox_url]
|
||||
end
|
||||
distribute!
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
true
|
||||
end
|
||||
|
||||
private
|
||||
protected
|
||||
|
||||
def inboxes
|
||||
@inboxes ||= @account.followers.inboxes
|
||||
@inboxes ||= AccountReachFinder.new(@account).inboxes
|
||||
end
|
||||
|
||||
def signed_payload
|
||||
@signed_payload ||= Oj.dump(serialize_payload(@account, ActivityPub::UpdateSerializer, signer: @account, sign_with: @options[:sign_with]))
|
||||
def payload
|
||||
@payload ||= Oj.dump(serialize_payload(@account, ActivityPub::UpdateSerializer, signer: @account, sign_with: @options[:sign_with]))
|
||||
end
|
||||
end
|
||||
|
@ -0,0 +1,5 @@
|
||||
class AddEditedAtToStatuses < ActiveRecord::Migration[6.1]
|
||||
def change
|
||||
add_column :statuses, :edited_at, :datetime
|
||||
end
|
||||
end
|
@ -0,0 +1,13 @@
|
||||
class CreateStatusEdits < ActiveRecord::Migration[6.1]
|
||||
def change
|
||||
create_table :status_edits do |t|
|
||||
t.belongs_to :status, null: false, foreign_key: { on_delete: :cascade }
|
||||
t.belongs_to :account, null: true, foreign_key: { on_delete: :nullify }
|
||||
t.text :text, null: false, default: ''
|
||||
t.text :spoiler_text, null: false, default: ''
|
||||
t.boolean :media_attachments_changed, null: false, default: false
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
@ -0,0 +1,29 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
describe Api::V1::Statuses::HistoriesController do
|
||||
render_views
|
||||
|
||||
let(:user) { Fabricate(:user, account: Fabricate(:account, username: 'alice')) }
|
||||
let(:app) { Fabricate(:application, name: 'Test app', website: 'http://testapp.com') }
|
||||
let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read:statuses', application: app) }
|
||||
|
||||
context 'with an oauth token' do
|
||||
before do
|
||||
allow(controller).to receive(:doorkeeper_token) { token }
|
||||
end
|
||||
|
||||
describe 'GET #show' do
|
||||
let(:status) { Fabricate(:status, account: user.account) }
|
||||
|
||||
before do
|
||||
get :show, params: { status_id: status.id }
|
||||
end
|
||||
|
||||
it 'returns http success' do
|
||||
expect(response).to have_http_status(200)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
@ -0,0 +1,29 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
describe Api::V1::Statuses::SourcesController do
|
||||
render_views
|
||||
|
||||
let(:user) { Fabricate(:user, account: Fabricate(:account, username: 'alice')) }
|
||||
let(:app) { Fabricate(:application, name: 'Test app', website: 'http://testapp.com') }
|
||||
let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read:statuses', application: app) }
|
||||
|
||||
context 'with an oauth token' do
|
||||
before do
|
||||
allow(controller).to receive(:doorkeeper_token) { token }
|
||||
end
|
||||
|
||||
describe 'GET #show' do
|
||||
let(:status) { Fabricate(:status, account: user.account) }
|
||||
|
||||
before do
|
||||
get :show, params: { status_id: status.id }
|
||||
end
|
||||
|
||||
it 'returns http success' do
|
||||
expect(response).to have_http_status(200)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
@ -0,0 +1,6 @@
|
||||
Fabricator(:preview_card) do
|
||||
url { Faker::Internet.url }
|
||||
title { Faker::Lorem.sentence }
|
||||
description { Faker::Lorem.paragraph }
|
||||
type 'link'
|
||||
end
|
@ -0,0 +1,7 @@
|
||||
Fabricator(:status_edit) do
|
||||
status nil
|
||||
account nil
|
||||
text "MyText"
|
||||
spoiler_text "MyText"
|
||||
media_attachments_changed false
|
||||
end
|
@ -0,0 +1,109 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
describe StatusReachFinder do
|
||||
describe '#inboxes' do
|
||||
context 'for a local status' do
|
||||
let(:parent_status) { nil }
|
||||
let(:visibility) { :public }
|
||||
let(:alice) { Fabricate(:account, username: 'alice') }
|
||||
let(:status) { Fabricate(:status, account: alice, thread: parent_status, visibility: visibility) }
|
||||
|
||||
subject { described_class.new(status) }
|
||||
|
||||
context 'when it contains mentions of remote accounts' do
|
||||
let(:bob) { Fabricate(:account, username: 'bob', domain: 'foo.bar', protocol: :activitypub, inbox_url: 'https://foo.bar/inbox') }
|
||||
|
||||
before do
|
||||
status.mentions.create!(account: bob)
|
||||
end
|
||||
|
||||
it 'includes the inbox of the mentioned account' do
|
||||
expect(subject.inboxes).to include 'https://foo.bar/inbox'
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it has been reblogged by a remote account' do
|
||||
let(:bob) { Fabricate(:account, username: 'bob', domain: 'foo.bar', protocol: :activitypub, inbox_url: 'https://foo.bar/inbox') }
|
||||
|
||||
before do
|
||||
bob.statuses.create!(reblog: status)
|
||||
end
|
||||
|
||||
it 'includes the inbox of the reblogger' do
|
||||
expect(subject.inboxes).to include 'https://foo.bar/inbox'
|
||||
end
|
||||
|
||||
context 'when status is not public' do
|
||||
let(:visibility) { :private }
|
||||
|
||||
it 'does not include the inbox of the reblogger' do
|
||||
expect(subject.inboxes).to_not include 'https://foo.bar/inbox'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it has been favourited by a remote account' do
|
||||
let(:bob) { Fabricate(:account, username: 'bob', domain: 'foo.bar', protocol: :activitypub, inbox_url: 'https://foo.bar/inbox') }
|
||||
|
||||
before do
|
||||
bob.favourites.create!(status: status)
|
||||
end
|
||||
|
||||
it 'includes the inbox of the favouriter' do
|
||||
expect(subject.inboxes).to include 'https://foo.bar/inbox'
|
||||
end
|
||||
|
||||
context 'when status is not public' do
|
||||
let(:visibility) { :private }
|
||||
|
||||
it 'does not include the inbox of the favouriter' do
|
||||
expect(subject.inboxes).to_not include 'https://foo.bar/inbox'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it has been replied to by a remote account' do
|
||||
let(:bob) { Fabricate(:account, username: 'bob', domain: 'foo.bar', protocol: :activitypub, inbox_url: 'https://foo.bar/inbox') }
|
||||
|
||||
before do
|
||||
bob.statuses.create!(thread: status, text: 'Hoge')
|
||||
end
|
||||
|
||||
context do
|
||||
it 'includes the inbox of the replier' do
|
||||
expect(subject.inboxes).to include 'https://foo.bar/inbox'
|
||||
end
|
||||
end
|
||||
|
||||
context 'when status is not public' do
|
||||
let(:visibility) { :private }
|
||||
|
||||
it 'does not include the inbox of the replier' do
|
||||
expect(subject.inboxes).to_not include 'https://foo.bar/inbox'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is a reply to a remote account' do
|
||||
let(:bob) { Fabricate(:account, username: 'bob', domain: 'foo.bar', protocol: :activitypub, inbox_url: 'https://foo.bar/inbox') }
|
||||
let(:parent_status) { Fabricate(:status, account: bob) }
|
||||
|
||||
context do
|
||||
it 'includes the inbox of the replied-to account' do
|
||||
expect(subject.inboxes).to include 'https://foo.bar/inbox'
|
||||
end
|
||||
end
|
||||
|
||||
context 'when status is not public and replied-to account is not mentioned' do
|
||||
let(:visibility) { :private }
|
||||
|
||||
it 'does not include the inbox of the replied-to account' do
|
||||
expect(subject.inboxes).to_not include 'https://foo.bar/inbox'
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
@ -0,0 +1,5 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe StatusEdit, type: :model do
|
||||
pending "add some examples to (or delete) #{__FILE__}"
|
||||
end
|
@ -1,37 +1,112 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe FanOutOnWriteService, type: :service do
|
||||
let(:author) { Fabricate(:account, username: 'tom') }
|
||||
let(:status) { Fabricate(:status, text: 'Hello @alice #test', account: author) }
|
||||
let(:alice) { Fabricate(:user, account: Fabricate(:account, username: 'alice')).account }
|
||||
let(:follower) { Fabricate(:account, username: 'bob') }
|
||||
let(:last_active_at) { Time.now.utc }
|
||||
|
||||
subject { FanOutOnWriteService.new }
|
||||
let!(:alice) { Fabricate(:user, current_sign_in_at: last_active_at, account: Fabricate(:account, username: 'alice')).account }
|
||||
let!(:bob) { Fabricate(:user, current_sign_in_at: last_active_at, account: Fabricate(:account, username: 'bob')).account }
|
||||
let!(:tom) { Fabricate(:user, current_sign_in_at: last_active_at, account: Fabricate(:account, username: 'tom')).account }
|
||||
|
||||
subject { described_class.new }
|
||||
|
||||
let(:status) { Fabricate(:status, account: alice, visibility: visibility, text: 'Hello @bob #hoge') }
|
||||
|
||||
before do
|
||||
alice
|
||||
follower.follow!(author)
|
||||
bob.follow!(alice)
|
||||
tom.follow!(alice)
|
||||
|
||||
ProcessMentionsService.new.call(status)
|
||||
ProcessHashtagsService.new.call(status)
|
||||
|
||||
allow(Redis.current).to receive(:publish)
|
||||
|
||||
subject.call(status)
|
||||
end
|
||||
|
||||
it 'delivers status to home timeline' do
|
||||
expect(HomeFeed.new(author).get(10).map(&:id)).to include status.id
|
||||
def home_feed_of(account)
|
||||
HomeFeed.new(account).get(10).map(&:id)
|
||||
end
|
||||
|
||||
context 'when status is public' do
|
||||
let(:visibility) { 'public' }
|
||||
|
||||
it 'is added to the home feed of its author' do
|
||||
expect(home_feed_of(alice)).to include status.id
|
||||
end
|
||||
|
||||
it 'is added to the home feed of a follower' do
|
||||
expect(home_feed_of(bob)).to include status.id
|
||||
expect(home_feed_of(tom)).to include status.id
|
||||
end
|
||||
|
||||
it 'is broadcast to the hashtag stream' do
|
||||
expect(Redis.current).to have_received(:publish).with('timeline:hashtag:hoge', anything)
|
||||
expect(Redis.current).to have_received(:publish).with('timeline:hashtag:hoge:local', anything)
|
||||
end
|
||||
|
||||
it 'is broadcast to the public stream' do
|
||||
expect(Redis.current).to have_received(:publish).with('timeline:public', anything)
|
||||
expect(Redis.current).to have_received(:publish).with('timeline:public:local', anything)
|
||||
end
|
||||
end
|
||||
|
||||
it 'delivers status to local followers' do
|
||||
pending 'some sort of problem in test environment causes this to sometimes fail'
|
||||
expect(HomeFeed.new(follower).get(10).map(&:id)).to include status.id
|
||||
context 'when status is limited' do
|
||||
let(:visibility) { 'limited' }
|
||||
|
||||
it 'is added to the home feed of its author' do
|
||||
expect(home_feed_of(alice)).to include status.id
|
||||
end
|
||||
|
||||
it 'is added to the home feed of the mentioned follower' do
|
||||
expect(home_feed_of(bob)).to include status.id
|
||||
end
|
||||
|
||||
it 'is not added to the home feed of the other follower' do
|
||||
expect(home_feed_of(tom)).to_not include status.id
|
||||
end
|
||||
|
||||
it 'is not broadcast publicly' do
|
||||
expect(Redis.current).to_not have_received(:publish).with('timeline:hashtag:hoge', anything)
|
||||
expect(Redis.current).to_not have_received(:publish).with('timeline:public', anything)
|
||||
end
|
||||
end
|
||||
|
||||
it 'delivers status to hashtag' do
|
||||
expect(TagFeed.new(Tag.find_by(name: 'test'), alice).get(20).map(&:id)).to include status.id
|
||||
context 'when status is private' do
|
||||
let(:visibility) { 'private' }
|
||||
|
||||
it 'is added to the home feed of its author' do
|
||||
expect(home_feed_of(alice)).to include status.id
|
||||
end
|
||||
|
||||
it 'is added to the home feed of a follower' do
|
||||
expect(home_feed_of(bob)).to include status.id
|
||||
expect(home_feed_of(tom)).to include status.id
|
||||
end
|
||||
|
||||
it 'is not broadcast publicly' do
|
||||
expect(Redis.current).to_not have_received(:publish).with('timeline:hashtag:hoge', anything)
|
||||
expect(Redis.current).to_not have_received(:publish).with('timeline:public', anything)
|
||||
end
|
||||
end
|
||||
|
||||
it 'delivers status to public timeline' do
|
||||
expect(PublicFeed.new(alice).get(20).map(&:id)).to include status.id
|
||||
context 'when status is direct' do
|
||||
let(:visibility) { 'direct' }
|
||||
|
||||
it 'is added to the home feed of its author' do
|
||||
expect(home_feed_of(alice)).to include status.id
|
||||
end
|
||||
|
||||
it 'is added to the home feed of the mentioned follower' do
|
||||
expect(home_feed_of(bob)).to include status.id
|
||||
end
|
||||
|
||||
it 'is not added to the home feed of the other follower' do
|
||||
expect(home_feed_of(tom)).to_not include status.id
|
||||
end
|
||||
|
||||
it 'is not broadcast publicly' do
|
||||
expect(Redis.current).to_not have_received(:publish).with('timeline:hashtag:hoge', anything)
|
||||
expect(Redis.current).to_not have_received(:publish).with('timeline:public', anything)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
Loading…
Reference in new issue