Compare commits

..

30 Commits

Author SHA1 Message Date
Claire
5799d5d306 Merge pull request #3336 from ClearlyClaire/glitch-soc/merge-4.5
Merge upstream changes up to 55a7b1ea58 into stable-4.5
2026-01-07 14:40:15 +01:00
Claire
b5d868018d Merge commit '55a7b1ea5820b2fa8d754108b6a948d4bd60d98b' into glitch-soc/merge-4.5 2026-01-07 14:25:37 +01:00
Claire
55a7b1ea58 Bump version to v4.5.4 (#37409) 2026-01-07 14:23:34 +01:00
Claire
c1fb6893c5 Merge commit from fork 2026-01-07 14:15:14 +01:00
Claire
71ae4cf2cf Merge commit from fork 2026-01-07 14:14:42 +01:00
Claire
2ffe03457d Merge pull request #3334 from ClearlyClaire/glitch-soc/merge-4.5
Merge upstream changes up to a846ed17ff into stable-4.5
2026-01-06 20:38:29 +01:00
Claire
c1f5a9db23 [Glitch] Fix custom emojis not being rendered in profile fields
Port b622f4c698 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2026-01-06 18:14:35 +01:00
Claire
7c0701d906 [Glitch] Fix outdated link target for “locked” warning
Port e8a49bd6ae to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2026-01-06 18:14:35 +01:00
Claire
b134c6a8ef Merge commit 'a846ed17ffb85087de658678b482495981f02770' into glitch-soc/merge-4.5 2026-01-06 18:12:51 +01:00
Claire
a846ed17ff Fix custom emojis not being rendered in profile fields (#37365) 2026-01-06 14:11:56 +01:00
Claire
3013039720 Fix serialization of context pages (#37376) 2026-01-06 14:11:56 +01:00
Claire
ad4ba5aa00 Fix quotes with CWs but no text not having fallback link (#37361) 2026-01-06 14:11:56 +01:00
Claire
1c5461fffe Fix outdated link target for “locked” warning (#37366) 2026-01-06 14:11:56 +01:00
Claire
725c1a159d Merge pull request #3324 from ClearlyClaire/glitch-soc/merge-4.5
Merge upstream changes up to 3de59a9344 into stable-4.5
2025-12-28 19:47:25 +01:00
ChaosExAnima
b52efea5cb [Glitch] Remove rendering of custom emoji using the database
Port 3de59a9344 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-12-28 10:56:09 +01:00
Claire
a0bdfc46c7 [Glitch] Fix custom emojis not displaying in CWs and fav/boost notifications
Port 962ae88caf to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-12-28 10:55:34 +01:00
diondiondion
afcdc19730 [Glitch] Fix notifications page error in Tor browser
Port 7d9d3de972 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-12-28 10:55:10 +01:00
Echo
80aa3bc8ad [Glitch] Emojis: Show in embedded statuses
Port 546a95349e to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-12-28 10:54:47 +01:00
Claire
92955f7e6e [Glitch] Fix hashtag autocomplete replacing suggestion's first characters with input
Port 8d1ea4c531 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-12-28 10:53:53 +01:00
Claire
b868e598bc Merge commit '3de59a93441367fbca0fd22818c8411adc73a967' into glitch-soc/merge-4.5 2025-12-28 10:51:43 +01:00
ChaosExAnima
3de59a9344 Remove rendering of custom emoji using the database (#37284) 2025-12-19 11:02:32 +01:00
Echo
32c3376d84 Fixes CDN domain loading (#37310) 2025-12-19 11:02:32 +01:00
Claire
962ae88caf Fix custom emojis not displaying in CWs and fav/boost notifications (#37306) 2025-12-19 11:02:32 +01:00
diondiondion
7d9d3de972 Fix notifications page error in Tor browser (#37285) 2025-12-19 11:02:32 +01:00
Echo
546a95349e Emojis: Show in embedded statuses (#37272) 2025-12-19 11:02:32 +01:00
Claire
df1ab0ab90 Fix default Admin role not including view_feeds permission (#37301) 2025-12-19 11:02:32 +01:00
Claire
8d1ea4c531 Fix hashtag autocomplete replacing suggestion's first characters with input (#37281) 2025-12-19 11:02:32 +01:00
Claire
8233295e3b Fix mentions of domain-blocked users being processed (#37257) 2025-12-19 11:02:32 +01:00
Claire
4eb0a506d3 Change HTTP Signature verification status from 401 to 503 on temporary failure to get remote actor (#37221) 2025-12-19 11:02:32 +01:00
Claire
75739a5a9b Change build-releases workflow to tag images latest based on latest stable-x.y branch (#37179)
Co-authored-by: emilweth <7402764+emilweth@users.noreply.github.com>
2025-12-19 11:02:32 +01:00
37 changed files with 278 additions and 140 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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'])}`;

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
.inlineIcon {
vertical-align: middle;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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'])}`;

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
.inlineIcon {
vertical-align: middle;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,6 +14,7 @@ admin:
permissions:
- view_dashboard
- view_audit_log
- view_feeds
- manage_users
- manage_user_access
- delete_user_data

View File

@@ -59,7 +59,7 @@ services:
web:
# You can uncomment the following line if you want to not use the prebuilt image, for example if you have local code changes
# build: .
image: ghcr.io/glitch-soc/mastodon:v4.5.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

View File

@@ -13,7 +13,7 @@ module Mastodon
end
def patch
3
4
end
def default_prerelease

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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