Merge branch 'v2.6.2-branch' of asonix/mastodon into asonix/changes

This commit is contained in:
Arlo (Hyena) 2018-11-29 16:03:51 +00:00 committed by Gitea
commit 255b754c67
50 changed files with 237 additions and 100 deletions

View File

@ -3,6 +3,40 @@ Changelog
All notable changes to this project will be documented in this file.
## [2.6.2] - 2018-11-23
### Added
- Add Page to whitelisted ActivityPub types (#9188)
- Add 20px to column width in web UI (#9227)
- Add amount of freed disk space in `tootctl media remove` (#9229, #9239, #9288)
- Add "Show thread" link to self-replies (#9228)
### Changed
- Change order of Atom and RSS links so Atom is first (#9302)
- Change Nginx configuration for Nanobox apps (#9310)
- Change the follow action to appear instant in web UI (#9220)
- Change how the ActiveRecord connection is instantiated in on_worker_boot (#9238)
- Change `tootctl accounts cull` to always touch accounts so they can be skipped (#9293)
- Change mime type comparison to ignore JSON-LD profile (#9179)
### Fixed
- Fix web UI crash when conversation has no last status (#9207)
- Fix follow limit validator reporting lower number past threshold (#9230)
- Fix form validation flash message color and input borders (#9235)
- Fix invalid twitter:player cards being displayed (#9254)
- Fix emoji update date being processed incorrectly (#9255)
- Fix playing embed resetting if status is reloaded in web UI (#9270, #9275)
- Fix web UI crash when favouriting a deleted status (#9272)
- Fix intermediary arrays being created for hash maps (#9291)
- Fix filter ID not being a string in REST API (#9303)
### Security
- Fix multiple remote account deletions being able to deadlock the database (#9292)
- Fix HTTP connection timeout of 10s not being enforced (#9329)
## [2.6.1] - 2018-10-30
### Fixed

View File

@ -17,7 +17,7 @@ class Api::V1::AccountsController < Api::BaseController
end
def follow
FollowService.new.call(current_user.account, @account.acct, reblogs: truthy_param?(:reblogs))
FollowService.new.call(current_user.account, @account, reblogs: truthy_param?(:reblogs))
options = @account.locked? ? {} : { following_map: { @account.id => { reblogs: truthy_param?(:reblogs) } }, requested_map: { @account.id => false } }

View File

@ -113,7 +113,7 @@ class ApplicationController < ActionController::Base
klass.reload_stale_associations!(cached_keys_with_value.values) if klass.respond_to?(:reload_stale_associations!)
unless uncached_ids.empty?
uncached = klass.where(id: uncached_ids).with_includes.map { |item| [item.id, item] }.to_h
uncached = klass.where(id: uncached_ids).with_includes.each_with_object({}) { |item, h| h[item.id] = item }
uncached.each_value do |item|
Rails.cache.write(item, item)

View File

@ -145,12 +145,14 @@ export function fetchAccountFail(id, error) {
export function followAccount(id, reblogs = true) {
return (dispatch, getState) => {
const alreadyFollowing = getState().getIn(['relationships', id, 'following']);
dispatch(followAccountRequest(id));
const locked = getState().getIn(['accounts', id, 'locked'], false);
dispatch(followAccountRequest(id, locked));
api(getState).post(`/api/v1/accounts/${id}/follow`, { reblogs }).then(response => {
dispatch(followAccountSuccess(response.data, alreadyFollowing));
}).catch(error => {
dispatch(followAccountFail(error));
dispatch(followAccountFail(error, locked));
});
};
};
@ -167,10 +169,12 @@ export function unfollowAccount(id) {
};
};
export function followAccountRequest(id) {
export function followAccountRequest(id, locked) {
return {
type: ACCOUNT_FOLLOW_REQUEST,
id,
locked,
skipLoading: true,
};
};
@ -179,13 +183,16 @@ export function followAccountSuccess(relationship, alreadyFollowing) {
type: ACCOUNT_FOLLOW_SUCCESS,
relationship,
alreadyFollowing,
skipLoading: true,
};
};
export function followAccountFail(error) {
export function followAccountFail(error, locked) {
return {
type: ACCOUNT_FOLLOW_FAIL,
error,
locked,
skipLoading: true,
};
};
@ -193,6 +200,7 @@ export function unfollowAccountRequest(id) {
return {
type: ACCOUNT_UNFOLLOW_REQUEST,
id,
skipLoading: true,
};
};
@ -201,6 +209,7 @@ export function unfollowAccountSuccess(relationship, statuses) {
type: ACCOUNT_UNFOLLOW_SUCCESS,
relationship,
statuses,
skipLoading: true,
};
};
@ -208,6 +217,7 @@ export function unfollowAccountFail(error) {
return {
type: ACCOUNT_UNFOLLOW_FAIL,
error,
skipLoading: true,
};
};

View File

@ -67,6 +67,7 @@ class Status extends ImmutablePureComponent {
unread: PropTypes.bool,
onMoveUp: PropTypes.func,
onMoveDown: PropTypes.func,
showThread: PropTypes.bool,
};
// Avoid checking props that are functions (and whose equality will always
@ -168,7 +169,7 @@ class Status extends ImmutablePureComponent {
let media = null;
let statusAvatar, prepend, rebloggedByText;
const { intl, hidden, featured, otherAccounts, unread } = this.props;
const { intl, hidden, featured, otherAccounts, unread, showThread } = this.props;
let { status, account, ...other } = this.props;
@ -309,6 +310,12 @@ class Status extends ImmutablePureComponent {
{media}
{showThread && status.get('in_reply_to_id') && status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) && (
<button className='status__content__read-more-button' onClick={this.handleClick}>
<FormattedMessage id='status.show_thread' defaultMessage='Show thread' />
</button>
)}
<StatusActionBar status={status} account={account} {...other} />
</div>
</div>

View File

@ -148,7 +148,6 @@ class StatusActionBar extends ImmutablePureComponent {
let menu = [];
let reblogIcon = 'retweet';
let replyIcon;
let replyTitle;
menu.push({ text: intl.formatMessage(messages.open), action: this.handleOpen });
@ -191,10 +190,8 @@ class StatusActionBar extends ImmutablePureComponent {
}
if (status.get('in_reply_to_id', null) === null) {
replyIcon = 'reply';
replyTitle = intl.formatMessage(messages.reply);
} else {
replyIcon = 'reply-all';
replyTitle = intl.formatMessage(messages.replyAll);
}
@ -204,7 +201,7 @@ class StatusActionBar extends ImmutablePureComponent {
return (
<div className='status__action-bar'>
<div className='status__action-bar__counter'><IconButton className='status__action-bar-button' disabled={anonymousAccess} title={replyTitle} icon={replyIcon} onClick={this.handleReplyClick} /><span className='status__action-bar__counter__label' >{obfuscatedCount(status.get('replies_count'))}</span></div>
<div className='status__action-bar__counter'><IconButton className='status__action-bar-button' disabled={anonymousAccess} title={replyTitle} icon='reply' onClick={this.handleReplyClick} /><span className='status__action-bar__counter__label' >{obfuscatedCount(status.get('replies_count'))}</span></div>
<IconButton className='status__action-bar-button' disabled={anonymousAccess || !publicStatus} active={status.get('reblogged')} pressed={status.get('reblogged')} title={!publicStatus ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} />
<IconButton className='status__action-bar-button star-icon' disabled={anonymousAccess} animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} />
{shareButton}

View File

@ -104,6 +104,7 @@ export default class StatusList extends ImmutablePureComponent {
onMoveUp={this.handleMoveUp}
onMoveDown={this.handleMoveDown}
contextType={timelineId}
showThread
/>
))
) : null;
@ -117,6 +118,7 @@ export default class StatusList extends ImmutablePureComponent {
onMoveUp={this.handleMoveUp}
onMoveDown={this.handleMoveDown}
contextType={timelineId}
showThread
/>
)).concat(scrollableContent);
}

View File

@ -159,7 +159,7 @@ class ActionBar extends React.PureComponent {
return (
<div className='detailed-status__action-bar'>
<div className='detailed-status__button'><IconButton title={intl.formatMessage(messages.reply)} icon={status.get('in_reply_to_id', null) === null ? 'reply' : 'reply-all'} onClick={this.handleReplyClick} /></div>
<div className='detailed-status__button'><IconButton title={intl.formatMessage(messages.reply)} icon='reply' onClick={this.handleReplyClick} /></div>
<div className='detailed-status__button'><IconButton disabled={reblog_disabled} active={status.get('reblogged')} title={reblog_disabled ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} /></div>
<div className='detailed-status__button'><IconButton className='star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} /></div>
{shareButton}

View File

@ -73,7 +73,7 @@ export default class Card extends React.PureComponent {
};
componentWillReceiveProps (nextProps) {
if (this.props.card !== nextProps.card) {
if (!Immutable.is(this.props.card, nextProps.card)) {
this.setState({ embedded: false });
}
}

View File

@ -56,7 +56,13 @@ const expandNormalizedConversations = (state, conversations, next) => {
list = list.concat(items);
return list.sortBy(x => x.get('last_status'), (a, b) => compareId(a, b) * -1);
return list.sortBy(x => x.get('last_status'), (a, b) => {
if(a === null || b === null) {
return -1;
}
return compareId(a, b) * -1;
});
});
}

View File

@ -1,6 +1,10 @@
import {
ACCOUNT_FOLLOW_SUCCESS,
ACCOUNT_FOLLOW_REQUEST,
ACCOUNT_FOLLOW_FAIL,
ACCOUNT_UNFOLLOW_SUCCESS,
ACCOUNT_UNFOLLOW_REQUEST,
ACCOUNT_UNFOLLOW_FAIL,
ACCOUNT_BLOCK_SUCCESS,
ACCOUNT_UNBLOCK_SUCCESS,
ACCOUNT_MUTE_SUCCESS,
@ -37,6 +41,14 @@ const initialState = ImmutableMap();
export default function relationships(state = initialState, action) {
switch(action.type) {
case ACCOUNT_FOLLOW_REQUEST:
return state.setIn([action.id, action.locked ? 'requested' : 'following'], true);
case ACCOUNT_FOLLOW_FAIL:
return state.setIn([action.id, action.locked ? 'requested' : 'following'], false);
case ACCOUNT_UNFOLLOW_REQUEST:
return state.setIn([action.id, 'following'], false);
case ACCOUNT_UNFOLLOW_FAIL:
return state.setIn([action.id, 'following'], true);
case ACCOUNT_FOLLOW_SUCCESS:
case ACCOUNT_UNFOLLOW_SUCCESS:
case ACCOUNT_BLOCK_SUCCESS:

View File

@ -38,11 +38,11 @@ export default function statuses(state = initialState, action) {
case FAVOURITE_REQUEST:
return state.setIn([action.status.get('id'), 'favourited'], true);
case FAVOURITE_FAIL:
return state.setIn([action.status.get('id'), 'favourited'], false);
return state.get(action.status.get('id')) === undefined ? state : state.setIn([action.status.get('id'), 'favourited'], false);
case REBLOG_REQUEST:
return state.setIn([action.status.get('id'), 'reblogged'], true);
case REBLOG_FAIL:
return state.setIn([action.status.get('id'), 'reblogged'], false);
return state.get(action.status.get('id')) === undefined ? state : state.setIn([action.status.get('id'), 'reblogged'], false);
case STATUS_MUTE_SUCCESS:
return state.setIn([action.id, 'muted'], true);
case STATUS_UNMUTE_SUCCESS:

View File

@ -1847,7 +1847,7 @@ a.account__display-name {
}
.column {
width: 330px;
width: 350px;
position: relative;
box-sizing: border-box;
display: flex;

View File

@ -330,9 +330,12 @@ code {
}
input[type=text],
input[type=number],
input[type=email],
input[type=password] {
border-bottom-color: $valid-value-color;
input[type=password],
textarea,
select {
border-color: lighten($error-red, 12%);
}
.error {

View File

@ -129,4 +129,10 @@ class ActivityPub::Activity
::FetchRemoteStatusService.new.call(@object['url'])
end
end
def lock_or_return(key, expire_after = 7.days.seconds)
yield if redis.set(key, true, nx: true, ex: expire_after)
ensure
redis.del(key)
end
end

View File

@ -177,7 +177,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
updated = tag['updated']
emoji = CustomEmoji.find_by(shortcode: shortcode, domain: @account.domain)
return unless emoji.nil? || image_url != emoji.image_remote_url || (updated && emoji.updated_at >= updated)
return unless emoji.nil? || image_url != emoji.image_remote_url || (updated && updated >= emoji.updated_at)
emoji ||= CustomEmoji.new(domain: @account.domain, shortcode: shortcode, uri: uri)
emoji.image_remote_url = image_url

View File

@ -12,8 +12,10 @@ class ActivityPub::Activity::Delete < ActivityPub::Activity
private
def delete_person
SuspendAccountService.new.call(@account)
@account.destroy!
lock_or_return("delete_in_progress:#{@account.id}") do
SuspendAccountService.new.call(@account)
@account.destroy!
end
end
def delete_note

View File

@ -21,7 +21,7 @@ class EntityCache
end
unless uncached_ids.empty?
uncached = CustomEmoji.where(shortcode: shortcodes, domain: domain, disabled: false).map { |item| [item.shortcode, item] }.to_h
uncached = CustomEmoji.where(shortcode: shortcodes, domain: domain, disabled: false).each_with_object({}) { |item, h| h[item.shortcode] = item }
uncached.each_value { |item| Rails.cache.write(to_key(:emoji, item.shortcode, domain), item, expires_in: MAX_EXPIRATION) }
end

View File

@ -128,9 +128,9 @@ class Formatter
return html if emojis.empty?
emoji_map = if animate
emojis.map { |e| [e.shortcode, full_asset_url(e.image.url)] }.to_h
emojis.each_with_object({}) { |e, h| h[e.shortcode] = full_asset_url(e.image.url) }
else
emojis.map { |e| [e.shortcode, full_asset_url(e.image.url(:static))] }.to_h
emojis.each_with_object({}) { |e, h| h[e.shortcode] = full_asset_url(e.image.url(:static)) }
end
i = -1

View File

@ -2,6 +2,7 @@
require 'ipaddr'
require 'socket'
require 'resolv'
class Request
REQUEST_TARGET = '(request-target)'
@ -45,7 +46,7 @@ class Request
end
begin
yield response.extend(ClientLimit)
yield response.extend(ClientLimit) if block_given?
ensure
http_client.close
end
@ -94,7 +95,7 @@ class Request
end
def timeout
{ write: 10, connect: 10, read: 10 }
{ connect: nil, read: 10, write: 10 }
end
def http_client
@ -139,16 +140,29 @@ class Request
class Socket < TCPSocket
class << self
def open(host, *args)
return super host, *args if thru_hidden_service? host
return super(host, *args) if thru_hidden_service?(host)
outer_e = nil
Addrinfo.foreach(host, nil, nil, :SOCK_STREAM) do |address|
begin
raise Mastodon::HostValidationError if PrivateAddressCheck.private_address? IPAddr.new(address.ip_address)
return super address.ip_address, *args
rescue => e
outer_e = e
Resolv::DNS.open do |dns|
dns.timeouts = 1
addresses = dns.getaddresses(host).take(2)
time_slot = 10.0 / addresses.size
addresses.each do |address|
begin
raise Mastodon::HostValidationError if PrivateAddressCheck.private_address?(IPAddr.new(address.to_s))
::Timeout.timeout(time_slot, HTTP::TimeoutError) do
return super(address.to_s, *args)
end
rescue => e
outer_e = e
end
end
end
raise outer_e if outer_e
end

View File

@ -31,7 +31,7 @@ module Settings
def all_as_records
vars = thing_scoped
records = vars.map { |r| [r.var, r] }.to_h
records = vars.each_with_object({}) { |r, h| h[r.var] = r }
Setting.default_settings.each do |key, default_value|
next if records.key?(key) || default_value.is_a?(Hash)
@ -65,7 +65,7 @@ module Settings
class << self
def default_settings
defaulting = DEFAULTING_TO_UNSCOPED.map { |k| [k, Setting[k]] }.to_h
defaulting = DEFAULTING_TO_UNSCOPED.each_with_object({}) { |k, h| h[k] = Setting[k] }
Setting.default_settings.merge!(defaulting)
end
end

View File

@ -45,9 +45,9 @@ module AccountInteractions
end
def domain_blocking_map(target_account_ids, account_id)
accounts_map = Account.where(id: target_account_ids).select('id, domain').map { |a| [a.id, a.domain] }.to_h
accounts_map = Account.where(id: target_account_ids).select('id, domain').each_with_object({}) { |a, h| h[a.id] = a.domain }
blocked_domains = domain_blocking_map_by_domain(accounts_map.values.compact, account_id)
accounts_map.map { |id, domain| [id, blocked_domains[domain]] }.to_h
accounts_map.reduce({}) { |h, (id, domain)| h.merge(id => blocked_domains[domain]) }
end
def domain_blocking_map_by_domain(target_domains, account_id)

View File

@ -75,7 +75,7 @@ class Notification < ApplicationRecord
return if account_ids.empty?
accounts = Account.where(id: account_ids).map { |a| [a.id, a] }.to_h
accounts = Account.where(id: account_ids).each_with_object({}) { |a, h| h[a.id] = a }
cached_items.each do |item|
item.from_account = accounts[item.from_account_id]

View File

@ -40,7 +40,7 @@ class Setting < RailsSettings::Base
def all_as_records
vars = thing_scoped
records = vars.map { |r| [r.var, r] }.to_h
records = vars.each_with_object({}) { |r, h| h[r.var] = r }
default_settings.each do |key, default_value|
next if records.key?(key) || default_value.is_a?(Hash)

View File

@ -310,19 +310,19 @@ class Status < ApplicationRecord
end
def favourites_map(status_ids, account_id)
Favourite.select('status_id').where(status_id: status_ids).where(account_id: account_id).map { |f| [f.status_id, true] }.to_h
Favourite.select('status_id').where(status_id: status_ids).where(account_id: account_id).each_with_object({}) { |f, h| h[f.status_id] = true }
end
def reblogs_map(status_ids, account_id)
select('reblog_of_id').where(reblog_of_id: status_ids).where(account_id: account_id).reorder(nil).map { |s| [s.reblog_of_id, true] }.to_h
select('reblog_of_id').where(reblog_of_id: status_ids).where(account_id: account_id).reorder(nil).each_with_object({}) { |s, h| h[s.reblog_of_id] = true }
end
def mutes_map(conversation_ids, account_id)
ConversationMute.select('conversation_id').where(conversation_id: conversation_ids).where(account_id: account_id).map { |m| [m.conversation_id, true] }.to_h
ConversationMute.select('conversation_id').where(conversation_id: conversation_ids).where(account_id: account_id).each_with_object({}) { |m, h| h[m.conversation_id] = true }
end
def pins_map(status_ids, account_id)
StatusPin.select('status_id').where(status_id: status_ids).where(account_id: account_id).map { |p| [p.status_id, true] }.to_h
StatusPin.select('status_id').where(status_id: status_ids).where(account_id: account_id).each_with_object({}) { |p, h| h[p.status_id] = true }
end
def reload_stale_associations!(cached_items)
@ -337,7 +337,7 @@ class Status < ApplicationRecord
return if account_ids.empty?
accounts = Account.where(id: account_ids).map { |a| [a.id, a] }.to_h
accounts = Account.where(id: account_ids).each_with_object({}) { |a, h| h[a.id] = a }
cached_items.each do |item|
item.account = accounts[item.account_id]

View File

@ -18,7 +18,7 @@ class TrendingTags
def get(limit)
key = "#{KEY}:#{Time.now.utc.beginning_of_day.to_i}"
tag_ids = redis.zrevrange(key, 0, limit - 1).map(&:to_i)
tags = Tag.where(id: tag_ids).to_a.map { |tag| [tag.id, tag] }.to_h
tags = Tag.where(id: tag_ids).to_a.each_with_object({}) { |tag, h| h[tag.id] = tag }
tag_ids.map { |tag_id| tags[tag_id] }.compact
end

View File

@ -3,4 +3,8 @@
class REST::FilterSerializer < ActiveModel::Serializer
attributes :id, :phrase, :context, :whole_word, :expires_at,
:irreversible
def id
object.id.to_s
end
end

View File

@ -232,7 +232,7 @@ class ActivityPub::ProcessAccountService < BaseService
updated = tag['updated']
emoji = CustomEmoji.find_by(shortcode: shortcode, domain: @account.domain)
return unless emoji.nil? || image_url != emoji.image_remote_url || (updated && emoji.updated_at >= updated)
return unless emoji.nil? || image_url != emoji.image_remote_url || (updated && updated >= emoji.updated_at)
emoji ||= CustomEmoji.new(domain: @account.domain, shortcode: shortcode, uri: uri)
emoji.image_remote_url = image_url

View File

@ -12,12 +12,12 @@ class BatchedRemoveStatusService < BaseService
def call(statuses)
statuses = Status.where(id: statuses.map(&:id)).includes(:account, :stream_entry).flat_map { |status| [status] + status.reblogs.includes(:account, :stream_entry).to_a }
@mentions = statuses.map { |s| [s.id, s.active_mentions.includes(:account).to_a] }.to_h
@tags = statuses.map { |s| [s.id, s.tags.pluck(:name)] }.to_h
@mentions = statuses.each_with_object({}) { |s, h| h[s.id] = s.active_mentions.includes(:account).to_a }
@tags = statuses.each_with_object({}) { |s, h| h[s.id] = s.tags.pluck(:name) }
@stream_entry_batches = []
@salmon_batches = []
@json_payloads = statuses.map { |s| [s.id, Oj.dump(event: :delete, payload: s.id.to_s)] }.to_h
@json_payloads = statuses.each_with_object({}) { |s, h| h[s.id] = Oj.dump(event: :delete, payload: s.id.to_s) }
@activity_xml = {}
# Ensure that rendered XML reflects destroyed state

View File

@ -18,6 +18,6 @@ module AuthorExtractor
acct = "#{username}@#{domain}"
end
ResolveAccountService.new.call(acct, update_profile)
ResolveAccountService.new.call(acct, update_profile: update_profile)
end
end

View File

@ -29,7 +29,7 @@ class FetchAtomService < BaseService
def perform_request(&block)
accept = 'text/html'
accept = 'application/activity+json, application/ld+json, application/atom+xml, ' + accept unless @unsupported_activity
accept = 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams", application/atom+xml, ' + accept unless @unsupported_activity
Request.new(:get, @url).add_headers('Accept' => accept).perform(&block)
end
@ -39,7 +39,7 @@ class FetchAtomService < BaseService
if response.mime_type == 'application/atom+xml'
[@url, { prefetched_body: response.body_with_limit }, :ostatus]
elsif ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].include?(response.mime_type)
elsif ['application/activity+json', 'application/ld+json'].include?(response.mime_type)
body = response.body_with_limit
json = body_to_json(body)
if supported_context?(json) && equals_or_includes_any?(json['type'], ActivityPub::FetchRemoteAccountService::SUPPORTED_TYPES) && json['inbox'].present?

View File

@ -136,14 +136,15 @@ class FetchLinkCardService < BaseService
detector = CharlockHolmes::EncodingDetector.new
detector.strip_tags = true
guess = detector.detect(@html, @html_charset)
page = Nokogiri::HTML(@html, nil, guess&.fetch(:encoding, nil))
guess = detector.detect(@html, @html_charset)
page = Nokogiri::HTML(@html, nil, guess&.fetch(:encoding, nil))
player_url = meta_property(page, 'twitter:player')
if meta_property(page, 'twitter:player')
if player_url && !bad_url?(Addressable::URI.parse(player_url))
@card.type = :video
@card.width = meta_property(page, 'twitter:player:width') || 0
@card.height = meta_property(page, 'twitter:player:height') || 0
@card.html = content_tag(:iframe, nil, src: meta_property(page, 'twitter:player'),
@card.html = content_tag(:iframe, nil, src: player_url,
width: @card.width,
height: @card.height,
allowtransparency: 'true',

View File

@ -7,9 +7,9 @@ class FollowService < BaseService
# @param [Account] source_account From which to follow
# @param [String, Account] uri User URI to follow in the form of username@domain (or account record)
# @param [true, false, nil] reblogs Whether or not to show reblogs, defaults to true
def call(source_account, uri, reblogs: nil)
def call(source_account, target_account, reblogs: nil)
reblogs = true if reblogs.nil?
target_account = uri.is_a?(Account) ? uri : ResolveAccountService.new.call(uri)
target_account = ResolveAccountService.new.call(target_account, skip_webfinger: true)
raise ActiveRecord::RecordNotFound if target_account.nil? || target_account.id == source_account.id || target_account.suspended?
raise Mastodon::NotPermittedError if target_account.blocking?(source_account) || source_account.blocking?(target_account)
@ -42,7 +42,7 @@ class FollowService < BaseService
follow_request = FollowRequest.create!(account: source_account, target_account: target_account, show_reblogs: reblogs)
if target_account.local?
NotifyService.new.call(target_account, follow_request)
LocalNotificationWorker.perform_async(target_account.id, follow_request.id, follow_request.class.name)
elsif target_account.ostatus?
NotificationWorker.perform_async(build_follow_request_xml(follow_request), source_account.id, target_account.id)
AfterRemoteFollowRequestWorker.perform_async(follow_request.id)
@ -57,7 +57,7 @@ class FollowService < BaseService
follow = source_account.follow!(target_account, reblogs: reblogs)
if target_account.local?
NotifyService.new.call(target_account, follow)
LocalNotificationWorker.perform_async(target_account.id, follow.id, follow.class.name)
else
Pubsubhubbub::SubscribeWorker.perform_async(target_account.id) unless target_account.subscribed?
NotificationWorker.perform_async(build_follow_xml(follow), source_account.id, target_account.id)

View File

@ -47,7 +47,7 @@ class ProcessMentionsService < BaseService
mentioned_account = mention.account
if mentioned_account.local?
LocalNotificationWorker.perform_async(mention.id)
LocalNotificationWorker.perform_async(mentioned_account.id, mention.id, mention.class.name)
elsif mentioned_account.ostatus? && !@status.stream_entry.hidden?
NotificationWorker.perform_async(ostatus_xml, @status.account_id, mentioned_account.id)
elsif mentioned_account.activitypub?

View File

@ -9,17 +9,27 @@ class ResolveAccountService < BaseService
# Find or create a local account for a remote user.
# When creating, look up the user's webfinger and fetch all
# important information from their feed
# @param [String] uri User URI in the form of username@domain
# @param [String, Account] uri User URI in the form of username@domain
# @param [Hash] options
# @return [Account]
def call(uri, update_profile = true, redirected = nil)
@username, @domain = uri.split('@')
@update_profile = update_profile
def call(uri, options = {})
@options = options
return Account.find_local(@username) if TagManager.instance.local_domain?(@domain)
if uri.is_a?(Account)
@account = uri
@username = @account.username
@domain = @account.domain
@account = Account.find_remote(@username, @domain)
return @account if @account.local? || !webfinger_update_due?
else
@username, @domain = uri.split('@')
return @account unless webfinger_update_due?
return Account.find_local(@username) if TagManager.instance.local_domain?(@domain)
@account = Account.find_remote(@username, @domain)
return @account unless webfinger_update_due?
end
Rails.logger.debug "Looking up webfinger for #{uri}"
@ -30,8 +40,8 @@ class ResolveAccountService < BaseService
if confirmed_username.casecmp(@username).zero? && confirmed_domain.casecmp(@domain).zero?
@username = confirmed_username
@domain = confirmed_domain
elsif redirected.nil?
return call("#{confirmed_username}@#{confirmed_domain}", update_profile, true)
elsif options[:redirected].nil?
return call("#{confirmed_username}@#{confirmed_domain}", options.merge(redirected: true))
else
Rails.logger.debug 'Requested and returned acct URIs do not match'
return
@ -76,7 +86,7 @@ class ResolveAccountService < BaseService
end
def webfinger_update_due?
@account.nil? || @account.possibly_stale?
@account.nil? || ((!@options[:skip_webfinger] || @account.ostatus?) && @account.possibly_stale?)
end
def activitypub_ready?
@ -93,7 +103,7 @@ class ResolveAccountService < BaseService
end
def update_profile?
@update_profile
@options[:update_profile]
end
def handle_activitypub

View File

@ -20,7 +20,7 @@ class ResolveURLService < BaseService
def process_url
if equals_or_includes_any?(type, %w(Application Group Organization Person Service))
FetchRemoteAccountService.new.call(atom_url, body, protocol)
elsif equals_or_includes_any?(type, %w(Note Article Image Video))
elsif equals_or_includes_any?(type, %w(Note Article Image Video Page))
FetchRemoteStatusService.new.call(atom_url, body, protocol)
end
end

View File

@ -14,7 +14,7 @@ class FollowLimitValidator < ActiveModel::Validator
if account.following_count < LIMIT
LIMIT
else
account.followers_count * RATIO
[(account.followers_count * RATIO).round, LIMIT].max
end
end
end

View File

@ -8,8 +8,8 @@
%meta{ name: 'robots', content: 'noindex' }/
%link{ rel: 'salmon', href: api_salmon_url(@account.id) }/
%link{ rel: 'alternate', type: 'application/rss+xml', href: account_url(@account, format: 'rss') }/
%link{ rel: 'alternate', type: 'application/atom+xml', href: account_url(@account, format: 'atom') }/
%link{ rel: 'alternate', type: 'application/rss+xml', href: account_url(@account, format: 'rss') }/
%link{ rel: 'alternate', type: 'application/activity+json', href: ActivityPub::TagManager.instance.uri_for(@account) }/
- if @older_url

View File

@ -1,3 +1,3 @@
- if object.errors.any?
.flash-message#error_explanation
.flash-message.alert#error_explanation
%strong= t('generic.validation_errors', count: object.errors.count)

View File

@ -3,9 +3,16 @@
class LocalNotificationWorker
include Sidekiq::Worker
def perform(mention_id)
mention = Mention.find(mention_id)
NotifyService.new.call(mention.account, mention)
def perform(receiver_account_id, activity_id = nil, activity_class_name = nil)
if activity_id.nil? && activity_class_name.nil?
activity = Mention.find(receiver_account_id)
receiver = activity.account
else
receiver = Account.find(receiver_account_id)
activity = activity_class_name.constantize.find(activity_id)
end
NotifyService.new.call(receiver, activity)
rescue ActiveRecord::RecordNotFound
true
end

View File

@ -13,7 +13,9 @@ workers ENV.fetch('WEB_CONCURRENCY') { 2 }
preload_app!
on_worker_boot do
ActiveRecord::Base.establish_connection if defined?(ActiveRecord)
ActiveSupport.on_load(:active_record) do
ActiveRecord::Base.establish_connection
end
end
plugin :tmp_restart

View File

@ -242,8 +242,9 @@ module Mastodon
end
culled += 1
say('.', :green, false)
say('+', :green, false)
else
account.touch # Touch account even during dry run to avoid getting the account into the window again
say('.', nil, false)
end
end

View File

@ -6,6 +6,8 @@ require_relative 'cli_helper'
module Mastodon
class MediaCLI < Thor
include ActionView::Helpers::NumberHelper
def self.exit_on_failure?
true
end
@ -36,16 +38,19 @@ module Mastodon
time_ago = options[:days].days.ago
queued = 0
processed = 0
dry_run = options[:dry_run] ? '(DRY RUN)' : ''
size = 0
dry_run = options[:dry_run] ? '(DRY RUN)' : ''
if options[:background]
MediaAttachment.where.not(remote_url: '').where.not(file_file_name: nil).where('created_at < ?', time_ago).select(:id).reorder(nil).find_in_batches do |media_attachments|
MediaAttachment.where.not(remote_url: '').where.not(file_file_name: nil).where('created_at < ?', time_ago).select(:id, :file_file_size).reorder(nil).find_in_batches do |media_attachments|
queued += media_attachments.size
size += media_attachments.reduce(0) { |sum, m| sum + (m.file_file_size || 0) }
Maintenance::UncacheMediaWorker.push_bulk(media_attachments.map(&:id)) unless options[:dry_run]
end
else
MediaAttachment.where.not(remote_url: '').where.not(file_file_name: nil).where('created_at < ?', time_ago).reorder(nil).find_in_batches do |media_attachments|
media_attachments.each do |m|
size += m.file_file_size || 0
Maintenance::UncacheMediaWorker.new.perform(m) unless options[:dry_run]
options[:verbose] ? say(m.id) : say('.', :green, false)
processed += 1
@ -56,9 +61,9 @@ module Mastodon
say
if options[:background]
say("Scheduled the deletion of #{queued} media attachments #{dry_run}", :green, true)
say("Scheduled the deletion of #{queued} media attachments (approx. #{number_to_human_size(size)}) #{dry_run}", :green, true)
else
say("Removed #{processed} media attachments #{dry_run}", :green, true)
say("Removed #{processed} media attachments (approx. #{number_to_human_size(size)}) #{dry_run}", :green, true)
end
end
end

View File

@ -38,7 +38,7 @@ http {
root /app/public;
client_max_body_size 8M;
client_max_body_size 80M;
location / {
try_files $uri @rails;

View File

@ -32,7 +32,7 @@ http {
listen 8080;
add_header Strict-Transport-Security "max-age=31536000";
add_header Content-Security-Policy "style-src 'self' 'unsafe-inline'; script-src 'self'; object-src 'self'; img-src data: https:; media-src data: https:; connect-src 'self' wss://<%= ENV["LOCAL_DOMAIN"] %>; upgrade-insecure-requests";
# add_header Content-Security-Policy "style-src 'self' 'unsafe-inline'; script-src 'self'; object-src 'self'; img-src data: https:; media-src data: https:; connect-src 'self' wss://<%= ENV["LOCAL_DOMAIN"] %>; upgrade-insecure-requests";
root /app/public;

View File

@ -32,11 +32,11 @@ http {
listen 8080;
add_header Strict-Transport-Security "max-age=31536000";
add_header Content-Security-Policy "style-src 'self' 'unsafe-inline'; script-src 'self'; object-src 'self'; img-src data: https:; media-src data: https:; connect-src 'self' wss://<%= ENV["LOCAL_DOMAIN"] %>; upgrade-insecure-requests";
# add_header Content-Security-Policy "style-src 'self' 'unsafe-inline'; script-src 'self'; object-src 'self'; img-src data: https:; media-src data: https:; connect-src 'self' wss://<%= ENV["LOCAL_DOMAIN"] %>; upgrade-insecure-requests";
root /app/public;
client_max_body_size 8M;
client_max_body_size 80M;
location / {
try_files $uri @rails;

View File

@ -99,10 +99,12 @@ describe AuthorizeInteractionsController do
allow(ResolveAccountService).to receive(:new).and_return(service)
allow(service).to receive(:call).with('user@hostname').and_return(target_account)
allow(service).to receive(:call).with(target_account, skip_webfinger: true).and_return(target_account)
post :create, params: { acct: 'acct:user@hostname' }
expect(service).to have_received(:call).with('user@hostname')
expect(service).to have_received(:call).with(target_account, skip_webfinger: true)
expect(account.following?(target_account)).to be true
expect(response).to render_template(:success)
end

View File

@ -48,9 +48,11 @@ describe Request do
end
it 'executes a HTTP request when the first address is private' do
allow(Addrinfo).to receive(:foreach).with('example.com', nil, nil, :SOCK_STREAM)
.and_yield(Addrinfo.new(["AF_INET", 0, "example.com", "0.0.0.0"], :PF_INET, :SOCK_STREAM))
.and_yield(Addrinfo.new(["AF_INET6", 0, "example.com", "2001:4860:4860::8844"], :PF_INET6, :SOCK_STREAM))
resolver = double
allow(resolver).to receive(:getaddresses).with('example.com').and_return(%w(0.0.0.0 2001:4860:4860::8844))
allow(resolver).to receive(:timeouts=).and_return(nil)
allow(Resolv::DNS).to receive(:open).and_yield(resolver)
expect { |block| subject.perform &block }.to yield_control
expect(a_request(:get, 'http://example.com')).to have_been_made.once
@ -81,9 +83,12 @@ describe Request do
end
it 'raises Mastodon::ValidationError' do
allow(Addrinfo).to receive(:foreach).with('example.com', nil, nil, :SOCK_STREAM)
.and_yield(Addrinfo.new(["AF_INET", 0, "example.com", "0.0.0.0"], :PF_INET, :SOCK_STREAM))
.and_yield(Addrinfo.new(["AF_INET6", 0, "example.com", "2001:db8::face"], :PF_INET6, :SOCK_STREAM))
resolver = double
allow(resolver).to receive(:getaddresses).with('example.com').and_return(%w(0.0.0.0 2001:db8::face))
allow(resolver).to receive(:timeouts=).and_return(nil)
allow(Resolv::DNS).to receive(:open).and_yield(resolver)
expect { subject.perform }.to raise_error Mastodon::ValidationError
end
end

View File

@ -101,7 +101,7 @@ RSpec.describe Notification, type: :model do
before do
allow(accounts_with_ids).to receive(:[]).with(stale_account1.id).and_return(account1)
allow(accounts_with_ids).to receive(:[]).with(stale_account2.id).and_return(account2)
allow(Account).to receive_message_chain(:where, :map, :to_h).and_return(accounts_with_ids)
allow(Account).to receive_message_chain(:where, :each_with_object).and_return(accounts_with_ids)
end
let(:cached_items) do

View File

@ -60,8 +60,15 @@ RSpec.describe FetchAtomService, type: :service do
it { is_expected.to eq [url, { :prefetched_body => "" }, :ostatus] }
end
context 'content_type is json' do
let(:content_type) { 'application/activity+json' }
context 'content_type is activity+json' do
let(:content_type) { 'application/activity+json; charset=utf-8' }
let(:body) { json }
it { is_expected.to eq [1, { prefetched_body: body, id: true }, :activitypub] }
end
context 'content_type is ld+json with profile' do
let(:content_type) { 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"' }
let(:body) { json }
it { is_expected.to eq [1, { prefetched_body: body, id: true }, :activitypub] }