diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml
index f68a877923..2f21564325 100644
--- a/.rubocop_todo.yml
+++ b/.rubocop_todo.yml
@@ -135,7 +135,6 @@ Style/FetchEnvVar:
# AllowedMethods: redirect
Style/FormatStringToken:
Exclude:
- - 'app/models/privacy_policy.rb'
- 'config/initializers/devise.rb'
- 'lib/paperclip/color_extractor.rb'
diff --git a/.ruby-version b/.ruby-version
index be94e6f53d..b347b11eac 100644
--- a/.ruby-version
+++ b/.ruby-version
@@ -1 +1 @@
-3.2.2
+3.2.3
diff --git a/Dockerfile b/Dockerfile
index 96f8b5cd27..119c266b89 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -7,15 +7,15 @@
ARG TARGETPLATFORM=${TARGETPLATFORM}
ARG BUILDPLATFORM=${BUILDPLATFORM}
-# Ruby image to use for base image, change with [--build-arg RUBY_VERSION="3.2.2"]
-ARG RUBY_VERSION="3.2.2"
+# Ruby image to use for base image, change with [--build-arg RUBY_VERSION="3.2.3"]
+ARG RUBY_VERSION="3.2.3"
# # Node version to use in base image, change with [--build-arg NODE_MAJOR_VERSION="20"]
ARG NODE_MAJOR_VERSION="20"
# Debian image to use for base image, change with [--build-arg DEBIAN_VERSION="bookworm"]
ARG DEBIAN_VERSION="bookworm"
# Node image to use for base image based on combined variables (ex: 20-bookworm-slim)
FROM docker.io/node:${NODE_MAJOR_VERSION}-${DEBIAN_VERSION}-slim as node
-# Ruby image to use for base image based on combined variables (ex: 3.2.2-slim-bookworm)
+# Ruby image to use for base image based on combined variables (ex: 3.2.3-slim-bookworm)
FROM docker.io/ruby:${RUBY_VERSION}-slim-${DEBIAN_VERSION} as ruby
# Resulting version string is vX.X.X-MASTODON_VERSION_PRERELEASE+MASTODON_VERSION_METADATA
diff --git a/FEDERATION.md b/FEDERATION.md
index e3721d7241..2819fa935a 100644
--- a/FEDERATION.md
+++ b/FEDERATION.md
@@ -1,19 +1,35 @@
-## ActivityPub federation in Mastodon
+# Federation
+
+## Supported federation protocols and standards
+
+- [ActivityPub](https://www.w3.org/TR/activitypub/) (Server-to-Server)
+- [WebFinger](https://webfinger.net/)
+- [Http Signatures](https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures)
+- [NodeInfo](https://nodeinfo.diaspora.software/)
+
+## Supported FEPs
+
+- [FEP-67ff: FEDERATION.md](https://codeberg.org/fediverse/fep/src/branch/main/fep/67ff/fep-67ff.md)
+- [FEP-f1d5: NodeInfo in Fediverse Software](https://codeberg.org/fediverse/fep/src/branch/main/fep/f1d5/fep-f1d5.md)
+- [FEP-8fcf: Followers collection synchronization across servers](https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md)
+- [FEP-5feb: Search indexing consent for actors](https://codeberg.org/fediverse/fep/src/branch/main/fep/5feb/fep-5feb.md)
+
+## ActivityPub in Mastodon
Mastodon largely follows the ActivityPub server-to-server specification but it makes uses of some non-standard extensions, some of which are required for interacting with Mastodon at all.
-Supported vocabulary: https://docs.joinmastodon.org/spec/activitypub/
+- [Supported ActivityPub vocabulary](https://docs.joinmastodon.org/spec/activitypub/)
### Required extensions
-#### Webfinger
+#### WebFinger
In Mastodon, users are identified by a `username` and `domain` pair (e.g., `Gargron@mastodon.social`).
This is used both for discovery and for unambiguously mentioning users across the fediverse. Furthermore, this is part of Mastodon's database design from its very beginnings.
As a result, Mastodon requires that each ActivityPub actor uniquely maps back to an `acct:` URI that can be resolved via WebFinger.
-More information and examples are available at: https://docs.joinmastodon.org/spec/webfinger/
+- [WebFinger information and examples](https://docs.joinmastodon.org/spec/webfinger/)
#### HTTP Signatures
@@ -21,11 +37,13 @@ In order to authenticate activities, Mastodon relies on HTTP Signatures, signing
Mastodon requires all `POST` requests to be signed, and MAY require `GET` requests to be signed, depending on the configuration of the Mastodon server.
-More information on HTTP Signatures, as well as examples, can be found here: https://docs.joinmastodon.org/spec/security/#http
+- [HTTP Signatures information and examples](https://docs.joinmastodon.org/spec/security/#http)
### Optional extensions
-- Linked-Data Signatures: https://docs.joinmastodon.org/spec/security/#ld
-- Bearcaps: https://docs.joinmastodon.org/spec/bearcaps/
-- Followers collection synchronization: https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md
-- Search indexing consent for actors: https://codeberg.org/fediverse/fep/src/branch/main/fep/5feb/fep-5feb.md
+- [Linked-Data Signatures](https://docs.joinmastodon.org/spec/security/#ld)
+- [Bearcaps](https://docs.joinmastodon.org/spec/bearcaps/)
+
+### Additional documentation
+
+- [Mastodon documentation](https://docs.joinmastodon.org/)
diff --git a/app/controllers/activitypub/followers_synchronizations_controller.rb b/app/controllers/activitypub/followers_synchronizations_controller.rb
index 976caa3445..d2942104e5 100644
--- a/app/controllers/activitypub/followers_synchronizations_controller.rb
+++ b/app/controllers/activitypub/followers_synchronizations_controller.rb
@@ -24,7 +24,7 @@ class ActivityPub::FollowersSynchronizationsController < ActivityPub::BaseContro
end
def set_items
- @items = @account.followers.where(Account.arel_table[:uri].matches("#{Account.sanitize_sql_like(uri_prefix)}/%", false, true)).or(@account.followers.where(uri: uri_prefix)).pluck(:uri)
+ @items = @account.followers.matches_uri_prefix(uri_prefix).pluck(:uri)
end
def collection_presenter
diff --git a/app/controllers/api/v1/statuses/favourited_by_accounts_controller.rb b/app/controllers/api/v1/statuses/favourited_by_accounts_controller.rb
index 3cca246ce8..98b69c347f 100644
--- a/app/controllers/api/v1/statuses/favourited_by_accounts_controller.rb
+++ b/app/controllers/api/v1/statuses/favourited_by_accounts_controller.rb
@@ -14,7 +14,7 @@ class Api::V1::Statuses::FavouritedByAccountsController < Api::V1::Statuses::Bas
def load_accounts
scope = default_accounts
- scope = scope.where.not(id: current_account.excluded_from_timeline_account_ids) unless current_account.nil?
+ scope = scope.not_excluded_by_account(current_account) unless current_account.nil?
scope.merge(paginated_favourites).to_a
end
diff --git a/app/controllers/api/v1/statuses/reblogged_by_accounts_controller.rb b/app/controllers/api/v1/statuses/reblogged_by_accounts_controller.rb
index dd3e60846b..aacab5f8f4 100644
--- a/app/controllers/api/v1/statuses/reblogged_by_accounts_controller.rb
+++ b/app/controllers/api/v1/statuses/reblogged_by_accounts_controller.rb
@@ -14,7 +14,7 @@ class Api::V1::Statuses::RebloggedByAccountsController < Api::V1::Statuses::Base
def load_accounts
scope = default_accounts
- scope = scope.where.not(id: current_account.excluded_from_timeline_account_ids) unless current_account.nil?
+ scope = scope.not_excluded_by_account(current_account) unless current_account.nil?
scope.merge(paginated_statuses).to_a
end
diff --git a/app/controllers/auth/sessions_controller.rb b/app/controllers/auth/sessions_controller.rb
index 1c773511b4..41c8562363 100644
--- a/app/controllers/auth/sessions_controller.rb
+++ b/app/controllers/auth/sessions_controller.rb
@@ -1,6 +1,10 @@
# frozen_string_literal: true
class Auth::SessionsController < Devise::SessionsController
+ include Redisable
+
+ MAX_2FA_ATTEMPTS_PER_HOUR = 10
+
layout 'auth'
skip_before_action :check_self_destruct!
@@ -135,9 +139,23 @@ class Auth::SessionsController < Devise::SessionsController
session.delete(:attempt_user_updated_at)
end
+ def clear_2fa_attempt_from_user(user)
+ redis.del(second_factor_attempts_key(user))
+ end
+
+ def check_second_factor_rate_limits(user)
+ attempts, = redis.multi do |multi|
+ multi.incr(second_factor_attempts_key(user))
+ multi.expire(second_factor_attempts_key(user), 1.hour)
+ end
+
+ attempts >= MAX_2FA_ATTEMPTS_PER_HOUR
+ end
+
def on_authentication_success(user, security_measure)
@on_authentication_success_called = true
+ clear_2fa_attempt_from_user(user)
clear_attempt_from_session
user.update_sign_in!(new_sign_in: true)
@@ -168,5 +186,14 @@ class Auth::SessionsController < Devise::SessionsController
ip: request.remote_ip,
user_agent: request.user_agent
)
+
+ # Only send a notification email every hour at most
+ return if redis.set("2fa_failure_notification:#{user.id}", '1', ex: 1.hour, get: true).present?
+
+ UserMailer.failed_2fa(user, request.remote_ip, request.user_agent, Time.now.utc).deliver_later!
+ end
+
+ def second_factor_attempts_key(user)
+ "2fa_auth_attempts:#{user.id}:#{Time.now.utc.hour}"
end
end
diff --git a/app/controllers/concerns/auth/two_factor_authentication_concern.rb b/app/controllers/concerns/auth/two_factor_authentication_concern.rb
index ebd6a93441..edcdd2990f 100644
--- a/app/controllers/concerns/auth/two_factor_authentication_concern.rb
+++ b/app/controllers/concerns/auth/two_factor_authentication_concern.rb
@@ -66,6 +66,11 @@ module Auth::TwoFactorAuthenticationConcern
end
def authenticate_with_two_factor_via_otp(user)
+ if check_second_factor_rate_limits(user)
+ flash.now[:alert] = I18n.t('users.rate_limited')
+ return prompt_for_two_factor(user)
+ end
+
if valid_otp_attempt?(user)
on_authentication_success(user, :otp)
else
diff --git a/app/javascript/mastodon/actions/search.js b/app/javascript/mastodon/actions/search.js
index 38a089b486..a34a490e76 100644
--- a/app/javascript/mastodon/actions/search.js
+++ b/app/javascript/mastodon/actions/search.js
@@ -179,6 +179,11 @@ export const openURL = (value, history, onFailure) => (dispatch, getState) => {
export const clickSearchResult = (q, type) => (dispatch, getState) => {
const previous = getState().getIn(['search', 'recent']);
+
+ if (previous.some(x => x.get('q') === q && x.get('type') === type)) {
+ return;
+ }
+
const me = getState().getIn(['meta', 'me']);
const current = previous.add(fromJS({ type, q })).takeLast(4);
@@ -207,4 +212,4 @@ export const hydrateSearch = () => (dispatch, getState) => {
if (history !== null) {
dispatch(updateSearchHistory(history));
}
-};
\ No newline at end of file
+};
diff --git a/app/javascript/mastodon/features/compose/components/search.jsx b/app/javascript/mastodon/features/compose/components/search.jsx
index 0bcc41b929..ca02c23fc4 100644
--- a/app/javascript/mastodon/features/compose/components/search.jsx
+++ b/app/javascript/mastodon/features/compose/components/search.jsx
@@ -62,14 +62,14 @@ class Search extends PureComponent {
};
defaultOptions = [
- { label: <>has: >, action: e => { e.preventDefault(); this._insertText('has:'); } },
- { label: <>is: >, action: e => { e.preventDefault(); this._insertText('is:'); } },
- { label: <>language: >, action: e => { e.preventDefault(); this._insertText('language:'); } },
- { label: <>from: >, action: e => { e.preventDefault(); this._insertText('from:'); } },
- { label: <>before: >, action: e => { e.preventDefault(); this._insertText('before:'); } },
- { label: <>during: >, action: e => { e.preventDefault(); this._insertText('during:'); } },
- { label: <>after: >, action: e => { e.preventDefault(); this._insertText('after:'); } },
- { label: <>in: >, action: e => { e.preventDefault(); this._insertText('in:'); } }
+ { key: 'prompt-has', label: <>has: >, action: e => { e.preventDefault(); this._insertText('has:'); } },
+ { key: 'prompt-is', label: <>is: >, action: e => { e.preventDefault(); this._insertText('is:'); } },
+ { key: 'prompt-language', label: <>language: >, action: e => { e.preventDefault(); this._insertText('language:'); } },
+ { key: 'prompt-from', label: <>from: >, action: e => { e.preventDefault(); this._insertText('from:'); } },
+ { key: 'prompt-before', label: <>before: >, action: e => { e.preventDefault(); this._insertText('before:'); } },
+ { key: 'prompt-during', label: <>during: >, action: e => { e.preventDefault(); this._insertText('during:'); } },
+ { key: 'prompt-after', label: <>after: >, action: e => { e.preventDefault(); this._insertText('after:'); } },
+ { key: 'prompt-in', label: <>in: >, action: e => { e.preventDefault(); this._insertText('in:'); } }
];
setRef = c => {
@@ -262,6 +262,8 @@ class Search extends PureComponent {
const { recent } = this.props;
return recent.toArray().map(search => ({
+ key: `${search.get('type')}/${search.get('q')}`,
+
label: labelForRecentSearch(search),
action: () => this.handleRecentSearchClick(search),
@@ -346,8 +348,8 @@ class Search extends PureComponent {
- {recent.size > 0 ? this._getOptions().map(({ label, action, forget }, i) => (
-