diff --git a/.github/workflows/test-ruby.yml b/.github/workflows/test-ruby.yml
index 1c9665f32b..fcb637d4b0 100644
--- a/.github/workflows/test-ruby.yml
+++ b/.github/workflows/test-ruby.yml
@@ -52,7 +52,7 @@ jobs:
run: |
tar --exclude={"*.br","*.gz"} -zcf artifacts.tar.gz public/assets public/packs*
- - uses: actions/upload-artifact@v3
+ - uses: actions/upload-artifact@v4
if: matrix.mode == 'test'
with:
path: |-
@@ -118,7 +118,7 @@ jobs:
steps:
- uses: actions/checkout@v4
- - uses: actions/download-artifact@v3
+ - uses: actions/download-artifact@v4
with:
path: './'
name: ${{ github.sha }}
@@ -195,7 +195,7 @@ jobs:
steps:
- uses: actions/checkout@v4
- - uses: actions/download-artifact@v3
+ - uses: actions/download-artifact@v4
with:
path: './public'
name: ${{ github.sha }}
@@ -215,14 +215,14 @@ jobs:
- run: bundle exec rake spec:system
- name: Archive logs
- uses: actions/upload-artifact@v3
+ uses: actions/upload-artifact@v4
if: failure()
with:
name: e2e-logs-${{ matrix.ruby-version }}
path: log/
- name: Archive test screenshots
- uses: actions/upload-artifact@v3
+ uses: actions/upload-artifact@v4
if: failure()
with:
name: e2e-screenshots
@@ -286,7 +286,7 @@ jobs:
steps:
- uses: actions/checkout@v4
- - uses: actions/download-artifact@v3
+ - uses: actions/download-artifact@v4
with:
path: './public'
name: ${{ github.sha }}
@@ -336,14 +336,14 @@ jobs:
- run: bin/rspec --tag search
- name: Archive logs
- uses: actions/upload-artifact@v3
+ uses: actions/upload-artifact@v4
if: failure()
with:
name: test-search-logs-${{ matrix.ruby-version }}
path: log/
- name: Archive test screenshots
- uses: actions/upload-artifact@v3
+ uses: actions/upload-artifact@v4
if: failure()
with:
name: test-search-screenshots
@@ -407,7 +407,7 @@ jobs:
steps:
- uses: actions/checkout@v4
- - uses: actions/download-artifact@v3
+ - uses: actions/download-artifact@v4
with:
path: './'
name: ${{ github.sha }}
diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml
index 8a6406f577..3d153e4c4c 100644
--- a/.rubocop_todo.yml
+++ b/.rubocop_todo.yml
@@ -80,13 +80,6 @@ Rails/WhereExists:
Exclude:
- 'app/controllers/activitypub/inboxes_controller.rb'
- 'app/controllers/admin/email_domain_blocks_controller.rb'
- - 'app/lib/activitypub/activity/create.rb'
- - 'app/lib/delivery_failure_tracker.rb'
- - 'app/lib/feed_manager.rb'
- - 'app/lib/suspicious_sign_in_detector.rb'
- - 'app/models/poll.rb'
- - 'app/models/session_activation.rb'
- - 'app/models/status.rb'
- 'app/policies/status_policy.rb'
- 'app/serializers/rest/announcement_serializer.rb'
- 'app/workers/move_worker.rb'
diff --git a/Gemfile.lock b/Gemfile.lock
index cba0ab1cb0..57ea0cc970 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -180,7 +180,7 @@ GEM
activesupport
cbor (0.5.9.6)
charlock_holmes (0.7.7)
- chewy (7.4.0)
+ chewy (7.5.0)
activesupport (>= 5.2)
elasticsearch (>= 7.12.0, < 7.14.0)
elasticsearch-dsl
@@ -319,7 +319,7 @@ GEM
activesupport (>= 5.1)
haml (>= 4.0.6)
railties (>= 5.1)
- haml_lint (0.53.0)
+ haml_lint (0.55.0)
haml (>= 5.0)
parallel (~> 1.10)
rainbow
@@ -445,7 +445,7 @@ GEM
mime-types-data (3.2023.1205)
mini_mime (1.1.5)
mini_portile2 (2.8.5)
- minitest (5.20.0)
+ minitest (5.21.1)
msgpack (1.7.2)
multi_json (1.15.0)
multipart-post (2.3.0)
@@ -504,7 +504,7 @@ GEM
orm_adapter (0.5.0)
ox (2.14.17)
parallel (1.24.0)
- parser (3.2.2.4)
+ parser (3.3.0.5)
ast (~> 2.4.1)
racc
parslet (2.0.0)
@@ -610,7 +610,7 @@ GEM
redis (>= 4)
redlock (1.3.2)
redis (>= 3.0.0, < 6.0)
- regexp_parser (2.8.3)
+ regexp_parser (2.9.0)
reline (0.4.2)
io-console (~> 0.5)
request_store (1.5.1)
@@ -652,11 +652,11 @@ GEM
rspec-mocks (~> 3.0)
sidekiq (>= 5, < 8)
rspec-support (3.12.1)
- rubocop (1.59.0)
+ rubocop (1.60.2)
json (~> 2.3)
language_server-protocol (>= 3.17.0)
parallel (~> 1.10)
- parser (>= 3.2.2.4)
+ parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.8, < 3.0)
rexml (>= 3.2.5, < 4.0)
@@ -698,7 +698,8 @@ GEM
scenic (1.7.0)
activerecord (>= 4.0.0)
railties (>= 4.0.0)
- selenium-webdriver (4.16.0)
+ selenium-webdriver (4.17.0)
+ base64 (~> 0.2)
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 3.0)
websocket (~> 1.0)
diff --git a/app/controllers/api/v1/annual_reports_controller.rb b/app/controllers/api/v1/annual_reports_controller.rb
new file mode 100644
index 0000000000..9bc8e68ac2
--- /dev/null
+++ b/app/controllers/api/v1/annual_reports_controller.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+class Api::V1::AnnualReportsController < Api::BaseController
+ before_action -> { doorkeeper_authorize! :read, :'read:accounts' }, only: :index
+ before_action -> { doorkeeper_authorize! :write, :'write:accounts' }, except: :index
+ before_action :require_user!
+ before_action :set_annual_report, except: :index
+
+ def index
+ with_read_replica do
+ @presenter = AnnualReportsPresenter.new(GeneratedAnnualReport.where(account_id: current_account.id).pending)
+ @relationships = StatusRelationshipsPresenter.new(@presenter.statuses, current_account.id)
+ end
+
+ render json: @presenter,
+ serializer: REST::AnnualReportsSerializer,
+ relationships: @relationships
+ end
+
+ def read
+ @annual_report.view!
+ render_empty
+ end
+
+ private
+
+ def set_annual_report
+ @annual_report = GeneratedAnnualReport.find_by!(account_id: current_account.id, year: params[:id])
+ end
+end
diff --git a/app/controllers/concerns/web_app_controller_concern.rb b/app/controllers/concerns/web_app_controller_concern.rb
index 5687d6e5b6..b8c909877b 100644
--- a/app/controllers/concerns/web_app_controller_concern.rb
+++ b/app/controllers/concerns/web_app_controller_concern.rb
@@ -21,10 +21,19 @@ module WebAppControllerConcern
def redirect_unauthenticated_to_permalinks!
return if user_signed_in? && current_account.moved_to_account_id.nil?
- redirect_path = PermalinkRedirector.new(request.path).redirect_path
- return if redirect_path.blank?
+ permalink_redirector = PermalinkRedirector.new(request.path)
+ return if permalink_redirector.redirect_path.blank?
expires_in(15.seconds, public: true, stale_while_revalidate: 30.seconds, stale_if_error: 1.day) unless user_signed_in?
- redirect_to(redirect_path)
+
+ respond_to do |format|
+ format.html do
+ redirect_to(permalink_redirector.redirect_confirmation_path, allow_other_host: false)
+ end
+
+ format.json do
+ redirect_to(permalink_redirector.redirect_uri, allow_other_host: true)
+ end
+ end
end
end
diff --git a/app/controllers/redirect/accounts_controller.rb b/app/controllers/redirect/accounts_controller.rb
new file mode 100644
index 0000000000..98d2cc2b1f
--- /dev/null
+++ b/app/controllers/redirect/accounts_controller.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+class Redirect::AccountsController < ApplicationController
+ private
+
+ def set_resource
+ @resource = Account.find(params[:id])
+ not_found if @resource.local?
+ end
+end
diff --git a/app/controllers/redirect/base_controller.rb b/app/controllers/redirect/base_controller.rb
new file mode 100644
index 0000000000..90894ec1ed
--- /dev/null
+++ b/app/controllers/redirect/base_controller.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+class Redirect::BaseController < ApplicationController
+ vary_by 'Accept-Language'
+
+ before_action :set_resource
+ before_action :set_app_body_class
+
+ def show
+ @redirect_path = ActivityPub::TagManager.instance.url_for(@resource)
+
+ render 'redirects/show', layout: 'application'
+ end
+
+ private
+
+ def set_app_body_class
+ @body_classes = 'app-body'
+ end
+
+ def set_resource
+ raise NotImplementedError
+ end
+end
diff --git a/app/controllers/redirect/statuses_controller.rb b/app/controllers/redirect/statuses_controller.rb
new file mode 100644
index 0000000000..37a938c651
--- /dev/null
+++ b/app/controllers/redirect/statuses_controller.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+class Redirect::StatusesController < Redirect::BaseController
+ private
+
+ def set_resource
+ @resource = Status.find(params[:id])
+ not_found if @resource.local? || !@resource.distributable?
+ end
+end
diff --git a/app/javascript/mastodon/components/status_action_bar.jsx b/app/javascript/mastodon/components/status_action_bar.jsx
index 050595ea10..193743069d 100644
--- a/app/javascript/mastodon/components/status_action_bar.jsx
+++ b/app/javascript/mastodon/components/status_action_bar.jsx
@@ -19,8 +19,10 @@ import ReplyAllIcon from '@/material-icons/400-24px/reply_all.svg?react';
import StarIcon from '@/material-icons/400-24px/star-fill.svg?react';
import StarBorderIcon from '@/material-icons/400-24px/star.svg?react';
import VisibilityIcon from '@/material-icons/400-24px/visibility.svg?react';
-import RepeatDisabledIcon from 'mastodon/../svg-icons/repeat_disabled.svg?react';
-import RepeatPrivateIcon from 'mastodon/../svg-icons/repeat_private.svg?react';
+import RepeatActiveIcon from '@/svg-icons/repeat_active.svg?react';
+import RepeatDisabledIcon from '@/svg-icons/repeat_disabled.svg?react';
+import RepeatPrivateIcon from '@/svg-icons/repeat_private.svg?react';
+import RepeatPrivateActiveIcon from '@/svg-icons/repeat_private_active.svg?react';
import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'mastodon/permissions';
import { WithRouterPropTypes } from 'mastodon/utils/react_router';
@@ -444,7 +446,7 @@ class StatusActionBar extends ImmutablePureComponent {
if (status.get('reblogged')) {
reblogTitle = intl.formatMessage(messages.cancel_reblog_private);
- reblogIconComponent = publicStatus ? RepeatIcon : RepeatPrivateIcon;
+ reblogIconComponent = publicStatus ? RepeatActiveIcon : RepeatPrivateActiveIcon;
} else if (publicStatus) {
reblogTitle = intl.formatMessage(messages.reblog);
reblogIconComponent = RepeatIcon;
diff --git a/app/javascript/mastodon/features/compose/containers/search_container.js b/app/javascript/mastodon/features/compose/containers/search_container.js
index 758b6b07db..616b91369c 100644
--- a/app/javascript/mastodon/features/compose/containers/search_container.js
+++ b/app/javascript/mastodon/features/compose/containers/search_container.js
@@ -1,3 +1,4 @@
+import { createSelector } from '@reduxjs/toolkit';
import { connect } from 'react-redux';
import {
@@ -12,10 +13,15 @@ import {
import Search from '../components/search';
+const getRecentSearches = createSelector(
+ state => state.getIn(['search', 'recent']),
+ recent => recent.reverse(),
+);
+
const mapStateToProps = state => ({
value: state.getIn(['search', 'value']),
submitted: state.getIn(['search', 'submitted']),
- recent: state.getIn(['search', 'recent']).reverse(),
+ recent: getRecentSearches(state),
});
const mapDispatchToProps = dispatch => ({
diff --git a/app/javascript/mastodon/features/status/components/action_bar.jsx b/app/javascript/mastodon/features/status/components/action_bar.jsx
index e74f4a6c50..02a7c8ce4a 100644
--- a/app/javascript/mastodon/features/status/components/action_bar.jsx
+++ b/app/javascript/mastodon/features/status/components/action_bar.jsx
@@ -18,8 +18,10 @@ import ReplyIcon from '@/material-icons/400-24px/reply.svg?react';
import ReplyAllIcon from '@/material-icons/400-24px/reply_all.svg?react';
import StarIcon from '@/material-icons/400-24px/star-fill.svg?react';
import StarBorderIcon from '@/material-icons/400-24px/star.svg?react';
-import RepeatDisabledIcon from 'mastodon/../svg-icons/repeat_disabled.svg?react';
-import RepeatPrivateIcon from 'mastodon/../svg-icons/repeat_private.svg?react';
+import RepeatActiveIcon from '@/svg-icons/repeat_active.svg?react';
+import RepeatDisabledIcon from '@/svg-icons/repeat_disabled.svg?react';
+import RepeatPrivateIcon from '@/svg-icons/repeat_private.svg?react';
+import RepeatPrivateActiveIcon from '@/svg-icons/repeat_private_active.svg?react';
import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'mastodon/permissions';
import { WithRouterPropTypes } from 'mastodon/utils/react_router';
@@ -356,7 +358,7 @@ class ActionBar extends PureComponent {
if (status.get('reblogged')) {
reblogTitle = intl.formatMessage(messages.cancel_reblog_private);
- reblogIconComponent = publicStatus ? RepeatIcon : RepeatPrivateIcon;
+ reblogIconComponent = publicStatus ? RepeatActiveIcon : RepeatPrivateActiveIcon;
} else if (publicStatus) {
reblogTitle = intl.formatMessage(messages.reblog);
reblogIconComponent = RepeatIcon;
diff --git a/app/javascript/styles/mastodon/containers.scss b/app/javascript/styles/mastodon/containers.scss
index 3d646da239..b6e995787d 100644
--- a/app/javascript/styles/mastodon/containers.scss
+++ b/app/javascript/styles/mastodon/containers.scss
@@ -104,3 +104,59 @@
margin-inline-start: 10px;
}
}
+
+.redirect {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ height: 100vh;
+ font-size: 14px;
+ line-height: 18px;
+
+ &__logo {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ margin-bottom: 30px;
+
+ img {
+ height: 48px;
+ }
+ }
+
+ &__message {
+ text-align: center;
+
+ h1 {
+ font-size: 17px;
+ line-height: 22px;
+ font-weight: 700;
+ margin-bottom: 30px;
+ }
+
+ p {
+ margin-bottom: 30px;
+
+ &:last-child {
+ margin-bottom: 0;
+ }
+ }
+
+ a {
+ color: $highlight-text-color;
+ font-weight: 500;
+ text-decoration: none;
+
+ &:hover,
+ &:focus,
+ &:active {
+ text-decoration: underline;
+ }
+ }
+ }
+
+ &__link {
+ margin-top: 15px;
+ }
+}
diff --git a/app/javascript/svg-icons/repeat_active.svg b/app/javascript/svg-icons/repeat_active.svg
new file mode 100644
index 0000000000..a5bbb8fc4f
--- /dev/null
+++ b/app/javascript/svg-icons/repeat_active.svg
@@ -0,0 +1,4 @@
+
diff --git a/app/javascript/svg-icons/repeat_disabled.svg b/app/javascript/svg-icons/repeat_disabled.svg
old mode 100755
new mode 100644
diff --git a/app/javascript/svg-icons/repeat_private.svg b/app/javascript/svg-icons/repeat_private.svg
old mode 100755
new mode 100644
diff --git a/app/javascript/svg-icons/repeat_private_active.svg b/app/javascript/svg-icons/repeat_private_active.svg
new file mode 100644
index 0000000000..cf2a05c84e
--- /dev/null
+++ b/app/javascript/svg-icons/repeat_private_active.svg
@@ -0,0 +1,6 @@
+
diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb
index c74c676c25..b6e6779099 100644
--- a/app/lib/activitypub/activity/create.rb
+++ b/app/lib/activitypub/activity/create.rb
@@ -357,7 +357,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
already_voted = true
with_redis_lock("vote:#{replied_to_status.poll_id}:#{@account.id}") do
- already_voted = poll.votes.where(account: @account).exists?
+ already_voted = poll.votes.exists?(account: @account)
poll.votes.create!(account: @account, choice: poll.options.index(@object['name']), uri: object_uri)
end
@@ -503,7 +503,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
return false if local_usernames.empty?
- Account.local.where(username: local_usernames).exists?
+ Account.local.exists?(username: local_usernames)
end
def tombstone_exists?
diff --git a/app/lib/annual_report.rb b/app/lib/annual_report.rb
new file mode 100644
index 0000000000..cf4297f2a4
--- /dev/null
+++ b/app/lib/annual_report.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+class AnnualReport
+ include DatabaseHelper
+
+ SOURCES = [
+ AnnualReport::Archetype,
+ AnnualReport::TypeDistribution,
+ AnnualReport::TopStatuses,
+ AnnualReport::MostUsedApps,
+ AnnualReport::CommonlyInteractedWithAccounts,
+ AnnualReport::TimeSeries,
+ AnnualReport::TopHashtags,
+ AnnualReport::MostRebloggedAccounts,
+ AnnualReport::Percentiles,
+ ].freeze
+
+ SCHEMA = 1
+
+ def initialize(account, year)
+ @account = account
+ @year = year
+ end
+
+ def generate
+ return if GeneratedAnnualReport.exists?(account: @account, year: @year)
+
+ GeneratedAnnualReport.create(
+ account: @account,
+ year: @year,
+ schema_version: SCHEMA,
+ data: data
+ )
+ end
+
+ private
+
+ def data
+ with_read_replica do
+ SOURCES.each_with_object({}) { |klass, hsh| hsh.merge!(klass.new(@account, @year).generate) }
+ end
+ end
+end
diff --git a/app/lib/annual_report/archetype.rb b/app/lib/annual_report/archetype.rb
new file mode 100644
index 0000000000..ea9ef366df
--- /dev/null
+++ b/app/lib/annual_report/archetype.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+class AnnualReport::Archetype < AnnualReport::Source
+ # Average number of posts (including replies and reblogs) made by
+ # each active user in a single year (2023)
+ AVERAGE_PER_YEAR = 113
+
+ def generate
+ {
+ archetype: archetype,
+ }
+ end
+
+ private
+
+ def archetype
+ if (standalone_count + replies_count + reblogs_count) < AVERAGE_PER_YEAR
+ :lurker
+ elsif reblogs_count > (standalone_count * 2)
+ :booster
+ elsif polls_count > (standalone_count * 0.1) # standalone_count includes posts with polls
+ :pollster
+ elsif replies_count > (standalone_count * 2)
+ :replier
+ else
+ :oracle
+ end
+ end
+
+ def polls_count
+ @polls_count ||= base_scope.where.not(poll_id: nil).count
+ end
+
+ def reblogs_count
+ @reblogs_count ||= base_scope.where.not(reblog_of_id: nil).count
+ end
+
+ def replies_count
+ @replies_count ||= base_scope.where.not(in_reply_to_id: nil).where.not(in_reply_to_account_id: @account.id).count
+ end
+
+ def standalone_count
+ @standalone_count ||= base_scope.without_replies.without_reblogs.count
+ end
+
+ def base_scope
+ @account.statuses.where(id: year_as_snowflake_range)
+ end
+end
diff --git a/app/lib/annual_report/commonly_interacted_with_accounts.rb b/app/lib/annual_report/commonly_interacted_with_accounts.rb
new file mode 100644
index 0000000000..af5e854c22
--- /dev/null
+++ b/app/lib/annual_report/commonly_interacted_with_accounts.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+class AnnualReport::CommonlyInteractedWithAccounts < AnnualReport::Source
+ SET_SIZE = 40
+
+ def generate
+ {
+ commonly_interacted_with_accounts: commonly_interacted_with_accounts.map do |(account_id, count)|
+ {
+ account_id: account_id,
+ count: count,
+ }
+ end,
+ }
+ end
+
+ private
+
+ def commonly_interacted_with_accounts
+ @account.statuses.reorder(nil).where(id: year_as_snowflake_range).where.not(in_reply_to_account_id: @account.id).group(:in_reply_to_account_id).having('count(*) > 1').order(total: :desc).limit(SET_SIZE).pluck(Arel.sql('in_reply_to_account_id, count(*) AS total'))
+ end
+end
diff --git a/app/lib/annual_report/most_reblogged_accounts.rb b/app/lib/annual_report/most_reblogged_accounts.rb
new file mode 100644
index 0000000000..e3e8a7c90b
--- /dev/null
+++ b/app/lib/annual_report/most_reblogged_accounts.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+class AnnualReport::MostRebloggedAccounts < AnnualReport::Source
+ SET_SIZE = 10
+
+ def generate
+ {
+ most_reblogged_accounts: most_reblogged_accounts.map do |(account_id, count)|
+ {
+ account_id: account_id,
+ count: count,
+ }
+ end,
+ }
+ end
+
+ private
+
+ def most_reblogged_accounts
+ @account.statuses.reorder(nil).where(id: year_as_snowflake_range).where.not(reblog_of_id: nil).joins(reblog: :account).group('accounts.id').having('count(*) > 1').order(total: :desc).limit(SET_SIZE).pluck(Arel.sql('accounts.id, count(*) as total'))
+ end
+end
diff --git a/app/lib/annual_report/most_used_apps.rb b/app/lib/annual_report/most_used_apps.rb
new file mode 100644
index 0000000000..85ff1ff86e
--- /dev/null
+++ b/app/lib/annual_report/most_used_apps.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+class AnnualReport::MostUsedApps < AnnualReport::Source
+ SET_SIZE = 10
+
+ def generate
+ {
+ most_used_apps: most_used_apps.map do |(name, count)|
+ {
+ name: name,
+ count: count,
+ }
+ end,
+ }
+ end
+
+ private
+
+ def most_used_apps
+ @account.statuses.reorder(nil).where(id: year_as_snowflake_range).joins(:application).group('oauth_applications.name').order(total: :desc).limit(SET_SIZE).pluck(Arel.sql('oauth_applications.name, count(*) as total'))
+ end
+end
diff --git a/app/lib/annual_report/percentiles.rb b/app/lib/annual_report/percentiles.rb
new file mode 100644
index 0000000000..9fe4698ee5
--- /dev/null
+++ b/app/lib/annual_report/percentiles.rb
@@ -0,0 +1,62 @@
+# frozen_string_literal: true
+
+class AnnualReport::Percentiles < AnnualReport::Source
+ def generate
+ {
+ percentiles: {
+ followers: (total_with_fewer_followers / (total_with_any_followers + 1.0)) * 100,
+ statuses: (total_with_fewer_statuses / (total_with_any_statuses + 1.0)) * 100,
+ },
+ }
+ end
+
+ private
+
+ def followers_gained
+ @followers_gained ||= @account.passive_relationships.where("date_part('year', follows.created_at) = ?", @year).count
+ end
+
+ def statuses_created
+ @statuses_created ||= @account.statuses.where(id: year_as_snowflake_range).count
+ end
+
+ def total_with_fewer_followers
+ @total_with_fewer_followers ||= Follow.find_by_sql([<<~SQL.squish, { year: @year, comparison: followers_gained }]).first.total
+ WITH tmp0 AS (
+ SELECT follows.target_account_id
+ FROM follows
+ INNER JOIN accounts ON accounts.id = follows.target_account_id
+ WHERE date_part('year', follows.created_at) = :year
+ AND accounts.domain IS NULL
+ GROUP BY follows.target_account_id
+ HAVING COUNT(*) < :comparison
+ )
+ SELECT count(*) AS total
+ FROM tmp0
+ SQL
+ end
+
+ def total_with_fewer_statuses
+ @total_with_fewer_statuses ||= Status.find_by_sql([<<~SQL.squish, { comparison: statuses_created, min_id: year_as_snowflake_range.first, max_id: year_as_snowflake_range.last }]).first.total
+ WITH tmp0 AS (
+ SELECT statuses.account_id
+ FROM statuses
+ INNER JOIN accounts ON accounts.id = statuses.account_id
+ WHERE statuses.id BETWEEN :min_id AND :max_id
+ AND accounts.domain IS NULL
+ GROUP BY statuses.account_id
+ HAVING count(*) < :comparison
+ )
+ SELECT count(*) AS total
+ FROM tmp0
+ SQL
+ end
+
+ def total_with_any_followers
+ @total_with_any_followers ||= Follow.where("date_part('year', follows.created_at) = ?", @year).joins(:target_account).merge(Account.local).count('distinct follows.target_account_id')
+ end
+
+ def total_with_any_statuses
+ @total_with_any_statuses ||= Status.where(id: year_as_snowflake_range).joins(:account).merge(Account.local).count('distinct statuses.account_id')
+ end
+end
diff --git a/app/lib/annual_report/source.rb b/app/lib/annual_report/source.rb
new file mode 100644
index 0000000000..1ccb622676
--- /dev/null
+++ b/app/lib/annual_report/source.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+class AnnualReport::Source
+ attr_reader :account, :year
+
+ def initialize(account, year)
+ @account = account
+ @year = year
+ end
+
+ protected
+
+ def year_as_snowflake_range
+ (Mastodon::Snowflake.id_at(DateTime.new(year, 1, 1))..Mastodon::Snowflake.id_at(DateTime.new(year, 12, 31)))
+ end
+end
diff --git a/app/lib/annual_report/time_series.rb b/app/lib/annual_report/time_series.rb
new file mode 100644
index 0000000000..a144bac0d1
--- /dev/null
+++ b/app/lib/annual_report/time_series.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+class AnnualReport::TimeSeries < AnnualReport::Source
+ def generate
+ {
+ time_series: (1..12).map do |month|
+ {
+ month: month,
+ statuses: statuses_per_month[month] || 0,
+ following: following_per_month[month] || 0,
+ followers: followers_per_month[month] || 0,
+ }
+ end,
+ }
+ end
+
+ private
+
+ def statuses_per_month
+ @statuses_per_month ||= @account.statuses.reorder(nil).where(id: year_as_snowflake_range).group(:period).pluck(Arel.sql("date_part('month', created_at)::int AS period, count(*)")).to_h
+ end
+
+ def following_per_month
+ @following_per_month ||= @account.active_relationships.where("date_part('year', created_at) = ?", @year).group(:period).pluck(Arel.sql("date_part('month', created_at)::int AS period, count(*)")).to_h
+ end
+
+ def followers_per_month
+ @followers_per_month ||= @account.passive_relationships.where("date_part('year', created_at) = ?", @year).group(:period).pluck(Arel.sql("date_part('month', created_at)::int AS period, count(*)")).to_h
+ end
+end
diff --git a/app/lib/annual_report/top_hashtags.rb b/app/lib/annual_report/top_hashtags.rb
new file mode 100644
index 0000000000..488dacb1b4
--- /dev/null
+++ b/app/lib/annual_report/top_hashtags.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+class AnnualReport::TopHashtags < AnnualReport::Source
+ SET_SIZE = 40
+
+ def generate
+ {
+ top_hashtags: top_hashtags.map do |(name, count)|
+ {
+ name: name,
+ count: count,
+ }
+ end,
+ }
+ end
+
+ private
+
+ def top_hashtags
+ Tag.joins(:statuses).where(statuses: { id: @account.statuses.where(id: year_as_snowflake_range).reorder(nil).select(:id) }).group(:id).having('count(*) > 1').order(total: :desc).limit(SET_SIZE).pluck(Arel.sql('COALESCE(tags.display_name, tags.name), count(*) AS total'))
+ end
+end
diff --git a/app/lib/annual_report/top_statuses.rb b/app/lib/annual_report/top_statuses.rb
new file mode 100644
index 0000000000..112e5591ce
--- /dev/null
+++ b/app/lib/annual_report/top_statuses.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+class AnnualReport::TopStatuses < AnnualReport::Source
+ def generate
+ top_reblogs = base_scope.order(reblogs_count: :desc).first&.id
+ top_favourites = base_scope.where.not(id: top_reblogs).order(favourites_count: :desc).first&.id
+ top_replies = base_scope.where.not(id: [top_reblogs, top_favourites]).order(replies_count: :desc).first&.id
+
+ {
+ top_statuses: {
+ by_reblogs: top_reblogs,
+ by_favourites: top_favourites,
+ by_replies: top_replies,
+ },
+ }
+ end
+
+ def base_scope
+ @account.statuses.with_public_visibility.joins(:status_stat).where(id: year_as_snowflake_range).reorder(nil)
+ end
+end
diff --git a/app/lib/annual_report/type_distribution.rb b/app/lib/annual_report/type_distribution.rb
new file mode 100644
index 0000000000..fc12a6f1f4
--- /dev/null
+++ b/app/lib/annual_report/type_distribution.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+class AnnualReport::TypeDistribution < AnnualReport::Source
+ def generate
+ {
+ type_distribution: {
+ total: base_scope.count,
+ reblogs: base_scope.where.not(reblog_of_id: nil).count,
+ replies: base_scope.where.not(in_reply_to_id: nil).where.not(in_reply_to_account_id: @account.id).count,
+ standalone: base_scope.without_replies.without_reblogs.count,
+ },
+ }
+ end
+
+ private
+
+ def base_scope
+ @account.statuses.where(id: year_as_snowflake_range)
+ end
+end
diff --git a/app/lib/delivery_failure_tracker.rb b/app/lib/delivery_failure_tracker.rb
index d938269829..e17b45d667 100644
--- a/app/lib/delivery_failure_tracker.rb
+++ b/app/lib/delivery_failure_tracker.rb
@@ -28,7 +28,7 @@ class DeliveryFailureTracker
end
def available?
- !UnavailableDomain.where(domain: @host).exists?
+ !UnavailableDomain.exists?(domain: @host)
end
def exhausted_deliveries_days
diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb
index 2d9175d9b4..db1b1fbbcb 100644
--- a/app/lib/feed_manager.rb
+++ b/app/lib/feed_manager.rb
@@ -458,8 +458,8 @@ class FeedManager
check_for_blocks = status.active_mentions.pluck(:account_id)
check_for_blocks.push(status.in_reply_to_account) if status.reply? && !status.in_reply_to_account_id.nil?
- should_filter = blocks_or_mutes?(receiver_id, check_for_blocks, :mentions) # Filter if it's from someone I blocked, in reply to someone I blocked, or mentioning someone I blocked (or muted)
- should_filter ||= status.account.silenced? && !Follow.where(account_id: receiver_id, target_account_id: status.account_id).exists? # of if the account is silenced and I'm not following them
+ should_filter = blocks_or_mutes?(receiver_id, check_for_blocks, :mentions) # Filter if it's from someone I blocked, in reply to someone I blocked, or mentioning someone I blocked (or muted)
+ should_filter ||= status.account.silenced? && !Follow.exists?(account_id: receiver_id, target_account_id: status.account_id) # Filter if the account is silenced and I'm not following them
should_filter
end
@@ -472,7 +472,7 @@ class FeedManager
if status.reply? && status.in_reply_to_account_id != status.account_id
should_filter = status.in_reply_to_account_id != list.account_id
should_filter &&= !list.show_followed?
- should_filter &&= !(list.show_list? && ListAccount.where(list_id: list.id, account_id: status.in_reply_to_account_id).exists?)
+ should_filter &&= !(list.show_list? && ListAccount.exists?(list_id: list.id, account_id: status.in_reply_to_account_id))
return !!should_filter
end
diff --git a/app/lib/permalink_redirector.rb b/app/lib/permalink_redirector.rb
index 0dd37483e2..f551f69db8 100644
--- a/app/lib/permalink_redirector.rb
+++ b/app/lib/permalink_redirector.rb
@@ -5,17 +5,46 @@ class PermalinkRedirector
def initialize(path)
@path = path
+ @object = nil
+ end
+
+ def object
+ @object ||= begin
+ if at_username_status_request? || statuses_status_request?
+ status = Status.find_by(id: second_segment)
+ status if status&.distributable? && !status&.local?
+ elsif at_username_request?
+ username, domain = first_segment.delete_prefix('@').split('@')
+ domain = nil if TagManager.instance.local_domain?(domain)
+ account = Account.find_remote(username, domain)
+ account unless account&.local?
+ elsif accounts_request? && record_integer_id_request?
+ account = Account.find_by(id: second_segment)
+ account unless account&.local?
+ end
+ end
end
def redirect_path
- if at_username_status_request? || statuses_status_request?
- find_status_url_by_id(second_segment)
- elsif at_username_request?
- find_account_url_by_name(first_segment)
- elsif accounts_request? && record_integer_id_request?
- find_account_url_by_id(second_segment)
- elsif @path.start_with?('/deck')
- @path.delete_prefix('/deck')
+ return ActivityPub::TagManager.instance.url_for(object) if object.present?
+
+ @path.delete_prefix('/deck') if @path.start_with?('/deck')
+ end
+
+ def redirect_uri
+ return ActivityPub::TagManager.instance.uri_for(object) if object.present?
+
+ @path.delete_prefix('/deck') if @path.start_with?('/deck')
+ end
+
+ def redirect_confirmation_path
+ case object.class.name
+ when 'Account'
+ redirect_account_path(object.id)
+ when 'Status'
+ redirect_status_path(object.id)
+ else
+ @path.delete_prefix('/deck') if @path.start_with?('/deck')
end
end
@@ -56,22 +85,4 @@ class PermalinkRedirector
def path_segments
@path_segments ||= @path.delete_prefix('/deck').delete_prefix('/').split('/')
end
-
- def find_status_url_by_id(id)
- status = Status.find_by(id: id)
- ActivityPub::TagManager.instance.url_for(status) if status&.distributable? && !status.account.local?
- end
-
- def find_account_url_by_id(id)
- account = Account.find_by(id: id)
- ActivityPub::TagManager.instance.url_for(account) if account.present? && !account.local?
- end
-
- def find_account_url_by_name(name)
- username, domain = name.gsub(/\A@/, '').split('@')
- domain = nil if TagManager.instance.local_domain?(domain)
- account = Account.find_remote(username, domain)
-
- ActivityPub::TagManager.instance.url_for(account) if account.present? && !account.local?
- end
end
diff --git a/app/lib/suspicious_sign_in_detector.rb b/app/lib/suspicious_sign_in_detector.rb
index 1af5188c65..74f49aa558 100644
--- a/app/lib/suspicious_sign_in_detector.rb
+++ b/app/lib/suspicious_sign_in_detector.rb
@@ -19,7 +19,7 @@ class SuspiciousSignInDetector
end
def previously_seen_ip?(request)
- @user.ips.where('ip <<= ?', masked_ip(request)).exists?
+ @user.ips.exists?(['ip <<= ?', masked_ip(request)])
end
def freshly_signed_up?
diff --git a/app/lib/vacuum/media_attachments_vacuum.rb b/app/lib/vacuum/media_attachments_vacuum.rb
index ab7ea4092f..e558195290 100644
--- a/app/lib/vacuum/media_attachments_vacuum.rb
+++ b/app/lib/vacuum/media_attachments_vacuum.rb
@@ -27,11 +27,17 @@ class Vacuum::MediaAttachmentsVacuum
end
def media_attachments_past_retention_period
- MediaAttachment.remote.cached.where(MediaAttachment.arel_table[:created_at].lt(@retention_period.ago)).where(MediaAttachment.arel_table[:updated_at].lt(@retention_period.ago))
+ MediaAttachment
+ .remote
+ .cached
+ .created_before(@retention_period.ago)
+ .updated_before(@retention_period.ago)
end
def orphaned_media_attachments
- MediaAttachment.unattached.where(MediaAttachment.arel_table[:created_at].lt(TTL.ago))
+ MediaAttachment
+ .unattached
+ .created_before(TTL.ago)
end
def retention_period?
diff --git a/app/models/account_summary.rb b/app/models/account_summary.rb
index 0d8835b83c..2a21d09a8b 100644
--- a/app/models/account_summary.rb
+++ b/app/models/account_summary.rb
@@ -12,9 +12,11 @@
class AccountSummary < ApplicationRecord
self.primary_key = :account_id
+ has_many :follow_recommendation_suppressions, primary_key: :account_id, foreign_key: :account_id, inverse_of: false
+
scope :safe, -> { where(sensitive: false) }
scope :localized, ->(locale) { where(language: locale) }
- scope :filtered, -> { joins(arel_table.join(FollowRecommendationSuppression.arel_table, Arel::Nodes::OuterJoin).on(arel_table[:account_id].eq(FollowRecommendationSuppression.arel_table[:account_id])).join_sources).where(FollowRecommendationSuppression.arel_table[:id].eq(nil)) }
+ scope :filtered, -> { where.missing(:follow_recommendation_suppressions) }
def self.refresh
Scenic.database.refresh_materialized_view(table_name, concurrently: false, cascade: false)
diff --git a/app/models/generated_annual_report.rb b/app/models/generated_annual_report.rb
new file mode 100644
index 0000000000..43c97d7108
--- /dev/null
+++ b/app/models/generated_annual_report.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+# == Schema Information
+#
+# Table name: generated_annual_reports
+#
+# id :bigint(8) not null, primary key
+# account_id :bigint(8) not null
+# year :integer not null
+# data :jsonb not null
+# schema_version :integer not null
+# viewed_at :datetime
+# created_at :datetime not null
+# updated_at :datetime not null
+#
+
+class GeneratedAnnualReport < ApplicationRecord
+ belongs_to :account
+
+ scope :pending, -> { where(viewed_at: nil) }
+
+ def viewed?
+ viewed_at.present?
+ end
+
+ def view!
+ update!(viewed_at: Time.now.utc)
+ end
+
+ def account_ids
+ data['most_reblogged_accounts'].pluck('account_id') + data['commonly_interacted_with_accounts'].pluck('account_id')
+ end
+
+ def status_ids
+ data['top_statuses'].values
+ end
+end
diff --git a/app/models/instance.rb b/app/models/instance.rb
index 779727ce9f..ac6c250d47 100644
--- a/app/models/instance.rb
+++ b/app/models/instance.rb
@@ -13,14 +13,14 @@ class Instance < ApplicationRecord
attr_accessor :failure_days
- has_many :accounts, foreign_key: :domain, primary_key: :domain, inverse_of: false
-
with_options foreign_key: :domain, primary_key: :domain, inverse_of: false do
belongs_to :domain_block
belongs_to :domain_allow
- belongs_to :unavailable_domain # skipcq: RB-RL1031
+ belongs_to :unavailable_domain
belongs_to :instance_info
belongs_to :friend_domain
+
+ has_many :accounts, dependent: nil
end
scope :searchable, -> { where.not(domain: DomainBlock.select(:domain)) }
diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb
index 7d87c44070..809411c734 100644
--- a/app/models/media_attachment.rb
+++ b/app/models/media_attachment.rb
@@ -209,12 +209,14 @@ class MediaAttachment < ApplicationRecord
validates :file, presence: true, if: :local?
validates :thumbnail, absence: true, if: -> { local? && !audio_or_video? }
- scope :attached, -> { where.not(status_id: nil).or(where.not(scheduled_status_id: nil)) }
- scope :cached, -> { remote.where.not(file_file_name: nil) }
- scope :local, -> { where(remote_url: '') }
- scope :ordered, -> { order(id: :asc) }
- scope :remote, -> { where.not(remote_url: '') }
+ scope :attached, -> { where.not(status_id: nil).or(where.not(scheduled_status_id: nil)) }
+ scope :cached, -> { remote.where.not(file_file_name: nil) }
+ scope :created_before, ->(value) { where(arel_table[:created_at].lt(value)) }
+ scope :local, -> { where(remote_url: '') }
+ scope :ordered, -> { order(id: :asc) }
+ scope :remote, -> { where.not(remote_url: '') }
scope :unattached, -> { where(status_id: nil, scheduled_status_id: nil) }
+ scope :updated_before, ->(value) { where(arel_table[:updated_at].lt(value)) }
attr_accessor :skip_download
diff --git a/app/models/poll.rb b/app/models/poll.rb
index 37149c3d86..cc4184f80a 100644
--- a/app/models/poll.rb
+++ b/app/models/poll.rb
@@ -57,7 +57,7 @@ class Poll < ApplicationRecord
end
def voted?(account)
- account.id == account_id || votes.where(account: account).exists?
+ account.id == account_id || votes.exists?(account: account)
end
def own_votes(account)
diff --git a/app/models/session_activation.rb b/app/models/session_activation.rb
index 7f5f0d9a9a..c67180d3ba 100644
--- a/app/models/session_activation.rb
+++ b/app/models/session_activation.rb
@@ -41,7 +41,7 @@ class SessionActivation < ApplicationRecord
class << self
def active?(id)
- id && where(session_id: id).exists?
+ id && exists?(session_id: id)
end
def activate(**options)
diff --git a/app/models/status.rb b/app/models/status.rb
index 1e3cf3c557..d54517ead1 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -331,11 +331,11 @@ class Status < ApplicationRecord
end
def reported?
- @reported ||= Report.where(target_account: account).unresolved.where('? = ANY(status_ids)', id).exists?
+ @reported ||= Report.where(target_account: account).unresolved.exists?(['? = ANY(status_ids)', id])
end
def dtl?
- (%w(public public_unlisted login).include?(visibility) || (unlisted_visibility? && public_searchability?)) && tags.where(name: dtl_tag_name).exists?
+ (%w(public public_unlisted login).include?(visibility) || (unlisted_visibility? && public_searchability?)) && tags.exists?(name: dtl_tag_name)
end
def emojis
diff --git a/app/presenters/annual_reports_presenter.rb b/app/presenters/annual_reports_presenter.rb
new file mode 100644
index 0000000000..001e1d37b0
--- /dev/null
+++ b/app/presenters/annual_reports_presenter.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+class AnnualReportsPresenter
+ alias read_attribute_for_serialization send
+
+ attr_reader :annual_reports
+
+ def initialize(annual_reports)
+ @annual_reports = annual_reports
+ end
+
+ def accounts
+ @accounts ||= Account.where(id: @annual_reports.flat_map(&:account_ids)).includes(:account_stat, :moved_to_account, user: :role)
+ end
+
+ def statuses
+ @statuses ||= Status.where(id: @annual_reports.flat_map(&:status_ids)).with_includes
+ end
+
+ def self.model_name
+ @model_name ||= ActiveModel::Name.new(self)
+ end
+end
diff --git a/app/serializers/rest/annual_report_serializer.rb b/app/serializers/rest/annual_report_serializer.rb
new file mode 100644
index 0000000000..1fb5ddb5c1
--- /dev/null
+++ b/app/serializers/rest/annual_report_serializer.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+class REST::AnnualReportSerializer < ActiveModel::Serializer
+ attributes :year, :data, :schema_version
+end
diff --git a/app/serializers/rest/annual_reports_serializer.rb b/app/serializers/rest/annual_reports_serializer.rb
new file mode 100644
index 0000000000..ea9572be1b
--- /dev/null
+++ b/app/serializers/rest/annual_reports_serializer.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class REST::AnnualReportsSerializer < ActiveModel::Serializer
+ has_many :annual_reports, serializer: REST::AnnualReportSerializer
+ has_many :accounts, serializer: REST::AccountSerializer
+ has_many :statuses, serializer: REST::StatusSerializer
+end
diff --git a/app/views/redirects/show.html.haml b/app/views/redirects/show.html.haml
new file mode 100644
index 0000000000..0d09387a9c
--- /dev/null
+++ b/app/views/redirects/show.html.haml
@@ -0,0 +1,8 @@
+.redirect
+ .redirect__logo
+ = link_to render_logo, root_path
+
+ .redirect__message
+ %h1= t('redirects.title', instance: site_hostname)
+ %p= t('redirects.prompt')
+ %p= link_to @redirect_path, @redirect_path, rel: 'noreferrer noopener'
diff --git a/app/workers/generate_annual_report_worker.rb b/app/workers/generate_annual_report_worker.rb
new file mode 100644
index 0000000000..7094c1ab9c
--- /dev/null
+++ b/app/workers/generate_annual_report_worker.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class GenerateAnnualReportWorker
+ include Sidekiq::Worker
+
+ def perform(account_id, year)
+ AnnualReport.new(Account.find(account_id), year).generate
+ rescue ActiveRecord::RecordNotFound, ActiveRecord::RecordNotUnique
+ true
+ end
+end
diff --git a/app/workers/scheduler/indexing_scheduler.rb b/app/workers/scheduler/indexing_scheduler.rb
index 5c985e25a0..f52d0141d4 100644
--- a/app/workers/scheduler/indexing_scheduler.rb
+++ b/app/workers/scheduler/indexing_scheduler.rb
@@ -24,6 +24,8 @@ class Scheduler::IndexingScheduler
end
end
+ private
+
def indexes
[AccountsIndex, TagsIndex, PublicStatusesIndex, StatusesIndex]
end
diff --git a/config/locales/ar.yml b/config/locales/ar.yml
index 3c8c643fe7..e6d653c674 100644
--- a/config/locales/ar.yml
+++ b/config/locales/ar.yml
@@ -1934,6 +1934,7 @@ ar:
go_to_sso_account_settings: انتقل إلى إعدادات حساب مزود الهوية الخاص بك
invalid_otp_token: رمز المصادقة بخطوتين غير صالح
otp_lost_help_html: إن فقدتَهُما ، يمكنك الاتصال بـ %{email}
+ rate_limited: عدد محاولات التحقق كثير جدًا، يرجى المحاولة مرة أخرى لاحقًا.
seamless_external_login: لقد قمت بتسجيل الدخول عبر خدمة خارجية، إنّ إعدادات الكلمة السرية و البريد الإلكتروني غير متوفرة.
signed_in_as: 'تم تسجيل دخولك بصفة:'
verification:
diff --git a/config/locales/bg.yml b/config/locales/bg.yml
index c3eaa7e4c2..b9a3135448 100644
--- a/config/locales/bg.yml
+++ b/config/locales/bg.yml
@@ -1793,6 +1793,7 @@ bg:
failed_2fa:
details: 'Ето подробности на опита за влизане:'
explanation: Някой се опита да влезе в акаунта ви, но предостави невалиден втори фактор за удостоверяване.
+ further_actions_html: Ако не бяхте вие, то препоръчваме да направите %{action} незабавно, тъй като може да се злепостави.
subject: Неуспешен втори фактор за удостоверяване
title: Провал на втория фактор за удостоверяване
suspicious_sign_in:
diff --git a/config/locales/da.yml b/config/locales/da.yml
index 58fd723aef..d92d001905 100644
--- a/config/locales/da.yml
+++ b/config/locales/da.yml
@@ -1790,6 +1790,12 @@ da:
extra: Sikkerhedskopien kan nu downloades!
subject: Dit arkiv er klar til download
title: Arkiv download
+ failed_2fa:
+ details: 'Her er detaljerne om login-forsøget:'
+ explanation: Nogen har forsøgt at logge ind på kontoen, men har angivet en ugyldig anden godkendelsesfaktor.
+ further_actions_html: Var dette ikke dig, anbefales det straks at %{action}, da den kan være kompromitteret.
+ subject: Anden faktor godkendelsesfejl
+ title: Fejlede på anden faktor godkendelse
suspicious_sign_in:
change_password: ændrer din adgangskode
details: 'Her er nogle detaljer om login-forsøget:'
diff --git a/config/locales/devise.ru.yml b/config/locales/devise.ru.yml
index ccbd13438d..9dd418f2cd 100644
--- a/config/locales/devise.ru.yml
+++ b/config/locales/devise.ru.yml
@@ -47,14 +47,19 @@ ru:
subject: 'Mastodon: Инструкция по сбросу пароля'
title: Сброс пароля
two_factor_disabled:
+ explanation: Вход в систему теперь возможен только с использованием адреса электронной почты и пароля.
subject: 'Mastodon: Двухфакторная авторизация отключена'
+ subtitle: Двухфакторная аутентификация для вашей учетной записи была отключена.
title: 2ФА отключена
two_factor_enabled:
+ explanation: Для входа в систему потребуется токен, сгенерированный сопряженным приложением TOTP.
subject: 'Mastodon: Настроена двухфакторная авторизация'
+ subtitle: Для вашей учетной записи была включена двухфакторная аутентификация.
title: 2ФА включена
two_factor_recovery_codes_changed:
explanation: Предыдущие резервные коды были аннулированы и созданы новые.
subject: 'Mastodon: Резервные коды двуфакторной авторизации обновлены'
+ subtitle: Предыдущие коды восстановления были аннулированы и сгенерированы новые.
title: Коды восстановления 2FA изменены
unlock_instructions:
subject: 'Mastodon: Инструкция по разблокировке'
@@ -68,9 +73,13 @@ ru:
subject: 'Мастодон: Ключ Безопасности удален'
title: Один из ваших защитных ключей был удален
webauthn_disabled:
+ explanation: Аутентификация с помощью ключей безопасности была отключена для вашей учетной записи.
+ extra: Теперь вход в систему возможен только с использованием токена, сгенерированного сопряженным приложением TOTP.
subject: 'Мастодон: Аутентификация с ключами безопасности отключена'
title: Ключи безопасности отключены
webauthn_enabled:
+ explanation: Для вашей учетной записи включена аутентификация по ключу безопасности.
+ extra: Теперь ваш ключ безопасности можно использовать для входа в систему.
subject: 'Мастодон: Включена аутентификация по ключу безопасности'
title: Ключи безопасности включены
omniauth_callbacks:
diff --git a/config/locales/devise.sq.yml b/config/locales/devise.sq.yml
index 7cea2f8e2e..32136a0baa 100644
--- a/config/locales/devise.sq.yml
+++ b/config/locales/devise.sq.yml
@@ -47,14 +47,19 @@ sq:
subject: 'Mastodon: Udhëzime ricaktimi fjalëkalimi'
title: Ricaktim fjalëkalimi
two_factor_disabled:
+ explanation: Hyrja tanimë është e mundshme duke përdorur vetëm adresë email dhe fjalëkalim.
subject: 'Mastodon: U çaktivizua mirëfilltësimi dyfaktorësh'
+ subtitle: Mirëfilltësimi dyfaktorësh për llogarinë tuaj është çaktivizuar.
title: 2FA u çaktivizua
two_factor_enabled:
+ explanation: Për të kryer hyrjen do të kërkohet doemos një token i prodhuar nga aplikacioni TOTP i çiftuar.
subject: 'Mastodon: U aktivizua mirëfilltësimi dyfaktorësh'
+ subtitle: Për llogarinë tuaj është aktivizuar mirëfilltësmi dyfaktorësh.
title: 2FA u aktivizua
two_factor_recovery_codes_changed:
explanation: Kodet e dikurshëm të rikthimit janë bërë të pavlefshëm dhe janë prodhuar të rinj.
subject: 'Mastodon: U riprodhuan kode rikthimi dyfaktorësh'
+ subtitle: Kodet e dikurshëm të rikthimit janë bërë të pavlefshëm dhe janë prodhuar të rinj.
title: Kodet e rikthimit 2FA u ndryshuan
unlock_instructions:
subject: 'Mastodon: Udhëzime shkyçjeje'
@@ -68,9 +73,13 @@ sq:
subject: 'Mastodon: Fshirje kyçi sigurie'
title: Një nga kyçet tuaj të sigurisë është fshirë
webauthn_disabled:
+ explanation: Mirëfilltësimi me kyçe sigurie është çaktivizuar për llogarinë tuaj.
+ extra: Hyrjet tani janë të mundshme vetëm duke përdorur token-in e prodhuar nga aplikacioni TOTP i çiftuar.
subject: 'Mastodon: U çaktivizua mirëfilltësimi me kyçe sigurie'
title: U çaktivizuan kyçe sigurie
webauthn_enabled:
+ explanation: Mirëfilltësimi me kyçe sigurie është aktivizuar për këtë llogari.
+ extra: Kyçi juaj i sigurisë tanimë mund të përdoret për hyrje.
subject: 'Mastodon: U aktivizua mirëfilltësim me kyçe sigurie'
title: U aktivizuan kyçe sigurie
omniauth_callbacks:
diff --git a/config/locales/en.yml b/config/locales/en.yml
index eb9b4d2b06..f6913c6813 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -1761,6 +1761,9 @@ en:
duplication: Cannot react same things
limit_reached: Limit of different reactions reached
unrecognized_emoji: is not a recognized emoji
+ redirects:
+ prompt: If you trust this link, click it to continue.
+ title: You are leaving %{instance}.
relationships:
activity: Account activity
confirm_follow_selected_followers: Are you sure you want to follow selected followers?
diff --git a/config/locales/es-MX.yml b/config/locales/es-MX.yml
index 040d8a9d3c..b84fb7cf96 100644
--- a/config/locales/es-MX.yml
+++ b/config/locales/es-MX.yml
@@ -1792,6 +1792,10 @@ es-MX:
title: Descargar archivo
failed_2fa:
details: 'Estos son los detalles del intento de inicio de sesión:'
+ explanation: Alguien ha intentado iniciar sesión en tu cuenta pero proporcionó un segundo factor de autenticación inválido.
+ further_actions_html: Si no fuiste tú, se recomienda %{action} inmediatamente ya que puede estar comprometido.
+ subject: Fallo de autenticación de segundo factor
+ title: Falló la autenticación de segundo factor
suspicious_sign_in:
change_password: cambies tu contraseña
details: 'Aquí están los detalles del inicio de sesión:'
diff --git a/config/locales/es.yml b/config/locales/es.yml
index ffe3eb5b00..95816d6bcb 100644
--- a/config/locales/es.yml
+++ b/config/locales/es.yml
@@ -1792,6 +1792,10 @@ es:
title: Descargar archivo
failed_2fa:
details: 'Estos son los detalles del intento de inicio de sesión:'
+ explanation: Alguien ha intentado iniciar sesión en tu cuenta pero proporcionó un segundo factor de autenticación inválido.
+ further_actions_html: Si no fuiste tú, se recomienda %{action} inmediatamente ya que puede estar comprometida.
+ subject: Fallo de autenticación del segundo factor
+ title: Fallo en la autenticación del segundo factor
suspicious_sign_in:
change_password: cambies tu contraseña
details: 'Aquí están los detalles del inicio de sesión:'
diff --git a/config/locales/fy.yml b/config/locales/fy.yml
index f861bc3e4a..c59ad72725 100644
--- a/config/locales/fy.yml
+++ b/config/locales/fy.yml
@@ -1790,6 +1790,12 @@ fy:
extra: It stiet no klear om download te wurden!
subject: Jo argyf stiet klear om download te wurden
title: Argyf ophelje
+ failed_2fa:
+ details: 'Hjir binne de details fan de oanmeldbesykjen:'
+ explanation: Ien hat probearre om oan te melden op jo account, mar hat in ûnjildige twaddeferifikaasjefaktor opjûn.
+ further_actions_html: As jo dit net wiene, rekommandearje wy jo oan daliks %{action}, omdat it kompromitearre wêze kin.
+ subject: Twaddefaktorautentikaasjeflater
+ title: Twastapsferifikaasje mislearre
suspicious_sign_in:
change_password: wizigje jo wachtwurd
details: 'Hjir binne de details fan oanmeldbesykjen:'
diff --git a/config/locales/gl.yml b/config/locales/gl.yml
index 3c43a4e23d..087ed2ec76 100644
--- a/config/locales/gl.yml
+++ b/config/locales/gl.yml
@@ -1790,6 +1790,12 @@ gl:
extra: Está preparada para descargala!
subject: O teu ficheiro xa está preparado para descargar
title: Leve o ficheiro
+ failed_2fa:
+ details: 'Detalles do intento de acceso:'
+ explanation: Alguén intentou acceder á túa conta mais fíxoo cun segundo factor de autenticación non válido.
+ further_actions_html: Se non foches ti, recomendámosche %{action} inmediatamente xa que a conta podería estar en risco.
+ subject: Fallo co segundo factor de autenticación
+ title: Fallou o segundo factor de autenticación
suspicious_sign_in:
change_password: cambia o teu contrasinal
details: 'Estos son os detalles do acceso:'
diff --git a/config/locales/ru.yml b/config/locales/ru.yml
index 2644275c37..24edbdc75e 100644
--- a/config/locales/ru.yml
+++ b/config/locales/ru.yml
@@ -439,6 +439,7 @@ ru:
view: Посмотреть доменные блокировки
email_domain_blocks:
add_new: Добавить новую
+ allow_registrations_with_approval: Разрешить регистрацию с одобрением
attempts_over_week:
few: "%{count} попытки за последнюю неделю"
many: "%{count} попыток за последнюю неделю"
@@ -1659,6 +1660,7 @@ ru:
unknown_browser: Неизвестный браузер
weibo: Weibo
current_session: Текущая сессия
+ date: Дата
description: "%{browser} на %{platform}"
explanation: Здесь отображаются все браузеры, с которых выполнен вход в вашу учётную запись. Авторизованные приложения находятся в секции «Приложения».
ip: IP
@@ -1837,16 +1839,27 @@ ru:
webauthn: Ключи безопасности
user_mailer:
appeal_approved:
+ action: Настройки аккаунта
explanation: Апелляция на разблокировку против вашей учетной записи %{strike_date}, которую вы подали на %{appeal_date}, была одобрена. Ваша учетная запись снова на хорошем счету.
subject: Ваше обжалование от %{date} была одобрено
+ subtitle: Ваш аккаунт снова с хорошей репутацией.
title: Обжалование одобрено
appeal_rejected:
explanation: Апелляция на разблокировку против вашей учетной записи %{strike_date}, которую вы подали на %{appeal_date}, была одобрена. Ваша учетная запись восстановлена.
subject: Ваше обжалование от %{date} отклонено
+ subtitle: Ваша апелляция отклонена.
title: Обжалование отклонено
backup_ready:
+ explanation: Вы запросили полное резервное копирование вашей учетной записи Mastodon.
+ extra: Теперь он готов к загрузке!
subject: Ваш архив готов к загрузке
title: Архив ваших данных готов
+ failed_2fa:
+ details: 'Вот подробности попытки регистрации:'
+ explanation: Кто-то пытался войти в вашу учетную запись, но указал неверный второй фактор аутентификации.
+ further_actions_html: Если это не вы, мы рекомендуем %{action} немедленно принять меры, так как он может быть скомпрометирован.
+ subject: Сбой двухфакторной аутентификации
+ title: Сбой двухфакторной аутентификации
suspicious_sign_in:
change_password: сменить пароль
details: 'Подробности о новом входе:'
@@ -1900,6 +1913,7 @@ ru:
go_to_sso_account_settings: Перейти к настройкам сторонних аккаунтов учетной записи
invalid_otp_token: Введен неверный код двухфакторной аутентификации
otp_lost_help_html: Если Вы потеряли доступ к обоим, свяжитесь с %{email}
+ rate_limited: Слишком много попыток аутентификации, повторите попытку позже.
seamless_external_login: Вы залогинены через сторонний сервис, поэтому настройки e-mail и пароля недоступны.
signed_in_as: 'Выполнен вход под именем:'
verification:
diff --git a/config/locales/simple_form.sk.yml b/config/locales/simple_form.sk.yml
index e13a05835f..614812a3a9 100644
--- a/config/locales/simple_form.sk.yml
+++ b/config/locales/simple_form.sk.yml
@@ -60,6 +60,7 @@ sk:
fields:
name: Označenie
value: Obsah
+ unlocked: Automaticky prijímaj nových nasledovateľov
account_alias:
acct: Adresa starého účtu
account_migration:
diff --git a/config/locales/sk.yml b/config/locales/sk.yml
index c639bbe1a6..e83ae348f6 100644
--- a/config/locales/sk.yml
+++ b/config/locales/sk.yml
@@ -430,6 +430,7 @@ sk:
dashboard:
instance_accounts_dimension: Najsledovanejšie účty
instance_accounts_measure: uložené účty
+ instance_followers_measure: naši nasledovatelia tam
instance_follows_measure: ich sledovatelia tu
instance_languages_dimension: Najpopulárnejšie jazyky
instance_media_attachments_measure: uložené mediálne prílohy
@@ -1257,6 +1258,8 @@ sk:
extra: Teraz je pripravená na stiahnutie!
subject: Tvoj archív je pripravený na stiahnutie
title: Odber archívu
+ failed_2fa:
+ details: 'Tu sú podrobnosti o pokuse o prihlásenie:'
warning:
subject:
disable: Tvoj účet %{acct} bol zamrazený
diff --git a/config/locales/sq.yml b/config/locales/sq.yml
index 1693db7f31..d6e6925c70 100644
--- a/config/locales/sq.yml
+++ b/config/locales/sq.yml
@@ -1604,6 +1604,7 @@ sq:
unknown_browser: Shfletues i Panjohur
weibo: Weibo
current_session: Sesioni i tanishëm
+ date: Datë
description: "%{browser} në %{platform}"
explanation: Këta janë shfletuesit e përdorur tani për hyrje te llogaria juaj Mastodon.
ip: IP
@@ -1770,16 +1771,27 @@ sq:
webauthn: Kyçe sigurie
user_mailer:
appeal_approved:
+ action: Rregullime Llogarie
explanation: Apelimi i paralajmërimit kundër llogarisë tuaj më %{strike_date}, të cilin e parashtruar më %{appeal_date} është miratuar. Llogaria juaj është sërish në pozita të mira.
subject: Apelimi juaj i datës %{date} u miratua
+ subtitle: Llogaria juaj edhe një herë është e shëndetshme.
title: Apelimi u miratua
appeal_rejected:
explanation: Apelimi i paralajmërimit kundër llogarisë tuaj më %{strike_date}, të cilin e parashtruar më %{appeal_date}, u hodh poshtë.
subject: Apelimi juaj prej %{date} është hedhur poshtë
+ subtitle: Apelimi juaj është hedhur poshtë.
title: Apelimi u hodh poshtë
backup_ready:
+ explanation: Kërkuat një kopjeruajtje të plotë të llogarisë tuaj Mastodon.
+ extra: Tani është gati për shkarkim!
subject: Arkivi juaj është gati për shkarkim
title: Marrje arkivi me vete
+ failed_2fa:
+ details: 'Ja hollësitë e përpjekjes për hyrje:'
+ explanation: Dikush ka provuar të hyjë në llogarinë tuaj, por dha faktor të dytë mirëfilltësimi.
+ further_actions_html: Nëse s’qetë ju, rekomandojmë të %{action} menjëherë, ngaqë mund të jetë komprometua.
+ subject: Dështim faktori të dytë mirëfilltësimesh
+ title: Dështoi mirëfilltësimi me faktor të dytë
suspicious_sign_in:
change_password: ndryshoni fjalëkalimin tuaj
details: 'Ja hollësitë për hyrjen:'
@@ -1833,6 +1845,7 @@ sq:
go_to_sso_account_settings: Kaloni te rregullime llogarie te shërbimi juaj i identitetit
invalid_otp_token: Kod dyfaktorësh i pavlefshëm
otp_lost_help_html: Nëse humbët hyrjen te të dy, mund të lidheni me %{email}
+ rate_limited: Shumë përpjekje mirëfilltësimi, riprovoni më vonë.
seamless_external_login: Jeni futur përmes një shërbimi të jashtëm, ndaj s’ka rregullime fjalëkalimi dhe email.
signed_in_as: 'I futur si:'
verification:
diff --git a/config/locales/tr.yml b/config/locales/tr.yml
index fa84d2a96d..2b5b5ad45b 100644
--- a/config/locales/tr.yml
+++ b/config/locales/tr.yml
@@ -1791,7 +1791,7 @@ tr:
subject: Arşiviniz indirilmeye hazır
title: Arşiv paketlemesi
failed_2fa:
- details: 'Oturum açma denemesinin ayrıntıları şöyledir:'
+ details: 'İşte oturum açma girişiminin ayrıntıları:'
explanation: Birisi hesabınızda oturum açmaya çalıştı ancak hatalı bir iki aşamalı doğrulama kodu kullandı.
further_actions_html: Eğer bu kişi siz değilseniz, hemen %{action} yapmanızı öneriyoruz çünkü hesabınız ifşa olmuş olabilir.
subject: İki aşamalı doğrulama başarısızlığı
diff --git a/config/locales/vi.yml b/config/locales/vi.yml
index 3817b18f07..1ece72e154 100644
--- a/config/locales/vi.yml
+++ b/config/locales/vi.yml
@@ -1758,6 +1758,12 @@ vi:
extra: Hiện nó đã sẵn sàng tải xuống!
subject: Dữ liệu cá nhân của bạn đã sẵn sàng để tải về
title: Nhận dữ liệu cá nhân
+ failed_2fa:
+ details: 'Chi tiết thông tin đăng nhập:'
+ explanation: Ai đó đã cố đăng nhập vào tài khoản của bạn nhưng cung cấp yếu tố xác thực thứ hai không hợp lệ.
+ further_actions_html: Nếu không phải bạn, hãy lập tức %{action} vì có thể có rủi ro.
+ subject: Xác minh hai bước thất bại
+ title: Xác minh hai bước thất bại
suspicious_sign_in:
change_password: đổi mật khẩu của bạn
details: 'Chi tiết thông tin đăng nhập:'
diff --git a/config/routes.rb b/config/routes.rb
index bc4bac0bf2..257c2b4179 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -171,6 +171,11 @@ Rails.application.routes.draw do
end
end
+ namespace :redirect do
+ resources :accounts, only: :show
+ resources :statuses, only: :show
+ end
+
resources :media, only: [:show] do
get :player
end
diff --git a/config/routes/api.rb b/config/routes/api.rb
index 005d8f6839..7887d5aa93 100644
--- a/config/routes/api.rb
+++ b/config/routes/api.rb
@@ -62,6 +62,12 @@ namespace :api, format: false do
resources :scheduled_statuses, only: [:index, :show, :update, :destroy]
resources :preferences, only: [:index]
+ resources :annual_reports, only: [:index] do
+ member do
+ post :read
+ end
+ end
+
resources :announcements, only: [:index] do
scope module: :announcements do
resources :reactions, only: [:update, :destroy]
diff --git a/db/migrate/20240111033014_create_generated_annual_reports.rb b/db/migrate/20240111033014_create_generated_annual_reports.rb
new file mode 100644
index 0000000000..2a755fb14e
--- /dev/null
+++ b/db/migrate/20240111033014_create_generated_annual_reports.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class CreateGeneratedAnnualReports < ActiveRecord::Migration[7.1]
+ def change
+ create_table :generated_annual_reports do |t|
+ t.belongs_to :account, null: false, foreign_key: { on_cascade: :delete }, index: false
+ t.integer :year, null: false
+ t.jsonb :data, null: false
+ t.integer :schema_version, null: false
+ t.datetime :viewed_at
+
+ t.timestamps
+ end
+
+ add_index :generated_annual_reports, [:account_id, :year], unique: true
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 1ba22b16a2..28c3c8463a 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -694,6 +694,17 @@ ActiveRecord::Schema[7.1].define(version: 2024_01_21_231131) do
t.index ["inbox_url"], name: "index_friend_domains_on_inbox_url", unique: true
end
+ create_table "generated_annual_reports", force: :cascade do |t|
+ t.bigint "account_id", null: false
+ t.integer "year", null: false
+ t.jsonb "data", null: false
+ t.integer "schema_version", null: false
+ t.datetime "viewed_at"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["account_id", "year"], name: "index_generated_annual_reports_on_account_id_and_year", unique: true
+ end
+
create_table "identities", force: :cascade do |t|
t.string "provider", default: "", null: false
t.string "uid", default: "", null: false
@@ -1487,6 +1498,7 @@ ActiveRecord::Schema[7.1].define(version: 2024_01_21_231131) do
add_foreign_key "follow_requests", "accounts", name: "fk_76d644b0e7", on_delete: :cascade
add_foreign_key "follows", "accounts", column: "target_account_id", name: "fk_745ca29eac", on_delete: :cascade
add_foreign_key "follows", "accounts", name: "fk_32ed1b5560", on_delete: :cascade
+ add_foreign_key "generated_annual_reports", "accounts"
add_foreign_key "identities", "users", name: "fk_bea040f377", on_delete: :cascade
add_foreign_key "imports", "accounts", name: "fk_6db1b6e408", on_delete: :cascade
add_foreign_key "invites", "users", on_delete: :cascade
diff --git a/lib/mastodon/cli/statuses.rb b/lib/mastodon/cli/statuses.rb
index 7acf3f9b77..48d76e0288 100644
--- a/lib/mastodon/cli/statuses.rb
+++ b/lib/mastodon/cli/statuses.rb
@@ -120,7 +120,7 @@ module Mastodon::CLI
say('Beginning removal of now-orphaned media attachments to free up disk space...')
- scope = MediaAttachment.unattached.where('created_at < ?', options[:days].pred.days.ago)
+ scope = MediaAttachment.unattached.created_before(options[:days].pred.days.ago)
processed = 0
removed = 0
progress = create_progress_bar(scope.count)
diff --git a/spec/controllers/api/base_controller_spec.rb b/spec/controllers/api/base_controller_spec.rb
index db1e8777f7..f8e014be2f 100644
--- a/spec/controllers/api/base_controller_spec.rb
+++ b/spec/controllers/api/base_controller_spec.rb
@@ -12,7 +12,7 @@ describe Api::BaseController do
head 200
end
- def error
+ def failure
FakeService.new
end
end
@@ -30,7 +30,7 @@ describe Api::BaseController do
it 'does not protect from forgery' do
ActionController::Base.allow_forgery_protection = true
- post 'success'
+ post :success
expect(response).to have_http_status(200)
end
end
@@ -50,47 +50,55 @@ describe Api::BaseController do
it 'returns http forbidden for unconfirmed accounts' do
user.update(confirmed_at: nil)
- post 'success'
+ post :success
expect(response).to have_http_status(403)
end
it 'returns http forbidden for pending accounts' do
user.update(approved: false)
- post 'success'
+ post :success
expect(response).to have_http_status(403)
end
it 'returns http forbidden for disabled accounts' do
user.update(disabled: true)
- post 'success'
+ post :success
expect(response).to have_http_status(403)
end
it 'returns http forbidden for suspended accounts' do
user.account.suspend!
- post 'success'
+ post :success
expect(response).to have_http_status(403)
end
end
describe 'error handling' do
before do
- routes.draw { get 'error' => 'api/base#error' }
+ routes.draw { get 'failure' => 'api/base#failure' }
end
{
ActiveRecord::RecordInvalid => 422,
- Mastodon::ValidationError => 422,
ActiveRecord::RecordNotFound => 404,
- Mastodon::UnexpectedResponseError => 503,
+ ActiveRecord::RecordNotUnique => 422,
+ Date::Error => 422,
HTTP::Error => 503,
- OpenSSL::SSL::SSLError => 503,
+ Mastodon::InvalidParameterError => 400,
Mastodon::NotPermittedError => 403,
+ Mastodon::RaceConditionError => 503,
+ Mastodon::RateLimitExceededError => 429,
+ Mastodon::UnexpectedResponseError => 503,
+ Mastodon::ValidationError => 422,
+ OpenSSL::SSL::SSLError => 503,
+ Seahorse::Client::NetworkingError => 503,
+ Stoplight::Error::RedLight => 503,
}.each do |error, code|
it "Handles error class of #{error}" do
allow(FakeService).to receive(:new).and_raise(error)
- get 'error'
+ get :failure
+
expect(response).to have_http_status(code)
expect(FakeService).to have_received(:new)
end