forked from berserker/microblog
commit
e58e0eb9aa
94 changed files with 1767 additions and 813 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 |
# frozen_string_literal: true |
||||||
|
|
||||||
class ActivityPub::Activity::Update < ActivityPub::Activity |
class ActivityPub::Activity::Update < ActivityPub::Activity |
||||||
SUPPORTED_TYPES = %w(Application Group Organization Person Service).freeze |
|
||||||
|
|
||||||
def perform |
def perform |
||||||
dereference_object! |
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 |
update_account |
||||||
elsif equals_or_includes_any?(@object['type'], %w(Question)) |
elsif equals_or_includes_any?(@object['type'], %w(Note Question)) |
||||||
update_poll |
update_status |
||||||
end |
end |
||||||
end |
end |
||||||
|
|
||||||
private |
private |
||||||
|
|
||||||
def update_account |
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) |
ActivityPub::ProcessAccountService.new.call(@account.username, @account.domain, @object, signed_with_known_key: true) |
||||||
end |
end |
||||||
|
|
||||||
def update_poll |
def update_status |
||||||
return reject_payload! if invalid_origin?(@object['id']) |
return reject_payload! if invalid_origin?(@object['id']) |
||||||
|
|
||||||
status = Status.find_by(uri: object_uri, account_id: @account.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 |
||||||
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,124 @@ |
|||||||
|
# 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 |
||||||
|
elsif direct_message == false |
||||||
|
:limited |
||||||
|
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 |
||||||
|
|
||||||
|
def direct_message |
||||||
|
@object['directMessage'] |
||||||
|
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, :content_type |
||||||
|
|
||||||
|
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.any? || added_media_attachments.any? |
||||||
|
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,13 +1,22 @@ |
|||||||
= simple_form_for(new_user, url: user_session_path, namespace: 'login') do |f| |
- unless omniauth_only? |
||||||
.fields-group |
= simple_form_for(new_user, url: user_session_path, namespace: 'login') do |f| |
||||||
- if use_seamless_external_login? |
.fields-group |
||||||
= f.input :email, placeholder: t('simple_form.labels.defaults.username_or_email'), input_html: { 'aria-label' => t('simple_form.labels.defaults.username_or_email') }, hint: false |
- if use_seamless_external_login? |
||||||
- else |
= f.input :email, placeholder: t('simple_form.labels.defaults.username_or_email'), input_html: { 'aria-label' => t('simple_form.labels.defaults.username_or_email') }, hint: false |
||||||
= f.input :email, placeholder: t('simple_form.labels.defaults.email'), input_html: { 'aria-label' => t('simple_form.labels.defaults.email') }, hint: false |
- else |
||||||
|
= f.input :email, placeholder: t('simple_form.labels.defaults.email'), input_html: { 'aria-label' => t('simple_form.labels.defaults.email') }, hint: false |
||||||
|
|
||||||
= f.input :password, placeholder: t('simple_form.labels.defaults.password'), input_html: { 'aria-label' => t('simple_form.labels.defaults.password') }, hint: false |
= f.input :password, placeholder: t('simple_form.labels.defaults.password'), input_html: { 'aria-label' => t('simple_form.labels.defaults.password') }, hint: false |
||||||
|
|
||||||
.actions |
.actions |
||||||
= f.button :button, t('auth.login'), type: :submit, class: 'button button-primary' |
= f.button :button, t('auth.login'), type: :submit, class: 'button button-primary' |
||||||
|
|
||||||
%p.hint.subtle-hint= link_to t('auth.trouble_logging_in'), new_user_password_path |
%p.hint.subtle-hint= link_to t('auth.trouble_logging_in'), new_user_password_path |
||||||
|
|
||||||
|
- if Devise.mappings[:user].omniauthable? and User.omniauth_providers.any? |
||||||
|
.simple_form.alternative-login |
||||||
|
%h4= omniauth_only? ? t('auth.log_in_with') : t('auth.or_log_in_with') |
||||||
|
|
||||||
|
.actions |
||||||
|
- User.omniauth_providers.each do |provider| |
||||||
|
= provider_sign_in_link(provider) |
||||||
|
@ -1,54 +1,32 @@ |
|||||||
# frozen_string_literal: true |
# frozen_string_literal: true |
||||||
|
|
||||||
class ActivityPub::DistributionWorker |
class ActivityPub::DistributionWorker < ActivityPub::RawDistributionWorker |
||||||
include Sidekiq::Worker |
# Distribute a new status or an edit of a status to all the places |
||||||
include Payloadable |
# where the status is supposed to go or where it was interacted with |
||||||
|
|
||||||
sidekiq_options queue: 'push' |
|
||||||
|
|
||||||
def perform(status_id) |
def perform(status_id) |
||||||
@status = Status.find(status_id) |
@status = Status.find(status_id) |
||||||
@account = @status.account |
@account = @status.account |
||||||
|
|
||||||
return if skip_distribution? |
distribute! |
||||||
|
|
||||||
ActivityPub::DeliveryWorker.push_bulk(inboxes) do |inbox_url| |
|
||||||
[payload, @account.id, inbox_url, { synchronize_followers: !@status.distributable? }] |
|
||||||
end |
|
||||||
|
|
||||||
relay! if relayable? |
|
||||||
rescue ActiveRecord::RecordNotFound |
rescue ActiveRecord::RecordNotFound |
||||||
true |
true |
||||||
end |
end |
||||||
|
|
||||||
private |
protected |
||||||
|
|
||||||
def skip_distribution? |
|
||||||
@status.direct_visibility? || @status.limited_visibility? |
|
||||||
end |
|
||||||
|
|
||||||
def relayable? |
|
||||||
@status.public_visibility? |
|
||||||
end |
|
||||||
|
|
||||||
def inboxes |
def inboxes |
||||||
# Deliver the status to all followers. |
@inboxes ||= StatusReachFinder.new(@status).inboxes |
||||||
# 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 |
|
||||||
end |
end |
||||||
|
|
||||||
def payload |
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 |
end |
||||||
|
|
||||||
def relay! |
def options |
||||||
ActivityPub::DeliveryWorker.push_bulk(Relay.enabled.pluck(:inbox_url)) do |inbox_url| |
{ synchronize_followers: @status.private_visibility? } |
||||||
[payload, @account.id, inbox_url] |
|
||||||
end |
|
||||||
end |
end |
||||||
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 |
# frozen_string_literal: true |
||||||
|
|
||||||
class ActivityPub::UpdateDistributionWorker |
class ActivityPub::UpdateDistributionWorker < ActivityPub::RawDistributionWorker |
||||||
include Sidekiq::Worker |
# Distribute an profile update to servers that might have a copy |
||||||
include Payloadable |
# of the account in question |
||||||
|
|
||||||
sidekiq_options queue: 'push' |
|
||||||
|
|
||||||
def perform(account_id, options = {}) |
def perform(account_id, options = {}) |
||||||
@options = options.with_indifferent_access |
@options = options.with_indifferent_access |
||||||
@account = Account.find(account_id) |
@account = Account.find(account_id) |
||||||
|
|
||||||
ActivityPub::DeliveryWorker.push_bulk(inboxes) do |inbox_url| |
distribute! |
||||||
[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 |
|
||||||
rescue ActiveRecord::RecordNotFound |
rescue ActiveRecord::RecordNotFound |
||||||
true |
true |
||||||
end |
end |
||||||
|
|
||||||
private |
protected |
||||||
|
|
||||||
def inboxes |
def inboxes |
||||||
@inboxes ||= @account.followers.inboxes |
@inboxes ||= AccountReachFinder.new(@account).inboxes |
||||||
end |
end |
||||||
|
|
||||||
def signed_payload |
def payload |
||||||
@signed_payload ||= Oj.dump(serialize_payload(@account, ActivityPub::UpdateSerializer, signer: @account, sign_with: @options[:sign_with])) |
@payload ||= Oj.dump(serialize_payload(@account, ActivityPub::UpdateSerializer, signer: @account, sign_with: @options[:sign_with])) |
||||||
end |
end |
||||||
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,9 @@ |
|||||||
|
class RemoveMentionsStatusIdIndex < ActiveRecord::Migration[6.1] |
||||||
|
def up |
||||||
|
remove_index :mentions, name: :mentions_status_id_index if index_exists?(:mentions, :status_id, name: :mentions_status_id_index) |
||||||
|
end |
||||||
|
|
||||||
|
def down |
||||||
|
# As this index should not exist and is a duplicate of another index, do not re-create it |
||||||
|
end |
||||||
|
end |
@ -0,0 +1,13 @@ |
|||||||
|
# frozen_string_literal: true |
||||||
|
|
||||||
|
class RemoveIndexUsersOnRememberToken < ActiveRecord::Migration[6.1] |
||||||
|
disable_ddl_transaction! |
||||||
|
|
||||||
|
def up |
||||||
|
remove_index :users, name: :index_users_on_remember_token |
||||||
|
end |
||||||
|
|
||||||
|
def down |
||||||
|
add_index :users, :remember_token, algorithm: :concurrently, unique: true, name: :index_users_on_remember_token |
||||||
|
end |
||||||
|
end |
@ -0,0 +1,8 @@ |
|||||||
|
class RemoveRememberableFromUsers < ActiveRecord::Migration[6.1] |
||||||
|
def change |
||||||
|
safety_assured do |
||||||
|
remove_column :users, :remember_token, :string, null: true, default: nil |
||||||
|
remove_column :users, :remember_created_at, :datetime, null: true, default: nil |
||||||
|
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' |
require 'rails_helper' |
||||||
|
|
||||||
RSpec.describe FanOutOnWriteService, type: :service do |
RSpec.describe FanOutOnWriteService, type: :service do |
||||||
let(:author) { Fabricate(:account, username: 'tom') } |
let(:last_active_at) { Time.now.utc } |
||||||
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') } |
|
||||||
|
|
||||||
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 |
before do |
||||||
alice |
bob.follow!(alice) |
||||||
follower.follow!(author) |
tom.follow!(alice) |
||||||
|
|
||||||
ProcessMentionsService.new.call(status) |
ProcessMentionsService.new.call(status) |
||||||
ProcessHashtagsService.new.call(status) |
ProcessHashtagsService.new.call(status) |
||||||
|
|
||||||
|
allow(Redis.current).to receive(:publish) |
||||||
|
|
||||||
subject.call(status) |
subject.call(status) |
||||||
end |
end |
||||||
|
|
||||||
it 'delivers status to home timeline' do |
def home_feed_of(account) |
||||||
expect(HomeFeed.new(author).get(10).map(&:id)).to include status.id |
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 |
end |
||||||
|
|
||||||
it 'delivers status to local followers' do |
context 'when status is limited' do |
||||||
pending 'some sort of problem in test environment causes this to sometimes fail' |
let(:visibility) { 'limited' } |
||||||
expect(HomeFeed.new(follower).get(10).map(&:id)).to include status.id |
|
||||||
|
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 |
||||||
|
|
||||||
it 'delivers status to hashtag' do |
context 'when status is private' do |
||||||
expect(TagFeed.new(Tag.find_by(name: 'test'), alice).get(20).map(&:id)).to include status.id |
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 |
end |
||||||
|
|
||||||
it 'delivers status to public timeline' do |
context 'when status is direct' do |
||||||
expect(PublicFeed.new(alice).get(20).map(&:id)).to include status.id |
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 |
||||||
end |
end |
||||||
|
Loading…
Reference in new issue