Merge pull request 'v4.2.2' (#49) from asonix/changes into asonix/downstream

Reviewed-on: #49
This commit is contained in:
asonix 2023-12-04 19:33:41 +00:00
commit 93c5b81807
41 changed files with 500 additions and 98 deletions

View file

@ -2,6 +2,33 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## [4.2.2] - 2023-12-04
### Changed
- Change dismissed banners to be stored server-side ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27055))
- Change GIF max matrix size error to explicitly mention GIF files ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27927))
- Change `Follow` activities delivery to bypass availability check ([ShadowJonathan](https://github.com/mastodon/mastodon/pull/27586))
- Change single-column navigation notice to be displayed outside of the logo container ([renchap](https://github.com/mastodon/mastodon/pull/27462), [renchap](https://github.com/mastodon/mastodon/pull/27476))
- Change Content-Security-Policy to be tighter on media paths ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26889))
- Change post language code to include country code when relevant ([gunchleoc](https://github.com/mastodon/mastodon/pull/27099), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/27207))
### Fixed
- Fix upper border radius of onboarding columns ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27890))
- Fix incoming status creation date not being restricted to standard ISO8601 ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27655), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/28081))
- Fix some posts from threads received out-of-order sometimes not being inserted into timelines ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27653))
- Fix posts from force-sensitized accounts being able to trend ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27620))
- Fix error when trying to delete already-deleted file with OpenStack Swift ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27569))
- Fix batch attachment deletion when using OpenStack Swift ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27554))
- Fix processing LDSigned activities from actors with unknown public keys ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27474))
- Fix error and incorrect URLs in `/api/v1/accounts/:id/featured_tags` for remote accounts ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27459))
- Fix report processing notice not mentioning the report number when performing a custom action ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27442))
- Fix handling of `inLanguage` attribute in preview card processing ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27423))
- Fix own posts being removed from home timeline when unfollowing a used hashtag ([kmycode](https://github.com/mastodon/mastodon/pull/27391))
- Fix some link anchors being recognized as hashtags ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27271), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/27584))
- Fix format-dependent redirects being cached regardless of requested format ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27634))
## [4.2.1] - 2023-10-10 ## [4.2.1] - 2023-10-10
### Added ### Added

View file

@ -17,6 +17,6 @@ A "vulnerability in Mastodon" is a vulnerability in the code distributed through
| ------- | ---------------- | | ------- | ---------------- |
| 4.2.x | Yes | | 4.2.x | Yes |
| 4.1.x | Yes | | 4.1.x | Yes |
| 4.0.x | Until 2023-10-31 | | 4.0.x | No |
| 3.5.x | Until 2023-12-31 | | 3.5.x | Until 2023-12-31 |
| < 3.5 | No | | < 3.5 | No |

View file

@ -1,6 +1,8 @@
# frozen_string_literal: true # frozen_string_literal: true
class AccountsIndex < Chewy::Index class AccountsIndex < Chewy::Index
include DatetimeClampingConcern
settings index: index_preset(refresh_interval: '30s'), analysis: { settings index: index_preset(refresh_interval: '30s'), analysis: {
filter: { filter: {
english_stop: { english_stop: {
@ -60,7 +62,7 @@ class AccountsIndex < Chewy::Index
field(:following_count, type: 'long') field(:following_count, type: 'long')
field(:followers_count, type: 'long') field(:followers_count, type: 'long')
field(:properties, type: 'keyword', value: ->(account) { account.searchable_properties }) field(:properties, type: 'keyword', value: ->(account) { account.searchable_properties })
field(:last_status_at, type: 'date', value: ->(account) { account.last_status_at || account.created_at }) field(:last_status_at, type: 'date', value: ->(account) { clamp_date(account.last_status_at || account.created_at) })
field(:display_name, type: 'text', analyzer: 'verbatim') { field :edge_ngram, type: 'text', analyzer: 'edge_ngram', search_analyzer: 'verbatim' } field(:display_name, type: 'text', analyzer: 'verbatim') { field :edge_ngram, type: 'text', analyzer: 'edge_ngram', search_analyzer: 'verbatim' }
field(:username, type: 'text', analyzer: 'verbatim', value: ->(account) { [account.username, account.domain].compact.join('@') }) { field :edge_ngram, type: 'text', analyzer: 'edge_ngram', search_analyzer: 'verbatim' } field(:username, type: 'text', analyzer: 'verbatim', value: ->(account) { [account.username, account.domain].compact.join('@') }) { field :edge_ngram, type: 'text', analyzer: 'edge_ngram', search_analyzer: 'verbatim' }
field(:text, type: 'text', analyzer: 'verbatim', value: ->(account) { account.searchable_text }) { field :stemmed, type: 'text', analyzer: 'natural' } field(:text, type: 'text', analyzer: 'verbatim', value: ->(account) { account.searchable_text }) { field :stemmed, type: 'text', analyzer: 'natural' }

View file

@ -0,0 +1,14 @@
# frozen_string_literal: true
module DatetimeClampingConcern
extend ActiveSupport::Concern
MIN_ISO8601_DATETIME = '0000-01-01T00:00:00Z'.to_datetime.freeze
MAX_ISO8601_DATETIME = '9999-12-31T23:59:59Z'.to_datetime.freeze
class_methods do
def clamp_date(datetime)
datetime.clamp(MIN_ISO8601_DATETIME, MAX_ISO8601_DATETIME)
end
end
end

View file

@ -1,6 +1,8 @@
# frozen_string_literal: true # frozen_string_literal: true
class PublicStatusesIndex < Chewy::Index class PublicStatusesIndex < Chewy::Index
include DatetimeClampingConcern
settings index: index_preset(refresh_interval: '30s', number_of_shards: 5), analysis: { settings index: index_preset(refresh_interval: '30s', number_of_shards: 5), analysis: {
filter: { filter: {
english_stop: { english_stop: {
@ -62,6 +64,6 @@ class PublicStatusesIndex < Chewy::Index
field(:tags, type: 'text', analyzer: 'hashtag', value: ->(status) { status.tags.map(&:display_name) }) field(:tags, type: 'text', analyzer: 'hashtag', value: ->(status) { status.tags.map(&:display_name) })
field(:language, type: 'keyword') field(:language, type: 'keyword')
field(:properties, type: 'keyword', value: ->(status) { status.searchable_properties }) field(:properties, type: 'keyword', value: ->(status) { status.searchable_properties })
field(:created_at, type: 'date') field(:created_at, type: 'date', value: ->(status) { clamp_date(status.created_at) })
end end
end end

View file

@ -1,6 +1,8 @@
# frozen_string_literal: true # frozen_string_literal: true
class StatusesIndex < Chewy::Index class StatusesIndex < Chewy::Index
include DatetimeClampingConcern
settings index: index_preset(refresh_interval: '30s', number_of_shards: 5), analysis: { settings index: index_preset(refresh_interval: '30s', number_of_shards: 5), analysis: {
filter: { filter: {
english_stop: { english_stop: {
@ -60,6 +62,6 @@ class StatusesIndex < Chewy::Index
field(:searchable_by, type: 'long', value: ->(status) { status.searchable_by }) field(:searchable_by, type: 'long', value: ->(status) { status.searchable_by })
field(:language, type: 'keyword') field(:language, type: 'keyword')
field(:properties, type: 'keyword', value: ->(status) { status.searchable_properties }) field(:properties, type: 'keyword', value: ->(status) { status.searchable_properties })
field(:created_at, type: 'date') field(:created_at, type: 'date', value: ->(status) { clamp_date(status.created_at) })
end end
end end

View file

@ -1,6 +1,8 @@
# frozen_string_literal: true # frozen_string_literal: true
class TagsIndex < Chewy::Index class TagsIndex < Chewy::Index
include DatetimeClampingConcern
settings index: index_preset(refresh_interval: '30s'), analysis: { settings index: index_preset(refresh_interval: '30s'), analysis: {
analyzer: { analyzer: {
content: { content: {
@ -42,6 +44,6 @@ class TagsIndex < Chewy::Index
field(:name, type: 'text', analyzer: 'content', value: :display_name) { field(:edge_ngram, type: 'text', analyzer: 'edge_ngram', search_analyzer: 'content') } field(:name, type: 'text', analyzer: 'content', value: :display_name) { field(:edge_ngram, type: 'text', analyzer: 'edge_ngram', search_analyzer: 'content') }
field(:reviewed, type: 'boolean', value: ->(tag) { tag.reviewed? }) field(:reviewed, type: 'boolean', value: ->(tag) { tag.reviewed? })
field(:usage, type: 'long', value: ->(tag, crutches) { tag.history.aggregate(crutches.time_period).accounts }) field(:usage, type: 'long', value: ->(tag, crutches) { tag.history.aggregate(crutches.time_period).accounts })
field(:last_status_at, type: 'date', value: ->(tag) { tag.last_status_at || tag.created_at }) field(:last_status_at, type: 'date', value: ->(tag) { clamp_date(tag.last_status_at || tag.created_at) })
end end
end end

View file

@ -21,7 +21,7 @@ module Admin
account_action.save! account_action.save!
if account_action.with_report? if account_action.with_report?
redirect_to admin_reports_path, notice: I18n.t('admin.reports.processed_msg', id: params[:report_id]) redirect_to admin_reports_path, notice: I18n.t('admin.reports.processed_msg', id: resource_params[:report_id])
else else
redirect_to admin_account_path(@account.id) redirect_to admin_account_path(@account.id)
end end

View file

@ -257,6 +257,7 @@ module LanguagesHelper
def valid_locale_or_nil(str) def valid_locale_or_nil(str)
return if str.blank? return if str.blank?
return str if valid_locale?(str)
code, = str.to_s.split(/[_-]/) # Strip out the region from e.g. en_US or ja-JP code, = str.to_s.split(/[_-]/) # Strip out the region from e.g. en_US or ja-JP

View file

@ -1,9 +1,16 @@
/* eslint-disable @typescript-eslint/no-unsafe-call,
@typescript-eslint/no-unsafe-return,
@typescript-eslint/no-unsafe-assignment,
@typescript-eslint/no-unsafe-member-access
-- the settings store is not yet typed */
import type { PropsWithChildren } from 'react'; import type { PropsWithChildren } from 'react';
import { useCallback, useState } from 'react'; import { useCallback, useState, useEffect } from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import { changeSetting } from 'mastodon/actions/settings';
import { bannerSettings } from 'mastodon/settings'; import { bannerSettings } from 'mastodon/settings';
import { useAppSelector, useAppDispatch } from 'mastodon/store';
import { IconButton } from './icon_button'; import { IconButton } from './icon_button';
@ -19,13 +26,25 @@ export const DismissableBanner: React.FC<PropsWithChildren<Props>> = ({
id, id,
children, children,
}) => { }) => {
const [visible, setVisible] = useState(!bannerSettings.get(id)); const dismissed = useAppSelector((state) =>
state.settings.getIn(['dismissed_banners', id], false),
);
const dispatch = useAppDispatch();
const [visible, setVisible] = useState(!bannerSettings.get(id) && !dismissed);
const intl = useIntl(); const intl = useIntl();
const handleDismiss = useCallback(() => { const handleDismiss = useCallback(() => {
setVisible(false); setVisible(false);
bannerSettings.set(id, true); bannerSettings.set(id, true);
}, [id]); dispatch(changeSetting(['dismissed_banners', id], true));
}, [id, dispatch]);
useEffect(() => {
if (!visible && !dismissed) {
dispatch(changeSetting(['dismissed_banners', id], true));
}
}, [id, dispatch, visible, dismissed]);
if (!visible) { if (!visible) {
return null; return null;

View file

@ -100,7 +100,7 @@ class LinkFooter extends PureComponent {
{DividingCircle} {DividingCircle}
<a href={source_url} rel='noopener noreferrer' target='_blank'><FormattedMessage id='footer.source_code' defaultMessage='View source code' /></a> <a href={source_url} rel='noopener noreferrer' target='_blank'><FormattedMessage id='footer.source_code' defaultMessage='View source code' /></a>
{DividingCircle} {DividingCircle}
<span class='version'>v{version}</span> <span className='version'>v{version}</span>
</p> </p>
</div> </div>
); );

View file

@ -53,24 +53,30 @@ class NavigationPanel extends Component {
const { intl } = this.props; const { intl } = this.props;
const { signedIn, disabledAccountId } = this.context.identity; const { signedIn, disabledAccountId } = this.context.identity;
let banner = undefined;
if(transientSingleColumn)
banner = (<div className='switch-to-advanced'>
{intl.formatMessage(messages.openedInClassicInterface)}
{" "}
<a href={`/deck${location.pathname}`} className='switch-to-advanced__toggle'>
{intl.formatMessage(messages.advancedInterface)}
</a>
</div>);
return ( return (
<div className='navigation-panel'> <div className='navigation-panel'>
<div className='navigation-panel__logo'> <div className='navigation-panel__logo'>
<Link to='/' className='column-link column-link--logo'><WordmarkLogo /></Link> <Link to='/' className='column-link column-link--logo'><WordmarkLogo /></Link>
{!banner && <hr />}
{transientSingleColumn ? (
<div class='switch-to-advanced'>
{intl.formatMessage(messages.openedInClassicInterface)}
{" "}
<a href={`/deck${location.pathname}`} class='switch-to-advanced__toggle'>
{intl.formatMessage(messages.advancedInterface)}
</a>
</div>
) : (
<hr />
)}
</div> </div>
{banner &&
<div class='navigation-panel__banner'>
{banner}
</div>
}
{signedIn && ( {signedIn && (
<> <>
<ColumnLink transparent to='/home' icon='home' text={intl.formatMessage(messages.home)} /> <ColumnLink transparent to='/home' icon='home' text={intl.formatMessage(messages.home)} />

View file

@ -100,6 +100,15 @@ const initialState = ImmutableMap({
body: '', body: '',
}), }),
}), }),
dismissed_banners: ImmutableMap({
'public_timeline': false,
'community_timeline': false,
'home.explore_prompt': false,
'explore/links': false,
'explore/statuses': false,
'explore/tags': false,
}),
}); });
const defaultColumns = fromJS([ const defaultColumns = fromJS([

View file

@ -2239,8 +2239,7 @@ $ui-header-height: 55px;
> .scrollable { > .scrollable {
background: $ui-base-color; background: $ui-base-color;
border-bottom-left-radius: 4px; border-radius: 0 0 4px 4px;
border-bottom-right-radius: 4px;
} }
} }
@ -2466,6 +2465,7 @@ $ui-header-height: 55px;
.navigation-panel__sign-in-banner, .navigation-panel__sign-in-banner,
.navigation-panel__logo, .navigation-panel__logo,
.navigation-panel__banner,
.getting-started__trends { .getting-started__trends {
display: none; display: none;
} }

View file

@ -14,6 +14,8 @@ module ActivityPub::CaseTransform
when String when String
camel_lower_cache[value] ||= if value.start_with?('_:') camel_lower_cache[value] ||= if value.start_with?('_:')
"_:#{value.delete_prefix('_:').underscore.camelize(:lower)}" "_:#{value.delete_prefix('_:').underscore.camelize(:lower)}"
elsif LanguagesHelper::ISO_639_1_REGIONAL.key?(value.to_sym)
value
else else
value.underscore.camelize(:lower) value.underscore.camelize(:lower)
end end

View file

@ -18,8 +18,8 @@ class ActivityPub::LinkedDataSignature
return unless type == 'RsaSignature2017' return unless type == 'RsaSignature2017'
creator = ActivityPub::TagManager.instance.uri_to_actor(creator_uri) creator = ActivityPub::TagManager.instance.uri_to_actor(creator_uri)
creator ||= ActivityPub::FetchRemoteKeyService.new.call(creator_uri, id: false) creator = ActivityPub::FetchRemoteKeyService.new.call(creator_uri, id: false) if creator&.public_key.blank?
return if creator.nil? return if creator.nil?
@ -28,6 +28,8 @@ class ActivityPub::LinkedDataSignature
to_be_verified = options_hash + document_hash to_be_verified = options_hash + document_hash
creator if creator.keypair.public_key.verify(OpenSSL::Digest.new('SHA256'), Base64.decode64(signature), to_be_verified) creator if creator.keypair.public_key.verify(OpenSSL::Digest.new('SHA256'), Base64.decode64(signature), to_be_verified)
rescue OpenSSL::PKey::RSAError
false
end end
def sign!(creator, sign_with: nil) def sign!(creator, sign_with: nil)

View file

@ -53,7 +53,8 @@ class ActivityPub::Parser::StatusParser
end end
def created_at def created_at
@object['published']&.to_datetime datetime = @object['published']&.to_datetime
datetime if datetime.present? && (0..9999).cover?(datetime.year)
rescue ArgumentError rescue ArgumentError
nil nil
end end

View file

@ -75,7 +75,12 @@ class AttachmentBatch
end end
when :fog when :fog
logger.debug { "Deleting #{attachment.path(style)}" } logger.debug { "Deleting #{attachment.path(style)}" }
attachment.directory.files.new(key: attachment.path(style)).destroy
begin
attachment.send(:directory).files.new(key: attachment.path(style)).destroy
rescue Fog::Storage::OpenStack::NotFound
# Ignore failure to delete a file that has already been deleted
end
when :azure when :azure
logger.debug { "Deleting #{attachment.path(style)}" } logger.debug { "Deleting #{attachment.path(style)}" }
attachment.destroy attachment.destroy

View file

@ -192,6 +192,7 @@ class FeedManager
# also tagged with another followed hashtag or from a followed user # also tagged with another followed hashtag or from a followed user
scope = from_tag.statuses scope = from_tag.statuses
.where(id: timeline_status_ids) .where(id: timeline_status_ids)
.where.not(account: into_account)
.where.not(account: into_account.following) .where.not(account: into_account.following)
.tagged_with_none(TagFollow.where(account: into_account).pluck(:tag_id)) .tagged_with_none(TagFollow.where(account: into_account).pluck(:tag_id))

View file

@ -36,7 +36,8 @@ class LinkDetailsExtractor
end end
def language def language
json['inLanguage'] lang = json['inLanguage']
lang.is_a?(Hash) ? (lang['alternateName'] || lang['name']) : lang
end end
def type def type

View file

@ -52,9 +52,13 @@ module Attachmentable
return if attachment.blank? || !/image.*/.match?(attachment.content_type) || attachment.queued_for_write[:original].blank? return if attachment.blank? || !/image.*/.match?(attachment.content_type) || attachment.queued_for_write[:original].blank?
width, height = FastImage.size(attachment.queued_for_write[:original].path) width, height = FastImage.size(attachment.queued_for_write[:original].path)
matrix_limit = attachment.content_type == 'image/gif' ? GIF_MATRIX_LIMIT : MAX_MATRIX_LIMIT return unless width.present? && height.present?
raise Mastodon::DimensionsValidationError, "#{width}x#{height} images are not supported" if width.present? && height.present? && (width * height > matrix_limit) if attachment.content_type == 'image/gif' && width * height > GIF_MATRIX_LIMIT
raise Mastodon::DimensionsValidationError, "#{width}x#{height} GIF files are not supported"
elsif width * height > MAX_MATRIX_LIMIT
raise Mastodon::DimensionsValidationError, "#{width}x#{height} images are not supported"
end
end end
def appropriate_extension(attachment) def appropriate_extension(attachment)

View file

@ -35,7 +35,7 @@ class Tag < ApplicationRecord
HASHTAG_LAST_SEQUENCE = '([[:word:]_]*[[:alpha:]][[:word:]_]*)' HASHTAG_LAST_SEQUENCE = '([[:word:]_]*[[:alpha:]][[:word:]_]*)'
HASHTAG_NAME_PAT = "#{HASHTAG_FIRST_SEQUENCE}|#{HASHTAG_LAST_SEQUENCE}" HASHTAG_NAME_PAT = "#{HASHTAG_FIRST_SEQUENCE}|#{HASHTAG_LAST_SEQUENCE}"
HASHTAG_RE = %r{(?:^|[^/)\w])#(#{HASHTAG_NAME_PAT})}i HASHTAG_RE = %r{(?<![=/)\w])#(#{HASHTAG_NAME_PAT})}i
HASHTAG_NAME_RE = /\A(#{HASHTAG_NAME_PAT})\z/i HASHTAG_NAME_RE = /\A(#{HASHTAG_NAME_PAT})\z/i
HASHTAG_INVALID_CHARS_RE = /[^[:alnum:]#{HASHTAG_SEPARATORS}]/ HASHTAG_INVALID_CHARS_RE = /[^[:alnum:]#{HASHTAG_SEPARATORS}]/

View file

@ -106,7 +106,7 @@ class Trends::Statuses < Trends::Base
private private
def eligible?(status) def eligible?(status)
status.public_visibility? && status.account.discoverable? && !status.account.silenced? && status.spoiler_text.blank? && !status.sensitive? && !status.reply? && valid_locale?(status.language) status.public_visibility? && status.account.discoverable? && !status.account.silenced? && !status.account.sensitized? && status.spoiler_text.blank? && !status.sensitive? && !status.reply? && valid_locale?(status.language)
end end
def calculate_scores(statuses, at_time) def calculate_scores(statuses, at_time)

View file

@ -10,7 +10,9 @@ class REST::FeaturedTagSerializer < ActiveModel::Serializer
end end
def url def url
short_account_tag_url(object.account, object.tag) # The path is hardcoded because we have to deal with both local and
# remote users, which are different routes
account_with_domain_url(object.account, "tagged/#{object.tag.to_param}")
end end
def name def name

View file

@ -8,6 +8,7 @@ class FanOutOnWriteService < BaseService
# @param [Hash] options # @param [Hash] options
# @option options [Boolean] update # @option options [Boolean] update
# @option options [Array<Integer>] silenced_account_ids # @option options [Array<Integer>] silenced_account_ids
# @option options [Boolean] skip_notifications
def call(status, options = {}) def call(status, options = {})
@status = status @status = status
@account = status.account @account = status.account
@ -37,8 +38,11 @@ class FanOutOnWriteService < BaseService
def fan_out_to_local_recipients! def fan_out_to_local_recipients!
deliver_to_self! deliver_to_self!
notify_mentioned_accounts!
notify_about_update! if update? unless @options[:skip_notifications]
notify_mentioned_accounts!
notify_about_update! if update?
end
case @status.visibility.to_sym case @status.visibility.to_sym
when :public, :unlisted, :private when :public, :unlisted, :private

View file

@ -71,7 +71,7 @@ class FollowService < BaseService
if @target_account.local? if @target_account.local?
LocalNotificationWorker.perform_async(@target_account.id, follow_request.id, follow_request.class.name, 'follow_request') LocalNotificationWorker.perform_async(@target_account.id, follow_request.id, follow_request.class.name, 'follow_request')
elsif @target_account.activitypub? elsif @target_account.activitypub?
ActivityPub::DeliveryWorker.perform_async(build_json(follow_request), @source_account.id, @target_account.inbox_url) ActivityPub::DeliveryWorker.perform_async(build_json(follow_request), @source_account.id, @target_account.inbox_url, { 'bypass_availability' => true })
end end
follow_request follow_request

View file

@ -23,9 +23,10 @@ class ActivityPub::DeliveryWorker
HEADERS = { 'Content-Type' => 'application/activity+json' }.freeze HEADERS = { 'Content-Type' => 'application/activity+json' }.freeze
def perform(json, source_account_id, inbox_url, options = {}) def perform(json, source_account_id, inbox_url, options = {})
return unless DeliveryFailureTracker.available?(inbox_url)
@options = options.with_indifferent_access @options = options.with_indifferent_access
return unless @options[:bypass_availability] || DeliveryFailureTracker.available?(inbox_url)
@json = json @json = json
@source_account = Account.find(source_account_id) @source_account = Account.find(source_account_id)
@inbox_url = inbox_url @inbox_url = inbox_url

View file

@ -7,13 +7,18 @@ class ThreadResolveWorker
sidekiq_options queue: 'pull', retry: 3 sidekiq_options queue: 'pull', retry: 3
def perform(child_status_id, parent_url, options = {}) def perform(child_status_id, parent_url, options = {})
child_status = Status.find(child_status_id) child_status = Status.find(child_status_id)
parent_status = FetchRemoteStatusService.new.call(parent_url, **options.deep_symbolize_keys) return if child_status.in_reply_to_id.present?
parent_status = ActivityPub::TagManager.instance.uri_to_resource(parent_url, Status)
parent_status ||= FetchRemoteStatusService.new.call(parent_url, **options.deep_symbolize_keys)
return if parent_status.nil? return if parent_status.nil?
child_status.thread = parent_status child_status.thread = parent_status
child_status.save! child_status.save!
DistributionWorker.perform_async(child_status_id, { 'skip_notifications' => true }) if child_status.within_realtime_window?
rescue ActiveRecord::RecordNotFound rescue ActiveRecord::RecordNotFound
true true
end end

View file

@ -5,7 +5,11 @@
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy
def host_to_url(str) def host_to_url(str)
"http#{Rails.configuration.x.use_https ? 's' : ''}://#{str.split('/').first}" if str.present? return if str.blank?
uri = Addressable::URI.parse("http#{Rails.configuration.x.use_https ? 's' : ''}://#{str}")
uri.path += '/' unless uri.path.blank? || uri.path.end_with?('/')
uri.to_s
end end
base_host = Rails.configuration.x.web_domain base_host = Rails.configuration.x.web_domain

View file

@ -3,6 +3,18 @@
require 'sidekiq_unique_jobs/web' require 'sidekiq_unique_jobs/web'
require 'sidekiq-scheduler/web' require 'sidekiq-scheduler/web'
class RedirectWithVary < ActionDispatch::Routing::PathRedirect
def serve(...)
super.tap do |_, headers, _|
headers['Vary'] = 'Origin, Accept'
end
end
end
def redirect_with_vary(path)
RedirectWithVary.new(301, path)
end
Rails.application.routes.draw do Rails.application.routes.draw do
# Paths of routes on the web app that to not require to be indexed or # Paths of routes on the web app that to not require to be indexed or
# have alternative format representations requiring separate controllers # have alternative format representations requiring separate controllers
@ -90,10 +102,13 @@ Rails.application.routes.draw do
confirmations: 'auth/confirmations', confirmations: 'auth/confirmations',
} }
get '/users/:username', to: redirect('/@%{username}'), constraints: lambda { |req| req.format.nil? || req.format.html? } # rubocop:disable Style/FormatStringToken - those do not go through the usual formatting functions and are not safe to correct
get '/users/:username/following', to: redirect('/@%{username}/following'), constraints: lambda { |req| req.format.nil? || req.format.html? } get '/users/:username', to: redirect_with_vary('/@%{username}'), constraints: lambda { |req| req.format.nil? || req.format.html? }
get '/users/:username/followers', to: redirect('/@%{username}/followers'), constraints: lambda { |req| req.format.nil? || req.format.html? } get '/users/:username/following', to: redirect_with_vary('/@%{username}/following'), constraints: lambda { |req| req.format.nil? || req.format.html? }
get '/users/:username/statuses/:id', to: redirect('/@%{username}/%{id}'), constraints: lambda { |req| req.format.nil? || req.format.html? } get '/users/:username/followers', to: redirect_with_vary('/@%{username}/followers'), constraints: lambda { |req| req.format.nil? || req.format.html? }
get '/users/:username/statuses/:id', to: redirect_with_vary('/@%{username}/%{id}'), constraints: lambda { |req| req.format.nil? || req.format.html? }
# rubocop:enable Style/FormatStringToken
get '/authorize_follow', to: redirect { |_, request| "/authorize_interaction?#{request.params.to_query}" } get '/authorize_follow', to: redirect { |_, request| "/authorize_interaction?#{request.params.to_query}" }
resources :accounts, path: 'users', only: [:show], param: :username do resources :accounts, path: 'users', only: [:show], param: :username do
@ -134,7 +149,7 @@ Rails.application.routes.draw do
get '/@:account_username/:id/embed', to: 'statuses#embed', as: :embed_short_account_status get '/@:account_username/:id/embed', to: 'statuses#embed', as: :embed_short_account_status
end end
get '/@:username_with_domain/(*any)', to: 'home#index', constraints: { username_with_domain: %r{([^/])+?} }, format: false get '/@:username_with_domain/(*any)', to: 'home#index', constraints: { username_with_domain: %r{([^/])+?} }, as: :account_with_domain, format: false
get '/settings', to: redirect('/settings/profile') get '/settings', to: redirect('/settings/profile')
draw(:settings) draw(:settings)

View file

@ -56,7 +56,7 @@ services:
web: web:
build: . build: .
image: ghcr.io/mastodon/mastodon:v4.2.1 image: ghcr.io/mastodon/mastodon:v4.2.2
restart: always restart: always
env_file: .env.development env_file: .env.development
command: bash -c "rm -f /mastodon/tmp/pids/server.pid; bundle exec rails s -b 0.0.0.0 -p 3000" command: bash -c "rm -f /mastodon/tmp/pids/server.pid; bundle exec rails s -b 0.0.0.0 -p 3000"
@ -77,7 +77,7 @@ services:
streaming: streaming:
build: . build: .
image: ghcr.io/mastodon/mastodon:v4.2.1 image: ghcr.io/mastodon/mastodon:v4.2.2
restart: always restart: always
env_file: .env.development env_file: .env.development
command: node ./streaming command: node ./streaming
@ -95,7 +95,7 @@ services:
sidekiq: sidekiq:
build: . build: .
image: ghcr.io/mastodon/mastodon:v4.2.1 image: ghcr.io/mastodon/mastodon:v4.2.2
restart: always restart: always
env_file: .env.development env_file: .env.development
command: bundle exec sidekiq command: bundle exec sidekiq

View file

@ -13,7 +13,7 @@ module Mastodon
end end
def patch def patch
1 2
end end
def default_prerelease def default_prerelease

View file

@ -1,23 +0,0 @@
# frozen_string_literal: true
require 'rails_helper'
describe Api::V1::Accounts::FeaturedTagsController do
render_views
let(:user) { Fabricate(:user) }
let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read:accounts') }
let(:account) { Fabricate(:account) }
before do
allow(controller).to receive(:doorkeeper_token) { token }
end
describe 'GET #index' do
it 'returns http success' do
get :index, params: { account_id: account.id, limit: 2 }
expect(response).to have_http_status(200)
end
end
end

View file

@ -23,6 +23,109 @@ RSpec.describe ActivityPub::Activity::Create do
stub_request(:get, 'http://example.com/emojib.png').to_return(body: attachment_fixture('emojo.png'), headers: { 'Content-Type' => 'application/octet-stream' }) stub_request(:get, 'http://example.com/emojib.png').to_return(body: attachment_fixture('emojo.png'), headers: { 'Content-Type' => 'application/octet-stream' })
end end
describe 'processing posts received out of order' do
let(:follower) { Fabricate(:account, username: 'bob') }
let(:object_json) do
{
id: [ActivityPub::TagManager.instance.uri_for(sender), 'post1'].join('/'),
type: 'Note',
to: [
'https://www.w3.org/ns/activitystreams#Public',
ActivityPub::TagManager.instance.uri_for(follower),
],
content: '@bob lorem ipsum',
published: 1.hour.ago.utc.iso8601,
updated: 1.hour.ago.utc.iso8601,
tag: {
type: 'Mention',
href: ActivityPub::TagManager.instance.uri_for(follower),
},
}
end
let(:reply_json) do
{
id: [ActivityPub::TagManager.instance.uri_for(sender), 'reply'].join('/'),
type: 'Note',
inReplyTo: object_json[:id],
to: [
'https://www.w3.org/ns/activitystreams#Public',
ActivityPub::TagManager.instance.uri_for(follower),
],
content: '@bob lorem ipsum',
published: Time.now.utc.iso8601,
updated: Time.now.utc.iso8601,
tag: {
type: 'Mention',
href: ActivityPub::TagManager.instance.uri_for(follower),
},
}
end
def activity_for_object(json)
{
'@context': 'https://www.w3.org/ns/activitystreams',
id: [json[:id], 'activity'].join('/'),
type: 'Create',
actor: ActivityPub::TagManager.instance.uri_for(sender),
object: json,
}.with_indifferent_access
end
before do
follower.follow!(sender)
end
around do |example|
Sidekiq::Testing.fake! do
example.run
Sidekiq::Worker.clear_all
end
end
it 'correctly processes posts and inserts them in timelines', :aggregate_failures do
# Simulate a temporary failure preventing from fetching the parent post
stub_request(:get, object_json[:id]).to_return(status: 500)
# When receiving the reply…
described_class.new(activity_for_object(reply_json), sender, delivery: true).perform
# NOTE: Refering explicitly to the workers is a bit awkward
DistributionWorker.drain
FeedInsertWorker.drain
# …it creates a status with an unknown parent
reply = Status.find_by(uri: reply_json[:id])
expect(reply.reply?).to be true
expect(reply.in_reply_to_id).to be_nil
# …and creates a notification
expect(LocalNotificationWorker.jobs.size).to eq 1
# …but does not insert it into timelines
expect(redis.zscore(FeedManager.instance.key(:home, follower.id), reply.id)).to be_nil
# When receiving the parent…
described_class.new(activity_for_object(object_json), sender, delivery: true).perform
Sidekiq::Worker.drain_all
# …it creates a status and insert it into timelines
parent = Status.find_by(uri: object_json[:id])
expect(parent.reply?).to be false
expect(parent.in_reply_to_id).to be_nil
expect(reply.reload.in_reply_to_id).to eq parent.id
# Check that the both statuses have been inserted into the home feed
expect(redis.zscore(FeedManager.instance.key(:home, follower.id), parent.id)).to be_within(0.1).of(parent.id.to_f)
expect(redis.zscore(FeedManager.instance.key(:home, follower.id), reply.id)).to be_within(0.1).of(reply.id.to_f)
# Creates two notifications
expect(Notification.count).to eq 2
end
end
describe '#perform' do describe '#perform' do
context 'when fetching' do context 'when fetching' do
subject { described_class.new(json, sender) } subject { described_class.new(json, sender) }
@ -31,6 +134,46 @@ RSpec.describe ActivityPub::Activity::Create do
subject.perform subject.perform
end end
context 'when object publication date is below ISO8601 range' do
let(:object_json) do
{
id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
type: 'Note',
content: 'Lorem ipsum',
published: '-0977-11-03T08:31:22Z',
}
end
it 'creates status with a valid creation date', :aggregate_failures do
status = sender.statuses.first
expect(status).to_not be_nil
expect(status.text).to eq 'Lorem ipsum'
expect(status.created_at).to be_within(30).of(Time.now.utc)
end
end
context 'when object publication date is above ISO8601 range' do
let(:object_json) do
{
id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
type: 'Note',
content: 'Lorem ipsum',
published: '10000-11-03T08:31:22Z',
}
end
it 'creates status with a valid creation date', :aggregate_failures do
status = sender.statuses.first
expect(status).to_not be_nil
expect(status.text).to eq 'Lorem ipsum'
expect(status.created_at).to be_within(30).of(Time.now.utc)
end
end
context 'when object has been edited' do context 'when object has been edited' do
let(:object_json) do let(:object_json) do
{ {
@ -42,18 +185,16 @@ RSpec.describe ActivityPub::Activity::Create do
} }
end end
it 'creates status' do it 'creates status with appropriate creation and edition dates', :aggregate_failures do
status = sender.statuses.first status = sender.statuses.first
expect(status).to_not be_nil expect(status).to_not be_nil
expect(status.text).to eq 'Lorem ipsum' expect(status.text).to eq 'Lorem ipsum'
end
it 'marks status as edited' do expect(status.created_at).to eq '2022-01-22T15:00:00Z'.to_datetime
status = sender.statuses.first
expect(status).to_not be_nil
expect(status.edited?).to be true expect(status.edited?).to be true
expect(status.edited_at).to eq '2022-01-22T16:00:00Z'.to_datetime
end end
end end

View file

@ -38,6 +38,40 @@ RSpec.describe ActivityPub::LinkedDataSignature do
end end
end end
context 'when local account record is missing a public key' do
let(:raw_signature) do
{
'creator' => 'http://example.com/alice',
'created' => '2017-09-23T20:21:34Z',
}
end
let(:signature) { raw_signature.merge('type' => 'RsaSignature2017', 'signatureValue' => sign(sender, raw_signature, raw_json)) }
let(:service_stub) { instance_double(ActivityPub::FetchRemoteKeyService) }
before do
# Ensure signature is computed with the old key
signature
# Unset key
old_key = sender.public_key
sender.update!(private_key: '', public_key: '')
allow(ActivityPub::FetchRemoteKeyService).to receive(:new).and_return(service_stub)
allow(service_stub).to receive(:call).with('http://example.com/alice', id: false) do
sender.update!(public_key: old_key)
sender
end
end
it 'fetches key and returns creator' do
expect(subject.verify_actor!).to eq sender
expect(service_stub).to have_received(:call).with('http://example.com/alice', id: false).once
end
end
context 'when signature is missing' do context 'when signature is missing' do
let(:signature) { nil } let(:signature) { nil }

View file

@ -525,6 +525,44 @@ RSpec.describe FeedManager do
end end
end end
describe '#unmerge_tag_from_home' do
let(:receiver) { Fabricate(:account) }
let(:tag) { Fabricate(:tag) }
it 'leaves a tagged status' do
status = Fabricate(:status)
status.tags << tag
described_class.instance.push_to_home(receiver, status)
described_class.instance.unmerge_tag_from_home(tag, receiver)
expect(redis.zrange("feed:home:#{receiver.id}", 0, -1)).to_not include(status.id.to_s)
end
it 'remains a tagged status written by receiver\'s followee' do
followee = Fabricate(:account)
receiver.follow!(followee)
status = Fabricate(:status, account: followee)
status.tags << tag
described_class.instance.push_to_home(receiver, status)
described_class.instance.unmerge_tag_from_home(tag, receiver)
expect(redis.zrange("feed:home:#{receiver.id}", 0, -1)).to include(status.id.to_s)
end
it 'remains a tagged status written by receiver' do
status = Fabricate(:status, account: receiver)
status.tags << tag
described_class.instance.push_to_home(receiver, status)
described_class.instance.unmerge_tag_from_home(tag, receiver)
expect(redis.zrange("feed:home:#{receiver.id}", 0, -1)).to include(status.id.to_s)
end
end
describe '#clear_from_home' do describe '#clear_from_home' do
let(:account) { Fabricate(:account) } let(:account) { Fabricate(:account) }
let(:followed_account) { Fabricate(:account) } let(:followed_account) { Fabricate(:account) }

View file

@ -82,6 +82,10 @@ RSpec.describe LinkDetailsExtractor do
'name' => 'Pet News', 'name' => 'Pet News',
'url' => 'https://example.com', 'url' => 'https://example.com',
}, },
'inLanguage' => {
name: 'English',
alternateName: 'en',
},
}.to_json }.to_json
end end
@ -115,6 +119,12 @@ RSpec.describe LinkDetailsExtractor do
expect(subject.provider_name).to eq 'Pet News' expect(subject.provider_name).to eq 'Pet News'
end end
end end
describe '#language' do
it 'returns the language from structured data' do
expect(subject.language).to eq 'en'
end
end
end end
context 'when is wrapped in CDATA tags' do context 'when is wrapped in CDATA tags' do

View file

@ -32,44 +32,52 @@ RSpec.describe Tag do
expect(subject.match('https://en.wikipedia.org/wiki/Ghostbusters_(song)#Lawsuit')).to be_nil expect(subject.match('https://en.wikipedia.org/wiki/Ghostbusters_(song)#Lawsuit')).to be_nil
end end
it 'does not match URLs with hashtag-like anchors after a numeral' do
expect(subject.match('https://gcc.gnu.org/bugzilla/show_bug.cgi?id=111895#c4')).to be_nil
end
it 'does not match URLs with hashtag-like anchors after an empty query parameter' do
expect(subject.match('https://en.wikipedia.org/wiki/Ghostbusters_(song)?foo=#Lawsuit')).to be_nil
end
it 'matches #' do it 'matches #' do
expect(subject.match('this is #').to_s).to eq ' #' expect(subject.match('this is #').to_s).to eq '#'
end end
it 'matches digits at the start' do it 'matches digits at the start' do
expect(subject.match('hello #3d').to_s).to eq ' #3d' expect(subject.match('hello #3d').to_s).to eq '#3d'
end end
it 'matches digits in the middle' do it 'matches digits in the middle' do
expect(subject.match('hello #l33ts35k').to_s).to eq ' #l33ts35k' expect(subject.match('hello #l33ts35k').to_s).to eq '#l33ts35k'
end end
it 'matches digits at the end' do it 'matches digits at the end' do
expect(subject.match('hello #world2016').to_s).to eq ' #world2016' expect(subject.match('hello #world2016').to_s).to eq '#world2016'
end end
it 'matches underscores at the beginning' do it 'matches underscores at the beginning' do
expect(subject.match('hello #_test').to_s).to eq ' #_test' expect(subject.match('hello #_test').to_s).to eq '#_test'
end end
it 'matches underscores at the end' do it 'matches underscores at the end' do
expect(subject.match('hello #test_').to_s).to eq ' #test_' expect(subject.match('hello #test_').to_s).to eq '#test_'
end end
it 'matches underscores in the middle' do it 'matches underscores in the middle' do
expect(subject.match('hello #one_two_three').to_s).to eq ' #one_two_three' expect(subject.match('hello #one_two_three').to_s).to eq '#one_two_three'
end end
it 'matches middle dots' do it 'matches middle dots' do
expect(subject.match('hello #one·two·three').to_s).to eq ' #one·two·three' expect(subject.match('hello #one·two·three').to_s).to eq '#one·two·three'
end end
it 'matches ・unicode in ぼっち・ざ・ろっく correctly' do it 'matches ・unicode in ぼっち・ざ・ろっく correctly' do
expect(subject.match('testing #ぼっち・ざ・ろっく').to_s).to eq ' #ぼっち・ざ・ろっく' expect(subject.match('testing #ぼっち・ざ・ろっく').to_s).to eq '#ぼっち・ざ・ろっく'
end end
it 'matches ZWNJ' do it 'matches ZWNJ' do
expect(subject.match('just add #نرم‌افزار and').to_s).to eq ' #نرم‌افزار' expect(subject.match('just add #نرم‌افزار and').to_s).to eq '#نرم‌افزار'
end end
it 'does not match middle dots at the start' do it 'does not match middle dots at the start' do
@ -77,7 +85,7 @@ RSpec.describe Tag do
end end
it 'does not match middle dots at the end' do it 'does not match middle dots at the end' do
expect(subject.match('hello #one·two·three·').to_s).to eq ' #one·two·three' expect(subject.match('hello #one·two·three·').to_s).to eq '#one·two·three'
end end
it 'does not match purely-numeric hashtags' do it 'does not match purely-numeric hashtags' do

View file

@ -0,0 +1,50 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'account featured tags API' do
let(:user) { Fabricate(:user) }
let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) }
let(:scopes) { 'read:accounts' }
let(:headers) { { 'Authorization' => "Bearer #{token.token}" } }
let(:account) { Fabricate(:account) }
describe 'GET /api/v1/accounts/:id/featured_tags' do
subject do
get "/api/v1/accounts/#{account.id}/featured_tags", headers: headers
end
before do
account.featured_tags.create!(name: 'foo')
account.featured_tags.create!(name: 'bar')
end
it 'returns the expected tags', :aggregate_failures do
subject
expect(response).to have_http_status(200)
expect(body_as_json).to contain_exactly(a_hash_including({
name: 'bar',
url: "https://cb6e6126.ngrok.io/@#{account.username}/tagged/bar",
}), a_hash_including({
name: 'foo',
url: "https://cb6e6126.ngrok.io/@#{account.username}/tagged/foo",
}))
end
context 'when the account is remote' do
it 'returns the expected tags', :aggregate_failures do
subject
expect(response).to have_http_status(200)
expect(body_as_json).to contain_exactly(a_hash_including({
name: 'bar',
url: "https://cb6e6126.ngrok.io/@#{account.pretty_acct}/tagged/bar",
}), a_hash_including({
name: 'foo',
url: "https://cb6e6126.ngrok.io/@#{account.pretty_acct}/tagged/foo",
}))
end
end
end
end

View file

@ -124,7 +124,7 @@ describe 'Caching behavior' do
expect(response.cookies).to be_empty expect(response.cookies).to be_empty
end end
it 'sets public cache control' do it 'sets public cache control', :aggregate_failures do
# expect(response.cache_control[:max_age]&.to_i).to be_positive # expect(response.cache_control[:max_age]&.to_i).to be_positive
expect(response.cache_control[:public]).to be_truthy expect(response.cache_control[:public]).to be_truthy
expect(response.cache_control[:private]).to be_falsy expect(response.cache_control[:private]).to be_falsy
@ -141,11 +141,8 @@ describe 'Caching behavior' do
end end
shared_examples 'non-cacheable error' do shared_examples 'non-cacheable error' do
it 'does not return HTTP success' do it 'does not return HTTP success and does not have cache headers', :aggregate_failures do
expect(response).to_not have_http_status(200) expect(response).to_not have_http_status(200)
end
it 'does not have cache headers' do
expect(response.cache_control[:public]).to be_falsy expect(response.cache_control[:public]).to be_falsy
end end
end end
@ -182,6 +179,15 @@ describe 'Caching behavior' do
end end
context 'when anonymously accessed' do context 'when anonymously accessed' do
describe '/users/alice' do
it 'redirects with proper cache header', :aggregate_failures do
get '/users/alice'
expect(response).to redirect_to('/@alice')
expect(response.headers['Vary']&.split(',')&.map { |x| x.strip.downcase }).to include('accept')
end
end
TestEndpoints::ALWAYS_CACHED.each do |endpoint| TestEndpoints::ALWAYS_CACHED.each do |endpoint|
describe endpoint do describe endpoint do
before { get endpoint } before { get endpoint }

View file

@ -7,7 +7,7 @@ describe ActivityPub::NoteSerializer do
let!(:account) { Fabricate(:account) } let!(:account) { Fabricate(:account) }
let!(:other) { Fabricate(:account) } let!(:other) { Fabricate(:account) }
let!(:parent) { Fabricate(:status, account: account, visibility: :public) } let!(:parent) { Fabricate(:status, account: account, visibility: :public, language: 'zh-TW') }
let!(:reply_by_account_first) { Fabricate(:status, account: account, thread: parent, visibility: :public) } let!(:reply_by_account_first) { Fabricate(:status, account: account, thread: parent, visibility: :public) }
let!(:reply_by_account_next) { Fabricate(:status, account: account, thread: parent, visibility: :public) } let!(:reply_by_account_next) { Fabricate(:status, account: account, thread: parent, visibility: :public) }
let!(:reply_by_other_first) { Fabricate(:status, account: other, thread: parent, visibility: :public) } let!(:reply_by_other_first) { Fabricate(:status, account: other, thread: parent, visibility: :public) }
@ -18,8 +18,15 @@ describe ActivityPub::NoteSerializer do
@serialization = ActiveModelSerializers::SerializableResource.new(parent, serializer: described_class, adapter: ActivityPub::Adapter) @serialization = ActiveModelSerializers::SerializableResource.new(parent, serializer: described_class, adapter: ActivityPub::Adapter)
end end
it 'has a Note type' do it 'has the expected shape' do
expect(subject['type']).to eql('Note') expect(subject).to include({
'@context' => include('https://www.w3.org/ns/activitystreams'),
'type' => 'Note',
'attributedTo' => ActivityPub::TagManager.instance.uri_for(account),
'contentMap' => include({
'zh-TW' => a_kind_of(String),
}),
})
end end
it 'has a replies collection' do it 'has a replies collection' do