Merge commit '400c1f3e8ec0ffd33ad30d9334b9210cdb89b14c' into glitch-soc/merge-upstream

This commit is contained in:
Claire
2026-01-20 16:14:14 +01:00
48 changed files with 671 additions and 131 deletions

View File

@@ -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

View File

@@ -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 |

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -128,15 +128,18 @@ export default function api(withAuthorization = true) {
}
type ApiUrl = `v${1 | '1_alpha' | 2}/${string}`;
type RequestParamsOrData = Record<string, unknown>;
type RequestParamsOrData<T = unknown> = T | Record<string, unknown>;
export async function apiRequest<ApiResponse = unknown>(
export async function apiRequest<
ApiResponse = unknown,
ApiParamsOrData = unknown,
>(
method: Method,
url: string,
args: {
signal?: AbortSignal;
params?: RequestParamsOrData;
data?: RequestParamsOrData;
params?: RequestParamsOrData<ApiParamsOrData>;
data?: RequestParamsOrData<ApiParamsOrData>;
timeout?: number;
} = {},
) {
@@ -149,30 +152,30 @@ export async function apiRequest<ApiResponse = unknown>(
return data;
}
export async function apiRequestGet<ApiResponse = unknown>(
export async function apiRequestGet<ApiResponse = unknown, ApiParams = unknown>(
url: ApiUrl,
params?: RequestParamsOrData,
params?: RequestParamsOrData<ApiParams>,
) {
return apiRequest<ApiResponse>('GET', url, { params });
}
export async function apiRequestPost<ApiResponse = unknown>(
export async function apiRequestPost<ApiResponse = unknown, ApiData = unknown>(
url: ApiUrl,
data?: RequestParamsOrData,
data?: RequestParamsOrData<ApiData>,
) {
return apiRequest<ApiResponse>('POST', url, { data });
}
export async function apiRequestPut<ApiResponse = unknown>(
export async function apiRequestPut<ApiResponse = unknown, ApiData = unknown>(
url: ApiUrl,
data?: RequestParamsOrData,
data?: RequestParamsOrData<ApiData>,
) {
return apiRequest<ApiResponse>('PUT', url, { data });
}
export async function apiRequestDelete<ApiResponse = unknown>(
url: ApiUrl,
params?: RequestParamsOrData,
) {
export async function apiRequestDelete<
ApiResponse = unknown,
ApiParams = unknown,
>(url: ApiUrl, params?: RequestParamsOrData<ApiParams>) {
return apiRequest<ApiResponse>('DELETE', url, { params });
}

View File

@@ -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 (
<>
<button
className={classNames('account__domain-pill', { active: open })}
className={classNames('account__domain-pill', className, {
active: open,
})}
ref={triggerRef}
onClick={handleClick}
aria-expanded={open}
@@ -40,6 +45,7 @@ export const DomainPill: React.FC<{
type='button'
>
{domain}
{children}
</button>
<Overlay

View File

@@ -1,25 +1,22 @@
import { useCallback } from 'react';
import { useIntl } from 'react-intl';
import classNames from 'classnames';
import { Helmet } from 'react-helmet';
import { AccountBio } from '@/mastodon/components/account_bio';
import { DisplayName } from '@/mastodon/components/display_name';
import { AnimateEmojiProvider } from '@/mastodon/components/emoji/context';
import LockIcon from '@/material-icons/400-24px/lock.svg?react';
import { openModal } from 'mastodon/actions/modal';
import { Avatar } from 'mastodon/components/avatar';
import { Icon } from 'mastodon/components/icon';
import { AccountNote } from 'mastodon/features/account/components/account_note';
import { DomainPill } from 'mastodon/features/account/components/domain_pill';
import FollowRequestNoteContainer from 'mastodon/features/account/containers/follow_request_note_container';
import { autoPlayGif, me, domain as localDomain } from 'mastodon/initial_state';
import type { Account } from 'mastodon/models/account';
import { getAccountHidden } from 'mastodon/selectors/accounts';
import { useAppSelector, useAppDispatch } from 'mastodon/store';
import { isRedesignEnabled } from '../common';
import { AccountName } from './account_name';
import { AccountBadges } from './badges';
import { AccountButtons } from './buttons';
import { FamiliarFollowers } from './familiar_followers';
@@ -28,6 +25,7 @@ import { AccountInfo } from './info';
import { MemorialNote } from './memorial_note';
import { MovedNote } from './moved_note';
import { AccountNumberFields } from './number_fields';
import redesignClasses from './redesign.module.scss';
import { AccountTabs } from './tabs';
const titleFromAccount = (account: Account) => {
@@ -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 (
<div className='account-timeline__header'>
@@ -133,38 +128,27 @@ export const AccountHeader: React.FC<{
/>
</a>
<AccountButtons
accountId={accountId}
className='account__header__buttons--desktop'
/>
{!isRedesignEnabled() && (
<AccountButtons
accountId={accountId}
className='account__header__buttons--desktop'
/>
)}
</div>
<div className='account__header__tabs__name'>
<h1>
<DisplayName account={account} variant='simple' />
<small>
<span>
@{username}
<span className='invisible'>@{domain}</span>
</span>
<DomainPill
username={username ?? ''}
domain={domain ?? ''}
isSelf={me === account.id}
/>
{account.locked && (
<Icon
id='lock'
icon={LockIcon}
aria-label={intl.formatMessage({
id: 'account.locked_info',
defaultMessage:
'This account privacy status is set to locked. The owner manually reviews who can follow them.',
})}
/>
)}
</small>
</h1>
<div
className={classNames(
'account__header__tabs__name',
isRedesignEnabled() && redesignClasses.nameWrapper,
)}
>
<AccountName
accountId={accountId}
className={classNames(
isRedesignEnabled() && redesignClasses.name,
)}
/>
{isRedesignEnabled() && <AccountButtons accountId={accountId} />}
</div>
<AccountBadges accountId={accountId} />

View File

@@ -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 (
<h1 className={className}>
<DisplayName account={account} variant='simple' />
<small>
<span>
@{username}
{isRedesignEnabled() && '@'}
<span className='invisible'>
{!isRedesignEnabled() && '@'}
{domain}
</span>
</span>
<DomainPill
username={username}
domain={domain}
isSelf={me === account.id}
className={(isRedesignEnabled() && classes.domainPill) || ''}
>
{isRedesignEnabled() && <Icon id='info' icon={InfoIcon} />}
</DomainPill>
{!isRedesignEnabled() && account.locked && (
<Icon
id='lock'
icon={LockIcon}
aria-label={intl.formatMessage({
id: 'account.locked_info',
defaultMessage:
'This account privacy status is set to locked. The owner manually reviews who can follow them.',
})}
/>
)}
</small>
</h1>
);
};

View File

@@ -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;
}

View File

@@ -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",

View File

@@ -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",

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -81,6 +81,13 @@ class Account < ApplicationRecord
DISPLAY_NAME_LENGTH_LIMIT = (ENV['MAX_DISPLAY_NAME_CHARS'] || 30).to_i
NOTE_LENGTH_LIMIT = (ENV['MAX_BIO_CHARS'] || 500).to_i
# 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

View File

@@ -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

View File

@@ -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

View File

@@ -27,6 +27,8 @@ class CustomEmoji < ApplicationRecord
LOCAL_LIMIT = (ENV['MAX_EMOJI_SIZE'] || 256.kilobytes).to_i
LIMIT = [LOCAL_LIMIT, (ENV['MAX_REMOTE_EMOJI_SIZE'] || 256.kilobytes).to_i].max
MINIMUM_SHORTCODE_SIZE = 2
MAX_SHORTCODE_SIZE = 128
MAX_FEDERATED_SHORTCODE_SIZE = 2048
SHORTCODE_RE_FRAGMENT = '[a-zA-Z0-9_]{2,}'
@@ -48,7 +50,8 @@ class CustomEmoji < ApplicationRecord
validates_attachment :image, content_type: { content_type: IMAGE_MIME_TYPES }, presence: true
validates_attachment_size :image, less_than: LIMIT, unless: :local?
validates_attachment_size :image, less_than: LOCAL_LIMIT, if: :local?
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) }

View File

@@ -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) }

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -18,6 +18,8 @@ class ActivityPub::CollectionSerializer < ActivityPub::Serializer
ActivityPub::HashtagSerializer
when 'ActivityPub::CollectionPresenter'
ActivityPub::CollectionSerializer
when 'Collection'
ActivityPub::FeaturedCollectionSerializer
when 'String'
StringSerializer
else

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -14,6 +14,8 @@ class FanOutOnWriteService < BaseService
@account = status.account
@options = options
return if @status.proper.account.suspended?
check_race_condition!
warm_payload_cache!

View File

@@ -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:

View File

@@ -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: "<strong>Nõuanne:</strong> Me ei küsi salasõna uuesti järgmise tunni jooksul."
hint_html: "<strong>Nõuanne:</strong> 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})

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddLanguageToCollections < ActiveRecord::Migration[8.0]
def change
add_column :collections, :language, :string
end
end

View File

@@ -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

View File

@@ -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/glitch-soc/mastodon:v4.5.4
image: ghcr.io/glitch-soc/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/glitch-soc/mastodon-streaming:v4.5.4
image: ghcr.io/glitch-soc/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/glitch-soc/mastodon:v4.5.4
image: ghcr.io/glitch-soc/mastodon:v4.5.5
restart: always
env_file: .env.production
command: bundle exec sidekiq

View File

@@ -17,7 +17,7 @@ module Mastodon
end
def default_prerelease
'alpha.2'
'alpha.3'
end
def prerelease

View File

@@ -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

View File

@@ -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) }

View File

@@ -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

View File

@@ -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

View File

@@ -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',
}

View File

@@ -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' }

View File

@@ -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

View File

@@ -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

View File

@@ -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,