From 51224bb437f195922d9bfa0680e011dcc5a54220 Mon Sep 17 00:00:00 2001 From: David Roetzel Date: Tue, 20 Jan 2026 09:27:05 +0100 Subject: [PATCH 01/10] Add "featured collections" collection to actors (#37512) --- .../featured_collections_controller.rb | 77 +++++++++ app/models/concerns/account/interactions.rb | 7 + app/policies/account_policy.rb | 4 + app/policies/collection_policy.rb | 14 +- .../activitypub/actor_serializer.rb | 7 + .../activitypub/collection_serializer.rb | 2 + config/routes.rb | 2 + .../concerns/account/interactions_spec.rb | 38 +++++ spec/policies/account_policy_spec.rb | 20 +++ .../activitypub/featured_collections_spec.rb | 153 ++++++++++++++++++ .../activitypub/collection_serializer_spec.rb | 6 + 11 files changed, 317 insertions(+), 13 deletions(-) create mode 100644 app/controllers/activitypub/featured_collections_controller.rb create mode 100644 spec/requests/activitypub/featured_collections_spec.rb diff --git a/app/controllers/activitypub/featured_collections_controller.rb b/app/controllers/activitypub/featured_collections_controller.rb new file mode 100644 index 0000000000..872d03423d --- /dev/null +++ b/app/controllers/activitypub/featured_collections_controller.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +class ActivityPub::FeaturedCollectionsController < ApplicationController + include SignatureAuthentication + include Authorization + include AccountOwnedConcern + + PER_PAGE = 5 + + vary_by -> { public_fetch_mode? ? 'Accept, Accept-Language, Cookie' : 'Accept, Accept-Language, Cookie, Signature' } + + before_action :check_feature_enabled + before_action :require_account_signature!, if: -> { authorized_fetch_mode? } + before_action :set_collections + + skip_around_action :set_locale + skip_before_action :require_functional!, unless: :limited_federation_mode? + + def index + respond_to do |format| + format.json do + expires_in(page_requested? ? 0 : 3.minutes, public: public_fetch_mode?) + + render json: collection_presenter, + serializer: ActivityPub::CollectionSerializer, + adapter: ActivityPub::Adapter, + content_type: 'application/activity+json' + end + end + end + + private + + def set_collections + authorize @account, :index_collections? + @collections = @account.collections.page(params[:page]).per(PER_PAGE) + rescue Mastodon::NotPermittedError + not_found + end + + def page_requested? + params[:page].present? + end + + def next_page_url + ap_account_featured_collections_url(@account, page: @collections.next_page) if @collections.respond_to?(:next_page) + end + + def prev_page_url + ap_account_featured_collections_url(@account, page: @collections.prev_page) if @collections.respond_to?(:prev_page) + end + + def collection_presenter + if page_requested? + ActivityPub::CollectionPresenter.new( + id: ap_account_featured_collections_url(@account, page: params.fetch(:page, 1)), + type: :unordered, + size: @account.collections.count, + items: @collections, + part_of: ap_account_featured_collections_url(@account), + next: next_page_url, + prev: prev_page_url + ) + else + ActivityPub::CollectionPresenter.new( + id: ap_account_featured_collections_url(@account), + type: :unordered, + size: @account.collections.count, + first: ap_account_featured_collections_url(@account, page: 1) + ) + end + end + + def check_feature_enabled + raise ActionController::RoutingError unless Mastodon::Feature.collections_enabled? + end +end diff --git a/app/models/concerns/account/interactions.rb b/app/models/concerns/account/interactions.rb index c51ccf1229..d4a415ee31 100644 --- a/app/models/concerns/account/interactions.rb +++ b/app/models/concerns/account/interactions.rb @@ -164,6 +164,13 @@ module Account::Interactions end end + def blocking_or_domain_blocking?(other_account) + return true if blocking?(other_account) + return false if other_account.domain.blank? + + domain_blocking?(other_account.domain) + end + def muting?(other_account) other_id = other_account.is_a?(Account) ? other_account.id : other_account diff --git a/app/policies/account_policy.rb b/app/policies/account_policy.rb index ab3b41d628..1fef35714c 100644 --- a/app/policies/account_policy.rb +++ b/app/policies/account_policy.rb @@ -68,4 +68,8 @@ class AccountPolicy < ApplicationPolicy def feature? record.featureable? && !current_account.blocking?(record) && !current_account.blocked_by?(record) end + + def index_collections? + current_account.nil? || !record.blocking_or_domain_blocking?(current_account) + end end diff --git a/app/policies/collection_policy.rb b/app/policies/collection_policy.rb index 70a869d16a..4d100c0e32 100644 --- a/app/policies/collection_policy.rb +++ b/app/policies/collection_policy.rb @@ -6,7 +6,7 @@ class CollectionPolicy < ApplicationPolicy end def show? - current_account.nil? || (!owner_blocking? && !owner_blocking_domain?) + current_account.nil? || !owner.blocking_or_domain_blocking?(current_account) end def create? @@ -27,18 +27,6 @@ class CollectionPolicy < ApplicationPolicy current_account == owner end - def owner_blocking_domain? - return false if current_account.nil? || current_account.domain.nil? - - owner.domain_blocking?(current_account.domain) - end - - def owner_blocking? - return false if current_account.nil? - - current_account.blocked_by?(owner) - end - def owner record.account end diff --git a/app/serializers/activitypub/actor_serializer.rb b/app/serializers/activitypub/actor_serializer.rb index c19d42bfb4..ff1a70104b 100644 --- a/app/serializers/activitypub/actor_serializer.rb +++ b/app/serializers/activitypub/actor_serializer.rb @@ -19,6 +19,7 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer :discoverable, :indexable, :published, :memorial attribute :interaction_policy, if: -> { Mastodon::Feature.collections_enabled? } + attribute :featured_collections, if: -> { Mastodon::Feature.collections_enabled? } has_one :public_key, serializer: ActivityPub::PublicKeySerializer @@ -177,6 +178,12 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer } end + def featured_collections + return nil if instance_actor? + + ap_account_featured_collections_url(object.id) + end + class CustomEmojiSerializer < ActivityPub::EmojiSerializer end diff --git a/app/serializers/activitypub/collection_serializer.rb b/app/serializers/activitypub/collection_serializer.rb index 1b410cecae..ba0d17f540 100644 --- a/app/serializers/activitypub/collection_serializer.rb +++ b/app/serializers/activitypub/collection_serializer.rb @@ -18,6 +18,8 @@ class ActivityPub::CollectionSerializer < ActivityPub::Serializer ActivityPub::HashtagSerializer when 'ActivityPub::CollectionPresenter' ActivityPub::CollectionSerializer + when 'Collection' + ActivityPub::FeaturedCollectionSerializer when 'String' StringSerializer else diff --git a/config/routes.rb b/config/routes.rb index b3338a725e..bf50b67fe1 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -124,6 +124,8 @@ Rails.application.routes.draw do scope path: 'ap', as: 'ap' do resources :accounts, path: 'users', only: [:show], param: :id, concerns: :account_resources do + resources :featured_collections, only: [:index], module: :activitypub + resources :statuses, only: [:show] do member do get :activity diff --git a/spec/models/concerns/account/interactions_spec.rb b/spec/models/concerns/account/interactions_spec.rb index cc50c46551..5bca795908 100644 --- a/spec/models/concerns/account/interactions_spec.rb +++ b/spec/models/concerns/account/interactions_spec.rb @@ -450,6 +450,44 @@ RSpec.describe Account::Interactions do end end + describe '#blocking_or_domain_blocking?' do + subject { account.blocking_or_domain_blocking?(target_account) } + + context 'when blocking target_account' do + before do + account.block_relationships.create(target_account: target_account) + end + + it 'returns true' do + result = nil + expect { result = subject }.to execute_queries + + expect(result).to be true + end + end + + context 'when blocking the domain' do + let(:target_account) { Fabricate(:remote_account) } + + before do + account_domain_block = Fabricate(:account_domain_block, domain: target_account.domain) + account.domain_blocks << account_domain_block + end + + it 'returns true' do + result = nil + expect { result = subject }.to execute_queries + expect(result).to be true + end + end + + context 'when blocking neither target_account nor its domain' do + it 'returns false' do + expect(subject).to be false + end + end + end + describe '#muting?' do subject { account.muting?(target_account) } diff --git a/spec/policies/account_policy_spec.rb b/spec/policies/account_policy_spec.rb index f877bded25..96fcbdb4d8 100644 --- a/spec/policies/account_policy_spec.rb +++ b/spec/policies/account_policy_spec.rb @@ -188,4 +188,24 @@ RSpec.describe AccountPolicy do end end end + + permissions :index_collections? do + it 'permits when no user is given' do + expect(subject).to permit(nil, john) + end + + it 'permits unblocked users' do + expect(subject).to permit(john, john) + expect(subject).to permit(alice, john) + end + + it 'denies blocked users' do + domain_blocked_user = Fabricate(:remote_account) + john.block_domain!(domain_blocked_user.domain) + john.block!(alice) + + expect(subject).to_not permit(domain_blocked_user, john) + expect(subject).to_not permit(alice, john) + end + end end diff --git a/spec/requests/activitypub/featured_collections_spec.rb b/spec/requests/activitypub/featured_collections_spec.rb new file mode 100644 index 0000000000..09a17c53be --- /dev/null +++ b/spec/requests/activitypub/featured_collections_spec.rb @@ -0,0 +1,153 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Collections' do + describe 'GET /ap/users/@:account_id/featured_collections', feature: :collections do + subject { get ap_account_featured_collections_path(account.id, format: :json) } + + let(:collection) { Fabricate(:collection) } + let(:account) { collection.account } + + context 'when signed out' do + context 'when account is permanently suspended' do + before do + account.suspend! + account.deletion_request.destroy + end + + it 'returns http gone' do + subject + + expect(response) + .to have_http_status(410) + end + end + + context 'when account is temporarily suspended' do + before { account.suspend! } + + it 'returns http forbidden' do + subject + + expect(response) + .to have_http_status(403) + end + end + + context 'when account is accessible' do + it 'renders ActivityPub Collection successfully', :aggregate_failures do + subject + + expect(response) + .to have_http_status(200) + .and have_cacheable_headers.with_vary('Accept, Accept-Language, Cookie') + + expect(response.headers).to include( + 'Content-Type' => include('application/activity+json') + ) + expect(response.parsed_body) + .to include({ + 'type' => 'Collection', + 'totalItems' => 1, + 'first' => match(%r{^https://.*page=1.*$}), + }) + end + + context 'when requesting the first page' do + subject { get ap_account_featured_collections_path(account.id, page: 1, format: :json) } + + context 'when account has many collections' do + before do + Fabricate.times(5, :collection, account:) + end + + it 'includes a link to the next page', :aggregate_failures do + subject + + expect(response) + .to have_http_status(200) + + expect(response.parsed_body) + .to include({ + 'type' => 'CollectionPage', + 'totalItems' => 6, + 'next' => match(%r{^https://.*page=2.*$}), + }) + end + end + end + end + end + + context 'when signed in' do + let(:user) { Fabricate(:user) } + + before do + post user_session_path, params: { user: { email: user.email, password: user.password } } + end + + context 'when account blocks user' do + before { account.block!(user.account) } + + it 'returns http not found' do + subject + + expect(response) + .to have_http_status(404) + end + end + end + + context 'with "HTTP Signature" access signed by a remote account' do + subject do + get ap_account_featured_collections_path(account.id, format: :json), + headers: nil, + sign_with: remote_account + end + + let(:remote_account) { Fabricate(:account, domain: 'host.example') } + + context 'when account blocks the remote account' do + before { account.block!(remote_account) } + + it 'returns http not found' do + subject + + expect(response) + .to have_http_status(404) + end + end + + context 'when account domain blocks the domain of the remote account' do + before { account.block_domain!(remote_account.domain) } + + it 'returns http not found' do + subject + + expect(response) + .to have_http_status(404) + end + end + + context 'with JSON' do + it 'renders ActivityPub FeaturedCollection object successfully', :aggregate_failures do + subject + + expect(response) + .to have_http_status(200) + .and have_cacheable_headers.with_vary('Accept, Accept-Language, Cookie') + + expect(response.headers).to include( + 'Content-Type' => include('application/activity+json') + ) + expect(response.parsed_body) + .to include({ + 'type' => 'Collection', + 'totalItems' => 1, + }) + end + end + end + end +end diff --git a/spec/serializers/activitypub/collection_serializer_spec.rb b/spec/serializers/activitypub/collection_serializer_spec.rb index 7726df914f..d7099ba3d5 100644 --- a/spec/serializers/activitypub/collection_serializer_spec.rb +++ b/spec/serializers/activitypub/collection_serializer_spec.rb @@ -35,5 +35,11 @@ RSpec.describe ActivityPub::CollectionSerializer do it { is_expected.to eq(ActiveModel::Serializer::CollectionSerializer) } end + + context 'with a Collection' do + let(:model) { Collection.new } + + it { is_expected.to eq(ActivityPub::FeaturedCollectionSerializer) } + end end end From 8ff7662918dbe33ffe414a7bcbc3eac42dac11cd Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 20 Jan 2026 08:31:49 +0000 Subject: [PATCH 02/10] New Crowdin Translations (automated) (#37545) Co-authored-by: GitHub Actions --- app/javascript/mastodon/locales/et.json | 46 ++++++++++++++----------- app/javascript/mastodon/locales/tr.json | 4 +++ config/locales/devise.et.yml | 12 +++---- config/locales/et.yml | 18 +++++----- config/locales/simple_form.et.yml | 8 ++--- 5 files changed, 48 insertions(+), 40 deletions(-) diff --git a/app/javascript/mastodon/locales/et.json b/app/javascript/mastodon/locales/et.json index 854d70b0ce..e4c303153d 100644 --- a/app/javascript/mastodon/locales/et.json +++ b/app/javascript/mastodon/locales/et.json @@ -2,15 +2,15 @@ "about.blocks": "Modereeritavad serverid", "about.contact": "Kontakt:", "about.default_locale": "Vaikimisi", - "about.disclaimer": "Mastodon on tasuta ja vaba tarkvara ning Mastodon gGmbH kaubamärk.", + "about.disclaimer": "Mastodon on vaba, tasuta ja avatud lähtekoodiga tarkvara ning Mastodon gGmbH kaubamärk.", "about.domain_blocks.no_reason_available": "Põhjus on teadmata", - "about.domain_blocks.preamble": "Mastodon lubab tavaliselt vaadata sisu ning suhelda kasutajatega ükskõik millisest teisest fediversumi serverist. Need on erandid, mis on paika pandud sellel kindlal serveril.", - "about.domain_blocks.silenced.explanation": "Sa ei näe üldiselt profiile ja sisu sellelt serverilt, kui sa just tahtlikult seda ei otsi või jälgimise moel nõusolekut ei anna.", + "about.domain_blocks.preamble": "Mastodon lubab üldiselt vaadata sisu ning suhelda kasutajatega ükskõik millisest teisest födiversumi serverist. Need on erandid, mis kehtivad selles kindlas serveris.", + "about.domain_blocks.silenced.explanation": "Sa üldjuhul ei näe profiile ja sisu sellest serverist, kui sa just tahtlikult neid ei otsi või jälgimise moel nõusolekut ei anna.", "about.domain_blocks.silenced.title": "Piiratud", - "about.domain_blocks.suspended.explanation": "Mitte mingeid andmeid sellelt serverilt ei töödelda, salvestata ega vahetata, tehes igasuguse interaktsiooni või kirjavahetuse selle serveri kasutajatega võimatuks.", + "about.domain_blocks.suspended.explanation": "Mitte mingeid andmeid sellelt serverilt ei töödelda, salvestata ega vahetata, tehes igasuguse suhestumise või infovahetuse selle serveri kasutajatega võimatuks.", "about.domain_blocks.suspended.title": "Peatatud", "about.language_label": "Keel", - "about.not_available": "See info ei ole sellel serveril saadavaks tehtud.", + "about.not_available": "See info ei ole selles serveris saadavaks tehtud.", "about.powered_by": "Hajutatud sotsiaalmeedia, mille taga on {mastodon}", "about.rules": "Serveri reeglid", "account.account_note_header": "Isiklik märge", @@ -18,19 +18,19 @@ "account.badges.bot": "Robot", "account.badges.group": "Grupp", "account.block": "Blokeeri @{name}", - "account.block_domain": "Peida kõik domeenist {domain}", + "account.block_domain": "Blokeeri kõik domeenist {domain}", "account.block_short": "Blokeerimine", "account.blocked": "Blokeeritud", "account.blocking": "Blokeeritud kasutaja", "account.cancel_follow_request": "Võta jälgimistaotlus tagasi", "account.copy": "Kopeeri profiili link", "account.direct": "Maini privaatselt @{name}", - "account.disable_notifications": "Peata teavitused @{name} postitustest", + "account.disable_notifications": "Ära teavita, kui @{name} postitab", "account.domain_blocking": "Blokeeritud domeen", "account.edit_profile": "Muuda profiili", "account.edit_profile_short": "Muuda", - "account.enable_notifications": "Teavita mind @{name} postitustest", - "account.endorse": "Too profiilil esile", + "account.enable_notifications": "Teavita mind, kui {name} postitab", + "account.endorse": "Too profiilis esile", "account.familiar_followers_many": "Jälgijateks {name1}, {name2} ja veel {othersCount, plural, one {üks kasutaja, keda tead} other {# kasutajat, keda tead}}", "account.familiar_followers_one": "Jälgijaks {name1}", "account.familiar_followers_two": "Jälgijateks {name1} ja {name2}", @@ -57,11 +57,12 @@ "account.go_to_profile": "Mine profiilile", "account.hide_reblogs": "Peida @{name} jagamised", "account.in_memoriam": "In Memoriam.", + "account.joined_long": "Liitus {date}", "account.joined_short": "Liitus", "account.languages": "Muuda tellitud keeli", "account.link_verified_on": "Selle lingi autorsust kontrolliti {date}", "account.locked_info": "Selle konto privaatsussätteks on lukustatud. Omanik vaatab käsitsi üle, kes teda jälgida saab.", - "account.media": "Meedia", + "account.media": "Meedium", "account.mention": "Maini @{name}", "account.moved_to": "{name} on teada andnud, et ta uus konto on nüüd:", "account.mute": "Summuta @{name}", @@ -81,20 +82,22 @@ "account.share": "Jaga @{name} profiili", "account.show_reblogs": "Näita @{name} jagamisi", "account.statuses_counter": "{count, plural, one {{counter} postitus} other {{counter} postitust}}", - "account.unblock": "Eemalda blokeering @{name}", - "account.unblock_domain": "Tee {domain} nähtavaks", + "account.unblock": "Lõpeta {name} kasutaja blokeerimine", + "account.unblock_domain": "Lõpeta {domain} domeeni blokeerimine", "account.unblock_domain_short": "Lõpeta blokeerimine", - "account.unblock_short": "Eemalda blokeering", + "account.unblock_short": "Lõpeta blokeerimine", "account.unendorse": "Ära kuva profiilil", - "account.unfollow": "Jälgid", + "account.unfollow": "Ära jälgi", "account.unmute": "Lõpeta {name} kasutaja summutamine", "account.unmute_notifications_short": "Lõpeta teavituste summutamine", "account.unmute_short": "Lõpeta summutamine", + "account_fields_modal.close": "Sulge", + "account_fields_modal.title": "Kasutaja teave: {name}", "account_note.placeholder": "Klõpsa märke lisamiseks", "admin.dashboard.daily_retention": "Kasutajate päevane allesjäämine peale registreerumist", "admin.dashboard.monthly_retention": "Kasutajate kuine allesjäämine peale registreerumist", "admin.dashboard.retention.average": "Keskmine", - "admin.dashboard.retention.cohort": "Registreerumiskuu", + "admin.dashboard.retention.cohort": "Liitumiskuu", "admin.dashboard.retention.cohort_size": "Uued kasutajad", "admin.impact_report.instance_accounts": "Kontode profiilid, mille see kustutaks", "admin.impact_report.instance_followers": "Jälgijad, kelle meie kasutajad kaotaks", @@ -103,11 +106,11 @@ "alert.rate_limited.message": "Palun proovi uuesti pärast {retry_time, time, medium}.", "alert.rate_limited.title": "Kiiruspiirang", "alert.unexpected.message": "Tekkis ootamatu viga.", - "alert.unexpected.title": "Oih!", - "alt_text_badge.title": "Alternatiivtekst", - "alt_text_modal.add_alt_text": "Lisa alt-tekst", + "alert.unexpected.title": "Vaat kus lops!", + "alt_text_badge.title": "Selgitustekst", + "alt_text_modal.add_alt_text": "Lisa selgitustekst", "alt_text_modal.add_text_from_image": "Lisa tekst pildilt", - "alt_text_modal.cancel": "Tühista", + "alt_text_modal.cancel": "Katkesta", "alt_text_modal.change_thumbnail": "Muuda pisipilti", "alt_text_modal.describe_for_people_with_hearing_impairments": "Kirjelda seda kuulmispuudega inimeste jaoks…", "alt_text_modal.describe_for_people_with_visual_impairments": "Kirjelda seda nägemispuudega inimeste jaoks…", @@ -119,7 +122,7 @@ "annual_report.announcement.description": "Vaata teavet oma suhestumise kohta Mastodonis eelmisel aastal.", "annual_report.announcement.title": "{year}. aasta Mastodoni kokkuvõte on valmis", "annual_report.nav_item.badge": "Uus", - "annual_report.shared_page.donate": "Anneta", + "annual_report.shared_page.donate": "Toeta rahaliselt", "annual_report.shared_page.footer": "Loodud {heart} Mastodoni meeskonna poolt", "annual_report.shared_page.footer_server_info": "{username} kasutab {domain}-i, üht paljudest kogukondadest, mis toimivad Mastodonil.", "annual_report.summary.archetype.booster.desc_public": "{name} jätkas postituste otsimist, et neid edendada, tugevdades teisi loojaid täiusliku täpsusega.", @@ -589,6 +592,7 @@ "load_pending": "{count, plural, one {# uus kirje} other {# uut kirjet}}", "loading_indicator.label": "Laadimine…", "media_gallery.hide": "Peida", + "minicard.more_items": "+{count}", "moved_to_account_banner.text": "Kontot {disabledAccount} ei ole praegu võimalik kasutada, sest kolisid kontole {movedToAccount}.", "mute_modal.hide_from_notifications": "Peida teavituste hulgast", "mute_modal.hide_options": "Peida valikud", @@ -767,7 +771,7 @@ "onboarding.profile.upload_avatar": "Laadi üles profiilipilt", "onboarding.profile.upload_header": "Laadi üles profiili päis", "password_confirmation.exceeds_maxlength": "Salasõnakinnitus on pikem kui salasõna maksimumpikkus", - "password_confirmation.mismatching": "Salasõnakinnitus ei sobi kokku", + "password_confirmation.mismatching": "Salasõnad ei klapi", "picture_in_picture.restore": "Pane tagasi", "poll.closed": "Suletud", "poll.refresh": "Värskenda", diff --git a/app/javascript/mastodon/locales/tr.json b/app/javascript/mastodon/locales/tr.json index 4356fce6c5..1d8a0a247f 100644 --- a/app/javascript/mastodon/locales/tr.json +++ b/app/javascript/mastodon/locales/tr.json @@ -57,6 +57,7 @@ "account.go_to_profile": "Profile git", "account.hide_reblogs": "@{name} kişisinin yeniden paylaşımlarını gizle", "account.in_memoriam": "Hatırasına.", + "account.joined_long": "{date} tarihinde katıldı", "account.joined_short": "Katıldı", "account.languages": "Abone olunan dilleri değiştir", "account.link_verified_on": "Bu bağlantının sahipliği {date} tarihinde denetlendi", @@ -90,6 +91,8 @@ "account.unmute": "@{name} adlı kişinin sesini aç", "account.unmute_notifications_short": "Bildirimlerin sesini aç", "account.unmute_short": "Susturmayı kaldır", + "account_fields_modal.close": "Kapat", + "account_fields_modal.title": "{name} bilgileri", "account_note.placeholder": "Not eklemek için tıklayın", "admin.dashboard.daily_retention": "Kayıttan sonra günlük kullanıcı saklama oranı", "admin.dashboard.monthly_retention": "Kayıttan sonra aylık kullanıcı saklama oranı", @@ -589,6 +592,7 @@ "load_pending": "{count, plural, one {# yeni öğe} other {# yeni öğe}}", "loading_indicator.label": "Yükleniyor…", "media_gallery.hide": "Gizle", + "minicard.more_items": "+{count}", "moved_to_account_banner.text": "{disabledAccount} hesabınız, {movedToAccount} hesabına taşıdığınız için şu an devre dışı.", "mute_modal.hide_from_notifications": "Bildirimlerde gizle", "mute_modal.hide_options": "Seçenekleri gizle", diff --git a/config/locales/devise.et.yml b/config/locales/devise.et.yml index 5843761ddb..6ed4c2dd70 100644 --- a/config/locales/devise.et.yml +++ b/config/locales/devise.et.yml @@ -29,23 +29,23 @@ et: title: Kinnita e-postiaadress email_changed: explanation: 'Sinu konto e-postiaadress muudetakse:' - extra: Kui sa ei muutnud oma e-posti, on tõenäoline, et kellelgi on ligipääs su kontole. Palun muuda koheselt oma salasõna. Kui oled aga oma kontost välja lukustatud, võta ühendust oma serveri administraatoriga. + extra: Kui sa ei muutnud oma e-posti, on tõenäoline, et kellelgi on ligipääs su kontole. Palun muuda koheselt oma salasõna. Kui oled aga oma kontole ligipääsu kaotanud, palun võta kohe ühendust oma serveri haldajaga. subject: 'Mastodon: e-post muudetud' title: Uus e-postiaadress password_change: explanation: Konto salasõna on vahetatud. extra: Kui sa ei muutnud oma salasõna, on tõenäoline, et keegi on su kontole ligi pääsenud. Palun muuda viivitamata oma salasõna. Kui sa oma kontole ligi ei pääse, võta ühendust serveri haldajaga. - subject: 'Mastodon: salasõna muudetud' - title: Salasõna muudetud + subject: 'Mastodon: salasõna on muudetud' + title: Salasõna on muudetud reconfirmation_instructions: explanation: Kinnita uus aadress, et oma e-posti aadress muuta. extra: Kui see muudatus pole sinu poolt algatatud, palun eira seda kirja. Selle Mastodoni konto e-postiaadress ei muutu enne, kui vajutad üleval olevale lingile. subject: 'Mastodon: kinnita e-postiaadress %{instance} jaoks' title: Kinnita e-postiaadress reset_password_instructions: - action: Salasõna muutmine - explanation: Kontole on küsitud uut salasõna. - extra: Kui see tuleb üllatusena, võib seda kirja eirata. Salasõna ei muutu enne ülaoleva lingi külastamist ja uue salasõna määramist. + action: Muuda salasõna + explanation: Sa palusid oma kasutajakontole luua uus salasõna. + extra: Kui see tuleb üllatusena, võid seda kirja eirata. Salasõna ei muutu enne ülaoleva lingi külastamist ja uue salasõna sisestamist. subject: 'Mastodon: salasõna lähtestamisjuhendid' title: Salasõna lähtestamine two_factor_disabled: diff --git a/config/locales/et.yml b/config/locales/et.yml index 508329e996..58df54c8df 100644 --- a/config/locales/et.yml +++ b/config/locales/et.yml @@ -142,7 +142,7 @@ et: security: Turvalisus security_measures: only_password: Ainult salasõna - password_and_2fa: Salasõna ja 2-etapine autentimine (2FA) + password_and_2fa: Salasõna ja kahefaktoriline autentimine (2FA) sensitive: Tundlik sisu sensitized: Märgitud kui tundlik sisu shared_inbox_url: Jagatud sisendkausta URL @@ -292,7 +292,7 @@ et: remove_avatar_user_html: "%{name} eemaldas %{target} avatari" reopen_report_html: "%{name} taasavas raporti %{target}" resend_user_html: "%{name} lähtestas %{target} kinnituskirja e-posti" - reset_password_user_html: "%{name} lähtestas %{target} salasõna" + reset_password_user_html: "%{name} lähtestas %{target} kasutaja salasõna" resolve_report_html: "%{name} lahendas raporti %{target}" sensitive_account_html: "%{name} märkis %{target} meedia kui tundlik sisu" silence_account_html: "%{name} piiras %{target} konto" @@ -787,7 +787,7 @@ et: manage_taxonomies: Halda taksonoomiaid manage_taxonomies_description: Luba kasutajatel populaarset sisu üle vaadata ning uuendada teemaviidete seadistusi manage_user_access: Halda kasutajate ligipääsu - manage_user_access_description: Võimaldab kasutajatel keelata teiste kasutajate kaheastmelise autentimise, muuta oma e-posti aadressi ja lähtestada oma parooli + manage_user_access_description: Võimaldab kasutajatel keelata teiste kasutajate kaheastmelise autentimise, muuta nende e-posti aadressi ja lähtestada oma salasõna manage_users: Kasutajate haldamine manage_users_description: Lubab kasutajail näha teiste kasutajate üksikasju ja teha nende suhtes modereerimisotsuseid manage_webhooks: Halda webhook'e @@ -1249,7 +1249,7 @@ et: suffix: Kasutajakontoga saad jälgida inimesi, postitada uudiseid ning pidada kirjavahetust ükskõik millise Mastodoni serveri kasutajatega ja muudki! didnt_get_confirmation: Ei saanud kinnituslinki? dont_have_your_security_key: Pole turvavõtit? - forgot_password: Salasõna ununenud? + forgot_password: Kas unustasid oma salasõna? invalid_reset_password_token: Salasõna lähtestusvõti on vale või aegunud. Palun taotle uus. link_to_otp: Kaheastmeline kood telefonist või taastekood link_to_webauth: Turvavõtmete seadme kasutamine @@ -1270,7 +1270,7 @@ et: register: Loo konto registration_closed: "%{instance} ei võta vastu uusi liikmeid" resend_confirmation: Saada kinnituslink uuesti - reset_password: Salasõna lähtestamine + reset_password: Lähtesta salasõna rules: accept: Nõus back: Tagasi @@ -1280,7 +1280,7 @@ et: title: Mõned põhireeglid. title_invited: Oled kutsutud. security: Turvalisus - set_new_password: Uue salasõna määramine + set_new_password: Sisesta uus salasõna setup: email_below_hint_html: Kontrolli rämpsposti kausta või palu uue kirja saatmist. Kui sinu e-posti aadress on vale, siis saad seda parandada. email_settings_hint_html: Klõpsa aadressile %{email} saadetud linki, et alustada Mastodoni kasutamist. Me oleme ootel. @@ -1316,9 +1316,9 @@ et: title: Autori tunnustamine challenge: confirm: Jätka - hint_html: "Nõuanne: Me ei küsi salasõna uuesti järgmise tunni jooksul." + hint_html: "Nõuanne: Me ei küsi sinu salasõna uuesti järgmise tunni jooksul." invalid_password: Vigane salasõna - prompt: Jätkamiseks salasõna veelkord + prompt: Jätkamiseks korda salasõna color_scheme: auto: Auto dark: Tume @@ -1627,7 +1627,7 @@ et: password: salasõna sign_in_token: e-posti turvvakood webauthn: turvavõtmed - description_html: Kui paistab tundmatuid tegevusi, tuleks vahetada salasõna ja aktiveerida kaheastmeline autentimine. + description_html: Kui paistab tundmatuid tegevusi, palun vaheta salasõna ja aktiveeri kaheastmeline autentimine. empty: Autentimisajalugu pole saadaval failed_sign_in_html: Nurjunud sisenemine meetodiga %{method} aadressilt %{ip} (%{browser}) successful_sign_in_html: Edukas sisenemine meetodiga %{method} aadressilt %{ip} (%{browser}) diff --git a/config/locales/simple_form.et.yml b/config/locales/simple_form.et.yml index d51e816d2e..2e45c6ffa0 100644 --- a/config/locales/simple_form.et.yml +++ b/config/locales/simple_form.et.yml @@ -51,7 +51,7 @@ et: inbox_url: Kopeeri soovitud sõnumivahendusserveri avalehe võrguaadress irreversible: Filtreeritud postitused kaovad taastamatult, isegi kui filter on hiljem eemaldatud locale: Kasutajaliidese, e-kirjade ja tõuketeadete keel - password: Vajalik on vähemalt 8 märki + password: Vajalik on vähemalt 8 tähemärki phrase: Kattub olenemata postituse teksti suurtähtedest või sisuhoiatusest scopes: Milliseid API-sid see rakendus tohib kasutada. Kui valid kõrgeima taseme, ei pea üksikuid eraldi valima. setting_advanced_layout: Näita Mastodoni mitme veeruga paigutuses, mispuhul näed korraga nii ajajoont, teavitusi, kui sinu valitud kolmandat veergu. Ei sobi kasutamiseks väikeste ekraanide puhul. @@ -113,7 +113,7 @@ et: trends: Trendid näitavad, millised postitused, teemaviited ja uudislood koguvad sinu serveris tähelepanu. wrapstodon: Paku kohalikele kasutajatele luua nende Mastodoni kasutamise aastast mänguline kokkuvõte. See võimalus on saadaval igal aastal 10. ja 31. detsembri vahel ja seda pakutakse kasutajatele, kes tegid vähemalt ühe avaliku või vaikse avaliku postituse ja kes kasutas aasta jooksul vähemalt ühte silti. form_challenge: - current_password: Turvalisse alasse sisenemine + current_password: Sisened turvalisse alasse imports: data: CSV fail eksporditi teisest Mastodoni serverist invite_request: @@ -214,8 +214,8 @@ et: avatar: Profiilipilt bot: See konto on robot chosen_languages: Keelte filtreerimine - confirm_new_password: Uue salasõna kinnitamine - confirm_password: Salasõna kinnitamine + confirm_new_password: Korda uut salasõna + confirm_password: Korda salasõna context: Filtreeri kontekste current_password: Kehtiv salasõna data: Andmed From fa9b905fdf110afc2e0e710283bea8e81c1161aa Mon Sep 17 00:00:00 2001 From: diondiondion Date: Tue, 20 Jan 2026 12:04:06 +0100 Subject: [PATCH 03/10] Improve `apiRequest` helper types (#37544) --- app/javascript/mastodon/api.ts | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/app/javascript/mastodon/api.ts b/app/javascript/mastodon/api.ts index 1820e00a53..2af29c783e 100644 --- a/app/javascript/mastodon/api.ts +++ b/app/javascript/mastodon/api.ts @@ -128,15 +128,18 @@ export default function api(withAuthorization = true) { } type ApiUrl = `v${1 | '1_alpha' | 2}/${string}`; -type RequestParamsOrData = Record; +type RequestParamsOrData = T | Record; -export async function apiRequest( +export async function apiRequest< + ApiResponse = unknown, + ApiParamsOrData = unknown, +>( method: Method, url: string, args: { signal?: AbortSignal; - params?: RequestParamsOrData; - data?: RequestParamsOrData; + params?: RequestParamsOrData; + data?: RequestParamsOrData; timeout?: number; } = {}, ) { @@ -149,30 +152,30 @@ export async function apiRequest( return data; } -export async function apiRequestGet( +export async function apiRequestGet( url: ApiUrl, - params?: RequestParamsOrData, + params?: RequestParamsOrData, ) { return apiRequest('GET', url, { params }); } -export async function apiRequestPost( +export async function apiRequestPost( url: ApiUrl, - data?: RequestParamsOrData, + data?: RequestParamsOrData, ) { return apiRequest('POST', url, { data }); } -export async function apiRequestPut( +export async function apiRequestPut( url: ApiUrl, - data?: RequestParamsOrData, + data?: RequestParamsOrData, ) { return apiRequest('PUT', url, { data }); } -export async function apiRequestDelete( - url: ApiUrl, - params?: RequestParamsOrData, -) { +export async function apiRequestDelete< + ApiResponse = unknown, + ApiParams = unknown, +>(url: ApiUrl, params?: RequestParamsOrData) { return apiRequest('DELETE', url, { params }); } From a1c17fef3aa863fc18eaa0941fa32f15222f859c Mon Sep 17 00:00:00 2001 From: Echo Date: Tue, 20 Jan 2026 12:10:46 +0100 Subject: [PATCH 04/10] Profile redesign: Account name (#37527) --- .../account/components/domain_pill.tsx | 10 ++- .../components/account_header.tsx | 62 +++++++---------- .../components/account_name.tsx | 68 +++++++++++++++++++ .../components/redesign.module.scss | 41 +++++++++++ 4 files changed, 140 insertions(+), 41 deletions(-) create mode 100644 app/javascript/mastodon/features/account_timeline/components/account_name.tsx diff --git a/app/javascript/mastodon/features/account/components/domain_pill.tsx b/app/javascript/mastodon/features/account/components/domain_pill.tsx index 13f5ebacf1..1f334bc004 100644 --- a/app/javascript/mastodon/features/account/components/domain_pill.tsx +++ b/app/javascript/mastodon/features/account/components/domain_pill.tsx @@ -1,3 +1,4 @@ +import type { ReactNode } from 'react'; import { useState, useRef, useCallback, useId } from 'react'; import { FormattedMessage } from 'react-intl'; @@ -15,7 +16,9 @@ export const DomainPill: React.FC<{ domain: string; username: string; isSelf: boolean; -}> = ({ domain, username, isSelf }) => { + children?: ReactNode; + className?: string; +}> = ({ domain, username, isSelf, children, className }) => { const accessibilityId = useId(); const [open, setOpen] = useState(false); const [expanded, setExpanded] = useState(false); @@ -32,7 +35,9 @@ export const DomainPill: React.FC<{ return ( <> { @@ -47,7 +45,6 @@ export const AccountHeader: React.FC<{ hideTabs?: boolean; }> = ({ accountId, hideTabs }) => { const dispatch = useAppDispatch(); - const intl = useIntl(); const account = useAppSelector((state) => state.accounts.get(accountId)); const relationship = useAppSelector((state) => state.relationships.get(accountId), @@ -85,8 +82,6 @@ export const AccountHeader: React.FC<{ const suspendedOrHidden = hidden || account.suspended; const isLocal = !account.acct.includes('@'); - const username = account.acct.split('@')[0]; - const domain = isLocal ? localDomain : account.acct.split('@')[1]; return (
@@ -133,38 +128,27 @@ export const AccountHeader: React.FC<{ /> - + {!isRedesignEnabled() && ( + + )}
-
-

- - - - @{username} - @{domain} - - - {account.locked && ( - - )} - -

+
+ + {isRedesignEnabled() && }
diff --git a/app/javascript/mastodon/features/account_timeline/components/account_name.tsx b/app/javascript/mastodon/features/account_timeline/components/account_name.tsx new file mode 100644 index 0000000000..90ccf7486d --- /dev/null +++ b/app/javascript/mastodon/features/account_timeline/components/account_name.tsx @@ -0,0 +1,68 @@ +import type { FC } from 'react'; + +import { useIntl } from 'react-intl'; + +import { DisplayName } from '@/mastodon/components/display_name'; +import { Icon } from '@/mastodon/components/icon'; +import { useAccount } from '@/mastodon/hooks/useAccount'; +import { useAppSelector } from '@/mastodon/store'; +import InfoIcon from '@/material-icons/400-24px/info.svg?react'; +import LockIcon from '@/material-icons/400-24px/lock.svg?react'; + +import { DomainPill } from '../../account/components/domain_pill'; +import { isRedesignEnabled } from '../common'; + +import classes from './redesign.module.scss'; + +export const AccountName: FC<{ accountId: string; className?: string }> = ({ + accountId, + className, +}) => { + const intl = useIntl(); + const account = useAccount(accountId); + const me = useAppSelector((state) => state.meta.get('me') as string); + const localDomain = useAppSelector( + (state) => state.meta.get('domain') as string, + ); + + if (!account) { + return null; + } + + const [username = '', domain = localDomain] = account.acct.split('@'); + + return ( +

+ + + + @{username} + {isRedesignEnabled() && '@'} + + {!isRedesignEnabled() && '@'} + {domain} + + + + {isRedesignEnabled() && } + + {!isRedesignEnabled() && account.locked && ( + + )} + +

+ ); +}; diff --git a/app/javascript/mastodon/features/account_timeline/components/redesign.module.scss b/app/javascript/mastodon/features/account_timeline/components/redesign.module.scss index dd09f199e5..757f5a4231 100644 --- a/app/javascript/mastodon/features/account_timeline/components/redesign.module.scss +++ b/app/javascript/mastodon/features/account_timeline/components/redesign.module.scss @@ -1,3 +1,44 @@ +.nameWrapper { + display: flex; + gap: 16px; +} + +.name { + flex-grow: 1; + font-size: 22px; + white-space: initial; + text-overflow: initial; + line-height: normal; + + :global(.icon-info) { + margin-left: 2px; + width: 1em; + height: 1em; + align-self: center; + } +} + +// Overrides .account__header__tabs__name h1 small +h1.name > small { + gap: 0; +} + +.domainPill { + appearance: none; + border: none; + background: none; + padding: 0; + text-decoration: underline; + color: inherit; + font-size: 1em; + font-weight: initial; + + &:global(.active) { + background: none; + color: inherit; + } +} + .fieldList { margin-top: 16px; } From 7cdc05b9a8b098ca0d369d1ba75396e4d6150c33 Mon Sep 17 00:00:00 2001 From: David Roetzel Date: Tue, 20 Jan 2026 14:22:54 +0100 Subject: [PATCH 05/10] Add `language` attribute to collections (#37549) --- .../api/v1_alpha/collections_controller.rb | 4 ++-- app/models/collection.rb | 2 ++ .../featured_collection_serializer.rb | 13 ++++++++++++- .../rest/base_collection_serializer.rb | 2 +- ...20260119153538_add_language_to_collections.rb | 7 +++++++ db/schema.rb | 3 ++- spec/models/collection_spec.rb | 6 ++++++ spec/requests/api/v1_alpha/collections_spec.rb | 1 + .../featured_collection_serializer_spec.rb | 16 ++++++++++++++++ .../rest/collection_serializer_spec.rb | 2 ++ 10 files changed, 51 insertions(+), 5 deletions(-) create mode 100644 db/migrate/20260119153538_add_language_to_collections.rb diff --git a/app/controllers/api/v1_alpha/collections_controller.rb b/app/controllers/api/v1_alpha/collections_controller.rb index d0c4e0f3f0..9d6b2f9a38 100644 --- a/app/controllers/api/v1_alpha/collections_controller.rb +++ b/app/controllers/api/v1_alpha/collections_controller.rb @@ -81,11 +81,11 @@ class Api::V1Alpha::CollectionsController < Api::BaseController end def collection_creation_params - params.permit(:name, :description, :sensitive, :discoverable, :tag_name, account_ids: []) + params.permit(:name, :description, :language, :sensitive, :discoverable, :tag_name, account_ids: []) end def collection_update_params - params.permit(:name, :description, :sensitive, :discoverable, :tag_name) + params.permit(:name, :description, :language, :sensitive, :discoverable, :tag_name) end def check_feature_enabled diff --git a/app/models/collection.rb b/app/models/collection.rb index b732a3d220..334318b73d 100644 --- a/app/models/collection.rb +++ b/app/models/collection.rb @@ -8,6 +8,7 @@ # description :text not null # discoverable :boolean not null # item_count :integer default(0), not null +# language :string # local :boolean not null # name :string not null # original_number_of_items :integer @@ -36,6 +37,7 @@ class Collection < ApplicationRecord presence: true, numericality: { greater_than_or_equal: 0 }, if: :remote? + validates :language, language: { if: :local?, allow_nil: true } validate :tag_is_usable validate :items_do_not_exceed_limit diff --git a/app/serializers/activitypub/featured_collection_serializer.rb b/app/serializers/activitypub/featured_collection_serializer.rb index e70d155a1a..af4c554851 100644 --- a/app/serializers/activitypub/featured_collection_serializer.rb +++ b/app/serializers/activitypub/featured_collection_serializer.rb @@ -17,9 +17,12 @@ class ActivityPub::FeaturedCollectionSerializer < ActivityPub::Serializer end end - attributes :id, :type, :total_items, :name, :summary, :attributed_to, + attributes :id, :type, :total_items, :name, :attributed_to, :sensitive, :discoverable, :published, :updated + attribute :summary, unless: :language_present? + attribute :summary_map, if: :language_present? + has_one :tag, key: :topic, serializer: ActivityPub::NoteSerializer::TagSerializer has_many :collection_items, key: :ordered_items, serializer: FeaturedItemSerializer @@ -36,6 +39,10 @@ class ActivityPub::FeaturedCollectionSerializer < ActivityPub::Serializer object.description end + def summary_map + { object.language => object.description } + end + def attributed_to ActivityPub::TagManager.instance.uri_for(object.account) end @@ -51,4 +58,8 @@ class ActivityPub::FeaturedCollectionSerializer < ActivityPub::Serializer def updated object.updated_at.iso8601 end + + def language_present? + object.language.present? + end end diff --git a/app/serializers/rest/base_collection_serializer.rb b/app/serializers/rest/base_collection_serializer.rb index be26aac6fe..6bb75e99a3 100644 --- a/app/serializers/rest/base_collection_serializer.rb +++ b/app/serializers/rest/base_collection_serializer.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class REST::BaseCollectionSerializer < ActiveModel::Serializer - attributes :id, :uri, :name, :description, :local, :sensitive, + attributes :id, :uri, :name, :description, :language, :local, :sensitive, :discoverable, :item_count, :created_at, :updated_at belongs_to :tag, serializer: REST::StatusSerializer::TagSerializer diff --git a/db/migrate/20260119153538_add_language_to_collections.rb b/db/migrate/20260119153538_add_language_to_collections.rb new file mode 100644 index 0000000000..066288b070 --- /dev/null +++ b/db/migrate/20260119153538_add_language_to_collections.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddLanguageToCollections < ActiveRecord::Migration[8.0] + def change + add_column :collections, :language, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index 8dc1e88fd1..8801882808 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2026_01_15_153219) do +ActiveRecord::Schema[8.0].define(version: 2026_01_19_153538) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -382,6 +382,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_01_15_153219) do t.datetime "created_at", null: false t.datetime "updated_at", null: false t.integer "item_count", default: 0, null: false + t.string "language" t.index ["account_id"], name: "index_collections_on_account_id" t.index ["tag_id"], name: "index_collections_on_tag_id" end diff --git a/spec/models/collection_spec.rb b/spec/models/collection_spec.rb index bcc31fd087..b50969b68a 100644 --- a/spec/models/collection_spec.rb +++ b/spec/models/collection_spec.rb @@ -16,12 +16,18 @@ RSpec.describe Collection do it { is_expected.to_not allow_value(nil).for(:discoverable) } + it { is_expected.to allow_value('en').for(:language) } + + it { is_expected.to_not allow_value('randomstuff').for(:language) } + context 'when collection is remote' do subject { Fabricate.build :collection, local: false } it { is_expected.to validate_presence_of(:uri) } it { is_expected.to validate_presence_of(:original_number_of_items) } + + it { is_expected.to allow_value('randomstuff').for(:language) } end context 'when using a hashtag as category' do diff --git a/spec/requests/api/v1_alpha/collections_spec.rb b/spec/requests/api/v1_alpha/collections_spec.rb index 3921fabfde..b529fc2d92 100644 --- a/spec/requests/api/v1_alpha/collections_spec.rb +++ b/spec/requests/api/v1_alpha/collections_spec.rb @@ -115,6 +115,7 @@ RSpec.describe 'Api::V1Alpha::Collections', feature: :collections do { name: 'Low-traffic bots', description: 'Really nice bots, please follow', + language: 'en', sensitive: '0', discoverable: '1', } diff --git a/spec/serializers/activitypub/featured_collection_serializer_spec.rb b/spec/serializers/activitypub/featured_collection_serializer_spec.rb index b01cce12d8..e6bb4ea4b0 100644 --- a/spec/serializers/activitypub/featured_collection_serializer_spec.rb +++ b/spec/serializers/activitypub/featured_collection_serializer_spec.rb @@ -45,4 +45,20 @@ RSpec.describe ActivityPub::FeaturedCollectionSerializer do 'updated' => match_api_datetime_format, }) end + + context 'when a language is set' do + before do + collection.language = 'en' + end + + it 'uses "summaryMap" to include the language' do + expect(subject).to include({ + 'summaryMap' => { + 'en' => 'These are really amazing', + }, + }) + + expect(subject).to_not have_key('summary') + end + end end diff --git a/spec/serializers/rest/collection_serializer_spec.rb b/spec/serializers/rest/collection_serializer_spec.rb index f0baf7dff8..80ed6a559e 100644 --- a/spec/serializers/rest/collection_serializer_spec.rb +++ b/spec/serializers/rest/collection_serializer_spec.rb @@ -18,6 +18,7 @@ RSpec.describe REST::CollectionSerializer do id: 2342, name: 'Exquisite follows', description: 'Always worth a follow', + language: 'en', local: true, sensitive: true, discoverable: false, @@ -31,6 +32,7 @@ RSpec.describe REST::CollectionSerializer do 'id' => '2342', 'name' => 'Exquisite follows', 'description' => 'Always worth a follow', + 'language' => 'en', 'local' => true, 'sensitive' => true, 'discoverable' => false, From c2f776b74704409d8ba299f67f140367e3f1c4ad Mon Sep 17 00:00:00 2001 From: Claire Date: Tue, 20 Jan 2026 15:10:38 +0100 Subject: [PATCH 06/10] Merge commit from fork --- app/controllers/api/web/push_subscriptions_controller.rb | 2 +- spec/requests/api/web/push_subscriptions_spec.rb | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/app/controllers/api/web/push_subscriptions_controller.rb b/app/controllers/api/web/push_subscriptions_controller.rb index ced68d39fc..2edd92dbc7 100644 --- a/app/controllers/api/web/push_subscriptions_controller.rb +++ b/app/controllers/api/web/push_subscriptions_controller.rb @@ -62,7 +62,7 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController end def set_push_subscription - @push_subscription = ::Web::PushSubscription.find(params[:id]) + @push_subscription = ::Web::PushSubscription.where(user_id: active_session.user_id).find(params[:id]) end def subscription_params diff --git a/spec/requests/api/web/push_subscriptions_spec.rb b/spec/requests/api/web/push_subscriptions_spec.rb index 21830d1b1c..88c0302f86 100644 --- a/spec/requests/api/web/push_subscriptions_spec.rb +++ b/spec/requests/api/web/push_subscriptions_spec.rb @@ -163,9 +163,10 @@ RSpec.describe 'API Web Push Subscriptions' do end describe 'PUT /api/web/push_subscriptions/:id' do - before { sign_in Fabricate :user } + before { sign_in user } - let(:subscription) { Fabricate :web_push_subscription } + let(:user) { Fabricate(:user) } + let(:subscription) { Fabricate(:web_push_subscription, user: user) } it 'gracefully handles invalid nested params' do put api_web_push_subscription_path(subscription), params: { data: 'invalid' } From 7a0d3d3bf851429c044d07cee250b367d809b496 Mon Sep 17 00:00:00 2001 From: Claire Date: Tue, 20 Jan 2026 15:13:10 +0100 Subject: [PATCH 07/10] Merge commit from fork --- app/models/custom_filter.rb | 3 +++ app/models/custom_filter_keyword.rb | 4 +++- app/models/list.rb | 3 ++- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/app/models/custom_filter.rb b/app/models/custom_filter.rb index 07bbfd4373..1151c7de98 100644 --- a/app/models/custom_filter.rb +++ b/app/models/custom_filter.rb @@ -30,6 +30,8 @@ class CustomFilter < ApplicationRecord EXPIRATION_DURATIONS = [30.minutes, 1.hour, 6.hours, 12.hours, 1.day, 1.week].freeze + TITLE_LENGTH_LIMIT = 256 + include Expireable include Redisable @@ -41,6 +43,7 @@ class CustomFilter < ApplicationRecord accepts_nested_attributes_for :keywords, reject_if: :all_blank, allow_destroy: true validates :title, :context, presence: true + validates :title, length: { maximum: TITLE_LENGTH_LIMIT } validate :context_must_be_valid normalizes :context, with: ->(context) { context.map(&:strip).filter_map(&:presence) } diff --git a/app/models/custom_filter_keyword.rb b/app/models/custom_filter_keyword.rb index 112798b10a..1abec4ddc4 100644 --- a/app/models/custom_filter_keyword.rb +++ b/app/models/custom_filter_keyword.rb @@ -17,7 +17,9 @@ class CustomFilterKeyword < ApplicationRecord belongs_to :custom_filter - validates :keyword, presence: true + KEYWORD_LENGTH_LIMIT = 512 + + validates :keyword, presence: true, length: { maximum: KEYWORD_LENGTH_LIMIT } alias_attribute :phrase, :keyword diff --git a/app/models/list.rb b/app/models/list.rb index 8fd1953ab3..49ead642ac 100644 --- a/app/models/list.rb +++ b/app/models/list.rb @@ -17,6 +17,7 @@ class List < ApplicationRecord include Paginable PER_ACCOUNT_LIMIT = 50 + TITLE_LENGTH_LIMIT = 256 enum :replies_policy, { list: 0, followed: 1, none: 2 }, prefix: :show, validate: true @@ -26,7 +27,7 @@ class List < ApplicationRecord has_many :accounts, through: :list_accounts has_many :active_accounts, -> { merge(ListAccount.active) }, through: :list_accounts, source: :account - validates :title, presence: true + validates :title, presence: true, length: { maximum: TITLE_LENGTH_LIMIT } validate :validate_account_lists_limit, on: :create From 841dfce9f76ef5ff6542cc6f98ec9c6df3fab5a1 Mon Sep 17 00:00:00 2001 From: Claire Date: Tue, 20 Jan 2026 15:13:43 +0100 Subject: [PATCH 08/10] Merge commit from fork --- app/lib/activitypub/activity/update.rb | 3 ++- app/services/fan_out_on_write_service.rb | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/app/lib/activitypub/activity/update.rb b/app/lib/activitypub/activity/update.rb index d94f876761..e22bea2c64 100644 --- a/app/lib/activitypub/activity/update.rb +++ b/app/lib/activitypub/activity/update.rb @@ -30,7 +30,8 @@ class ActivityPub::Activity::Update < ActivityPub::Activity @status = Status.find_by(uri: object_uri, account_id: @account.id) # Ignore updates for old unknown objects, since those are updates we are not interested in - return if @status.nil? && object_too_old? + # Also ignore unknown objects from suspended users for the same reasons + return if @status.nil? && (@account.suspended? || object_too_old?) # We may be getting `Create` and `Update` out of order @status ||= ActivityPub::Activity::Create.new(@json, @account, **@options).perform diff --git a/app/services/fan_out_on_write_service.rb b/app/services/fan_out_on_write_service.rb index 64769230b7..428077b11f 100644 --- a/app/services/fan_out_on_write_service.rb +++ b/app/services/fan_out_on_write_service.rb @@ -14,6 +14,8 @@ class FanOutOnWriteService < BaseService @account = status.account @options = options + return if @status.proper.account.suspended? + check_race_condition! warm_payload_cache! From 028e76f7703eecbbbf6e953c7d8d38184969e5dc Mon Sep 17 00:00:00 2001 From: Claire Date: Tue, 20 Jan 2026 15:14:45 +0100 Subject: [PATCH 09/10] Merge commit from fork * Add limit on inbox payload size The 1MB limit is consistent with the limit we use when fetching remote resources * Add limit to number of options from federated polls * Add a limit to the number of federated profile fields * Add limit on federated username length * Add hard limits for federated display name and account bio * Add hard limits for `alsoKnownAs` and `attributionDomains` * Add hard limit on federated custom emoji shortcode * Highlight most destructive limits and expand on their reasoning --- FEDERATION.md | 19 +++++++++++++++++++ .../activitypub/inboxes_controller.rb | 5 +++++ app/lib/activitypub/activity.rb | 1 + app/lib/activitypub/parser/poll_parser.rb | 6 +++++- app/models/account.rb | 9 ++++++++- app/models/custom_emoji.rb | 5 ++++- .../activitypub/process_account_service.rb | 14 +++++++++----- 7 files changed, 51 insertions(+), 8 deletions(-) diff --git a/FEDERATION.md b/FEDERATION.md index 03ea5449de..eb91d9545f 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -48,3 +48,22 @@ Mastodon requires all `POST` requests to be signed, and MAY require `GET` reques ### Additional documentation - [Mastodon documentation](https://docs.joinmastodon.org/) + +## Size limits + +Mastodon imposes a few hard limits on federated content. +These limits are intended to be very generous and way above what the Mastodon user experience is optimized for, so as to accomodate future changes and unusual or unforeseen usage patterns, while still providing some limits for performance reasons. +The following table attempts to summary those limits. + +| Limited property | Size limit | Consequence of exceeding the limit | +| ------------------------------------------------------------- | ---------- | ---------------------------------- | +| Serialized JSON-LD | 1MB | **Activity is rejected/dropped** | +| Profile fields (actor `PropertyValue` attachments) name/value | 2047 | Field name/value is truncated | +| Number of profile fields (actor `PropertyValue` attachments) | 50 | Fields list is truncated | +| Poll options (number of `anyOf`/`oneOf` in a `Question`) | 500 | Items list is truncated | +| Account username (actor `preferredUsername`) length | 2048 | **Actor will be rejected** | +| Account display name (actor `name`) length | 2048 | Display name will be truncated | +| Account note (actor `summary`) length | 20kB | Account note will be truncated | +| Account `attributionDomains` | 256 | List will be truncated | +| Account aliases (actor `alsoKnownAs`) | 256 | List will be truncated | +| Custom emoji shortcode (`Emoji` `name`) | 2048 | Emoji will be rejected | diff --git a/app/controllers/activitypub/inboxes_controller.rb b/app/controllers/activitypub/inboxes_controller.rb index 1f7abb97fa..cf46bf21b5 100644 --- a/app/controllers/activitypub/inboxes_controller.rb +++ b/app/controllers/activitypub/inboxes_controller.rb @@ -3,6 +3,7 @@ class ActivityPub::InboxesController < ActivityPub::BaseController include JsonLdHelper + before_action :skip_large_payload before_action :skip_unknown_actor_activity before_action :require_actor_signature! skip_before_action :authenticate_user! @@ -16,6 +17,10 @@ class ActivityPub::InboxesController < ActivityPub::BaseController private + def skip_large_payload + head 413 if request.content_length > ActivityPub::Activity::MAX_JSON_SIZE + end + def skip_unknown_actor_activity head 202 if unknown_affected_account? end diff --git a/app/lib/activitypub/activity.rb b/app/lib/activitypub/activity.rb index d07d1c2f24..eab345ce45 100644 --- a/app/lib/activitypub/activity.rb +++ b/app/lib/activitypub/activity.rb @@ -5,6 +5,7 @@ class ActivityPub::Activity include Redisable include Lockable + MAX_JSON_SIZE = 1.megabyte SUPPORTED_TYPES = %w(Note Question).freeze CONVERTED_TYPES = %w(Image Audio Video Article Page Event).freeze diff --git a/app/lib/activitypub/parser/poll_parser.rb b/app/lib/activitypub/parser/poll_parser.rb index 758c03f07e..d43eaf6cfb 100644 --- a/app/lib/activitypub/parser/poll_parser.rb +++ b/app/lib/activitypub/parser/poll_parser.rb @@ -3,6 +3,10 @@ class ActivityPub::Parser::PollParser include JsonLdHelper + # Limit the number of items for performance purposes. + # We truncate rather than error out to avoid missing the post entirely. + MAX_ITEMS = 500 + def initialize(json) @json = json end @@ -48,6 +52,6 @@ class ActivityPub::Parser::PollParser private def items - @json['anyOf'] || @json['oneOf'] + (@json['anyOf'] || @json['oneOf'])&.take(MAX_ITEMS) end end diff --git a/app/models/account.rb b/app/models/account.rb index deb1589a09..32f6e39840 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -81,6 +81,13 @@ class Account < ApplicationRecord DISPLAY_NAME_LENGTH_LIMIT = 30 NOTE_LENGTH_LIMIT = 500 + # Hard limits for federated content + USERNAME_LENGTH_HARD_LIMIT = 2048 + DISPLAY_NAME_LENGTH_HARD_LIMIT = 2048 + NOTE_LENGTH_HARD_LIMIT = 20.kilobytes + ATTRIBUTION_DOMAINS_HARD_LIMIT = 256 + ALSO_KNOWN_AS_HARD_LIMIT = 256 + AUTOMATED_ACTOR_TYPES = %w(Application Service).freeze include Attachmentable # Load prior to Avatar & Header concerns @@ -114,7 +121,7 @@ class Account < ApplicationRecord validates_with UniqueUsernameValidator, if: -> { will_save_change_to_username? } # Remote user validations, also applies to internal actors - validates :username, format: { with: USERNAME_ONLY_RE }, if: -> { (remote? || actor_type_application?) && will_save_change_to_username? } + validates :username, format: { with: USERNAME_ONLY_RE }, length: { maximum: USERNAME_LENGTH_HARD_LIMIT }, if: -> { (remote? || actor_type_application?) && will_save_change_to_username? } # Remote user validations validates :uri, presence: true, unless: :local?, on: :create diff --git a/app/models/custom_emoji.rb b/app/models/custom_emoji.rb index 65a2faa9fd..5c39e053be 100644 --- a/app/models/custom_emoji.rb +++ b/app/models/custom_emoji.rb @@ -26,6 +26,8 @@ class CustomEmoji < ApplicationRecord LIMIT = 256.kilobytes MINIMUM_SHORTCODE_SIZE = 2 + MAX_SHORTCODE_SIZE = 128 + MAX_FEDERATED_SHORTCODE_SIZE = 2048 SHORTCODE_RE_FRAGMENT = '[a-zA-Z0-9_]{2,}' @@ -45,7 +47,8 @@ class CustomEmoji < ApplicationRecord normalizes :domain, with: ->(domain) { domain.downcase.strip } validates_attachment :image, content_type: { content_type: IMAGE_MIME_TYPES }, presence: true, size: { less_than: LIMIT } - validates :shortcode, uniqueness: { scope: :domain }, format: { with: SHORTCODE_ONLY_RE }, length: { minimum: MINIMUM_SHORTCODE_SIZE } + validates :shortcode, uniqueness: { scope: :domain }, format: { with: SHORTCODE_ONLY_RE }, length: { minimum: MINIMUM_SHORTCODE_SIZE, maximum: MAX_FEDERATED_SHORTCODE_SIZE } + validates :shortcode, length: { maximum: MAX_SHORTCODE_SIZE }, if: :local? scope :local, -> { where(domain: nil) } scope :remote, -> { where.not(domain: nil) } diff --git a/app/services/activitypub/process_account_service.rb b/app/services/activitypub/process_account_service.rb index f133fbc84a..6f4aa2fdb6 100644 --- a/app/services/activitypub/process_account_service.rb +++ b/app/services/activitypub/process_account_service.rb @@ -6,6 +6,7 @@ class ActivityPub::ProcessAccountService < BaseService include Redisable include Lockable + MAX_PROFILE_FIELDS = 50 SUBDOMAINS_RATELIMIT = 10 DISCOVERIES_PER_REQUEST = 400 @@ -124,15 +125,15 @@ class ActivityPub::ProcessAccountService < BaseService def set_immediate_attributes! @account.featured_collection_url = valid_collection_uri(@json['featured']) - @account.display_name = @json['name'] || '' - @account.note = @json['summary'] || '' + @account.display_name = (@json['name'] || '')[0...(Account::DISPLAY_NAME_LENGTH_HARD_LIMIT)] + @account.note = (@json['summary'] || '')[0...(Account::NOTE_LENGTH_HARD_LIMIT)] @account.locked = @json['manuallyApprovesFollowers'] || false @account.fields = property_values || {} - @account.also_known_as = as_array(@json['alsoKnownAs'] || []).map { |item| value_or_id(item) } + @account.also_known_as = as_array(@json['alsoKnownAs'] || []).take(Account::ALSO_KNOWN_AS_HARD_LIMIT).map { |item| value_or_id(item) } @account.discoverable = @json['discoverable'] || false @account.indexable = @json['indexable'] || false @account.memorial = @json['memorial'] || false - @account.attribution_domains = as_array(@json['attributionDomains'] || []).map { |item| value_or_id(item) } + @account.attribution_domains = as_array(@json['attributionDomains'] || []).take(Account::ATTRIBUTION_DOMAINS_HARD_LIMIT).map { |item| value_or_id(item) } end def set_fetchable_key! @@ -253,7 +254,10 @@ class ActivityPub::ProcessAccountService < BaseService def property_values return unless @json['attachment'].is_a?(Array) - as_array(@json['attachment']).select { |attachment| attachment['type'] == 'PropertyValue' }.map { |attachment| attachment.slice('name', 'value') } + as_array(@json['attachment']) + .select { |attachment| attachment['type'] == 'PropertyValue' } + .take(MAX_PROFILE_FIELDS) + .map { |attachment| attachment.slice('name', 'value') } end def mismatching_origin?(url) From 400c1f3e8ec0ffd33ad30d9334b9210cdb89b14c Mon Sep 17 00:00:00 2001 From: Claire Date: Tue, 20 Jan 2026 16:00:10 +0100 Subject: [PATCH 10/10] Bump version to v4.5.5 (#37551) --- CHANGELOG.md | 27 +++++++++++++++++++++++++++ docker-compose.yml | 6 +++--- lib/mastodon/version.rb | 2 +- 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c5ec67d85..39e975479e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,33 @@ All notable changes to this project will be documented in this file. +## [4.5.5] - 2026-01-20 + +### Security + +- Fix missing limits on various federated properties [GHSA-gg8q-rcg7-p79g](https://github.com/mastodon/mastodon/security/advisories/GHSA-gg8q-rcg7-p79g) +- Fix remote user suspension bypass [GHSA-5h2f-wg8j-xqwp](https://github.com/mastodon/mastodon/security/advisories/GHSA-5h2f-wg8j-xqwp) +- Fix missing length limits on some user-provided fields [GHSA-6x3w-9g92-gvf3](https://github.com/mastodon/mastodon/security/advisories/GHSA-6x3w-9g92-gvf3) +- Fix missing access check for push notification settings update [GHSA-f3q8-7vw3-69v4](https://github.com/mastodon/mastodon/security/advisories/GHSA-f3q8-7vw3-69v4) + +### Changed + +- Skip tombstone creation on deleting from 404 (#37533 by @ClearlyClaire) + +### Fixed + +- Fix potential duplicate handling of quote accept/reject/delete (#37537 by @ClearlyClaire) +- Fix `FeedManager#filter_from_home` error when handling a reblog of a deleted status (#37486 by @ClearlyClaire) +- Fix needlessly complicated SQL query in status batch removal (#37469 by @ClearlyClaire) +- Fix `quote_approval_policy` being reset to user defaults when omitted in status update (#37436 and #37474 by @mjankowski and @shleeable) +- Fix `Vary` parsing in cache control enforcement (#37426 by @MegaManSec) +- Fix missing URI scheme test in `QuoteRequest` handling (#37425 by @MegaManSec) +- Fix thread-unsafe ActivityPub activity dispatch (#37423 by @MegaManSec) +- Fix URI generation for reblogs by accounts with numerical ActivityPub identifiers (#37415 by @oneiros) +- Fix SignatureParser accepting duplicate parameters in HTTP Signature header (#37375 by @shleeable) +- Fix emoji with variant selector not being rendered properly (#37320 by @ChaosExAnima) +- Fix mobile admin sidebar displaying under batch table toolbar (#37307 by @diondiondion) + ## [4.5.4] - 2026-01-07 ### Security diff --git a/docker-compose.yml b/docker-compose.yml index d4974eb1bd..52d2a83f44 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -59,7 +59,7 @@ services: web: # You can uncomment the following line if you want to not use the prebuilt image, for example if you have local code changes # build: . - image: ghcr.io/mastodon/mastodon:v4.5.4 + image: ghcr.io/mastodon/mastodon:v4.5.5 restart: always env_file: .env.production command: bundle exec puma -C config/puma.rb @@ -83,7 +83,7 @@ services: # build: # dockerfile: ./streaming/Dockerfile # context: . - image: ghcr.io/mastodon/mastodon-streaming:v4.5.4 + image: ghcr.io/mastodon/mastodon-streaming:v4.5.5 restart: always env_file: .env.production command: node ./streaming/index.js @@ -102,7 +102,7 @@ services: sidekiq: # You can uncomment the following line if you want to not use the prebuilt image, for example if you have local code changes # build: . - image: ghcr.io/mastodon/mastodon:v4.5.4 + image: ghcr.io/mastodon/mastodon:v4.5.5 restart: always env_file: .env.production command: bundle exec sidekiq diff --git a/lib/mastodon/version.rb b/lib/mastodon/version.rb index 235ac92cbd..f532276f85 100644 --- a/lib/mastodon/version.rb +++ b/lib/mastodon/version.rb @@ -17,7 +17,7 @@ module Mastodon end def default_prerelease - 'alpha.2' + 'alpha.3' end def prerelease