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/mastodon/cli_helper.rb'
- 'lib/cli.rb' - 'lib/cli.rb'
RSpec/FilePath:
CustomTransform:
DeepL: deepl
RSpec/NotToNot: RSpec/NotToNot:
EnforcedStyle: to_not EnforcedStyle: to_not
@ -123,3 +127,6 @@ Style/TrailingCommaInArrayLiteral:
Style/TrailingCommaInHashLiteral: Style/TrailingCommaInHashLiteral:
EnforcedStyleForMultiline: 'comma' EnforcedStyleForMultiline: 'comma'
Style/SymbolArray:
Enabled: false

View file

@ -2235,134 +2235,3 @@ Style/SlicingWithRange:
- 'lib/active_record/batches.rb' - 'lib/active_record/batches.rb'
- 'lib/mastodon/premailer_webpack_strategy.rb' - 'lib/mastodon/premailer_webpack_strategy.rb'
- 'lib/tasks/repo.rake' - '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 'fabrication', '~> 2.30'
gem 'fuubar', '~> 2.5' gem 'fuubar', '~> 2.5'
gem 'i18n-tasks', '~> 1.0', require: false gem 'i18n-tasks', '~> 1.0', require: false
gem 'pry-byebug', '~> 3.10'
gem 'pry-rails', '~> 0.3'
gem 'rspec-rails', '~> 6.0' gem 'rspec-rails', '~> 6.0'
gem 'rubocop-performance', require: false gem 'rubocop-performance', require: false
gem 'rubocop-rails', require: false gem 'rubocop-rails', require: false
@ -119,7 +117,6 @@ end
group :test do group :test do
gem 'capybara', '~> 3.38' gem 'capybara', '~> 3.38'
gem 'climate_control', '~> 0.2'
gem 'faker', '~> 3.1' gem 'faker', '~> 3.1'
gem 'json-schema', '~> 3.0' gem 'json-schema', '~> 3.0'
gem 'rack-test', '~> 2.0' gem 'rack-test', '~> 2.0'

View file

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

View file

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

View file

@ -6,7 +6,7 @@ import { Link } from 'react-router-dom';
import classnames from 'classnames'; import classnames from 'classnames';
import PollContainer from 'mastodon/containers/poll_container'; import PollContainer from 'mastodon/containers/poll_container';
import Icon from 'mastodon/components/icon'; 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) 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 hidden = this.props.onExpandedToggle ? !this.props.expanded : this.state.hidden;
const renderReadMore = this.props.onClick && status.get('collapsed'); 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 content = { __html: status.get('translation') ? status.getIn(['translation', 'content']) : status.get('contentHtml') };
const spoilerContent = { __html: status.get('spoilerHtml') }; const spoilerContent = { __html: status.get('spoilerHtml') };

View file

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

View file

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

View file

@ -80,7 +80,6 @@
* @property {boolean} use_blurhash * @property {boolean} use_blurhash
* @property {boolean=} use_pending_items * @property {boolean=} use_pending_items
* @property {string} version * @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 useBlurhash = getMeta('use_blurhash');
export const usePendingItems = getMeta('use_pending_items'); export const usePendingItems = getMeta('use_pending_items');
export const version = getMeta('version'); export const version = getMeta('version');
export const translationEnabled = getMeta('translation_enabled');
export const languages = initialState?.languages; export const languages = initialState?.languages;
export const statusPageUrl = getMeta('status_page_url'); export const statusPageUrl = getMeta('status_page_url');

View file

@ -23,3 +23,4 @@
@import 'mastodon/dashboard'; @import 'mastodon/dashboard';
@import 'mastodon/rtl'; @import 'mastodon/rtl';
@import 'mastodon/accessibility'; @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? ENV['DEEPL_API_KEY'].present? || ENV['LIBRE_TRANSLATE_ENDPOINT'].present?
end end
def supported?(_source_language, _target_language)
false
end
def translate(_text, _source_language, _target_language) def translate(_text, _source_language, _target_language)
raise NotImplementedError raise NotImplementedError
end end

View file

@ -11,33 +11,53 @@ class TranslationService::DeepL < TranslationService
end end
def translate(text, source_language, target_language) 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 case res.code
when 429 when 429
raise TooManyRequestsError raise TooManyRequestsError
when 456 when 456
raise QuotaExceededError raise QuotaExceededError
when 200...300 when 200...300
transform_response(res.body_with_limit) yield res
else else
raise UnexpectedResponseError raise UnexpectedResponseError
end end
end end
end end
private def base_url
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
if @plan == 'free' if @plan == 'free'
'https://api-free.deepl.com/v2/translate' 'https://api-free.deepl.com'
else else
'https://api.deepl.com/v2/translate' 'https://api.deepl.com'
end end
end end

View file

@ -9,29 +9,45 @@ class TranslationService::LibreTranslate < TranslationService
end end
def translate(text, source_language, target_language) 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 case res.code
when 429 when 429
raise TooManyRequestsError raise TooManyRequestsError
when 403 when 403
raise QuotaExceededError raise QuotaExceededError
when 200...300 when 200...300
transform_response(res.body_with_limit, source_language) yield res
else else
raise UnexpectedResponseError raise UnexpectedResponseError
end end
end 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) def transform_response(str, source_language)
json = Oj.load(str, mode: :strict) json = Oj.load(str, mode: :strict)

View file

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

View file

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

View file

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

View file

@ -232,6 +232,16 @@ class Status < ApplicationRecord
public_visibility? || unlisted_visibility? public_visibility? || unlisted_visibility?
end 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? alias sign? distributable?
def with_media? def with_media?

View file

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

View file

@ -4,7 +4,7 @@ class REST::StatusSerializer < ActiveModel::Serializer
include FormattingHelper include FormattingHelper
attributes :id, :created_at, :in_reply_to_id, :in_reply_to_account_id, 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, :uri, :url, :replies_count, :reblogs_count,
:favourites_count, :edited_at :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) object.account.user_shows_application? || (current_user? && current_user.account_id == object.account_id)
end end
def translatable
current_user? && object.translatable?
end
def visibility def visibility
# This visibility is masked behind "private" # This visibility is masked behind "private"
# to avoid API changes because there are no # 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 include FormattingHelper
def call(status, target_language) def call(status, target_language)
raise Mastodon::NotPermittedError unless status.public_visibility? || status.unlisted_visibility? raise Mastodon::NotPermittedError unless status.translatable?
@status = status @status = status
@content = status_content_format(@status) @content = status_content_format(@status)

View file

@ -34,7 +34,7 @@
%td %td
- if @status.trend.allowed? - 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) %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') = t('admin.trends.pending_review')
- else - else
= t('admin.trends.not_allowed_to_trend') = 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_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 .batch-table__body
- if @accounts.empty? - if @accounts.empty?
= nothing_here 'nothing-here--under-tabs' = nothing_here 'nothing-here--under-tabs'

View file

@ -10,6 +10,16 @@ class ActivityPub::DeliveryWorker
sidekiq_options queue: 'push', retry: 16, dead: false 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 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 = {})

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) old_target_account = Account.find(old_target_account_id)
new_target_account = Account.find(new_target_account_id) new_target_account = Account.find(new_target_account_id)
follow = follower_account.active_relationships.find_by(target_account: old_target_account) FollowMigrationService.new.call(follower_account, new_target_account, old_target_account, bypass_locked: bypass_locked)
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)
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
true true
end end

View file

@ -128,6 +128,7 @@ Rails.application.configure do
enable_starttls_auto: enable_starttls_auto, enable_starttls_auto: enable_starttls_auto,
tls: ENV['SMTP_TLS'].presence && ENV['SMTP_TLS'] == 'true', tls: ENV['SMTP_TLS'].presence && ENV['SMTP_TLS'] == 'true',
ssl: ENV['SMTP_SSL'].presence && ENV['SMTP_SSL'] == '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 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 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 # Some S3-compatible providers might not actually be compatible with some APIs
# used by kt-paperclip, see https://github.com/mastodon/mastodon/issues/16822 # used by kt-paperclip, see https://github.com/mastodon/mastodon/issues/16822
if ENV['S3_FORCE_SINGLE_REQUEST'] == 'true' 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_followers: Are you sure you want to remove selected followers?
confirm_remove_selected_follows: Are you sure you want to remove selected follows? confirm_remove_selected_follows: Are you sure you want to remove selected follows?
dormant: Dormant dormant: Dormant
follow_failure: Could not follow some of the selected accounts.
follow_selected_followers: Follow selected followers follow_selected_followers: Follow selected followers
followers: Followers followers: Followers
following: Following following: Following

View file

@ -627,7 +627,7 @@ module Mastodon
exit(1) exit(1)
end 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) 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) exit(1)
end end

View file

@ -51,29 +51,22 @@ class Sanitize
end end
UNSUPPORTED_ELEMENTS_TRANSFORMER = lambda do |env| 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] current_node = env[:node]
case env[:node_name] current_node.name = 'strong'
when 'li' current_node.wrap('<p></p>')
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
end end
MASTODON_STRICT ||= freeze_config( 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: { attributes: {
'a' => %w(href rel class), 'a' => %w(href rel class),
'span' => %w(class), 'span' => %w(class),
'ol' => %w(start reversed),
'li' => %w(value),
}, },
add_attributes: { add_attributes: {

View file

@ -83,6 +83,7 @@
"object.values": "^1.1.6", "object.values": "^1.1.6",
"path-complete-extname": "^1.0.0", "path-complete-extname": "^1.0.0",
"pg": "^8.5.0", "pg": "^8.5.0",
"pg-connection-string": "^2.5.0",
"postcss": "^8.4.21", "postcss": "^8.4.21",
"postcss-loader": "^3.0.0", "postcss-loader": "^3.0.0",
"promise.prototype.finally": "^3.1.4", "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!(:media) { Fabricate(:media_attachment, account: target_account, status: statuses[0]) }
let(:report) { Fabricate(:report, target_account: target_account, status_ids: statuses.map(&:id)) } let(:report) { Fabricate(:report, target_account: target_account, status_ids: statuses.map(&:id)) }
let(:text) { 'hello' } let(:text) { 'hello' }
let(:common_params) do
{ report_id: report.id, text: text }
end
shared_examples 'common behavior' do shared_examples 'common behavior' do
it 'closes the report' do it 'closes the report' do
@ -72,6 +75,26 @@ describe Admin::Reports::ActionsController do
subject subject
expect(response).to redirect_to(admin_reports_path) expect(response).to redirect_to(admin_reports_path)
end 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 end
shared_examples 'all action types' do shared_examples 'all action types' do
@ -124,13 +147,13 @@ describe Admin::Reports::ActionsController do
end end
context 'action as submit button' do 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' it_behaves_like 'all action types'
end end
context 'action as submit button' do 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' it_behaves_like 'all action types'
end end

View file

@ -58,7 +58,7 @@ describe RelationshipsController do
end end
context 'when select parameter is provided' do 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 it 'soft-blocks followers from selected domains' do
poopfeast.follow!(user.account) poopfeast.follow!(user.account)
@ -69,6 +69,15 @@ describe RelationshipsController do
expect(poopfeast.following?(user.account)).to be false expect(poopfeast.following?(user.account)).to be false
end 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 'authenticate user'
include_examples 'redirects back to followers page' include_examples 'redirects back to followers page'
end end

View file

@ -6,24 +6,16 @@ describe Sanitize::Config do
describe '::MASTODON_STRICT' do describe '::MASTODON_STRICT' do
subject { Sanitize::Config::MASTODON_STRICT } subject { Sanitize::Config::MASTODON_STRICT }
it 'converts h1 to p' do it 'converts h1 to p strong' do
expect(Sanitize.fragment('<h1>Foo</h1>', subject)).to eq '<p>Foo</p>' expect(Sanitize.fragment('<h1>Foo</h1>', subject)).to eq '<p><strong>Foo</strong></p>'
end end
it 'converts ul to p' do 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><p>Foo<br>Bar</p>' 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 end
it 'converts p inside ul' do it 'keeps start and reversed attributes of ol' 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>' 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 '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>'
end end
it 'removes a without href' do it 'removes a without href' do
@ -45,5 +37,13 @@ describe Sanitize::Config do
it 'keeps a with href' 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>' 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 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
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
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 describe '#content' do
it 'returns the text of the status if it is not a reblog' do it 'returns the text of the status if it is not a reblog' do
expect(subject.content).to eql subject.text expect(subject.content).to eql subject.text

View file

@ -7,6 +7,7 @@ const express = require('express');
const http = require('http'); const http = require('http');
const redis = require('redis'); const redis = require('redis');
const pg = require('pg'); const pg = require('pg');
const dbUrlToConfig = require('pg-connection-string').parse;
const log = require('npmlog'); const log = require('npmlog');
const url = require('url'); const url = require('url');
const uuid = require('uuid'); const uuid = require('uuid');
@ -23,43 +24,6 @@ dotenv.config({
log.level = process.env.LOG_LEVEL || 'verbose'; 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 {Object.<string, any>} defaultConfig
* @param {string} redisUrl * @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" resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.4.0.tgz#c979922eb47832999a204da5dbe1ebf2341b6a10"
integrity sha512-3iBXuv7XKvxeMrIgym7njT+HlZkwZqqGX4Bu9cci8xHZNT+Um1gWKqCsAzcC0d95rcKMU5WBg6YRUcHyV0HZKQ== 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: pg-int8@1.0.1:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/pg-int8/-/pg-int8-1.0.1.tgz#943bd463bf5b71b4170115f80f8efc9a0c0eb78c" resolved "https://registry.yarnpkg.com/pg-int8/-/pg-int8-1.0.1.tgz#943bd463bf5b71b4170115f80f8efc9a0c0eb78c"