mirror of
https://github.com/glitch-soc/mastodon.git
synced 2026-03-29 19:21:36 +02:00
Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5799d5d306 | ||
|
|
b5d868018d | ||
|
|
55a7b1ea58 | ||
|
|
c1fb6893c5 | ||
|
|
71ae4cf2cf | ||
|
|
2ffe03457d | ||
|
|
c1f5a9db23 | ||
|
|
7c0701d906 | ||
|
|
b134c6a8ef | ||
|
|
a846ed17ff | ||
|
|
3013039720 | ||
|
|
ad4ba5aa00 | ||
|
|
1c5461fffe | ||
|
|
725c1a159d | ||
|
|
b52efea5cb | ||
|
|
a0bdfc46c7 | ||
|
|
afcdc19730 | ||
|
|
80aa3bc8ad | ||
|
|
92955f7e6e | ||
|
|
b868e598bc | ||
|
|
3de59a9344 | ||
|
|
32c3376d84 | ||
|
|
962ae88caf | ||
|
|
7d9d3de972 | ||
|
|
546a95349e | ||
|
|
df1ab0ab90 | ||
|
|
8d1ea4c531 | ||
|
|
8233295e3b | ||
|
|
4eb0a506d3 | ||
|
|
75739a5a9b |
42
.github/workflows/build-releases.yml
vendored
42
.github/workflows/build-releases.yml
vendored
@@ -9,7 +9,44 @@ permissions:
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
check-latest-stable:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
latest: ${{ steps.check.outputs.is_latest_stable }}
|
||||
steps:
|
||||
# Repository needs to be cloned to list branches
|
||||
- name: Clone repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Check latest stable
|
||||
shell: bash
|
||||
id: check
|
||||
run: |
|
||||
ref="${GITHUB_REF#refs/tags/}"
|
||||
|
||||
if [[ "$ref" =~ ^v([0-9]+)\.([0-9]+)(\.[0-9]+)?$ ]]; then
|
||||
current="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}"
|
||||
else
|
||||
echo "tag $ref is not semver"
|
||||
echo "is_latest_stable=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
latest=$(git for-each-ref --format='%(refname:short)' "refs/remotes/origin/stable-*.*" \
|
||||
| sed -E 's#^origin/stable-##' \
|
||||
| sort -Vr \
|
||||
| head -n1)
|
||||
|
||||
if [[ "$current" == "$latest" ]]; then
|
||||
echo "is_latest_stable=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "is_latest_stable=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
build-image:
|
||||
needs: check-latest-stable
|
||||
uses: ./.github/workflows/build-container-image.yml
|
||||
with:
|
||||
file_to_build: Dockerfile
|
||||
@@ -20,13 +57,14 @@ jobs:
|
||||
# Only tag with latest when ran against the latest stable branch
|
||||
# This needs to be updated after each minor version release
|
||||
flavor: |
|
||||
latest=${{ startsWith(github.ref, 'refs/tags/v4.5.') }}
|
||||
latest=${{ needs.check-latest-stable.outputs.latest }}
|
||||
tags: |
|
||||
type=pep440,pattern={{raw}}
|
||||
type=pep440,pattern=v{{major}}.{{minor}}
|
||||
secrets: inherit
|
||||
|
||||
build-image-streaming:
|
||||
needs: check-latest-stable
|
||||
uses: ./.github/workflows/build-container-image.yml
|
||||
with:
|
||||
file_to_build: streaming/Dockerfile
|
||||
@@ -37,7 +75,7 @@ jobs:
|
||||
# Only tag with latest when ran against the latest stable branch
|
||||
# This needs to be updated after each minor version release
|
||||
flavor: |
|
||||
latest=${{ startsWith(github.ref, 'refs/tags/v4.5.') }}
|
||||
latest=${{ needs.check-latest-stable.outputs.latest }}
|
||||
tags: |
|
||||
type=pep440,pattern={{raw}}
|
||||
type=pep440,pattern=v{{major}}.{{minor}}
|
||||
|
||||
25
CHANGELOG.md
25
CHANGELOG.md
@@ -2,6 +2,31 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [4.5.4] - 2026-01-07
|
||||
|
||||
### Security
|
||||
|
||||
- Fix SSRF protection bypass ([GHSA](https://github.com/mastodon/mastodon/security/advisories/GHSA-xfrj-c749-jxxq))
|
||||
- Fix missing ownership check in severed relationships controller ([GHSA](https://github.com/mastodon/mastodon/security/advisories/GHSA-ww85-x9cp-5v24))
|
||||
|
||||
### Changed
|
||||
|
||||
- Change HTTP Signature verification status from 401 to 503 on temporary failure to get remote actor (#37221 by @ClearlyClaire)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix custom emojis not being rendered in profile fields (#37365 by @ClearlyClaire)
|
||||
- Fix serialization of context pages (#37376 by @ClearlyClaire)
|
||||
- Fix quotes with CWs but no text not having fallback link (#37361 by @ClearlyClaire)
|
||||
- Fix outdated link target for “locked” warning (#37366 by @ClearlyClaire)
|
||||
- Fix local custom emojis sometimes being rendered in remote posts (#37284 by @ChaosExAnima)
|
||||
- Fix some assets not being loaded from configured CDN (#37310 by @ChaosExAnima)
|
||||
- Fix notifications page error in Tor browser (#37285 by @diondiondion)
|
||||
- Fix custom emojis not being displayed in CWs and fav/boost notifications (#37272 and #37306 by @ChaosExAnima and @ClearlyClaire)
|
||||
- Fix default `Admin` role not including `view_feeds` permission (#37301 by @ClearlyClaire)
|
||||
- Fix hashtag autocomplete replacing suggestion's first characters with input (#37281 by @ClearlyClaire)
|
||||
- Fix mentions of domain-blocked users being processed (#37257 by @ClearlyClaire)
|
||||
|
||||
## [4.5.3] - 2025-12-08
|
||||
|
||||
### Security
|
||||
|
||||
@@ -36,9 +36,8 @@ class ActivityPub::ContextsController < ActivityPub::BaseController
|
||||
|
||||
def context_presenter
|
||||
first_page = ActivityPub::CollectionPresenter.new(
|
||||
id: items_context_url(@conversation, page_params),
|
||||
type: :unordered,
|
||||
part_of: items_context_url(@conversation),
|
||||
part_of: context_url(@conversation),
|
||||
next: next_page,
|
||||
items: @items.map { |status| status.local? ? ActivityPub::TagManager.instance.uri_for(status) : status.uri }
|
||||
)
|
||||
@@ -52,7 +51,7 @@ class ActivityPub::ContextsController < ActivityPub::BaseController
|
||||
page = ActivityPub::CollectionPresenter.new(
|
||||
id: items_context_url(@conversation, page_params),
|
||||
type: :unordered,
|
||||
part_of: items_context_url(@conversation),
|
||||
part_of: context_url(@conversation),
|
||||
next: next_page,
|
||||
items: @items.map { |status| status.local? ? ActivityPub::TagManager.instance.uri_for(status) : status.uri }
|
||||
)
|
||||
|
||||
@@ -72,10 +72,13 @@ module SignatureVerification
|
||||
rescue Mastodon::SignatureVerificationError => e
|
||||
fail_with! e.message
|
||||
rescue *Mastodon::HTTP_CONNECTION_ERRORS => e
|
||||
@signature_verification_failure_code ||= 503
|
||||
fail_with! "Failed to fetch remote data: #{e.message}"
|
||||
rescue Mastodon::UnexpectedResponseError
|
||||
@signature_verification_failure_code ||= 503
|
||||
fail_with! 'Failed to fetch remote data (got unexpected reply from server)'
|
||||
rescue Stoplight::Error::RedLight
|
||||
@signature_verification_failure_code ||= 503
|
||||
fail_with! 'Fetching attempt skipped because of recent connection failure'
|
||||
end
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ class SeveredRelationshipsController < ApplicationController
|
||||
private
|
||||
|
||||
def set_event
|
||||
@event = AccountRelationshipSeveranceEvent.find(params[:id])
|
||||
@event = AccountRelationshipSeveranceEvent.where(account: current_account).find(params[:id])
|
||||
end
|
||||
|
||||
def following_data
|
||||
|
||||
@@ -709,7 +709,16 @@ export function selectComposeSuggestion(position, token, suggestion, path) {
|
||||
|
||||
dispatch(useEmoji(suggestion));
|
||||
} else if (suggestion.type === 'hashtag') {
|
||||
completion = token + suggestion.name.slice(token.length - 1);
|
||||
// TODO: it could make sense to keep the “most capitalized” of the two
|
||||
const tokenName = token.slice(1); // strip leading '#'
|
||||
const suggestionPrefix = suggestion.name.slice(0, tokenName.length);
|
||||
const prefixMatchesSuggestion = suggestionPrefix.localeCompare(tokenName, undefined, { sensitivity: 'accent' }) === 0;
|
||||
if (prefixMatchesSuggestion) {
|
||||
completion = token + suggestion.name.slice(tokenName.length);
|
||||
} else {
|
||||
completion = `${token.slice(0, 1)}${suggestion.name}`;
|
||||
}
|
||||
|
||||
startPosition = position - 1;
|
||||
} else if (suggestion.type === 'account') {
|
||||
completion = `@${getState().getIn(['accounts', suggestion.id, 'acct'])}`;
|
||||
|
||||
@@ -6,7 +6,6 @@ import CheckIcon from '@/material-icons/400-24px/check.svg?react';
|
||||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
import type { Account } from 'flavours/glitch/models/account';
|
||||
|
||||
import { CustomEmojiProvider } from './emoji/context';
|
||||
import { EmojiHTML } from './emoji/html';
|
||||
import { useElementHandledLink } from './status/handled_link';
|
||||
|
||||
@@ -22,12 +21,13 @@ export const AccountFields: React.FC<Pick<Account, 'fields' | 'emojis'>> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<CustomEmojiProvider emojis={emojis}>
|
||||
<>
|
||||
{fields.map((pair, i) => (
|
||||
<dl key={i} className={classNames({ verified: pair.verified_at })}>
|
||||
<EmojiHTML
|
||||
as='dt'
|
||||
htmlString={pair.name_emojified}
|
||||
extraEmojis={emojis}
|
||||
className='translate'
|
||||
{...htmlHandlers}
|
||||
/>
|
||||
@@ -52,12 +52,13 @@ export const AccountFields: React.FC<Pick<Account, 'fields' | 'emojis'>> = ({
|
||||
<EmojiHTML
|
||||
as='span'
|
||||
htmlString={pair.value_emojified}
|
||||
extraEmojis={emojis}
|
||||
{...htmlHandlers}
|
||||
/>
|
||||
</dd>
|
||||
</dl>
|
||||
))}
|
||||
</CustomEmojiProvider>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ export const ContentWarning: React.FC<{
|
||||
<EmojiHTML
|
||||
as='span'
|
||||
htmlString={text}
|
||||
extraEmojis={status.get('emoji') as List<CustomEmoji>}
|
||||
extraEmojis={status.get('emojis') as List<CustomEmoji>}
|
||||
/>
|
||||
</StatusBanner>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
.inlineIcon {
|
||||
vertical-align: middle;
|
||||
}
|
||||
@@ -12,6 +12,8 @@ import { Button } from '../button';
|
||||
import { useDismissableBannerState } from '../dismissable_banner';
|
||||
import { Icon } from '../icon';
|
||||
|
||||
import classes from './remove_quote_hint.module.css';
|
||||
|
||||
const DISMISSABLE_BANNER_ID = 'notifications/remove_quote_hint';
|
||||
|
||||
/**
|
||||
@@ -93,7 +95,7 @@ export const RemoveQuoteHint: React.FC<{
|
||||
id: 'status.more',
|
||||
defaultMessage: 'More',
|
||||
})}
|
||||
style={{ verticalAlign: 'middle' }}
|
||||
className={classes.inlineIcon}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
|
||||
@@ -31,7 +31,7 @@ export const Warning = () => {
|
||||
defaultMessage='Your account is not {locked}. Anyone can follow you to view your follower-only posts.'
|
||||
values={{
|
||||
locked: (
|
||||
<a href='/settings/profile'>
|
||||
<a href='/settings/privacy#account_unlocked'>
|
||||
<FormattedMessage
|
||||
id='compose_form.lock_disclaimer.lock'
|
||||
defaultMessage='locked'
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
stringToEmojiState,
|
||||
tokenizeText,
|
||||
} from './render';
|
||||
import type { EmojiStateCustom } from './types';
|
||||
|
||||
describe('tokenizeText', () => {
|
||||
test('returns an array of text to be a single token', () => {
|
||||
@@ -82,12 +83,8 @@ describe('stringToEmojiState', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('returns custom emoji state for valid custom emoji', () => {
|
||||
expect(stringToEmojiState(':smile:')).toEqual({
|
||||
type: 'custom',
|
||||
code: 'smile',
|
||||
data: undefined,
|
||||
});
|
||||
test('returns null for custom emoji without data', () => {
|
||||
expect(stringToEmojiState(':smile:')).toBeNull();
|
||||
});
|
||||
|
||||
test('returns custom emoji state with data when provided', () => {
|
||||
@@ -107,7 +104,6 @@ describe('stringToEmojiState', () => {
|
||||
|
||||
test('returns null for invalid emoji strings', () => {
|
||||
expect(stringToEmojiState('notanemoji')).toBeNull();
|
||||
expect(stringToEmojiState(':invalid-emoji:')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -130,18 +126,13 @@ describe('loadEmojiDataToState', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('loads custom emoji data into state', async () => {
|
||||
const dbCall = vi
|
||||
.spyOn(db, 'loadCustomEmojiByShortcode')
|
||||
.mockResolvedValueOnce(customEmojiFactory());
|
||||
const customState = { type: 'custom', code: 'smile' } as const;
|
||||
const result = await loadEmojiDataToState(customState, 'en');
|
||||
expect(dbCall).toHaveBeenCalledWith('smile');
|
||||
expect(result).toEqual({
|
||||
test('returns null for custom emoji without data', async () => {
|
||||
const customState = {
|
||||
type: 'custom',
|
||||
code: 'smile',
|
||||
data: customEmojiFactory(),
|
||||
});
|
||||
} as const satisfies EmojiStateCustom;
|
||||
const result = await loadEmojiDataToState(customState, 'en');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test('returns null if unicode emoji not found in database', async () => {
|
||||
@@ -151,13 +142,6 @@ describe('loadEmojiDataToState', () => {
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test('returns null if custom emoji not found in database', async () => {
|
||||
vi.spyOn(db, 'loadCustomEmojiByShortcode').mockResolvedValueOnce(undefined);
|
||||
const customState = { type: 'custom', code: 'smile' } as const;
|
||||
const result = await loadEmojiDataToState(customState, 'en');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test('retries loading emoji data once if initial load fails', async () => {
|
||||
const dbCall = vi
|
||||
.spyOn(db, 'loadEmojiByHexcode')
|
||||
|
||||
@@ -4,11 +4,7 @@ import {
|
||||
EMOJI_TYPE_UNICODE,
|
||||
EMOJI_TYPE_CUSTOM,
|
||||
} from './constants';
|
||||
import {
|
||||
loadCustomEmojiByShortcode,
|
||||
loadEmojiByHexcode,
|
||||
LocaleNotLoadedError,
|
||||
} from './database';
|
||||
import { loadEmojiByHexcode, LocaleNotLoadedError } from './database';
|
||||
import { importEmojiData } from './loader';
|
||||
import { emojiToUnicodeHex } from './normalize';
|
||||
import type {
|
||||
@@ -79,7 +75,7 @@ export function tokenizeText(text: string): TokenizedText {
|
||||
export function stringToEmojiState(
|
||||
code: string,
|
||||
customEmoji: ExtraCustomEmojiMap = {},
|
||||
): EmojiState | null {
|
||||
): EmojiStateUnicode | Required<EmojiStateCustom> | null {
|
||||
if (isUnicodeEmoji(code)) {
|
||||
return {
|
||||
type: EMOJI_TYPE_UNICODE,
|
||||
@@ -89,11 +85,13 @@ export function stringToEmojiState(
|
||||
|
||||
if (isCustomEmoji(code)) {
|
||||
const shortCode = code.slice(1, -1);
|
||||
return {
|
||||
type: EMOJI_TYPE_CUSTOM,
|
||||
code: shortCode,
|
||||
data: customEmoji[shortCode],
|
||||
};
|
||||
if (customEmoji[shortCode]) {
|
||||
return {
|
||||
type: EMOJI_TYPE_CUSTOM,
|
||||
code: shortCode,
|
||||
data: customEmoji[shortCode],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -114,26 +112,23 @@ export async function loadEmojiDataToState(
|
||||
return state;
|
||||
}
|
||||
|
||||
// Don't try to load data for custom emoji.
|
||||
if (state.type === EMOJI_TYPE_CUSTOM) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// First, try to load the data from IndexedDB.
|
||||
try {
|
||||
// This is duplicative, but that's because TS can't distinguish the state type easily.
|
||||
if (state.type === EMOJI_TYPE_UNICODE) {
|
||||
const data = await loadEmojiByHexcode(state.code, locale);
|
||||
if (data) {
|
||||
return {
|
||||
...state,
|
||||
data,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
const data = await loadCustomEmojiByShortcode(state.code);
|
||||
if (data) {
|
||||
return {
|
||||
...state,
|
||||
data,
|
||||
};
|
||||
}
|
||||
const data = await loadEmojiByHexcode(state.code, locale);
|
||||
if (data) {
|
||||
return {
|
||||
...state,
|
||||
type: EMOJI_TYPE_UNICODE,
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
// If not found, assume it's not an emoji and return null.
|
||||
log(
|
||||
'Could not find emoji %s of type %s for locale %s',
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { List } from 'immutable';
|
||||
|
||||
import { EmojiHTML } from '@/flavours/glitch/components/emoji/html';
|
||||
import { useElementHandledLink } from '@/flavours/glitch/components/status/handled_link';
|
||||
import type { CustomEmoji } from '@/flavours/glitch/models/custom_emoji';
|
||||
import type { Status } from '@/flavours/glitch/models/status';
|
||||
|
||||
import type { Mention } from './embedded_status';
|
||||
@@ -33,6 +34,7 @@ export const EmbeddedStatusContent: React.FC<{
|
||||
className={className}
|
||||
lang={status.get('language') as string}
|
||||
htmlString={status.get('contentHtml') as string}
|
||||
extraEmojis={status.get('emojis') as List<CustomEmoji>}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -675,7 +675,16 @@ export function selectComposeSuggestion(position, token, suggestion, path) {
|
||||
|
||||
dispatch(useEmoji(suggestion));
|
||||
} else if (suggestion.type === 'hashtag') {
|
||||
completion = token + suggestion.name.slice(token.length - 1);
|
||||
// TODO: it could make sense to keep the “most capitalized” of the two
|
||||
const tokenName = token.slice(1); // strip leading '#'
|
||||
const suggestionPrefix = suggestion.name.slice(0, tokenName.length);
|
||||
const prefixMatchesSuggestion = suggestionPrefix.localeCompare(tokenName, undefined, { sensitivity: 'accent' }) === 0;
|
||||
if (prefixMatchesSuggestion) {
|
||||
completion = token + suggestion.name.slice(tokenName.length);
|
||||
} else {
|
||||
completion = `${token.slice(0, 1)}${suggestion.name}`;
|
||||
}
|
||||
|
||||
startPosition = position - 1;
|
||||
} else if (suggestion.type === 'account') {
|
||||
completion = `@${getState().getIn(['accounts', suggestion.id, 'acct'])}`;
|
||||
|
||||
@@ -6,7 +6,6 @@ import CheckIcon from '@/material-icons/400-24px/check.svg?react';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import type { Account } from 'mastodon/models/account';
|
||||
|
||||
import { CustomEmojiProvider } from './emoji/context';
|
||||
import { EmojiHTML } from './emoji/html';
|
||||
import { useElementHandledLink } from './status/handled_link';
|
||||
|
||||
@@ -22,12 +21,13 @@ export const AccountFields: React.FC<Pick<Account, 'fields' | 'emojis'>> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<CustomEmojiProvider emojis={emojis}>
|
||||
<>
|
||||
{fields.map((pair, i) => (
|
||||
<dl key={i} className={classNames({ verified: pair.verified_at })}>
|
||||
<EmojiHTML
|
||||
as='dt'
|
||||
htmlString={pair.name_emojified}
|
||||
extraEmojis={emojis}
|
||||
className='translate'
|
||||
{...htmlHandlers}
|
||||
/>
|
||||
@@ -52,12 +52,13 @@ export const AccountFields: React.FC<Pick<Account, 'fields' | 'emojis'>> = ({
|
||||
<EmojiHTML
|
||||
as='span'
|
||||
htmlString={pair.value_emojified}
|
||||
extraEmojis={emojis}
|
||||
{...htmlHandlers}
|
||||
/>
|
||||
</dd>
|
||||
</dl>
|
||||
))}
|
||||
</CustomEmojiProvider>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ export const ContentWarning: React.FC<{
|
||||
<EmojiHTML
|
||||
as='span'
|
||||
htmlString={text}
|
||||
extraEmojis={status.get('emoji') as List<CustomEmoji>}
|
||||
extraEmojis={status.get('emojis') as List<CustomEmoji>}
|
||||
/>
|
||||
</StatusBanner>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
.inlineIcon {
|
||||
vertical-align: middle;
|
||||
}
|
||||
@@ -12,6 +12,8 @@ import { Button } from '../button';
|
||||
import { useDismissableBannerState } from '../dismissable_banner';
|
||||
import { Icon } from '../icon';
|
||||
|
||||
import classes from './remove_quote_hint.module.css';
|
||||
|
||||
const DISMISSABLE_BANNER_ID = 'notifications/remove_quote_hint';
|
||||
|
||||
/**
|
||||
@@ -93,7 +95,7 @@ export const RemoveQuoteHint: React.FC<{
|
||||
id: 'status.more',
|
||||
defaultMessage: 'More',
|
||||
})}
|
||||
style={{ verticalAlign: 'middle' }}
|
||||
className={classes.inlineIcon}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
|
||||
@@ -31,7 +31,7 @@ export const Warning = () => {
|
||||
defaultMessage='Your account is not {locked}. Anyone can follow you to view your follower-only posts.'
|
||||
values={{
|
||||
locked: (
|
||||
<a href='/settings/profile'>
|
||||
<a href='/settings/privacy#account_unlocked'>
|
||||
<FormattedMessage
|
||||
id='compose_form.lock_disclaimer.lock'
|
||||
defaultMessage='locked'
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
stringToEmojiState,
|
||||
tokenizeText,
|
||||
} from './render';
|
||||
import type { EmojiStateCustom } from './types';
|
||||
|
||||
describe('tokenizeText', () => {
|
||||
test('returns an array of text to be a single token', () => {
|
||||
@@ -82,12 +83,8 @@ describe('stringToEmojiState', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('returns custom emoji state for valid custom emoji', () => {
|
||||
expect(stringToEmojiState(':smile:')).toEqual({
|
||||
type: 'custom',
|
||||
code: 'smile',
|
||||
data: undefined,
|
||||
});
|
||||
test('returns null for custom emoji without data', () => {
|
||||
expect(stringToEmojiState(':smile:')).toBeNull();
|
||||
});
|
||||
|
||||
test('returns custom emoji state with data when provided', () => {
|
||||
@@ -107,7 +104,6 @@ describe('stringToEmojiState', () => {
|
||||
|
||||
test('returns null for invalid emoji strings', () => {
|
||||
expect(stringToEmojiState('notanemoji')).toBeNull();
|
||||
expect(stringToEmojiState(':invalid-emoji:')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -130,18 +126,13 @@ describe('loadEmojiDataToState', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('loads custom emoji data into state', async () => {
|
||||
const dbCall = vi
|
||||
.spyOn(db, 'loadCustomEmojiByShortcode')
|
||||
.mockResolvedValueOnce(customEmojiFactory());
|
||||
const customState = { type: 'custom', code: 'smile' } as const;
|
||||
const result = await loadEmojiDataToState(customState, 'en');
|
||||
expect(dbCall).toHaveBeenCalledWith('smile');
|
||||
expect(result).toEqual({
|
||||
test('returns null for custom emoji without data', async () => {
|
||||
const customState = {
|
||||
type: 'custom',
|
||||
code: 'smile',
|
||||
data: customEmojiFactory(),
|
||||
});
|
||||
} as const satisfies EmojiStateCustom;
|
||||
const result = await loadEmojiDataToState(customState, 'en');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test('returns null if unicode emoji not found in database', async () => {
|
||||
@@ -151,13 +142,6 @@ describe('loadEmojiDataToState', () => {
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test('returns null if custom emoji not found in database', async () => {
|
||||
vi.spyOn(db, 'loadCustomEmojiByShortcode').mockResolvedValueOnce(undefined);
|
||||
const customState = { type: 'custom', code: 'smile' } as const;
|
||||
const result = await loadEmojiDataToState(customState, 'en');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test('retries loading emoji data once if initial load fails', async () => {
|
||||
const dbCall = vi
|
||||
.spyOn(db, 'loadEmojiByHexcode')
|
||||
|
||||
@@ -4,11 +4,7 @@ import {
|
||||
EMOJI_TYPE_UNICODE,
|
||||
EMOJI_TYPE_CUSTOM,
|
||||
} from './constants';
|
||||
import {
|
||||
loadCustomEmojiByShortcode,
|
||||
loadEmojiByHexcode,
|
||||
LocaleNotLoadedError,
|
||||
} from './database';
|
||||
import { loadEmojiByHexcode, LocaleNotLoadedError } from './database';
|
||||
import { importEmojiData } from './loader';
|
||||
import { emojiToUnicodeHex } from './normalize';
|
||||
import type {
|
||||
@@ -79,7 +75,7 @@ export function tokenizeText(text: string): TokenizedText {
|
||||
export function stringToEmojiState(
|
||||
code: string,
|
||||
customEmoji: ExtraCustomEmojiMap = {},
|
||||
): EmojiState | null {
|
||||
): EmojiStateUnicode | Required<EmojiStateCustom> | null {
|
||||
if (isUnicodeEmoji(code)) {
|
||||
return {
|
||||
type: EMOJI_TYPE_UNICODE,
|
||||
@@ -89,11 +85,13 @@ export function stringToEmojiState(
|
||||
|
||||
if (isCustomEmoji(code)) {
|
||||
const shortCode = code.slice(1, -1);
|
||||
return {
|
||||
type: EMOJI_TYPE_CUSTOM,
|
||||
code: shortCode,
|
||||
data: customEmoji[shortCode],
|
||||
};
|
||||
if (customEmoji[shortCode]) {
|
||||
return {
|
||||
type: EMOJI_TYPE_CUSTOM,
|
||||
code: shortCode,
|
||||
data: customEmoji[shortCode],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -114,26 +112,23 @@ export async function loadEmojiDataToState(
|
||||
return state;
|
||||
}
|
||||
|
||||
// Don't try to load data for custom emoji.
|
||||
if (state.type === EMOJI_TYPE_CUSTOM) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// First, try to load the data from IndexedDB.
|
||||
try {
|
||||
// This is duplicative, but that's because TS can't distinguish the state type easily.
|
||||
if (state.type === EMOJI_TYPE_UNICODE) {
|
||||
const data = await loadEmojiByHexcode(state.code, locale);
|
||||
if (data) {
|
||||
return {
|
||||
...state,
|
||||
data,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
const data = await loadCustomEmojiByShortcode(state.code);
|
||||
if (data) {
|
||||
return {
|
||||
...state,
|
||||
data,
|
||||
};
|
||||
}
|
||||
const data = await loadEmojiByHexcode(state.code, locale);
|
||||
if (data) {
|
||||
return {
|
||||
...state,
|
||||
type: EMOJI_TYPE_UNICODE,
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
// If not found, assume it's not an emoji and return null.
|
||||
log(
|
||||
'Could not find emoji %s of type %s for locale %s',
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { List } from 'immutable';
|
||||
|
||||
import { EmojiHTML } from '@/mastodon/components/emoji/html';
|
||||
import { useElementHandledLink } from '@/mastodon/components/status/handled_link';
|
||||
import type { CustomEmoji } from '@/mastodon/models/custom_emoji';
|
||||
import type { Status } from '@/mastodon/models/status';
|
||||
|
||||
import type { Mention } from './embedded_status';
|
||||
@@ -33,6 +34,7 @@ export const EmbeddedStatusContent: React.FC<{
|
||||
className={className}
|
||||
lang={status.get('language') as string}
|
||||
htmlString={status.get('contentHtml') as string}
|
||||
extraEmojis={status.get('emojis') as List<CustomEmoji>}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -17,8 +17,6 @@ class HtmlAwareFormatter
|
||||
end
|
||||
|
||||
def to_s
|
||||
return ''.html_safe if text.blank?
|
||||
|
||||
if local?
|
||||
linkify
|
||||
else
|
||||
@@ -31,6 +29,8 @@ class HtmlAwareFormatter
|
||||
private
|
||||
|
||||
def reformat
|
||||
return ''.html_safe if text.blank?
|
||||
|
||||
Sanitize.fragment(text, Sanitize::Config::MASTODON_STRICT)
|
||||
end
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module PrivateAddressCheck
|
||||
module_function
|
||||
|
||||
CIDR_LIST = [
|
||||
IP4_CIDR_LIST = [
|
||||
IPAddr.new('0.0.0.0/8'), # Current network (only valid as source address)
|
||||
IPAddr.new('100.64.0.0/10'), # Shared Address Space
|
||||
IPAddr.new('172.16.0.0/12'), # Private network
|
||||
@@ -16,6 +14,9 @@ module PrivateAddressCheck
|
||||
IPAddr.new('224.0.0.0/4'), # IP multicast (former Class D network)
|
||||
IPAddr.new('240.0.0.0/4'), # Reserved (former Class E network)
|
||||
IPAddr.new('255.255.255.255'), # Broadcast
|
||||
].freeze
|
||||
|
||||
CIDR_LIST = (IP4_CIDR_LIST + IP4_CIDR_LIST.map(&:ipv4_mapped) + [
|
||||
IPAddr.new('64:ff9b::/96'), # IPv4/IPv6 translation (RFC 6052)
|
||||
IPAddr.new('100::/64'), # Discard prefix (RFC 6666)
|
||||
IPAddr.new('2001::/32'), # Teredo tunneling
|
||||
@@ -25,7 +26,9 @@ module PrivateAddressCheck
|
||||
IPAddr.new('2002::/16'), # 6to4
|
||||
IPAddr.new('fc00::/7'), # Unique local address
|
||||
IPAddr.new('ff00::/8'), # Multicast
|
||||
].freeze
|
||||
]).freeze
|
||||
|
||||
module_function
|
||||
|
||||
def private_address?(address)
|
||||
address.private? || address.loopback? || address.link_local? || CIDR_LIST.any? { |cidr| cidr.include?(address) }
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
class ActivityPub::ContextSerializer < ActivityPub::Serializer
|
||||
include RoutingHelper
|
||||
|
||||
attributes :id, :type, :attributed_to, :first
|
||||
attributes :id, :type, :attributed_to
|
||||
|
||||
has_one :first, serializer: ActivityPub::CollectionSerializer
|
||||
|
||||
def type
|
||||
'Collection'
|
||||
|
||||
@@ -71,7 +71,7 @@ class ProcessMentionsService < BaseService
|
||||
# Make sure we never mention blocked accounts
|
||||
unless @current_mentions.empty?
|
||||
mentioned_domains = @current_mentions.filter_map { |m| m.account.domain }.uniq
|
||||
blocked_domains = Set.new(mentioned_domains.empty? ? [] : AccountDomainBlock.where(account_id: @status.account_id, domain: mentioned_domains))
|
||||
blocked_domains = Set.new(mentioned_domains.empty? ? [] : AccountDomainBlock.where(account_id: @status.account_id, domain: mentioned_domains).pluck(:domain))
|
||||
mentioned_account_ids = @current_mentions.map(&:account_id)
|
||||
blocked_account_ids = Set.new(@status.account.block_relationships.where(target_account_id: mentioned_account_ids).pluck(:target_account_id))
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ admin:
|
||||
permissions:
|
||||
- view_dashboard
|
||||
- view_audit_log
|
||||
- view_feeds
|
||||
- manage_users
|
||||
- manage_user_access
|
||||
- delete_user_data
|
||||
|
||||
@@ -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.3
|
||||
image: ghcr.io/glitch-soc/mastodon:v4.5.4
|
||||
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.3
|
||||
image: ghcr.io/glitch-soc/mastodon-streaming:v4.5.4
|
||||
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.3
|
||||
image: ghcr.io/glitch-soc/mastodon:v4.5.4
|
||||
restart: always
|
||||
env_file: .env.production
|
||||
command: bundle exec sidekiq
|
||||
|
||||
@@ -13,7 +13,7 @@ module Mastodon
|
||||
end
|
||||
|
||||
def patch
|
||||
3
|
||||
4
|
||||
end
|
||||
|
||||
def default_prerelease
|
||||
|
||||
20
spec/lib/private_address_check_spec.rb
Normal file
20
spec/lib/private_address_check_spec.rb
Normal file
@@ -0,0 +1,20 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe PrivateAddressCheck do
|
||||
describe 'private_address?' do
|
||||
it 'returns true for private addresses' do
|
||||
# rubocop:disable RSpec/ExpectActual
|
||||
expect(
|
||||
[
|
||||
'192.168.1.7',
|
||||
'0.0.0.0',
|
||||
'127.0.0.1',
|
||||
'::ffff:0.0.0.1',
|
||||
]
|
||||
).to all satisfy('return true') { |addr| described_class.private_address?(IPAddr.new(addr)) }
|
||||
# rubocop:enable RSpec/ExpectActual
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -24,6 +24,11 @@ RSpec.describe 'ActivityPub Contexts' do
|
||||
expect(response.parsed_body[:type])
|
||||
.to eq 'Collection'
|
||||
|
||||
expect(response.parsed_body[:first])
|
||||
.to include(
|
||||
type: 'CollectionPage',
|
||||
partOf: context_url(conversation)
|
||||
)
|
||||
expect(response.parsed_body[:first][:items])
|
||||
.to be_an(Array)
|
||||
.and have_attributes(size: 2)
|
||||
|
||||
@@ -344,7 +344,7 @@ RSpec.describe '/api/v1/statuses' do
|
||||
.to start_with('application/json')
|
||||
expect(response.parsed_body[:quote]).to be_present
|
||||
expect(response.parsed_body[:spoiler_text]).to eq 'this is a CW'
|
||||
expect(response.parsed_body[:content]).to eq ''
|
||||
expect(response.parsed_body[:content]).to match(/RE: /)
|
||||
expect(response.headers['X-RateLimit-Limit']).to eq RateLimiter::FAMILIES[:statuses][:limit].to_s
|
||||
expect(response.headers['X-RateLimit-Remaining']).to eq (RateLimiter::FAMILIES[:statuses][:limit] - 1).to_s
|
||||
end
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Severed Relationships' do
|
||||
let(:account_rs_event) { Fabricate :account_relationship_severance_event }
|
||||
let(:account_rs_event) { Fabricate(:account_relationship_severance_event) }
|
||||
let(:user) { account_rs_event.account.user }
|
||||
|
||||
before { sign_in Fabricate(:user) }
|
||||
before { sign_in user }
|
||||
|
||||
describe 'GET /severed_relationships/:id/following' do
|
||||
it 'returns a CSV file with correct data' do
|
||||
@@ -22,6 +23,17 @@ RSpec.describe 'Severed Relationships' do
|
||||
expect(response.body)
|
||||
.to include('Account address')
|
||||
end
|
||||
|
||||
context 'when the user is not the subject of the event' do
|
||||
let(:user) { Fabricate(:user) }
|
||||
|
||||
it 'returns a 404' do
|
||||
get following_severed_relationship_path(account_rs_event, format: :csv)
|
||||
|
||||
expect(response)
|
||||
.to have_http_status(404)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /severed_relationships/:id/followers' do
|
||||
@@ -39,5 +51,16 @@ RSpec.describe 'Severed Relationships' do
|
||||
expect(response.body)
|
||||
.to include('Account address')
|
||||
end
|
||||
|
||||
context 'when the user is not the subject of the event' do
|
||||
let(:user) { Fabricate(:user) }
|
||||
|
||||
it 'returns a 404' do
|
||||
get followers_severed_relationship_path(account_rs_event, format: :csv)
|
||||
|
||||
expect(response)
|
||||
.to have_http_status(404)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -19,6 +19,25 @@ RSpec.describe REST::StatusSerializer do
|
||||
let(:bob) { Fabricate(:account, username: 'bob', domain: 'other.com') }
|
||||
let(:status) { Fabricate(:status, account: alice) }
|
||||
|
||||
context 'with a local status' do
|
||||
context 'with a quote and a CW but no contents' do
|
||||
let(:quoted_status) { Fabricate(:status, account: alice) }
|
||||
let(:status) { Fabricate.build(:status, account: alice, text: '', spoiler_text: 'this is a CW') }
|
||||
|
||||
before do
|
||||
Fabricate(:quote, status: status, quoted_status: quoted_status, state: :accepted)
|
||||
end
|
||||
|
||||
it 'renders the status with a CW and fallback link' do
|
||||
expect(subject)
|
||||
.to include(
|
||||
'content' => /RE: <a/,
|
||||
'spoiler_text' => 'this is a CW'
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a remote status' do
|
||||
let(:status) { Fabricate(:status, account: bob) }
|
||||
|
||||
|
||||
@@ -8,9 +8,9 @@ RSpec.describe ProcessMentionsService do
|
||||
let(:account) { Fabricate(:account, username: 'alice') }
|
||||
|
||||
context 'when mentions contain blocked accounts' do
|
||||
let(:non_blocked_account) { Fabricate(:account) }
|
||||
let(:individually_blocked_account) { Fabricate(:account) }
|
||||
let(:domain_blocked_account) { Fabricate(:account, domain: 'evil.com') }
|
||||
let!(:non_blocked_account) { Fabricate(:account) }
|
||||
let!(:individually_blocked_account) { Fabricate(:account) }
|
||||
let!(:domain_blocked_account) { Fabricate(:account, domain: 'evil.com', protocol: :activitypub) }
|
||||
let(:status) { Fabricate(:status, account: account, text: "Hello @#{non_blocked_account.acct} @#{individually_blocked_account.acct} @#{domain_blocked_account.acct}", visibility: :public) }
|
||||
|
||||
before do
|
||||
|
||||
@@ -154,6 +154,14 @@ export const config: UserConfigFnPromise = async ({ mode, command }) => {
|
||||
},
|
||||
},
|
||||
},
|
||||
experimental: {
|
||||
/**
|
||||
* Setting this causes Vite to not rely on the base config for import URLs,
|
||||
* and instead uses import.meta.url, which is what we want for proper CDN support.
|
||||
* @see https://github.com/mastodon/mastodon/pull/37310
|
||||
*/
|
||||
renderBuiltUrl: () => undefined,
|
||||
},
|
||||
worker: {
|
||||
format: 'es',
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user