Unverified Commit 1060666c authored by Eugen Rochko's avatar Eugen Rochko Committed by GitHub
Browse files

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 mistake
parent 2d1f082b
# 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
# 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
...@@ -34,7 +34,13 @@ module JsonLdHelper ...@@ -34,7 +34,13 @@ module JsonLdHelper
end end
def as_array(value) def as_array(value)
value.is_a?(Array) ? value : [value] if value.nil?
[]
elsif value.is_a?(Array)
value
else
[value]
end
end end
def value_or_id(value) def value_or_id(value)
......
...@@ -54,9 +54,10 @@ export function normalizeStatus(status, normalOldStatus) { ...@@ -54,9 +54,10 @@ export function normalizeStatus(status, normalOldStatus) {
normalStatus.poll = status.poll.id; normalStatus.poll = status.poll.id;
} }
// Only calculate these values when status first encountered // Only calculate these values when status first encountered and
// Otherwise keep the ones already in the reducer // when the underlying values change. Otherwise keep the ones
if (normalOldStatus) { // already in the reducer
if (normalOldStatus && normalOldStatus.get('content') === normalStatus.content && normalOldStatus.get('spoiler_text') === normalStatus.spoiler_text) {
normalStatus.search_index = normalOldStatus.get('search_index'); normalStatus.search_index = normalOldStatus.get('search_index');
normalStatus.contentHtml = normalOldStatus.get('contentHtml'); normalStatus.contentHtml = normalOldStatus.get('contentHtml');
normalStatus.spoilerHtml = normalOldStatus.get('spoilerHtml'); normalStatus.spoilerHtml = normalOldStatus.get('spoilerHtml');
......
...@@ -131,6 +131,9 @@ export function deleteStatusFail(id, error) { ...@@ -131,6 +131,9 @@ export function deleteStatusFail(id, error) {
}; };
}; };
export const updateStatus = status => dispatch =>
dispatch(importFetchedStatus(status));
export function fetchContext(id) { export function fetchContext(id) {
return (dispatch, getState) => { return (dispatch, getState) => {
dispatch(fetchContextRequest(id)); dispatch(fetchContextRequest(id));
......
...@@ -10,6 +10,7 @@ import { ...@@ -10,6 +10,7 @@ import {
} from './timelines'; } from './timelines';
import { updateNotifications, expandNotifications } from './notifications'; import { updateNotifications, expandNotifications } from './notifications';
import { updateConversations } from './conversations'; import { updateConversations } from './conversations';
import { updateStatus } from './statuses';
import { import {
fetchAnnouncements, fetchAnnouncements,
updateAnnouncements, updateAnnouncements,
...@@ -75,6 +76,9 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti ...@@ -75,6 +76,9 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti
case 'update': case 'update':
dispatch(updateTimeline(timelineId, JSON.parse(data.payload), options.accept)); dispatch(updateTimeline(timelineId, JSON.parse(data.payload), options.accept));
break; break;
case 'status.update':
dispatch(updateStatus(JSON.parse(data.payload)));
break;
case 'delete': case 'delete':
dispatch(deleteFromTimelines(data.payload)); dispatch(deleteFromTimelines(data.payload));
break; break;
......
...@@ -57,6 +57,7 @@ const messages = defineMessages({ ...@@ -57,6 +57,7 @@ const messages = defineMessages({
unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' }, unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' }, private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' },
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' }, direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
edited: { id: 'status.edited', defaultMessage: 'Edited {date}' },
}); });
export default @injectIntl export default @injectIntl
...@@ -483,7 +484,7 @@ class Status extends ImmutablePureComponent { ...@@ -483,7 +484,7 @@ class Status extends ImmutablePureComponent {
<div className='status__info'> <div className='status__info'>
<a onClick={this.handleClick} href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener noreferrer'> <a onClick={this.handleClick} href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener noreferrer'>
<span className='status__visibility-icon'><Icon id={visibilityIcon.icon} title={visibilityIcon.text} /></span> <span className='status__visibility-icon'><Icon id={visibilityIcon.icon} title={visibilityIcon.text} /></span>
<RelativeTimestamp timestamp={status.get('created_at')} /> <RelativeTimestamp timestamp={status.get('created_at')} />{status.get('edited_at') && <abbr title={intl.formatMessage(messages.edited, { date: intl.formatDate(status.get('edited_at'), { hour12: false, year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' }) })}> *</abbr>}
</a> </a>
<a onClick={this.handleAccountClick} href={status.getIn(['account', 'url'])} title={status.getIn(['account', 'acct'])} className='status__display-name' target='_blank' rel='noopener noreferrer'> <a onClick={this.handleAccountClick} href={status.getIn(['account', 'url'])} title={status.getIn(['account', 'acct'])} className='status__display-name' target='_blank' rel='noopener noreferrer'>
......
...@@ -6,7 +6,7 @@ import DisplayName from '../../../components/display_name'; ...@@ -6,7 +6,7 @@ import DisplayName from '../../../components/display_name';
import StatusContent from '../../../components/status_content'; import StatusContent from '../../../components/status_content';
import MediaGallery from '../../../components/media_gallery'; import MediaGallery from '../../../components/media_gallery';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { injectIntl, defineMessages, FormattedDate } from 'react-intl'; import { injectIntl, defineMessages, FormattedDate, FormattedMessage } from 'react-intl';
import Card from './card'; import Card from './card';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import Video from '../../video'; import Video from '../../video';
...@@ -116,6 +116,7 @@ class DetailedStatus extends ImmutablePureComponent { ...@@ -116,6 +116,7 @@ class DetailedStatus extends ImmutablePureComponent {
let reblogLink = ''; let reblogLink = '';
let reblogIcon = 'retweet'; let reblogIcon = 'retweet';
let favouriteLink = ''; let favouriteLink = '';
let edited = '';
if (this.props.measureHeight) { if (this.props.measureHeight) {
outerStyle.height = `${this.state.height}px`; outerStyle.height = `${this.state.height}px`;
...@@ -237,6 +238,15 @@ class DetailedStatus extends ImmutablePureComponent { ...@@ -237,6 +238,15 @@ class DetailedStatus extends ImmutablePureComponent {
); );
} }
if (status.get('edited_at')) {
edited = (
<React.Fragment>
<React.Fragment> · </React.Fragment>
<FormattedMessage id='status.edited' defaultMessage='Edited {date}' values={{ date: intl.formatDate(status.get('edited_at'), { hour12: false, month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' }) }} />
</React.Fragment>
);
}
return ( return (
<div style={outerStyle}> <div style={outerStyle}>
<div ref={this.setRef} className={classNames('detailed-status', `detailed-status-${status.get('visibility')}`, { compact })}> <div ref={this.setRef} className={classNames('detailed-status', `detailed-status-${status.get('visibility')}`, { compact })}>
...@@ -252,7 +262,7 @@ class DetailedStatus extends ImmutablePureComponent { ...@@ -252,7 +262,7 @@ class DetailedStatus extends ImmutablePureComponent {
<div className='detailed-status__meta'> <div className='detailed-status__meta'>
<a className='detailed-status__datetime' href={status.get('url')} target='_blank' rel='noopener noreferrer'> <a className='detailed-status__datetime' href={status.get('url')} target='_blank' rel='noopener noreferrer'>
<FormattedDate value={new Date(status.get('created_at'))} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' /> <FormattedDate value={new Date(status.get('created_at'))} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' />
</a>{visibilityLink}{applicationLink}{reblogLink} · {favouriteLink} </a>{edited}{visibilityLink}{applicationLink}{reblogLink} · {favouriteLink}
</div> </div>
</div> </div>
</div> </div>
......
...@@ -967,6 +967,17 @@ ...@@ -967,6 +967,17 @@
} }
} }
.status__content__edited-label {
display: block;
cursor: default;
font-size: 15px;
line-height: 20px;
padding: 0;
padding-top: 8px;
color: $dark-text-color;
font-weight: 500;
}
.status__content__spoiler-link { .status__content__spoiler-link {
display: inline-block; display: inline-block;
border-radius: 2px; border-radius: 2px;
......
...@@ -94,49 +94,6 @@ class ActivityPub::Activity ...@@ -94,49 +94,6 @@ class ActivityPub::Activity
equals_or_includes_any?(@object['type'], CONVERTED_TYPES) equals_or_includes_any?(@object['type'], CONVERTED_TYPES)
end end
def distribute(status)
crawl_links(status)
notify_about_reblog(status) if reblog_of_local_account?(status) && !reblog_by_following_group_account?(status)
notify_about_mentions(status)
# Only continue if the status is supposed to have arrived in real-time.
# Note that if @options[:override_timestamps] isn't set, the status
# may have a lower snowflake id than other existing statuses, potentially
# "hiding" it from paginated API calls
return unless @options[:override_timestamps] || status.within_realtime_window?
distribute_to_followers(status)
end
def reblog_of_local_account?(status)
status.reblog? && status.reblog.account.local?
end
def reblog_by_following_group_account?(status)
status.reblog? && status.account.group? && status.reblog.account.following?(status.account)
end
def notify_about_reblog(status)
NotifyService.new.call(status.reblog.account, :reblog, status)
end
def notify_about_mentions(status)
status.active_mentions.includes(:account).each do |mention|
next unless mention.account.local? && audience_includes?(mention.account)
NotifyService.new.call(mention.account, :mention, mention)
end
end
def crawl_links(status)
# Spread out crawling randomly to avoid DDoSing the link
LinkCrawlWorker.perform_in(rand(1..59).seconds, status.id)
end
def distribute_to_followers(status)
::DistributionWorker.perform_async(status.id)
end
def delete_arrived_first?(uri) def delete_arrived_first?(uri)
redis.exists?("delete_upon_arrival:#{@account.id}:#{uri}") redis.exists?("delete_upon_arrival:#{@account.id}:#{uri}")
end end
......
...@@ -25,7 +25,7 @@ class ActivityPub::Activity::Announce < ActivityPub::Activity ...@@ -25,7 +25,7 @@ class ActivityPub::Activity::Announce < ActivityPub::Activity
Trends.tags.register(@status) Trends.tags.register(@status)
Trends.links.register(@status) Trends.links.register(@status)
distribute(@status) distribute
end end
@status @status
...@@ -33,6 +33,22 @@ class ActivityPub::Activity::Announce < ActivityPub::Activity ...@@ -33,6 +33,22 @@ class ActivityPub::Activity::Announce < ActivityPub::Activity
private private
def distribute
# Notify the author of the original status if that status is local
NotifyService.new.call(@status.reblog.account, :reblog, @status) if reblog_of_local_account?(@status) && !reblog_by_following_group_account?(@status)
# Distribute into home and list feeds
::DistributionWorker.perform_async(@status.id) if @options[:override_timestamps] || @status.within_realtime_window?
end
def reblog_of_local_account?(status)
status.reblog? && status.reblog.account.local?
end
def reblog_by_following_group_account?(status)
status.reblog? && status.account.group? && status.reblog.account.following?(status.account)
end
def audience_to def audience_to
as_array(@json['to']).map { |x| value_or_id(x) } as_array(@json['to']).map { |x| value_or_id(x) }
end end
......
...@@ -69,9 +69,10 @@ class ActivityPub::Activity::Create < ActivityPub::Activity ...@@ -69,9 +69,10 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
end end
def process_status def process_status
@tags = [] @tags = []
@mentions = [] @mentions = []
@params = {} @silenced_account_ids = []
@params = {}
process_status_params process_status_params
process_tags process_tags
...@@ -84,10 +85,18 @@ class ActivityPub::Activity::Create < ActivityPub::Activity ...@@ -84,10 +85,18 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
resolve_thread(@status) resolve_thread(@status)
fetch_replies(@status) fetch_replies(@status)
distribute(@status) distribute
forward_for_reply forward_for_reply
end end
def distribute
# Spread out crawling randomly to avoid DDoSing the link
LinkCrawlWorker.perform_in(rand(1..59).seconds, @status.id)
# Distribute into home and list feeds and notify mentioned accounts
::DistributionWorker.perform_async(@status.id, silenced_account_ids: @silenced_account_ids) if @options[:override_timestamps] || @status.within_realtime_window?
end
def find_existing_status def find_existing_status
status = status_from_uri(object_uri) status = status_from_uri(object_uri)
status ||= Status.find_by(uri: @object['atomUri']) if @object['atomUri'].present? status ||= Status.find_by(uri: @object['atomUri']) if @object['atomUri'].present?
...@@ -95,19 +104,22 @@ class ActivityPub::Activity::Create < ActivityPub::Activity ...@@ -95,19 +104,22 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
end end
def process_status_params def process_status_params
@status_parser = ActivityPub::Parser::StatusParser.new(@json, followers_collection: @account.followers_url)
@params = begin @params = begin
{ {
uri: object_uri, uri: @status_parser.uri,
url: object_url || object_uri, url: @status_parser.url || @status_parser.uri,
account: @account, account: @account,
text: text_from_content || '', text: converted_object_type? ? converted_text : (@status_parser.text || ''),
language: detected_language, language: @status_parser.language || detected_language,
spoiler_text: converted_object_type? ? '' : (text_from_summary || ''), spoiler_text: converted_object_type? ? '' : (@status_parser.spoiler_text || ''),
created_at: @object['published'], created_at: @status_parser.created_at,
edited_at: @status_parser.edited_at,
override_timestamps: @options[:override_timestamps], override_timestamps: @options[:override_timestamps],
reply: @object['inReplyTo'].present?, reply: @status_parser.reply,
sensitive: @account.sensitized? || @object['sensitive'] || false, sensitive: @account.sensitized? || @status_parser.sensitive || false,
visibility: visibility_from_audience, visibility: @status_parser.visibility,
thread: replied_to_status, thread: replied_to_status,
conversation: conversation_from_uri(@object['conversation']), conversation: conversation_from_uri(@object['conversation']),
media_attachment_ids: process_attachments.take(4).map(&:id), media_attachment_ids: process_attachments.take(4).map(&:id),
...@@ -117,42 +129,40 @@ class ActivityPub::Activity::Create < ActivityPub::Activity ...@@ -117,42 +129,40 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
end end
def process_audience def process_audience
(audience_to + audience_cc).uniq.each do |audience| # Unlike with tags, there is no point in resolving accounts we don't already
next if ActivityPub::TagManager.instance.public_collection?(audience) # know here, because silent mentions would only be used for local access control anyway
accounts_in_audience = (audience_to + audience_cc).uniq.filter_map do |audience|
account_from_uri(audience) unless ActivityPub::TagManager.instance.public_collection?(audience)
end
# Unlike with tags, there is no point in resolving accounts we don't already # If the payload was delivered to a specific inbox, the inbox owner must have
# know here, because silent mentions would only be used for local access # access to it, unless they already have access to it anyway
# control anyway if @options[:delivered_to_account_id]
account = account_from_uri(audience) accounts_in_audience << delivered_to_account
accounts_in_audience.uniq!
end
next if account.nil? || @mentions.any? { |mention| mention.account_id == account.id } accounts_in_audience.each do |account|
# This runs after tags are processed, and those translate into non-silent
# mentions, which take precedence
next if @mentions.any? { |mention| mention.account_id == account.id }
@mentions << Mention.new(account: account, silent: true) @mentions << Mention.new(account: account, silent: true)
# If there is at least one silent mention, then the status can be considered # If there is at least one silent mention, then the status can be considered
# as a limited-audience status, and not strictly a direct message, but only # as a limited-audience status, and not strictly a direct message, but only
# if we considered a direct message in the first place # if we considered a direct message in the first place
next unless @params[:visibility] == :direct @params[:visibility] = :limited if @params[:visibility] == :direct
@params[:visibility] = :limited
end end
# If the payload was delivered to a specific inbox, the inbox owner must have # Accounts that are tagged but are not in the audience are not
# access to it, unless they already have access to it anyway # supposed to be notified explicitly
return if @options[:delivered_to_account_id].nil? || @mentions.any? { |mention| mention.account_id == @options[:delivered_to_account_id] } @silenced_account_ids = @mentions.map(&:account_id) - accounts_in_audience.map(&:id)
@mentions << Mention.new(account_id: @options[:delivered_to_account_id], silent: true)
return unless @params[:visibility] == :direct
@params[:visibility] = :limited
end end
def postprocess_audience_and_deliver def postprocess_audience_and_deliver
return if @status.mentions.find_by(account_id: @options[:delivered_to_account_id]) return if @status.mentions.find_by(account_id: @options[:delivered_to_account_id])
delivered_to_account = Account.find(@options[:delivered_to_account_id])
@status.mentions.create(account: delivered_to_account, silent: true) @status.mentions.create(account: delivered_to_account, silent: true)
@status.update(visibility: :limited) if @status.direct_visibility? @status.update(visibility: :limited) if @status.direct_visibility?
...@@ -161,6 +171,10 @@ class ActivityPub::Activity::Create < ActivityPub::Activity ...@@ -161,6 +171,10 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
FeedInsertWorker.perform_async(@status.id, delivered_to_account.id, :home) FeedInsertWorker.perform_async(@status.id, delivered_to_account.id, :home)
end end
def delivered_to_account
@delivered_to_account ||= Account.find(@options[:delivered_to_account_id])
end
def attach_tags(status) def attach_tags(status)
@tags.each do |tag| @tags.each do |tag|
status.tags << tag status.tags << tag
...@@ -215,21 +229,22 @@ class ActivityPub::Activity::Create < ActivityPub::Activity ...@@ -215,21 +229,22 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
def process_emoji(tag) def process_emoji(tag)
return if skip_download? return if skip_download?
return if tag['name'].blank? || tag['icon'].blank? || tag['icon']['url'].blank?
shortcode = tag['name'].delete(':') custom_emoji_parser = ActivityPub::Parser::CustomEmojiParser.new(tag)
image_url = tag['icon']['url']
uri = tag['id'] return if custom_emoji_parser.shortcode.blank? || custom_emoji_parser.image_remote_url.blank?
updated = tag['updated']
emoji = CustomEmoji.find_by(shortcode: shortcode, domain: @account.domain)
return unless emoji.nil? || image_url != emoji.image_remote_url || (updated && updated >= emoji.updated_at) emoji = CustomEmoji.find_by(shortcode: custom_emoji_parser.shortcode, domain: @account.domain)
emoji ||= CustomEmoji.new(domain: @account.domain, shortcode: shortcode, uri: uri) return 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)
emoji.image_remote_url = image_url
emoji.save begin
rescue Seahorse::Client::NetworkingError => e emoji ||= CustomEmoji.new(domain: @account.domain, shortcode: custom_emoji_parser.shortcode, uri: custom_emoji_parser.uri)
Rails.logger.warn "Error storing emoji: #{e}" 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 process_attachments def process_attachments
...@@ -238,14 +253,23 @@ class ActivityPub::Activity::Create < ActivityPub::Activity ...@@ -238,14 +253,23 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
media_attachments = [] media_attachments = []
as_array(@object['attachment']).each do |attachment| as_array(@object['attachment']).each do |attachment|
next if attachment['url'].blank? || media_attachments.size >= 4 media_attachment_parser = ActivityPub::Parser::MediaAttachmentParser.new(attachment)
next if media_attachment_parser.remote_url.blank? || media_attachments.size >= 4
begin begin
href = Addressable::URI.parse(attachment['url']).normalize.to_s media_attachment = MediaAttachment.create(
media_attachment = MediaAttachment.create(account: @account, remote_url: href, thumbnail_remote_url: icon_url_from_attachment(attachment), description: attachment['summary'].presence || attachment['name'].presence, focus: attachment['focalPoint'], blurhash: supported_blurhash?(attachment['blurhash']) ? attachment['blurhash'] : nil) account: @account,
remote_url: media_attachment_parser.remote_url,
thumbnail_remote_url: media_attachment_parser.thumbnail_remote_url,
description: media_attachment_parser.description,
focus: media_attachment_parser.focus,
blurhash: media_attachment_parser.blurhash
)
media_attachments << media_attachment media_attachments << media_attachment