1
0
Fork 0
mirror of https://github.com/mastodon/mastodon.git synced 2024-08-20 21:08:15 -07:00

Compare commits

..

20 commits

Author SHA1 Message Date
Claire
aa98c8fbeb
Disable Style/SymbolArray (#23921) 2023-03-03 22:55:43 +01:00
Nick Schonning
1840d5d50c
Remove pry gems (#23884) 2023-03-03 22:53:08 +01:00
Nick Schonning
b00f945d92
Remove implied StandardError rescue (#23942) 2023-03-03 22:49:16 +01:00
Nick Schonning
c65c34dfd1
Remove climate_control gem (#23886) 2023-03-03 22:48:48 +01:00
Claire
050f1669c6
Fix original account being unfollowed on migration before the follow request could be sent (#21957) 2023-03-03 21:13:55 +01:00
Claire
ddde4e0d95
Change ActivityPub::DeliveryWorker retries to be spread out more (#21956) 2023-03-03 21:08:22 +01:00
Christian Schmidt
5a8c651e8f
Only offer translation for supported languages (#23879) 2023-03-03 21:06:31 +01:00
Ramūns Usovs
0872f3e3d7
Allow streaming to connect to postgress with self-signed certs (#21431) 2023-03-03 21:01:18 +01:00
Jamie Hoyle
de137e6bb0
Added support for specifying S3 storage classes in environment (#22480) 2023-03-03 20:53:37 +01:00
Claire
3f52e717fa
Add tests for moderation actions without custom text (#23184) 2023-03-03 20:50:46 +01:00
Claire
6b16b77ab0
Fix external authentication not running onboarding code for new users (#23458) 2023-03-03 20:45:55 +01:00
Claire
8784498ebf
Fix tootctl accounts migrate error due to typo (#23567) 2023-03-03 20:45:12 +01:00
Claire
d6679d1751
Add mail headers to avoid auto-replies (#23597) 2023-03-03 20:44:46 +01:00
Claire
f94aa70b81
Fix error when displaying post history of a trendable post in the admin interface (#23574) 2023-03-03 20:44:02 +01:00
Terry Garcia
a1347f456e
Switched bookmark and favourites around (#23701) 2023-03-03 20:37:49 +01:00
Claire
02c6bad3ca
Change unintended SMTP read timeout from 5 seconds to 20 seconds (#23750) 2023-03-03 20:37:22 +01:00
Claire
f8bb4d0d6b
Fix server error when failing to follow back followers from /relationships (#23787) 2023-03-03 20:36:18 +01:00
Claire
b55fc883b6
Fix duplicate “Publish” button on mobile (#23804) 2023-03-03 20:25:36 +01:00
Claire
c2a046ded1
Fix “Remove all followers from the selected domains” being more destructive than it claims (#23805) 2023-03-03 20:25:15 +01:00
Claire
3a6451c867
Add support for incoming rich text (#23913) 2023-03-03 20:19:29 +01:00
42 changed files with 594 additions and 266 deletions

View file

@ -97,6 +97,10 @@ Rails/Exit:
- 'lib/mastodon/cli_helper.rb'
- 'lib/cli.rb'
RSpec/FilePath:
CustomTransform:
DeepL: deepl
RSpec/NotToNot:
EnforcedStyle: to_not
@ -123,3 +127,6 @@ Style/TrailingCommaInArrayLiteral:
Style/TrailingCommaInHashLiteral:
EnforcedStyleForMultiline: 'comma'
Style/SymbolArray:
Enabled: false

View file

@ -2235,134 +2235,3 @@ Style/SlicingWithRange:
- 'lib/active_record/batches.rb'
- 'lib/mastodon/premailer_webpack_strategy.rb'
- 'lib/tasks/repo.rake'
# Offense count: 272
# This cop supports safe autocorrection (--autocorrect).
# Configuration parameters: EnforcedStyle, MinSize.
# SupportedStyles: percent, brackets
Style/SymbolArray:
Exclude:
- 'app/controllers/accounts_controller.rb'
- 'app/controllers/activitypub/replies_controller.rb'
- 'app/controllers/admin/accounts_controller.rb'
- 'app/controllers/admin/announcements_controller.rb'
- 'app/controllers/admin/domain_blocks_controller.rb'
- 'app/controllers/admin/email_domain_blocks_controller.rb'
- 'app/controllers/admin/relationships_controller.rb'
- 'app/controllers/admin/relays_controller.rb'
- 'app/controllers/admin/roles_controller.rb'
- 'app/controllers/admin/rules_controller.rb'
- 'app/controllers/admin/statuses_controller.rb'
- 'app/controllers/admin/trends/statuses_controller.rb'
- 'app/controllers/admin/warning_presets_controller.rb'
- 'app/controllers/admin/webhooks_controller.rb'
- 'app/controllers/api/v1/accounts/credentials_controller.rb'
- 'app/controllers/api/v1/accounts_controller.rb'
- 'app/controllers/api/v1/admin/accounts_controller.rb'
- 'app/controllers/api/v1/admin/canonical_email_blocks_controller.rb'
- 'app/controllers/api/v1/admin/domain_allows_controller.rb'
- 'app/controllers/api/v1/admin/domain_blocks_controller.rb'
- 'app/controllers/api/v1/admin/email_domain_blocks_controller.rb'
- 'app/controllers/api/v1/admin/ip_blocks_controller.rb'
- 'app/controllers/api/v1/admin/reports_controller.rb'
- 'app/controllers/api/v1/crypto/deliveries_controller.rb'
- 'app/controllers/api/v1/crypto/keys/claims_controller.rb'
- 'app/controllers/api/v1/crypto/keys/uploads_controller.rb'
- 'app/controllers/api/v1/featured_tags_controller.rb'
- 'app/controllers/api/v1/filters_controller.rb'
- 'app/controllers/api/v1/lists_controller.rb'
- 'app/controllers/api/v1/notifications_controller.rb'
- 'app/controllers/api/v1/push/subscriptions_controller.rb'
- 'app/controllers/api/v1/scheduled_statuses_controller.rb'
- 'app/controllers/api/v1/statuses/reblogged_by_accounts_controller.rb'
- 'app/controllers/api/v1/statuses_controller.rb'
- 'app/controllers/api/v2/filters/keywords_controller.rb'
- 'app/controllers/api/v2/filters/statuses_controller.rb'
- 'app/controllers/api/v2/filters_controller.rb'
- 'app/controllers/api/web/push_subscriptions_controller.rb'
- 'app/controllers/application_controller.rb'
- 'app/controllers/auth/registrations_controller.rb'
- 'app/controllers/filters_controller.rb'
- 'app/controllers/settings/applications_controller.rb'
- 'app/controllers/settings/featured_tags_controller.rb'
- 'app/controllers/settings/profiles_controller.rb'
- 'app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb'
- 'app/controllers/statuses_controller.rb'
- 'app/lib/feed_manager.rb'
- 'app/models/account.rb'
- 'app/models/account_filter.rb'
- 'app/models/admin/status_filter.rb'
- 'app/models/announcement.rb'
- 'app/models/concerns/ldap_authenticable.rb'
- 'app/models/concerns/status_threading_concern.rb'
- 'app/models/custom_filter.rb'
- 'app/models/domain_block.rb'
- 'app/models/import.rb'
- 'app/models/list.rb'
- 'app/models/media_attachment.rb'
- 'app/models/preview_card.rb'
- 'app/models/relay.rb'
- 'app/models/report.rb'
- 'app/models/site_upload.rb'
- 'app/models/status.rb'
- 'app/serializers/initial_state_serializer.rb'
- 'app/serializers/rest/notification_serializer.rb'
- 'db/migrate/20160220174730_create_accounts.rb'
- 'db/migrate/20160221003621_create_follows.rb'
- 'db/migrate/20160223171800_create_favourites.rb'
- 'db/migrate/20160224223247_create_mentions.rb'
- 'db/migrate/20160314164231_add_owner_to_application.rb'
- 'db/migrate/20160316103650_add_missing_indices.rb'
- 'db/migrate/20160926213048_remove_owner_from_application.rb'
- 'db/migrate/20161003145426_create_blocks.rb'
- 'db/migrate/20161006213403_rails_settings_migration.rb'
- 'db/migrate/20161105130633_create_statuses_tags_join_table.rb'
- 'db/migrate/20161119211120_create_notifications.rb'
- 'db/migrate/20161128103007_create_subscriptions.rb'
- 'db/migrate/20161222204147_create_follow_requests.rb'
- 'db/migrate/20170112154826_migrate_settings.rb'
- 'db/migrate/20170301222600_create_mutes.rb'
- 'db/migrate/20170406215816_add_notifications_and_favourites_indices.rb'
- 'db/migrate/20170424003227_create_account_domain_blocks.rb'
- 'db/migrate/20170427011934_re_add_owner_to_application.rb'
- 'db/migrate/20170507141759_optimize_index_subscriptions.rb'
- 'db/migrate/20170508230434_create_conversation_mutes.rb'
- 'db/migrate/20170720000000_add_index_favourites_on_account_id_and_id.rb'
- 'db/migrate/20170823162448_create_status_pins.rb'
- 'db/migrate/20170901142658_create_join_table_preview_cards_statuses.rb'
- 'db/migrate/20170905044538_add_index_id_account_id_activity_type_on_notifications.rb'
- 'db/migrate/20170917153509_create_custom_emojis.rb'
- 'db/migrate/20170918125918_ids_to_bigints.rb'
- 'db/migrate/20171116161857_create_list_accounts.rb'
- 'db/migrate/20171122120436_add_index_account_and_reblog_of_id_to_statuses.rb'
- 'db/migrate/20171125185353_add_index_reblog_of_id_and_account_to_statuses.rb'
- 'db/migrate/20171125190735_remove_old_reblog_index_on_statuses.rb'
- 'db/migrate/20171129172043_add_index_on_stream_entries.rb'
- 'db/migrate/20171226094803_more_faster_index_on_notifications.rb'
- 'db/migrate/20180106000232_add_index_on_statuses_for_api_v1_accounts_account_id_statuses.rb'
- 'db/migrate/20180514140000_revert_index_change_on_statuses_for_api_v1_accounts_account_id_statuses.rb'
- 'db/migrate/20180808175627_create_account_pins.rb'
- 'db/migrate/20180831171112_create_bookmarks.rb'
- 'db/migrate/20180929222014_create_account_conversations.rb'
- 'db/migrate/20181007025445_create_pghero_space_stats.rb'
- 'db/migrate/20181203003808_create_accounts_tags_join_table.rb'
- 'db/migrate/20190316190352_create_account_identity_proofs.rb'
- 'db/migrate/20190511134027_add_silenced_at_suspended_at_to_accounts.rb'
- 'db/migrate/20190820003045_update_statuses_index.rb'
- 'db/migrate/20190823221802_add_local_index_to_statuses.rb'
- 'db/migrate/20190904222339_create_markers.rb'
- 'db/migrate/20200113125135_create_announcement_mutes.rb'
- 'db/migrate/20200114113335_create_announcement_reactions.rb'
- 'db/migrate/20200119112504_add_public_index_to_statuses.rb'
- 'db/migrate/20200628133322_create_account_notes.rb'
- 'db/migrate/20200917222316_add_index_notifications_on_type.rb'
- 'db/migrate/20210425135952_add_index_on_media_attachments_account_id_status_id.rb'
- 'db/migrate/20220714171049_create_tag_follows.rb'
- 'db/migrate/20221021055441_add_index_featured_tags_on_account_id_and_tag_id.rb'
- 'db/post_migrate/20190511152737_remove_suspended_silenced_account_fields.rb'
- 'db/post_migrate/20200917222734_remove_index_notifications_on_account_activity.rb'
- 'spec/controllers/api/v1/streaming_controller_spec.rb'
- 'spec/controllers/api/v2/admin/accounts_controller_spec.rb'
- 'spec/controllers/concerns/signature_verification_spec.rb'
- 'spec/fabricators/notification_fabricator.rb'
- 'spec/models/public_feed_spec.rb'

View file

@ -104,8 +104,6 @@ group :development, :test do
gem 'fabrication', '~> 2.30'
gem 'fuubar', '~> 2.5'
gem 'i18n-tasks', '~> 1.0', require: false
gem 'pry-byebug', '~> 3.10'
gem 'pry-rails', '~> 0.3'
gem 'rspec-rails', '~> 6.0'
gem 'rubocop-performance', require: false
gem 'rubocop-rails', require: false
@ -119,7 +117,6 @@ end
group :test do
gem 'capybara', '~> 3.38'
gem 'climate_control', '~> 0.2'
gem 'faker', '~> 3.1'
gem 'json-schema', '~> 3.0'
gem 'rack-test', '~> 2.0'

View file

@ -155,7 +155,6 @@ GEM
bundler-audit (0.9.1)
bundler (>= 1.2.0, < 3)
thor (~> 1.0)
byebug (11.1.3)
capistrano (3.17.2)
airbrussh (>= 1.0.0)
i18n
@ -497,14 +496,6 @@ GEM
net-smtp
premailer (~> 1.7, >= 1.7.9)
private_address_check (0.5.0)
pry (0.14.1)
coderay (~> 1.1)
method_source (~> 1.0)
pry-byebug (3.10.1)
byebug (~> 11.0)
pry (>= 0.13, < 0.15)
pry-rails (0.3.9)
pry (>= 0.10.4)
public_suffix (5.0.1)
puma (6.1.0)
nio4r (~> 2.0)
@ -792,7 +783,6 @@ DEPENDENCIES
capybara (~> 3.38)
charlock_holmes (~> 0.7.7)
chewy (~> 7.2)
climate_control (~> 0.2)
cocoon (~> 1.2)
color_diff (~> 0.1)
concurrent-ruby
@ -850,8 +840,6 @@ DEPENDENCIES
posix-spawn
premailer-rails
private_address_check (~> 0.5)
pry-byebug (~> 3.10)
pry-rails (~> 0.3)
public_suffix (~> 5.0)
puma (~> 6.1)
pundit (~> 2.3)

View file

@ -19,6 +19,8 @@ class RelationshipsController < ApplicationController
@form.save
rescue ActionController::ParameterMissing
# Do nothing
rescue Mastodon::NotPermittedError, ActiveRecord::RecordNotFound
flash[:alert] = I18n.t('relationships.follow_failure') if action_from_button == 'follow'
ensure
redirect_to relationships_path(filter_params)
end
@ -60,8 +62,8 @@ class RelationshipsController < ApplicationController
'unfollow'
elsif params[:remove_from_followers]
'remove_from_followers'
elsif params[:block_domains]
'block_domains'
elsif params[:block_domains] || params[:remove_domains_from_followers]
'remove_domains_from_followers'
end
end

View file

@ -6,7 +6,7 @@ import { Link } from 'react-router-dom';
import classnames from 'classnames';
import PollContainer from 'mastodon/containers/poll_container';
import Icon from 'mastodon/components/icon';
import { autoPlayGif, languages as preloadedLanguages, translationEnabled } from 'mastodon/initial_state';
import { autoPlayGif, languages as preloadedLanguages } from 'mastodon/initial_state';
const MAX_HEIGHT = 706; // 22px * 32 (+ 2px padding at the top)
@ -220,7 +220,7 @@ class StatusContent extends React.PureComponent {
const hidden = this.props.onExpandedToggle ? !this.props.expanded : this.state.hidden;
const renderReadMore = this.props.onClick && status.get('collapsed');
const renderTranslate = translationEnabled && this.context.identity.signedIn && this.props.onTranslate && ['public', 'unlisted'].includes(status.get('visibility')) && status.get('contentHtml').length > 0 && status.get('language') !== null && intl.locale !== status.get('language');
const renderTranslate = this.props.onTranslate && status.get('translatable');
const content = { __html: status.get('translation') ? status.getIn(['translation', 'content']) : status.get('contentHtml') };
const spoilerContent = { __html: status.get('spoilerHtml') };

View file

@ -22,8 +22,8 @@ const mapDispatchToProps = (dispatch) => ({
},
});
export default @connect(null, mapDispatchToProps)
@withRouter
export default @withRouter
@connect(null, mapDispatchToProps)
class Header extends React.PureComponent {
static contextTypes = {

View file

@ -82,8 +82,8 @@ class NavigationPanel extends React.Component {
{signedIn && (
<React.Fragment>
<ColumnLink transparent to='/conversations' icon='at' text={intl.formatMessage(messages.direct)} />
<ColumnLink transparent to='/favourites' icon='star' text={intl.formatMessage(messages.favourites)} />
<ColumnLink transparent to='/bookmarks' icon='bookmark' text={intl.formatMessage(messages.bookmarks)} />
<ColumnLink transparent to='/favourites' icon='star' text={intl.formatMessage(messages.favourites)} />
<ColumnLink transparent to='/lists' icon='list-ul' text={intl.formatMessage(messages.lists)} />
<ListPanel />

View file

@ -80,7 +80,6 @@
* @property {boolean} use_blurhash
* @property {boolean=} use_pending_items
* @property {string} version
* @property {boolean} translation_enabled
*/
/**
@ -132,7 +131,6 @@ export const unfollowModal = getMeta('unfollow_modal');
export const useBlurhash = getMeta('use_blurhash');
export const usePendingItems = getMeta('use_pending_items');
export const version = getMeta('version');
export const translationEnabled = getMeta('translation_enabled');
export const languages = initialState?.languages;
export const statusPageUrl = getMeta('status_page_url');

View file

@ -23,3 +23,4 @@
@import 'mastodon/dashboard';
@import 'mastodon/rtl';
@import 'mastodon/accessibility';
@import 'mastodon/rich_text';

View file

@ -0,0 +1,64 @@
.status__content__text,
.e-content,
.reply-indicator__content {
pre,
blockquote {
margin-bottom: 20px;
white-space: pre-wrap;
unicode-bidi: plaintext;
&:last-child {
margin-bottom: 0;
}
}
blockquote {
padding-left: 10px;
border-left: 3px solid $darker-text-color;
color: $darker-text-color;
white-space: normal;
p:last-child {
margin-bottom: 0;
}
}
& > ul,
& > ol {
margin-bottom: 20px;
}
b,
strong {
font-weight: 700;
}
em,
i {
font-style: italic;
}
ul,
ol {
margin-left: 2em;
p {
margin: 0;
}
}
ul {
list-style-type: disc;
}
ol {
list-style-type: decimal;
}
}
.reply-indicator__content {
blockquote {
border-left-color: $inverted-text-color;
color: $inverted-text-color;
}
}

View file

@ -21,6 +21,10 @@ class TranslationService
ENV['DEEPL_API_KEY'].present? || ENV['LIBRE_TRANSLATE_ENDPOINT'].present?
end
def supported?(_source_language, _target_language)
false
end
def translate(_text, _source_language, _target_language)
raise NotImplementedError
end

View file

@ -11,33 +11,53 @@ class TranslationService::DeepL < TranslationService
end
def translate(text, source_language, target_language)
request(text, source_language, target_language).perform do |res|
form = { text: text, source_lang: source_language&.upcase, target_lang: target_language, tag_handling: 'html' }
request(:post, '/v2/translate', form: form) do |res|
transform_response(res.body_with_limit)
end
end
def supported?(source_language, target_language)
source_language.in?(languages('source')) && target_language.in?(languages('target'))
end
private
def languages(type)
Rails.cache.fetch("translation_service/deepl/languages/#{type}", expires_in: 7.days, race_condition_ttl: 1.minute) do
request(:get, "/v2/languages?type=#{type}") do |res|
# In DeepL, EN and PT are deprecated in favor of EN-GB/EN-US and PT-BR/PT-PT, so
# they are supported but not returned by the API.
extra = type == 'source' ? [nil] : %w(en pt)
languages = Oj.load(res.body_with_limit).map { |language| language['language'].downcase }
languages + extra
end
end
end
def request(verb, path, **options)
req = Request.new(verb, "#{base_url}#{path}", **options)
req.add_headers(Authorization: "DeepL-Auth-Key #{@api_key}")
req.perform do |res|
case res.code
when 429
raise TooManyRequestsError
when 456
raise QuotaExceededError
when 200...300
transform_response(res.body_with_limit)
yield res
else
raise UnexpectedResponseError
end
end
end
private
def request(text, source_language, target_language)
req = Request.new(:post, endpoint_url, form: { text: text, source_lang: source_language&.upcase, target_lang: target_language, tag_handling: 'html' })
req.add_headers(Authorization: "DeepL-Auth-Key #{@api_key}")
req
end
def endpoint_url
def base_url
if @plan == 'free'
'https://api-free.deepl.com/v2/translate'
'https://api-free.deepl.com'
else
'https://api.deepl.com/v2/translate'
'https://api.deepl.com'
end
end

View file

@ -9,29 +9,45 @@ class TranslationService::LibreTranslate < TranslationService
end
def translate(text, source_language, target_language)
request(text, source_language, target_language).perform do |res|
body = Oj.dump(q: text, source: source_language.presence || 'auto', target: target_language, format: 'html', api_key: @api_key)
request(:post, '/translate', body: body) do |res|
transform_response(res.body_with_limit, source_language)
end
end
def supported?(source_language, target_language)
languages.key?(source_language) && languages[source_language].include?(target_language)
end
private
def languages
Rails.cache.fetch('translation_service/libre_translate/languages', expires_in: 7.days, race_condition_ttl: 1.minute) do
request(:get, '/languages') do |res|
languages = Oj.load(res.body_with_limit).to_h { |language| [language['code'], language['targets']] }
languages[nil] = languages.values.flatten.uniq
languages
end
end
end
def request(verb, path, **options)
req = Request.new(verb, "#{@base_url}#{path}", allow_local: true, **options)
req.add_headers('Content-Type': 'application/json')
req.perform do |res|
case res.code
when 429
raise TooManyRequestsError
when 403
raise QuotaExceededError
when 200...300
transform_response(res.body_with_limit, source_language)
yield res
else
raise UnexpectedResponseError
end
end
end
private
def request(text, source_language, target_language)
body = Oj.dump(q: text, source: source_language.presence || 'auto', target: target_language, format: 'html', api_key: @api_key)
req = Request.new(:post, "#{@base_url}/translate", body: body, allow_local: true)
req.add_headers('Content-Type': 'application/json')
req
end
def transform_response(str, source_language)
json = Oj.load(str, mode: :strict)

View file

@ -7,9 +7,17 @@ class ApplicationMailer < ActionMailer::Base
helper :instance
helper :formatting
after_action :set_autoreply_headers!
protected
def locale_for_account(account, &block)
I18n.with_locale(account.user_locale || I18n.default_locale, &block)
end
def set_autoreply_headers!
headers['Precedence'] = 'list'
headers['X-Auto-Response-Suppress'] = 'All'
headers['Auto-Submitted'] = 'auto-generated'
end
end

View file

@ -61,7 +61,7 @@ module Omniauthable
user.account.avatar_remote_url = nil
end
user.skip_confirmation! if email_is_verified
user.confirm! if email_is_verified
user.save!
user
end

View file

@ -17,8 +17,8 @@ class Form::AccountBatch
unfollow!
when 'remove_from_followers'
remove_from_followers!
when 'block_domains'
block_domains!
when 'remove_domains_from_followers'
remove_domains_from_followers!
when 'approve'
approve!
when 'reject'
@ -35,9 +35,15 @@ class Form::AccountBatch
private
def follow!
error = nil
accounts.each do |target_account|
FollowService.new.call(current_account, target_account)
rescue Mastodon::NotPermittedError, ActiveRecord::RecordNotFound => e
error ||= e
end
raise error if error.present?
end
def unfollow!
@ -50,10 +56,8 @@ class Form::AccountBatch
RemoveFromFollowersService.new.call(current_account, account_ids)
end
def block_domains!
AfterAccountDomainBlockWorker.push_bulk(account_domains) do |domain|
[current_account.id, domain]
end
def remove_domains_from_followers!
RemoveDomainsFromFollowersService.new.call(current_account, account_domains)
end
def account_domains

View file

@ -232,6 +232,16 @@ class Status < ApplicationRecord
public_visibility? || unlisted_visibility?
end
def translatable?
translate_target_locale = I18n.locale.to_s.split(/[_-]/).first
distributable? &&
content.present? &&
language != translate_target_locale &&
TranslationService.configured? &&
TranslationService.configured.supported?(language, translate_target_locale)
end
alias sign? distributable?
def with_media?

View file

@ -30,7 +30,6 @@ class InitialStateSerializer < ActiveModel::Serializer
timeline_preview: Setting.timeline_preview,
activity_api_enabled: Setting.activity_api_enabled,
single_user_mode: Rails.configuration.x.single_user_mode,
translation_enabled: TranslationService.configured?,
trends_as_landing_page: Setting.trends_as_landing_page,
status_page_url: Setting.status_page_url,
}

View file

@ -4,7 +4,7 @@ class REST::StatusSerializer < ActiveModel::Serializer
include FormattingHelper
attributes :id, :created_at, :in_reply_to_id, :in_reply_to_account_id,
:sensitive, :spoiler_text, :visibility, :language,
:sensitive, :spoiler_text, :visibility, :language, :translatable,
:uri, :url, :replies_count, :reblogs_count,
:favourites_count, :edited_at
@ -50,6 +50,10 @@ class REST::StatusSerializer < ActiveModel::Serializer
object.account.user_shows_application? || (current_user? && current_user.account_id == object.account_id)
end
def translatable
current_user? && object.translatable?
end
def visibility
# This visibility is masked behind "private"
# to avoid API changes because there are no

View file

@ -0,0 +1,40 @@
# frozen_string_literal: true
class FollowMigrationService < FollowService
# Follow an account with the same settings as another account, and unfollow the old account once the request is sent
# @param [Account] source_account From which to follow
# @param [Account] target_account Account to follow
# @param [Account] old_target_account Account to unfollow once the follow request has been sent to the new one
# @option [Boolean] bypass_locked Whether to immediately follow the new account even if it is locked
def call(source_account, target_account, old_target_account, bypass_locked: false)
@old_target_account = old_target_account
follow = source_account.active_relationships.find_by(target_account: old_target_account)
reblogs = follow&.show_reblogs?
notify = follow&.notify?
languages = follow&.languages
super(source_account, target_account, reblogs: reblogs, notify: notify, languages: languages, bypass_locked: bypass_locked, bypass_limit: true)
end
private
def request_follow!
follow_request = @source_account.request_follow!(@target_account, **follow_options.merge(rate_limit: @options[:with_rate_limit], bypass_limit: @options[:bypass_limit]))
if @target_account.local?
LocalNotificationWorker.perform_async(@target_account.id, follow_request.id, follow_request.class.name, 'follow_request')
UnfollowService.new.call(@source_account, @old_target_account, skip_unmerge: true)
elsif @target_account.activitypub?
ActivityPub::MigratedFollowDeliveryWorker.perform_async(build_json(follow_request), @source_account.id, @target_account.inbox_url, @old_target_account.id)
end
follow_request
end
def direct_follow!
follow = super
UnfollowService.new.call(@source_account, @old_target_account, skip_unmerge: true)
follow
end
end

View file

@ -0,0 +1,23 @@
# frozen_string_literal: true
class RemoveDomainsFromFollowersService < BaseService
include Payloadable
def call(source_account, target_domains)
source_account.passive_relationships.where(account_id: Account.where(domain: target_domains)).find_each do |follow|
follow.destroy
create_notification(follow) if source_account.local? && !follow.account.local? && follow.account.activitypub?
end
end
private
def create_notification(follow)
ActivityPub::DeliveryWorker.perform_async(build_json(follow), follow.target_account_id, follow.account.inbox_url)
end
def build_json(follow)
Oj.dump(serialize_payload(follow, ActivityPub::RejectFollowSerializer))
end
end

View file

@ -6,7 +6,7 @@ class TranslateStatusService < BaseService
include FormattingHelper
def call(status, target_language)
raise Mastodon::NotPermittedError unless status.public_visibility? || status.unlisted_visibility?
raise Mastodon::NotPermittedError unless status.translatable?
@status = status
@content = status_content_format(@status)

View file

@ -34,7 +34,7 @@
%td
- if @status.trend.allowed?
%abbr{ title: t('admin.trends.tags.current_score', score: @status.trend.score) }= t('admin.trends.tags.trending_rank', rank: @status.trend.rank)
- elsif @status.trend.requires_review?
- elsif @status.requires_review?
= t('admin.trends.pending_review')
- else
= t('admin.trends.not_allowed_to_trend')

View file

@ -48,7 +48,7 @@
= f.button safe_join([fa_icon('trash'), t('relationships.remove_selected_followers')]), name: :remove_from_followers, class: 'table-action-link', type: :submit, data: { confirm: t('relationships.confirm_remove_selected_followers') } unless following_relationship?
= f.button safe_join([fa_icon('trash'), t('relationships.remove_selected_domains')]), name: :block_domains, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') } if followed_by_relationship?
= f.button safe_join([fa_icon('trash'), t('relationships.remove_selected_domains')]), name: :remove_domains_from_followers, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') } if followed_by_relationship?
.batch-table__body
- if @accounts.empty?
= nothing_here 'nothing-here--under-tabs'

View file

@ -10,6 +10,16 @@ class ActivityPub::DeliveryWorker
sidekiq_options queue: 'push', retry: 16, dead: false
# Unfortunately, we cannot control Sidekiq's jitter, so add our own
sidekiq_retry_in do |count|
# This is Sidekiq's default delay
delay = (count**4) + 15
# Our custom jitter, that will be added to Sidekiq's built-in one.
# Sidekiq's built-in jitter is `rand(10) * (count + 1)`
jitter = rand(0.5 * (count**4))
delay + jitter
end
HEADERS = { 'Content-Type' => 'application/activity+json' }.freeze
def perform(json, source_account_id, inbox_url, options = {})

View file

@ -0,0 +1,17 @@
# frozen_string_literal: true
class ActivityPub::MigratedFollowDeliveryWorker < ActivityPub::DeliveryWorker
def perform(json, source_account_id, inbox_url, old_target_account_id, options = {})
super(json, source_account_id, inbox_url, options)
unfollow_old_account!(old_target_account_id)
end
private
def unfollow_old_account!(old_target_account_id)
old_target_account = Account.find(old_target_account_id)
UnfollowService.new.call(@source_account, old_target_account, skip_unmerge: true)
rescue
true
end
end

View file

@ -10,13 +10,7 @@ class UnfollowFollowWorker
old_target_account = Account.find(old_target_account_id)
new_target_account = Account.find(new_target_account_id)
follow = follower_account.active_relationships.find_by(target_account: old_target_account)
reblogs = follow&.show_reblogs?
notify = follow&.notify?
languages = follow&.languages
FollowService.new.call(follower_account, new_target_account, reblogs: reblogs, notify: notify, languages: languages, bypass_locked: bypass_locked, bypass_limit: true)
UnfollowService.new.call(follower_account, old_target_account, skip_unmerge: true)
FollowMigrationService.new.call(follower_account, new_target_account, old_target_account, bypass_locked: bypass_locked)
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
true
end

View file

@ -128,6 +128,7 @@ Rails.application.configure do
enable_starttls_auto: enable_starttls_auto,
tls: ENV['SMTP_TLS'].presence && ENV['SMTP_TLS'] == 'true',
ssl: ENV['SMTP_SSL'].presence && ENV['SMTP_SSL'] == 'true',
read_timeout: 20,
}
config.action_mailer.delivery_method = ENV.fetch('SMTP_DELIVERY_METHOD', 'smtp').to_sym

View file

@ -90,6 +90,12 @@ if ENV['S3_ENABLED'] == 'true'
)
end
if ENV.has_key?('S3_STORAGE_CLASS')
Paperclip::Attachment.default_options[:s3_headers].merge!(
'X-Amz-Storage-Class' => ENV['S3_STORAGE_CLASS']
)
end
# Some S3-compatible providers might not actually be compatible with some APIs
# used by kt-paperclip, see https://github.com/mastodon/mastodon/issues/16822
if ENV['S3_FORCE_SINGLE_REQUEST'] == 'true'

View file

@ -1408,6 +1408,7 @@ en:
confirm_remove_selected_followers: Are you sure you want to remove selected followers?
confirm_remove_selected_follows: Are you sure you want to remove selected follows?
dormant: Dormant
follow_failure: Could not follow some of the selected accounts.
follow_selected_followers: Follow selected followers
followers: Followers
following: Following

View file

@ -627,7 +627,7 @@ module Mastodon
exit(1)
end
unless options[:force] || migration.target_acount_id == account.moved_to_account_id
unless options[:force] || migration.target_account_id == account.moved_to_account_id
say('The specified account is not redirecting to its last migration target. Use --force if you want to replay the migration anyway', :red)
exit(1)
end

View file

@ -51,29 +51,22 @@ class Sanitize
end
UNSUPPORTED_ELEMENTS_TRANSFORMER = lambda do |env|
return unless %w(h1 h2 h3 h4 h5 h6 blockquote pre ul ol li).include?(env[:node_name])
return unless %w(h1 h2 h3 h4 h5 h6).include?(env[:node_name])
current_node = env[:node]
case env[:node_name]
when 'li'
current_node.traverse do |node|
next unless %w(p ul ol li).include?(node.name)
node.add_next_sibling('<br>') if node.next_sibling
node.replace(node.children) unless node.text?
end
else
current_node.name = 'p'
end
current_node.name = 'strong'
current_node.wrap('<p></p>')
end
MASTODON_STRICT ||= freeze_config(
elements: %w(p br span a),
elements: %w(p br span a del pre blockquote code b strong u i em ul ol li),
attributes: {
'a' => %w(href rel class),
'span' => %w(class),
'ol' => %w(start reversed),
'li' => %w(value),
},
add_attributes: {

View file

@ -83,6 +83,7 @@
"object.values": "^1.1.6",
"path-complete-extname": "^1.0.0",
"pg": "^8.5.0",
"pg-connection-string": "^2.5.0",
"postcss": "^8.4.21",
"postcss-loader": "^3.0.0",
"promise.prototype.finally": "^3.1.4",

View file

@ -57,6 +57,9 @@ describe Admin::Reports::ActionsController do
let!(:media) { Fabricate(:media_attachment, account: target_account, status: statuses[0]) }
let(:report) { Fabricate(:report, target_account: target_account, status_ids: statuses.map(&:id)) }
let(:text) { 'hello' }
let(:common_params) do
{ report_id: report.id, text: text }
end
shared_examples 'common behavior' do
it 'closes the report' do
@ -72,6 +75,26 @@ describe Admin::Reports::ActionsController do
subject
expect(response).to redirect_to(admin_reports_path)
end
context 'when text is unset' do
let(:common_params) do
{ report_id: report.id }
end
it 'closes the report' do
expect { subject }.to change { report.reload.action_taken? }.from(false).to(true)
end
it 'creates a strike with the expected text' do
expect { subject }.to change { report.target_account.strikes.count }.by(1)
expect(report.target_account.strikes.last.text).to eq ''
end
it 'redirects' do
subject
expect(response).to redirect_to(admin_reports_path)
end
end
end
shared_examples 'all action types' do
@ -124,13 +147,13 @@ describe Admin::Reports::ActionsController do
end
context 'action as submit button' do
subject { post :create, params: { report_id: report.id, text: text, action => '' } }
subject { post :create, params: common_params.merge({ action => '' }) }
it_behaves_like 'all action types'
end
context 'action as submit button' do
subject { post :create, params: { report_id: report.id, text: text, moderation_action: action } }
subject { post :create, params: common_params.merge({ moderation_action: action }) }
it_behaves_like 'all action types'
end

View file

@ -58,7 +58,7 @@ describe RelationshipsController do
end
context 'when select parameter is provided' do
subject { patch :update, params: { form_account_batch: { account_ids: [poopfeast.id] }, block_domains: '' } }
subject { patch :update, params: { form_account_batch: { account_ids: [poopfeast.id] }, remove_domains_from_followers: '' } }
it 'soft-blocks followers from selected domains' do
poopfeast.follow!(user.account)
@ -69,6 +69,15 @@ describe RelationshipsController do
expect(poopfeast.following?(user.account)).to be false
end
it 'does not unfollow users from selected domains' do
user.account.follow!(poopfeast)
sign_in user, scope: :user
subject
expect(user.account.following?(poopfeast)).to be true
end
include_examples 'authenticate user'
include_examples 'redirects back to followers page'
end

View file

@ -6,24 +6,16 @@ describe Sanitize::Config do
describe '::MASTODON_STRICT' do
subject { Sanitize::Config::MASTODON_STRICT }
it 'converts h1 to p' do
expect(Sanitize.fragment('<h1>Foo</h1>', subject)).to eq '<p>Foo</p>'
it 'converts h1 to p strong' do
expect(Sanitize.fragment('<h1>Foo</h1>', subject)).to eq '<p><strong>Foo</strong></p>'
end
it 'converts ul to p' do
expect(Sanitize.fragment('<p>Check out:</p><ul><li>Foo</li><li>Bar</li></ul>', subject)).to eq '<p>Check out:</p><p>Foo<br>Bar</p>'
it 'keeps ul' do
expect(Sanitize.fragment('<p>Check out:</p><ul><li>Foo</li><li>Bar</li></ul>', subject)).to eq '<p>Check out:</p><ul><li>Foo</li><li>Bar</li></ul>'
end
it 'converts p inside ul' do
expect(Sanitize.fragment('<ul><li><p>Foo</p><p>Bar</p></li><li>Baz</li></ul>', subject)).to eq '<p>Foo<br>Bar<br>Baz</p>'
end
it 'converts ul inside ul' do
expect(Sanitize.fragment('<ul><li>Foo</li><li><ul><li>Bar</li><li>Baz</li></ul></li></ul>', subject)).to eq '<p>Foo<br>Bar<br>Baz</p>'
end
it 'keep links in lists' do
expect(Sanitize.fragment('<p>Check out:</p><ul><li><a href="https://joinmastodon.org" rel="nofollow noopener noreferrer" target="_blank">joinmastodon.org</a></li><li>Bar</li></ul>', subject)).to eq '<p>Check out:</p><p><a href="https://joinmastodon.org" rel="nofollow noopener noreferrer" target="_blank">joinmastodon.org</a><br>Bar</p>'
it 'keeps start and reversed attributes of ol' do
expect(Sanitize.fragment('<p>Check out:</p><ol start="3" reversed=""><li>Foo</li><li>Bar</li></ol>', subject)).to eq '<p>Check out:</p><ol start="3" reversed=""><li>Foo</li><li>Bar</li></ol>'
end
it 'removes a without href' do
@ -45,5 +37,13 @@ describe Sanitize::Config do
it 'keeps a with href' do
expect(Sanitize.fragment('<a href="http://example.com">Test</a>', subject)).to eq '<a href="http://example.com" rel="nofollow noopener noreferrer" target="_blank">Test</a>'
end
it 'removes a with unparsable href' do
expect(Sanitize.fragment('<a href=" https://google.fr">Test</a>', subject)).to eq 'Test'
end
it 'keeps a with supported scheme and no host' do
expect(Sanitize.fragment('<a href="dweb:/a/foo">Test</a>', subject)).to eq '<a href="dweb:/a/foo" rel="nofollow noopener noreferrer" target="_blank">Test</a>'
end
end
end

View file

@ -0,0 +1,100 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe TranslationService::DeepL do
subject(:service) { described_class.new(plan, 'my-api-key') }
let(:plan) { 'advanced' }
before do
stub_request(:get, 'https://api.deepl.com/v2/languages?type=source').to_return(
body: '[{"language":"EN","name":"English"},{"language":"UK","name":"Ukrainian"}]'
)
stub_request(:get, 'https://api.deepl.com/v2/languages?type=target').to_return(
body: '[{"language":"EN-GB","name":"English (British)"},{"language":"ZH","name":"Chinese"}]'
)
end
describe '#supported?' do
it 'supports included languages as source and target languages' do
expect(service.supported?('uk', 'en')).to be true
end
it 'supports auto-detecting source language' do
expect(service.supported?(nil, 'en')).to be true
end
it 'supports "en" and "pt" as target languages though not included in language list' do
expect(service.supported?('uk', 'en')).to be true
expect(service.supported?('uk', 'pt')).to be true
end
it 'does not support non-included language as target language' do
expect(service.supported?('uk', 'nl')).to be false
end
it 'does not support non-included language as source language' do
expect(service.supported?('da', 'en')).to be false
end
end
describe '#translate' do
it 'returns translation with specified source language' do
stub_request(:post, 'https://api.deepl.com/v2/translate')
.with(body: 'text=Hasta+la+vista&source_lang=ES&target_lang=en&tag_handling=html')
.to_return(body: '{"translations":[{"detected_source_language":"ES","text":"See you soon"}]}')
translation = service.translate('Hasta la vista', 'es', 'en')
expect(translation.detected_source_language).to eq 'es'
expect(translation.provider).to eq 'DeepL.com'
expect(translation.text).to eq 'See you soon'
end
it 'returns translation with auto-detected source language' do
stub_request(:post, 'https://api.deepl.com/v2/translate')
.with(body: 'text=Guten+Tag&source_lang&target_lang=en&tag_handling=html')
.to_return(body: '{"translations":[{"detected_source_language":"DE","text":"Good Morning"}]}')
translation = service.translate('Guten Tag', nil, 'en')
expect(translation.detected_source_language).to eq 'de'
expect(translation.provider).to eq 'DeepL.com'
expect(translation.text).to eq 'Good Morning'
end
end
describe '#languages?' do
it 'returns source languages' do
expect(service.send(:languages, 'source')).to eq ['en', 'uk', nil]
end
it 'returns target languages' do
expect(service.send(:languages, 'target')).to eq %w(en-gb zh en pt)
end
end
describe '#request' do
before do
stub_request(:any, //)
# rubocop:disable Lint/EmptyBlock
service.send(:request, :get, '/v2/languages') { |res| }
# rubocop:enable Lint/EmptyBlock
end
it 'uses paid plan base URL' do
expect(a_request(:get, 'https://api.deepl.com/v2/languages')).to have_been_made.once
end
context 'with free plan' do
let(:plan) { 'free' }
it 'uses free plan base URL' do
expect(a_request(:get, 'https://api-free.deepl.com/v2/languages')).to have_been_made.once
end
end
it 'sends API key' do
expect(a_request(:get, 'https://api.deepl.com/v2/languages').with(headers: { Authorization: 'DeepL-Auth-Key my-api-key' })).to have_been_made.once
end
end
end

View file

@ -0,0 +1,71 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe TranslationService::LibreTranslate do
subject(:service) { described_class.new('https://libretranslate.example.com', 'my-api-key') }
before do
stub_request(:get, 'https://libretranslate.example.com/languages').to_return(
body: '[{"code": "en","name": "English","targets": ["de","es"]},{"code": "da","name": "Danish","targets": ["en","de"]}]'
)
end
describe '#supported?' do
it 'supports included language pair' do
expect(service.supported?('en', 'de')).to be true
end
it 'does not support reversed language pair' do
expect(service.supported?('de', 'en')).to be false
end
it 'supports auto-detecting source language' do
expect(service.supported?(nil, 'de')).to be true
end
it 'does not support auto-detecting for unsupported target language' do
expect(service.supported?(nil, 'pt')).to be false
end
end
describe '#languages' do
subject(:languages) { service.send(:languages) }
it 'includes supported source languages' do
expect(languages.keys).to eq ['en', 'da', nil]
end
it 'includes supported target languages for source language' do
expect(languages['en']).to eq %w(de es)
end
it 'includes supported target languages for auto-detected language' do
expect(languages[nil]).to eq %w(de es en)
end
end
describe '#translate' do
it 'returns translation with specified source language' do
stub_request(:post, 'https://libretranslate.example.com/translate')
.with(body: '{"q":"Hasta la vista","source":"es","target":"en","format":"html","api_key":"my-api-key"}')
.to_return(body: '{"translatedText": "See you"}')
translation = service.translate('Hasta la vista', 'es', 'en')
expect(translation.detected_source_language).to eq 'es'
expect(translation.provider).to eq 'LibreTranslate'
expect(translation.text).to eq 'See you'
end
it 'returns translation with auto-detected source language' do
stub_request(:post, 'https://libretranslate.example.com/translate')
.with(body: '{"q":"Guten Morgen","source":"auto","target":"en","format":"html","api_key":"my-api-key"}')
.to_return(body: '{"detectedLanguage":{"confidence":92,"language":"de"},"translatedText":"Good morning"}')
translation = service.translate('Guten Morgen', nil, 'en')
expect(translation.detected_source_language).to be_nil
expect(translation.provider).to eq 'LibreTranslate'
expect(translation.text).to eq 'Good morning'
end
end
end

View file

@ -114,6 +114,85 @@ RSpec.describe Status, type: :model do
end
end
describe '#translatable?' do
before do
allow(TranslationService).to receive(:configured?).and_return(true)
allow(TranslationService).to receive(:configured).and_return(TranslationService.new)
allow(TranslationService.configured).to receive(:supported?).with('es', 'en').and_return(true)
subject.language = 'es'
subject.visibility = :public
end
context 'all conditions are satisfied' do
it 'returns true' do
expect(subject.translatable?).to be true
end
end
context 'translation service is not configured' do
it 'returns false' do
allow(TranslationService).to receive(:configured?).and_return(false)
allow(TranslationService).to receive(:configured).and_raise(TranslationService::NotConfiguredError)
expect(subject.translatable?).to be false
end
end
context 'status language is nil' do
it 'returns true' do
subject.language = nil
allow(TranslationService.configured).to receive(:supported?).with(nil, 'en').and_return(true)
expect(subject.translatable?).to be true
end
end
context 'status language is same as default locale' do
it 'returns false' do
subject.language = I18n.locale
expect(subject.translatable?).to be false
end
end
context 'status language is unsupported' do
it 'returns false' do
subject.language = 'af'
allow(TranslationService.configured).to receive(:supported?).with('af', 'en').and_return(false)
expect(subject.translatable?).to be false
end
end
context 'default locale is unsupported' do
it 'returns false' do
allow(TranslationService.configured).to receive(:supported?).with('es', 'af').and_return(false)
I18n.with_locale('af') do
expect(subject.translatable?).to be false
end
end
end
context 'default locale has region' do
it 'returns true' do
I18n.with_locale('en-GB') do
expect(subject.translatable?).to be true
end
end
end
context 'status text is blank' do
it 'returns false' do
subject.text = ' '
expect(subject.translatable?).to be false
end
end
context 'status visiblity is hidden' do
it 'returns false' do
subject.visibility = 'limited'
expect(subject.translatable?).to be false
end
end
end
describe '#content' do
it 'returns the text of the status if it is not a reblog' do
expect(subject.content).to eql subject.text

View file

@ -7,6 +7,7 @@ const express = require('express');
const http = require('http');
const redis = require('redis');
const pg = require('pg');
const dbUrlToConfig = require('pg-connection-string').parse;
const log = require('npmlog');
const url = require('url');
const uuid = require('uuid');
@ -23,43 +24,6 @@ dotenv.config({
log.level = process.env.LOG_LEVEL || 'verbose';
/**
* @param {string} dbUrl
* @return {Object.<string, any>}
*/
const dbUrlToConfig = (dbUrl) => {
if (!dbUrl) {
return {};
}
const params = url.parse(dbUrl, true);
const config = {};
if (params.auth) {
[config.user, config.password] = params.auth.split(':');
}
if (params.hostname) {
config.host = params.hostname;
}
if (params.port) {
config.port = params.port;
}
if (params.pathname) {
config.database = params.pathname.split('/')[1];
}
const ssl = params.query && params.query.ssl;
if (ssl && ssl === 'true' || ssl === '1') {
config.ssl = true;
}
return config;
};
/**
* @param {Object.<string, any>} defaultConfig
* @param {string} redisUrl

View file

@ -8322,6 +8322,11 @@ pg-connection-string@^2.4.0:
resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.4.0.tgz#c979922eb47832999a204da5dbe1ebf2341b6a10"
integrity sha512-3iBXuv7XKvxeMrIgym7njT+HlZkwZqqGX4Bu9cci8xHZNT+Um1gWKqCsAzcC0d95rcKMU5WBg6YRUcHyV0HZKQ==
pg-connection-string@^2.5.0:
version "2.5.0"
resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.5.0.tgz#538cadd0f7e603fc09a12590f3b8a452c2c0cf34"
integrity sha512-r5o/V/ORTA6TmUnyWZR9nCj1klXCO2CEKNRlVuJptZe85QuhFayC7WeMic7ndayT5IRIR0S0xFxFi2ousartlQ==
pg-int8@1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/pg-int8/-/pg-int8-1.0.1.tgz#943bd463bf5b71b4170115f80f8efc9a0c0eb78c"