forked from berserker/microblog
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 mistakelocal
parent
2d1f082bb6
commit
1060666c58
56 changed files with 1409 additions and 568 deletions
@ -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