Merge pull request #3360 from ClearlyClaire/glitch-soc/merge-upstream

Merge upstream changes up to 400c1f3e8ec0ffd33ad30d9334b9210cdb89b14c
This commit is contained in:
Claire 2026-01-20 16:35:37 +01:00 committed by GitHub
commit e612378f08
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
53 changed files with 827 additions and 186 deletions

View File

@ -2,6 +2,33 @@
All notable changes to this project will be documented in this file. 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 ## [4.5.4] - 2026-01-07
### Security ### Security

View File

@ -48,3 +48,22 @@ Mastodon requires all `POST` requests to be signed, and MAY require `GET` reques
### Additional documentation ### Additional documentation
- [Mastodon documentation](https://docs.joinmastodon.org/) - [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 class ActivityPub::InboxesController < ActivityPub::BaseController
include JsonLdHelper include JsonLdHelper
before_action :skip_large_payload
before_action :skip_unknown_actor_activity before_action :skip_unknown_actor_activity
before_action :require_actor_signature! before_action :require_actor_signature!
skip_before_action :authenticate_user! skip_before_action :authenticate_user!
@ -16,6 +17,10 @@ class ActivityPub::InboxesController < ActivityPub::BaseController
private private
def skip_large_payload
head 413 if request.content_length > ActivityPub::Activity::MAX_JSON_SIZE
end
def skip_unknown_actor_activity def skip_unknown_actor_activity
head 202 if unknown_affected_account? head 202 if unknown_affected_account?
end end

View File

@ -81,11 +81,11 @@ class Api::V1Alpha::CollectionsController < Api::BaseController
end end
def collection_creation_params 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 end
def collection_update_params def collection_update_params
params.permit(:name, :description, :sensitive, :discoverable, :tag_name) params.permit(:name, :description, :language, :sensitive, :discoverable, :tag_name)
end end
def check_feature_enabled def check_feature_enabled

View File

@ -62,7 +62,7 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController
end end
def set_push_subscription 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 end
def subscription_params def subscription_params

View File

@ -107,15 +107,18 @@ export default function api(withAuthorization = true) {
} }
type ApiUrl = `v${1 | '1_alpha' | 2}/${string}`; 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, method: Method,
url: string, url: string,
args: { args: {
signal?: AbortSignal; signal?: AbortSignal;
params?: RequestParamsOrData; params?: RequestParamsOrData<ApiParamsOrData>;
data?: RequestParamsOrData; data?: RequestParamsOrData<ApiParamsOrData>;
timeout?: number; timeout?: number;
} = {}, } = {},
) { ) {
@ -128,30 +131,30 @@ export async function apiRequest<ApiResponse = unknown>(
return data; return data;
} }
export async function apiRequestGet<ApiResponse = unknown>( export async function apiRequestGet<ApiResponse = unknown, ApiParams = unknown>(
url: ApiUrl, url: ApiUrl,
params?: RequestParamsOrData, params?: RequestParamsOrData<ApiParams>,
) { ) {
return apiRequest<ApiResponse>('GET', url, { params }); return apiRequest<ApiResponse>('GET', url, { params });
} }
export async function apiRequestPost<ApiResponse = unknown>( export async function apiRequestPost<ApiResponse = unknown, ApiData = unknown>(
url: ApiUrl, url: ApiUrl,
data?: RequestParamsOrData, data?: RequestParamsOrData<ApiData>,
) { ) {
return apiRequest<ApiResponse>('POST', url, { data }); return apiRequest<ApiResponse>('POST', url, { data });
} }
export async function apiRequestPut<ApiResponse = unknown>( export async function apiRequestPut<ApiResponse = unknown, ApiData = unknown>(
url: ApiUrl, url: ApiUrl,
data?: RequestParamsOrData, data?: RequestParamsOrData<ApiData>,
) { ) {
return apiRequest<ApiResponse>('PUT', url, { data }); return apiRequest<ApiResponse>('PUT', url, { data });
} }
export async function apiRequestDelete<ApiResponse = unknown>( export async function apiRequestDelete<
url: ApiUrl, ApiResponse = unknown,
params?: RequestParamsOrData, ApiParams = unknown,
) { >(url: ApiUrl, params?: RequestParamsOrData<ApiParams>) {
return apiRequest<ApiResponse>('DELETE', url, { params }); 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 { useState, useRef, useCallback, useId } from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
@ -15,7 +16,9 @@ export const DomainPill: React.FC<{
domain: string; domain: string;
username: string; username: string;
isSelf: boolean; isSelf: boolean;
}> = ({ domain, username, isSelf }) => { children?: ReactNode;
className?: string;
}> = ({ domain, username, isSelf, children, className }) => {
const accessibilityId = useId(); const accessibilityId = useId();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
@ -32,7 +35,9 @@ export const DomainPill: React.FC<{
return ( return (
<> <>
<button <button
className={classNames('account__domain-pill', { active: open })} className={classNames('account__domain-pill', className, {
active: open,
})}
ref={triggerRef} ref={triggerRef}
onClick={handleClick} onClick={handleClick}
aria-expanded={open} aria-expanded={open}
@ -40,6 +45,7 @@ export const DomainPill: React.FC<{
type='button' type='button'
> >
{domain} {domain}
{children}
</button> </button>
<Overlay <Overlay

View File

@ -1,19 +1,13 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { useIntl } from 'react-intl';
import classNames from 'classnames'; import classNames from 'classnames';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import { AccountBio } from '@/flavours/glitch/components/account_bio'; import { AccountBio } from '@/flavours/glitch/components/account_bio';
import { DisplayName } from '@/flavours/glitch/components/display_name';
import { AnimateEmojiProvider } from '@/flavours/glitch/components/emoji/context'; import { AnimateEmojiProvider } from '@/flavours/glitch/components/emoji/context';
import LockIcon from '@/material-icons/400-24px/lock.svg?react';
import { openModal } from 'flavours/glitch/actions/modal'; import { openModal } from 'flavours/glitch/actions/modal';
import { Avatar } from 'flavours/glitch/components/avatar'; import { Avatar } from 'flavours/glitch/components/avatar';
import { Icon } from 'flavours/glitch/components/icon';
import { AccountNote } from 'flavours/glitch/features/account/components/account_note'; import { AccountNote } from 'flavours/glitch/features/account/components/account_note';
import { DomainPill } from 'flavours/glitch/features/account/components/domain_pill';
import FollowRequestNoteContainer from 'flavours/glitch/features/account/containers/follow_request_note_container'; import FollowRequestNoteContainer from 'flavours/glitch/features/account/containers/follow_request_note_container';
import { import {
autoPlayGif, autoPlayGif,
@ -25,7 +19,9 @@ import { getAccountHidden } from 'flavours/glitch/selectors/accounts';
import { useAppSelector, useAppDispatch } from 'flavours/glitch/store'; import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
import { ActionBar } from '../../account/components/action_bar'; import { ActionBar } from '../../account/components/action_bar';
import { isRedesignEnabled } from '../common';
import { AccountName } from './account_name';
import { AccountBadges } from './badges'; import { AccountBadges } from './badges';
import { AccountButtons } from './buttons'; import { AccountButtons } from './buttons';
import { FamiliarFollowers } from './familiar_followers'; import { FamiliarFollowers } from './familiar_followers';
@ -33,6 +29,7 @@ import { AccountHeaderFields } from './fields';
import { AccountInfo } from './info'; import { AccountInfo } from './info';
import { MemorialNote } from './memorial_note'; import { MemorialNote } from './memorial_note';
import { MovedNote } from './moved_note'; import { MovedNote } from './moved_note';
import redesignClasses from './redesign.module.scss';
import { AccountTabs } from './tabs'; import { AccountTabs } from './tabs';
const titleFromAccount = (account: Account) => { const titleFromAccount = (account: Account) => {
@ -52,7 +49,6 @@ export const AccountHeader: React.FC<{
hideTabs?: boolean; hideTabs?: boolean;
}> = ({ accountId, hideTabs }) => { }> = ({ accountId, hideTabs }) => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const intl = useIntl();
const account = useAppSelector((state) => state.accounts.get(accountId)); const account = useAppSelector((state) => state.accounts.get(accountId));
const relationship = useAppSelector((state) => const relationship = useAppSelector((state) =>
state.relationships.get(accountId), state.relationships.get(accountId),
@ -90,8 +86,6 @@ export const AccountHeader: React.FC<{
const suspendedOrHidden = hidden || account.suspended; const suspendedOrHidden = hidden || account.suspended;
const isLocal = !account.acct.includes('@'); const isLocal = !account.acct.includes('@');
const username = account.acct.split('@')[0];
const domain = isLocal ? localDomain : account.acct.split('@')[1];
return ( return (
<div className='account-timeline__header'> <div className='account-timeline__header'>
@ -138,38 +132,27 @@ export const AccountHeader: React.FC<{
/> />
</a> </a>
<AccountButtons {!isRedesignEnabled() && (
accountId={accountId} <AccountButtons
className='account__header__buttons--desktop' accountId={accountId}
/> className='account__header__buttons--desktop'
/>
)}
</div> </div>
<div className='account__header__tabs__name'> <div
<h1> className={classNames(
<DisplayName account={account} variant='simple' /> 'account__header__tabs__name',
<small> isRedesignEnabled() && redesignClasses.nameWrapper,
<span> )}
@{username} >
<span className='invisible'>@{domain}</span> <AccountName
</span> accountId={accountId}
<DomainPill className={classNames(
username={username ?? ''} isRedesignEnabled() && redesignClasses.name,
domain={domain ?? ''} )}
isSelf={me === account.id} />
/> {isRedesignEnabled() && <AccountButtons accountId={accountId} />}
{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> </div>
<AccountBadges accountId={accountId} /> <AccountBadges accountId={accountId} />

View File

@ -0,0 +1,68 @@
import type { FC } from 'react';
import { useIntl } from 'react-intl';
import { DisplayName } from '@/flavours/glitch/components/display_name';
import { Icon } from '@/flavours/glitch/components/icon';
import { useAccount } from '@/flavours/glitch/hooks/useAccount';
import { useAppSelector } from '@/flavours/glitch/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 { .fieldList {
margin-top: 16px; margin-top: 16px;
} }

View File

@ -128,15 +128,18 @@ export default function api(withAuthorization = true) {
} }
type ApiUrl = `v${1 | '1_alpha' | 2}/${string}`; 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, method: Method,
url: string, url: string,
args: { args: {
signal?: AbortSignal; signal?: AbortSignal;
params?: RequestParamsOrData; params?: RequestParamsOrData<ApiParamsOrData>;
data?: RequestParamsOrData; data?: RequestParamsOrData<ApiParamsOrData>;
timeout?: number; timeout?: number;
} = {}, } = {},
) { ) {
@ -149,30 +152,30 @@ export async function apiRequest<ApiResponse = unknown>(
return data; return data;
} }
export async function apiRequestGet<ApiResponse = unknown>( export async function apiRequestGet<ApiResponse = unknown, ApiParams = unknown>(
url: ApiUrl, url: ApiUrl,
params?: RequestParamsOrData, params?: RequestParamsOrData<ApiParams>,
) { ) {
return apiRequest<ApiResponse>('GET', url, { params }); return apiRequest<ApiResponse>('GET', url, { params });
} }
export async function apiRequestPost<ApiResponse = unknown>( export async function apiRequestPost<ApiResponse = unknown, ApiData = unknown>(
url: ApiUrl, url: ApiUrl,
data?: RequestParamsOrData, data?: RequestParamsOrData<ApiData>,
) { ) {
return apiRequest<ApiResponse>('POST', url, { data }); return apiRequest<ApiResponse>('POST', url, { data });
} }
export async function apiRequestPut<ApiResponse = unknown>( export async function apiRequestPut<ApiResponse = unknown, ApiData = unknown>(
url: ApiUrl, url: ApiUrl,
data?: RequestParamsOrData, data?: RequestParamsOrData<ApiData>,
) { ) {
return apiRequest<ApiResponse>('PUT', url, { data }); return apiRequest<ApiResponse>('PUT', url, { data });
} }
export async function apiRequestDelete<ApiResponse = unknown>( export async function apiRequestDelete<
url: ApiUrl, ApiResponse = unknown,
params?: RequestParamsOrData, ApiParams = unknown,
) { >(url: ApiUrl, params?: RequestParamsOrData<ApiParams>) {
return apiRequest<ApiResponse>('DELETE', url, { params }); 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 { useState, useRef, useCallback, useId } from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
@ -15,7 +16,9 @@ export const DomainPill: React.FC<{
domain: string; domain: string;
username: string; username: string;
isSelf: boolean; isSelf: boolean;
}> = ({ domain, username, isSelf }) => { children?: ReactNode;
className?: string;
}> = ({ domain, username, isSelf, children, className }) => {
const accessibilityId = useId(); const accessibilityId = useId();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
@ -32,7 +35,9 @@ export const DomainPill: React.FC<{
return ( return (
<> <>
<button <button
className={classNames('account__domain-pill', { active: open })} className={classNames('account__domain-pill', className, {
active: open,
})}
ref={triggerRef} ref={triggerRef}
onClick={handleClick} onClick={handleClick}
aria-expanded={open} aria-expanded={open}
@ -40,6 +45,7 @@ export const DomainPill: React.FC<{
type='button' type='button'
> >
{domain} {domain}
{children}
</button> </button>
<Overlay <Overlay

View File

@ -1,25 +1,22 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { useIntl } from 'react-intl';
import classNames from 'classnames'; import classNames from 'classnames';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import { AccountBio } from '@/mastodon/components/account_bio'; import { AccountBio } from '@/mastodon/components/account_bio';
import { DisplayName } from '@/mastodon/components/display_name';
import { AnimateEmojiProvider } from '@/mastodon/components/emoji/context'; import { AnimateEmojiProvider } from '@/mastodon/components/emoji/context';
import LockIcon from '@/material-icons/400-24px/lock.svg?react';
import { openModal } from 'mastodon/actions/modal'; import { openModal } from 'mastodon/actions/modal';
import { Avatar } from 'mastodon/components/avatar'; import { Avatar } from 'mastodon/components/avatar';
import { Icon } from 'mastodon/components/icon';
import { AccountNote } from 'mastodon/features/account/components/account_note'; 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 FollowRequestNoteContainer from 'mastodon/features/account/containers/follow_request_note_container';
import { autoPlayGif, me, domain as localDomain } from 'mastodon/initial_state'; import { autoPlayGif, me, domain as localDomain } from 'mastodon/initial_state';
import type { Account } from 'mastodon/models/account'; import type { Account } from 'mastodon/models/account';
import { getAccountHidden } from 'mastodon/selectors/accounts'; import { getAccountHidden } from 'mastodon/selectors/accounts';
import { useAppSelector, useAppDispatch } from 'mastodon/store'; import { useAppSelector, useAppDispatch } from 'mastodon/store';
import { isRedesignEnabled } from '../common';
import { AccountName } from './account_name';
import { AccountBadges } from './badges'; import { AccountBadges } from './badges';
import { AccountButtons } from './buttons'; import { AccountButtons } from './buttons';
import { FamiliarFollowers } from './familiar_followers'; import { FamiliarFollowers } from './familiar_followers';
@ -28,6 +25,7 @@ import { AccountInfo } from './info';
import { MemorialNote } from './memorial_note'; import { MemorialNote } from './memorial_note';
import { MovedNote } from './moved_note'; import { MovedNote } from './moved_note';
import { AccountNumberFields } from './number_fields'; import { AccountNumberFields } from './number_fields';
import redesignClasses from './redesign.module.scss';
import { AccountTabs } from './tabs'; import { AccountTabs } from './tabs';
const titleFromAccount = (account: Account) => { const titleFromAccount = (account: Account) => {
@ -47,7 +45,6 @@ export const AccountHeader: React.FC<{
hideTabs?: boolean; hideTabs?: boolean;
}> = ({ accountId, hideTabs }) => { }> = ({ accountId, hideTabs }) => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const intl = useIntl();
const account = useAppSelector((state) => state.accounts.get(accountId)); const account = useAppSelector((state) => state.accounts.get(accountId));
const relationship = useAppSelector((state) => const relationship = useAppSelector((state) =>
state.relationships.get(accountId), state.relationships.get(accountId),
@ -85,8 +82,6 @@ export const AccountHeader: React.FC<{
const suspendedOrHidden = hidden || account.suspended; const suspendedOrHidden = hidden || account.suspended;
const isLocal = !account.acct.includes('@'); const isLocal = !account.acct.includes('@');
const username = account.acct.split('@')[0];
const domain = isLocal ? localDomain : account.acct.split('@')[1];
return ( return (
<div className='account-timeline__header'> <div className='account-timeline__header'>
@ -133,38 +128,27 @@ export const AccountHeader: React.FC<{
/> />
</a> </a>
<AccountButtons {!isRedesignEnabled() && (
accountId={accountId} <AccountButtons
className='account__header__buttons--desktop' accountId={accountId}
/> className='account__header__buttons--desktop'
/>
)}
</div> </div>
<div className='account__header__tabs__name'> <div
<h1> className={classNames(
<DisplayName account={account} variant='simple' /> 'account__header__tabs__name',
<small> isRedesignEnabled() && redesignClasses.nameWrapper,
<span> )}
@{username} >
<span className='invisible'>@{domain}</span> <AccountName
</span> accountId={accountId}
<DomainPill className={classNames(
username={username ?? ''} isRedesignEnabled() && redesignClasses.name,
domain={domain ?? ''} )}
isSelf={me === account.id} />
/> {isRedesignEnabled() && <AccountButtons accountId={accountId} />}
{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> </div>
<AccountBadges accountId={accountId} /> <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 { .fieldList {
margin-top: 16px; margin-top: 16px;
} }

View File

@ -2,15 +2,15 @@
"about.blocks": "Modereeritavad serverid", "about.blocks": "Modereeritavad serverid",
"about.contact": "Kontakt:", "about.contact": "Kontakt:",
"about.default_locale": "Vaikimisi", "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.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.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 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.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.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.domain_blocks.suspended.title": "Peatatud",
"about.language_label": "Keel", "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.powered_by": "Hajutatud sotsiaalmeedia, mille taga on {mastodon}",
"about.rules": "Serveri reeglid", "about.rules": "Serveri reeglid",
"account.account_note_header": "Isiklik märge", "account.account_note_header": "Isiklik märge",
@ -18,19 +18,19 @@
"account.badges.bot": "Robot", "account.badges.bot": "Robot",
"account.badges.group": "Grupp", "account.badges.group": "Grupp",
"account.block": "Blokeeri @{name}", "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.block_short": "Blokeerimine",
"account.blocked": "Blokeeritud", "account.blocked": "Blokeeritud",
"account.blocking": "Blokeeritud kasutaja", "account.blocking": "Blokeeritud kasutaja",
"account.cancel_follow_request": "Võta jälgimistaotlus tagasi", "account.cancel_follow_request": "Võta jälgimistaotlus tagasi",
"account.copy": "Kopeeri profiili link", "account.copy": "Kopeeri profiili link",
"account.direct": "Maini privaatselt @{name}", "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.domain_blocking": "Blokeeritud domeen",
"account.edit_profile": "Muuda profiili", "account.edit_profile": "Muuda profiili",
"account.edit_profile_short": "Muuda", "account.edit_profile_short": "Muuda",
"account.enable_notifications": "Teavita mind @{name} postitustest", "account.enable_notifications": "Teavita mind, kui {name} postitab",
"account.endorse": "Too profiilil esile", "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_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_one": "Jälgijaks {name1}",
"account.familiar_followers_two": "Jälgijateks {name1} ja {name2}", "account.familiar_followers_two": "Jälgijateks {name1} ja {name2}",
@ -57,11 +57,12 @@
"account.go_to_profile": "Mine profiilile", "account.go_to_profile": "Mine profiilile",
"account.hide_reblogs": "Peida @{name} jagamised", "account.hide_reblogs": "Peida @{name} jagamised",
"account.in_memoriam": "In Memoriam.", "account.in_memoriam": "In Memoriam.",
"account.joined_long": "Liitus {date}",
"account.joined_short": "Liitus", "account.joined_short": "Liitus",
"account.languages": "Muuda tellitud keeli", "account.languages": "Muuda tellitud keeli",
"account.link_verified_on": "Selle lingi autorsust kontrolliti {date}", "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.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.mention": "Maini @{name}",
"account.moved_to": "{name} on teada andnud, et ta uus konto on nüüd:", "account.moved_to": "{name} on teada andnud, et ta uus konto on nüüd:",
"account.mute": "Summuta @{name}", "account.mute": "Summuta @{name}",
@ -81,20 +82,22 @@
"account.share": "Jaga @{name} profiili", "account.share": "Jaga @{name} profiili",
"account.show_reblogs": "Näita @{name} jagamisi", "account.show_reblogs": "Näita @{name} jagamisi",
"account.statuses_counter": "{count, plural, one {{counter} postitus} other {{counter} postitust}}", "account.statuses_counter": "{count, plural, one {{counter} postitus} other {{counter} postitust}}",
"account.unblock": "Eemalda blokeering @{name}", "account.unblock": "Lõpeta {name} kasutaja blokeerimine",
"account.unblock_domain": "Tee {domain} nähtavaks", "account.unblock_domain": "Lõpeta {domain} domeeni blokeerimine",
"account.unblock_domain_short": "Lõpeta 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.unendorse": "Ära kuva profiilil",
"account.unfollow": "Jälgid", "account.unfollow": "Ära jälgi",
"account.unmute": "Lõpeta {name} kasutaja summutamine", "account.unmute": "Lõpeta {name} kasutaja summutamine",
"account.unmute_notifications_short": "Lõpeta teavituste summutamine", "account.unmute_notifications_short": "Lõpeta teavituste summutamine",
"account.unmute_short": "Lõpeta 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", "account_note.placeholder": "Klõpsa märke lisamiseks",
"admin.dashboard.daily_retention": "Kasutajate päevane allesjäämine peale registreerumist", "admin.dashboard.daily_retention": "Kasutajate päevane allesjäämine peale registreerumist",
"admin.dashboard.monthly_retention": "Kasutajate kuine allesjäämine peale registreerumist", "admin.dashboard.monthly_retention": "Kasutajate kuine allesjäämine peale registreerumist",
"admin.dashboard.retention.average": "Keskmine", "admin.dashboard.retention.average": "Keskmine",
"admin.dashboard.retention.cohort": "Registreerumiskuu", "admin.dashboard.retention.cohort": "Liitumiskuu",
"admin.dashboard.retention.cohort_size": "Uued kasutajad", "admin.dashboard.retention.cohort_size": "Uued kasutajad",
"admin.impact_report.instance_accounts": "Kontode profiilid, mille see kustutaks", "admin.impact_report.instance_accounts": "Kontode profiilid, mille see kustutaks",
"admin.impact_report.instance_followers": "Jälgijad, kelle meie kasutajad kaotaks", "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.message": "Palun proovi uuesti pärast {retry_time, time, medium}.",
"alert.rate_limited.title": "Kiiruspiirang", "alert.rate_limited.title": "Kiiruspiirang",
"alert.unexpected.message": "Tekkis ootamatu viga.", "alert.unexpected.message": "Tekkis ootamatu viga.",
"alert.unexpected.title": "Oih!", "alert.unexpected.title": "Vaat kus lops!",
"alt_text_badge.title": "Alternatiivtekst", "alt_text_badge.title": "Selgitustekst",
"alt_text_modal.add_alt_text": "Lisa alt-tekst", "alt_text_modal.add_alt_text": "Lisa selgitustekst",
"alt_text_modal.add_text_from_image": "Lisa tekst pildilt", "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.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_hearing_impairments": "Kirjelda seda kuulmispuudega inimeste jaoks…",
"alt_text_modal.describe_for_people_with_visual_impairments": "Kirjelda seda nägemispuudega 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.description": "Vaata teavet oma suhestumise kohta Mastodonis eelmisel aastal.",
"annual_report.announcement.title": "{year}. aasta Mastodoni kokkuvõte on valmis", "annual_report.announcement.title": "{year}. aasta Mastodoni kokkuvõte on valmis",
"annual_report.nav_item.badge": "Uus", "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": "Loodud {heart} Mastodoni meeskonna poolt",
"annual_report.shared_page.footer_server_info": "{username} kasutab {domain}-i, üht paljudest kogukondadest, mis toimivad Mastodonil.", "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.", "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}}", "load_pending": "{count, plural, one {# uus kirje} other {# uut kirjet}}",
"loading_indicator.label": "Laadimine…", "loading_indicator.label": "Laadimine…",
"media_gallery.hide": "Peida", "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}.", "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_from_notifications": "Peida teavituste hulgast",
"mute_modal.hide_options": "Peida valikud", "mute_modal.hide_options": "Peida valikud",
@ -767,7 +771,7 @@
"onboarding.profile.upload_avatar": "Laadi üles profiilipilt", "onboarding.profile.upload_avatar": "Laadi üles profiilipilt",
"onboarding.profile.upload_header": "Laadi üles profiili päis", "onboarding.profile.upload_header": "Laadi üles profiili päis",
"password_confirmation.exceeds_maxlength": "Salasõnakinnitus on pikem kui salasõna maksimumpikkus", "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", "picture_in_picture.restore": "Pane tagasi",
"poll.closed": "Suletud", "poll.closed": "Suletud",
"poll.refresh": "Värskenda", "poll.refresh": "Värskenda",

View File

@ -57,6 +57,7 @@
"account.go_to_profile": "Profile git", "account.go_to_profile": "Profile git",
"account.hide_reblogs": "@{name} kişisinin yeniden paylaşımlarını gizle", "account.hide_reblogs": "@{name} kişisinin yeniden paylaşımlarını gizle",
"account.in_memoriam": "Hatırasına.", "account.in_memoriam": "Hatırasına.",
"account.joined_long": "{date} tarihinde katıldı",
"account.joined_short": "Katıldı", "account.joined_short": "Katıldı",
"account.languages": "Abone olunan dilleri değiştir", "account.languages": "Abone olunan dilleri değiştir",
"account.link_verified_on": "Bu bağlantının sahipliği {date} tarihinde denetlendi", "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": "@{name} adlı kişinin sesini aç",
"account.unmute_notifications_short": "Bildirimlerin sesini aç", "account.unmute_notifications_short": "Bildirimlerin sesini aç",
"account.unmute_short": "Susturmayı kaldır", "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", "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.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ı", "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}}", "load_pending": "{count, plural, one {# yeni öğe} other {# yeni öğe}}",
"loading_indicator.label": "Yükleniyor…", "loading_indicator.label": "Yükleniyor…",
"media_gallery.hide": "Gizle", "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ışı.", "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_from_notifications": "Bildirimlerde gizle",
"mute_modal.hide_options": "Seçenekleri gizle", "mute_modal.hide_options": "Seçenekleri gizle",

View File

@ -5,6 +5,7 @@ class ActivityPub::Activity
include Redisable include Redisable
include Lockable include Lockable
MAX_JSON_SIZE = 1.megabyte
SUPPORTED_TYPES = %w(Note Question).freeze SUPPORTED_TYPES = %w(Note Question).freeze
CONVERTED_TYPES = %w(Image Audio Video Article Page Event).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) @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 # 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 # We may be getting `Create` and `Update` out of order
@status ||= ActivityPub::Activity::Create.new(@json, @account, **@options).perform @status ||= ActivityPub::Activity::Create.new(@json, @account, **@options).perform

View File

@ -3,6 +3,10 @@
class ActivityPub::Parser::PollParser class ActivityPub::Parser::PollParser
include JsonLdHelper 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) def initialize(json)
@json = json @json = json
end end
@ -48,6 +52,6 @@ class ActivityPub::Parser::PollParser
private private
def items def items
@json['anyOf'] || @json['oneOf'] (@json['anyOf'] || @json['oneOf'])&.take(MAX_ITEMS)
end end
end end

View File

@ -81,6 +81,13 @@ class Account < ApplicationRecord
DISPLAY_NAME_LENGTH_LIMIT = (ENV['MAX_DISPLAY_NAME_CHARS'] || 30).to_i DISPLAY_NAME_LENGTH_LIMIT = (ENV['MAX_DISPLAY_NAME_CHARS'] || 30).to_i
NOTE_LENGTH_LIMIT = (ENV['MAX_BIO_CHARS'] || 500).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 AUTOMATED_ACTOR_TYPES = %w(Application Service).freeze
include Attachmentable # Load prior to Avatar & Header concerns include Attachmentable # Load prior to Avatar & Header concerns
@ -114,7 +121,7 @@ class Account < ApplicationRecord
validates_with UniqueUsernameValidator, if: -> { will_save_change_to_username? } validates_with UniqueUsernameValidator, if: -> { will_save_change_to_username? }
# Remote user validations, also applies to internal actors # 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 # Remote user validations
validates :uri, presence: true, unless: :local?, on: :create validates :uri, presence: true, unless: :local?, on: :create

View File

@ -8,6 +8,7 @@
# description :text not null # description :text not null
# discoverable :boolean not null # discoverable :boolean not null
# item_count :integer default(0), not null # item_count :integer default(0), not null
# language :string
# local :boolean not null # local :boolean not null
# name :string not null # name :string not null
# original_number_of_items :integer # original_number_of_items :integer
@ -36,6 +37,7 @@ class Collection < ApplicationRecord
presence: true, presence: true,
numericality: { greater_than_or_equal: 0 }, numericality: { greater_than_or_equal: 0 },
if: :remote? if: :remote?
validates :language, language: { if: :local?, allow_nil: true }
validate :tag_is_usable validate :tag_is_usable
validate :items_do_not_exceed_limit validate :items_do_not_exceed_limit

View File

@ -164,6 +164,13 @@ module Account::Interactions
end end
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) def muting?(other_account)
other_id = other_account.is_a?(Account) ? other_account.id : 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 LOCAL_LIMIT = (ENV['MAX_EMOJI_SIZE'] || 256.kilobytes).to_i
LIMIT = [LOCAL_LIMIT, (ENV['MAX_REMOTE_EMOJI_SIZE'] || 256.kilobytes).to_i].max LIMIT = [LOCAL_LIMIT, (ENV['MAX_REMOTE_EMOJI_SIZE'] || 256.kilobytes).to_i].max
MINIMUM_SHORTCODE_SIZE = 2 MINIMUM_SHORTCODE_SIZE = 2
MAX_SHORTCODE_SIZE = 128
MAX_FEDERATED_SHORTCODE_SIZE = 2048
SHORTCODE_RE_FRAGMENT = '[a-zA-Z0-9_]{2,}' 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 :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: LIMIT, unless: :local?
validates_attachment_size :image, less_than: LOCAL_LIMIT, if: :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 :local, -> { where(domain: nil) }
scope :remote, -> { where.not(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 EXPIRATION_DURATIONS = [30.minutes, 1.hour, 6.hours, 12.hours, 1.day, 1.week].freeze
TITLE_LENGTH_LIMIT = 256
include Expireable include Expireable
include Redisable include Redisable
@ -41,6 +43,7 @@ class CustomFilter < ApplicationRecord
accepts_nested_attributes_for :keywords, reject_if: :all_blank, allow_destroy: true accepts_nested_attributes_for :keywords, reject_if: :all_blank, allow_destroy: true
validates :title, :context, presence: true validates :title, :context, presence: true
validates :title, length: { maximum: TITLE_LENGTH_LIMIT }
validate :context_must_be_valid validate :context_must_be_valid
normalizes :context, with: ->(context) { context.map(&:strip).filter_map(&:presence) } normalizes :context, with: ->(context) { context.map(&:strip).filter_map(&:presence) }

View File

@ -17,7 +17,9 @@ class CustomFilterKeyword < ApplicationRecord
belongs_to :custom_filter 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 alias_attribute :phrase, :keyword

View File

@ -17,6 +17,7 @@ class List < ApplicationRecord
include Paginable include Paginable
PER_ACCOUNT_LIMIT = 50 PER_ACCOUNT_LIMIT = 50
TITLE_LENGTH_LIMIT = 256
enum :replies_policy, { list: 0, followed: 1, none: 2 }, prefix: :show, validate: true 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 :accounts, through: :list_accounts
has_many :active_accounts, -> { merge(ListAccount.active) }, through: :list_accounts, source: :account 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 validate :validate_account_lists_limit, on: :create

View File

@ -68,4 +68,8 @@ class AccountPolicy < ApplicationPolicy
def feature? def feature?
record.featureable? && !current_account.blocking?(record) && !current_account.blocked_by?(record) record.featureable? && !current_account.blocking?(record) && !current_account.blocked_by?(record)
end end
def index_collections?
current_account.nil? || !record.blocking_or_domain_blocking?(current_account)
end
end end

View File

@ -6,7 +6,7 @@ class CollectionPolicy < ApplicationPolicy
end end
def show? def show?
current_account.nil? || (!owner_blocking? && !owner_blocking_domain?) current_account.nil? || !owner.blocking_or_domain_blocking?(current_account)
end end
def create? def create?
@ -27,18 +27,6 @@ class CollectionPolicy < ApplicationPolicy
current_account == owner current_account == owner
end 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 def owner
record.account record.account
end end

View File

@ -19,6 +19,7 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer
:discoverable, :indexable, :published, :memorial :discoverable, :indexable, :published, :memorial
attribute :interaction_policy, if: -> { Mastodon::Feature.collections_enabled? } attribute :interaction_policy, if: -> { Mastodon::Feature.collections_enabled? }
attribute :featured_collections, if: -> { Mastodon::Feature.collections_enabled? }
has_one :public_key, serializer: ActivityPub::PublicKeySerializer has_one :public_key, serializer: ActivityPub::PublicKeySerializer
@ -177,6 +178,12 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer
} }
end end
def featured_collections
return nil if instance_actor?
ap_account_featured_collections_url(object.id)
end
class CustomEmojiSerializer < ActivityPub::EmojiSerializer class CustomEmojiSerializer < ActivityPub::EmojiSerializer
end end

View File

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

View File

@ -17,9 +17,12 @@ class ActivityPub::FeaturedCollectionSerializer < ActivityPub::Serializer
end end
end end
attributes :id, :type, :total_items, :name, :summary, :attributed_to, attributes :id, :type, :total_items, :name, :attributed_to,
:sensitive, :discoverable, :published, :updated :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_one :tag, key: :topic, serializer: ActivityPub::NoteSerializer::TagSerializer
has_many :collection_items, key: :ordered_items, serializer: FeaturedItemSerializer has_many :collection_items, key: :ordered_items, serializer: FeaturedItemSerializer
@ -36,6 +39,10 @@ class ActivityPub::FeaturedCollectionSerializer < ActivityPub::Serializer
object.description object.description
end end
def summary_map
{ object.language => object.description }
end
def attributed_to def attributed_to
ActivityPub::TagManager.instance.uri_for(object.account) ActivityPub::TagManager.instance.uri_for(object.account)
end end
@ -51,4 +58,8 @@ class ActivityPub::FeaturedCollectionSerializer < ActivityPub::Serializer
def updated def updated
object.updated_at.iso8601 object.updated_at.iso8601
end end
def language_present?
object.language.present?
end
end end

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
class REST::BaseCollectionSerializer < ActiveModel::Serializer 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 :discoverable, :item_count, :created_at, :updated_at
belongs_to :tag, serializer: REST::StatusSerializer::TagSerializer belongs_to :tag, serializer: REST::StatusSerializer::TagSerializer

View File

@ -6,6 +6,7 @@ class ActivityPub::ProcessAccountService < BaseService
include Redisable include Redisable
include Lockable include Lockable
MAX_PROFILE_FIELDS = 50
SUBDOMAINS_RATELIMIT = 10 SUBDOMAINS_RATELIMIT = 10
DISCOVERIES_PER_REQUEST = 400 DISCOVERIES_PER_REQUEST = 400
@ -124,15 +125,15 @@ class ActivityPub::ProcessAccountService < BaseService
def set_immediate_attributes! def set_immediate_attributes!
@account.featured_collection_url = valid_collection_uri(@json['featured']) @account.featured_collection_url = valid_collection_uri(@json['featured'])
@account.display_name = @json['name'] || '' @account.display_name = (@json['name'] || '')[0...(Account::DISPLAY_NAME_LENGTH_HARD_LIMIT)]
@account.note = @json['summary'] || '' @account.note = (@json['summary'] || '')[0...(Account::NOTE_LENGTH_HARD_LIMIT)]
@account.locked = @json['manuallyApprovesFollowers'] || false @account.locked = @json['manuallyApprovesFollowers'] || false
@account.fields = property_values || {} @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.discoverable = @json['discoverable'] || false
@account.indexable = @json['indexable'] || false @account.indexable = @json['indexable'] || false
@account.memorial = @json['memorial'] || 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 end
def set_fetchable_key! def set_fetchable_key!
@ -253,7 +254,10 @@ class ActivityPub::ProcessAccountService < BaseService
def property_values def property_values
return unless @json['attachment'].is_a?(Array) 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 end
def mismatching_origin?(url) def mismatching_origin?(url)

View File

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

View File

@ -29,23 +29,23 @@ et:
title: Kinnita e-postiaadress title: Kinnita e-postiaadress
email_changed: email_changed:
explanation: 'Sinu konto e-postiaadress muudetakse:' 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' subject: 'Mastodon: e-post muudetud'
title: Uus e-postiaadress title: Uus e-postiaadress
password_change: password_change:
explanation: Konto salasõna on vahetatud. 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. 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' subject: 'Mastodon: salasõna on muudetud'
title: Salasõna muudetud title: Salasõna on muudetud
reconfirmation_instructions: reconfirmation_instructions:
explanation: Kinnita uus aadress, et oma e-posti aadress muuta. 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. 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' subject: 'Mastodon: kinnita e-postiaadress %{instance} jaoks'
title: Kinnita e-postiaadress title: Kinnita e-postiaadress
reset_password_instructions: reset_password_instructions:
action: Salasõna muutmine action: Muuda salasõna
explanation: Kontole on küsitud uut salasõna. explanation: Sa palusid oma kasutajakontole luua uus 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. 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' subject: 'Mastodon: salasõna lähtestamisjuhendid'
title: Salasõna lähtestamine title: Salasõna lähtestamine
two_factor_disabled: two_factor_disabled:

View File

@ -142,7 +142,7 @@ et:
security: Turvalisus security: Turvalisus
security_measures: security_measures:
only_password: Ainult salasõna 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 sensitive: Tundlik sisu
sensitized: Märgitud kui tundlik sisu sensitized: Märgitud kui tundlik sisu
shared_inbox_url: Jagatud sisendkausta URL shared_inbox_url: Jagatud sisendkausta URL
@ -292,7 +292,7 @@ et:
remove_avatar_user_html: "%{name} eemaldas %{target} avatari" remove_avatar_user_html: "%{name} eemaldas %{target} avatari"
reopen_report_html: "%{name} taasavas raporti %{target}" reopen_report_html: "%{name} taasavas raporti %{target}"
resend_user_html: "%{name} lähtestas %{target} kinnituskirja e-posti" 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}" resolve_report_html: "%{name} lahendas raporti %{target}"
sensitive_account_html: "%{name} märkis %{target} meedia kui tundlik sisu" sensitive_account_html: "%{name} märkis %{target} meedia kui tundlik sisu"
silence_account_html: "%{name} piiras %{target} konto" silence_account_html: "%{name} piiras %{target} konto"
@ -787,7 +787,7 @@ et:
manage_taxonomies: Halda taksonoomiaid manage_taxonomies: Halda taksonoomiaid
manage_taxonomies_description: Luba kasutajatel populaarset sisu üle vaadata ning uuendada teemaviidete seadistusi manage_taxonomies_description: Luba kasutajatel populaarset sisu üle vaadata ning uuendada teemaviidete seadistusi
manage_user_access: Halda kasutajate ligipääsu 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: Kasutajate haldamine
manage_users_description: Lubab kasutajail näha teiste kasutajate üksikasju ja teha nende suhtes modereerimisotsuseid manage_users_description: Lubab kasutajail näha teiste kasutajate üksikasju ja teha nende suhtes modereerimisotsuseid
manage_webhooks: Halda webhook'e 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! 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? didnt_get_confirmation: Ei saanud kinnituslinki?
dont_have_your_security_key: Pole turvavõtit? 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. 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_otp: Kaheastmeline kood telefonist või taastekood
link_to_webauth: Turvavõtmete seadme kasutamine link_to_webauth: Turvavõtmete seadme kasutamine
@ -1270,7 +1270,7 @@ et:
register: Loo konto register: Loo konto
registration_closed: "%{instance} ei võta vastu uusi liikmeid" registration_closed: "%{instance} ei võta vastu uusi liikmeid"
resend_confirmation: Saada kinnituslink uuesti resend_confirmation: Saada kinnituslink uuesti
reset_password: Salasõna lähtestamine reset_password: Lähtesta salasõna
rules: rules:
accept: Nõus accept: Nõus
back: Tagasi back: Tagasi
@ -1280,7 +1280,7 @@ et:
title: Mõned põhireeglid. title: Mõned põhireeglid.
title_invited: Oled kutsutud. title_invited: Oled kutsutud.
security: Turvalisus security: Turvalisus
set_new_password: Uue salasõna määramine set_new_password: Sisesta uus salasõna
setup: 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_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. 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 title: Autori tunnustamine
challenge: challenge:
confirm: Jätka 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 invalid_password: Vigane salasõna
prompt: Jätkamiseks salasõna veelkord prompt: Jätkamiseks korda salasõna
color_scheme: color_scheme:
auto: Auto auto: Auto
dark: Tume dark: Tume
@ -1627,7 +1627,7 @@ et:
password: salasõna password: salasõna
sign_in_token: e-posti turvvakood sign_in_token: e-posti turvvakood
webauthn: turvavõtmed 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 empty: Autentimisajalugu pole saadaval
failed_sign_in_html: Nurjunud sisenemine meetodiga %{method} aadressilt %{ip} (%{browser}) failed_sign_in_html: Nurjunud sisenemine meetodiga %{method} aadressilt %{ip} (%{browser})
successful_sign_in_html: Edukas 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 inbox_url: Kopeeri soovitud sõnumivahendusserveri avalehe võrguaadress
irreversible: Filtreeritud postitused kaovad taastamatult, isegi kui filter on hiljem eemaldatud irreversible: Filtreeritud postitused kaovad taastamatult, isegi kui filter on hiljem eemaldatud
locale: Kasutajaliidese, e-kirjade ja tõuketeadete keel 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 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. 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. 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. 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. 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: form_challenge:
current_password: Turvalisse alasse sisenemine current_password: Sisened turvalisse alasse
imports: imports:
data: CSV fail eksporditi teisest Mastodoni serverist data: CSV fail eksporditi teisest Mastodoni serverist
invite_request: invite_request:
@ -214,8 +214,8 @@ et:
avatar: Profiilipilt avatar: Profiilipilt
bot: See konto on robot bot: See konto on robot
chosen_languages: Keelte filtreerimine chosen_languages: Keelte filtreerimine
confirm_new_password: Uue salasõna kinnitamine confirm_new_password: Korda uut salasõna
confirm_password: Salasõna kinnitamine confirm_password: Korda salasõna
context: Filtreeri kontekste context: Filtreeri kontekste
current_password: Kehtiv salasõna current_password: Kehtiv salasõna
data: Andmed data: Andmed

View File

@ -124,6 +124,8 @@ Rails.application.routes.draw do
scope path: 'ap', as: 'ap' do scope path: 'ap', as: 'ap' do
resources :accounts, path: 'users', only: [:show], param: :id, concerns: :account_resources 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 resources :statuses, only: [:show] do
member do member do
get :activity 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. # 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 # These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql" 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 "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.integer "item_count", default: 0, 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 ["account_id"], name: "index_collections_on_account_id"
t.index ["tag_id"], name: "index_collections_on_tag_id" t.index ["tag_id"], name: "index_collections_on_tag_id"
end end

View File

@ -59,7 +59,7 @@ services:
web: web:
# You can uncomment the following line if you want to not use the prebuilt image, for example if you have local code changes # You can uncomment the following line if you want to not use the prebuilt image, for example if you have local code changes
# build: . # build: .
image: ghcr.io/glitch-soc/mastodon:v4.5.4 image: ghcr.io/glitch-soc/mastodon:v4.5.5
restart: always restart: always
env_file: .env.production env_file: .env.production
command: bundle exec puma -C config/puma.rb command: bundle exec puma -C config/puma.rb
@ -83,7 +83,7 @@ services:
# build: # build:
# dockerfile: ./streaming/Dockerfile # dockerfile: ./streaming/Dockerfile
# context: . # context: .
image: ghcr.io/glitch-soc/mastodon-streaming:v4.5.4 image: ghcr.io/glitch-soc/mastodon-streaming:v4.5.5
restart: always restart: always
env_file: .env.production env_file: .env.production
command: node ./streaming/index.js command: node ./streaming/index.js
@ -102,7 +102,7 @@ services:
sidekiq: sidekiq:
# You can uncomment the following line if you want to not use the prebuilt image, for example if you have local code changes # You can uncomment the following line if you want to not use the prebuilt image, for example if you have local code changes
# build: . # build: .
image: ghcr.io/glitch-soc/mastodon:v4.5.4 image: ghcr.io/glitch-soc/mastodon:v4.5.5
restart: always restart: always
env_file: .env.production env_file: .env.production
command: bundle exec sidekiq command: bundle exec sidekiq

View File

@ -17,7 +17,7 @@ module Mastodon
end end
def default_prerelease def default_prerelease
'alpha.2' 'alpha.3'
end end
def prerelease 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_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 context 'when collection is remote' do
subject { Fabricate.build :collection, local: false } subject { Fabricate.build :collection, local: false }
it { is_expected.to validate_presence_of(:uri) } it { is_expected.to validate_presence_of(:uri) }
it { is_expected.to validate_presence_of(:original_number_of_items) } it { is_expected.to validate_presence_of(:original_number_of_items) }
it { is_expected.to allow_value('randomstuff').for(:language) }
end end
context 'when using a hashtag as category' do context 'when using a hashtag as category' do

View File

@ -450,6 +450,44 @@ RSpec.describe Account::Interactions do
end end
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 describe '#muting?' do
subject { account.muting?(target_account) } subject { account.muting?(target_account) }

View File

@ -188,4 +188,24 @@ RSpec.describe AccountPolicy do
end end
end 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 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', name: 'Low-traffic bots',
description: 'Really nice bots, please follow', description: 'Really nice bots, please follow',
language: 'en',
sensitive: '0', sensitive: '0',
discoverable: '1', discoverable: '1',
} }

View File

@ -163,9 +163,10 @@ RSpec.describe 'API Web Push Subscriptions' do
end end
describe 'PUT /api/web/push_subscriptions/:id' do 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 it 'gracefully handles invalid nested params' do
put api_web_push_subscription_path(subscription), params: { data: 'invalid' } 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) } it { is_expected.to eq(ActiveModel::Serializer::CollectionSerializer) }
end end
context 'with a Collection' do
let(:model) { Collection.new }
it { is_expected.to eq(ActivityPub::FeaturedCollectionSerializer) }
end
end end
end end

View File

@ -45,4 +45,20 @@ RSpec.describe ActivityPub::FeaturedCollectionSerializer do
'updated' => match_api_datetime_format, 'updated' => match_api_datetime_format,
}) })
end 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 end

View File

@ -18,6 +18,7 @@ RSpec.describe REST::CollectionSerializer do
id: 2342, id: 2342,
name: 'Exquisite follows', name: 'Exquisite follows',
description: 'Always worth a follow', description: 'Always worth a follow',
language: 'en',
local: true, local: true,
sensitive: true, sensitive: true,
discoverable: false, discoverable: false,
@ -31,6 +32,7 @@ RSpec.describe REST::CollectionSerializer do
'id' => '2342', 'id' => '2342',
'name' => 'Exquisite follows', 'name' => 'Exquisite follows',
'description' => 'Always worth a follow', 'description' => 'Always worth a follow',
'language' => 'en',
'local' => true, 'local' => true,
'sensitive' => true, 'sensitive' => true,
'discoverable' => false, 'discoverable' => false,