diff --git a/app/Services/Social/ContentSanitizer.php b/app/Services/Social/ContentSanitizer.php index 45a755e445..a1b1a19f20 100644 --- a/app/Services/Social/ContentSanitizer.php +++ b/app/Services/Social/ContentSanitizer.php @@ -26,8 +26,6 @@ class ContentSanitizer private const TAG_BODY = '/?[a-z][^<>]*>'; - private const TAG_PATTERN = '~<'.self::TAG_BODY.'~i'; - private const TYPED_BRACKET_PATTERN = '~<(?!'.self::TAG_BODY.')~i'; /** @@ -44,7 +42,6 @@ public function sanitize(string $content, Platform $platform): string return match ($platform) { Platform::LinkedIn, Platform::LinkedInPage => $this->convertBoldAndStrip($content), - Platform::Mastodon => $this->stripUnsafeHtml($content), Platform::Telegram => $this->toTelegramHtml($content), Platform::X => $this->defuseLinks($this->stripHtml($content)), default => $this->stripHtml($content), @@ -53,16 +50,13 @@ public function sanitize(string $content, Platform $platform): string /** * The content as a reader will see it, which is what a character limit applies - * to. Mirrors {@see self::sanitize()} arm for arm, because only two of them - * leave markup behind: + * to. Mirrors {@see self::sanitize()} arm for arm, because only one of them + * leaves markup behind: * * - Telegram is handed HTML with entities escaped for `parse_mode=HTML`, so its * tags and entities both resolve away — `&` renders as one character. - * - Mastodon keeps an HTML subset but its sanitizer already decoded entities, so - * only the tags come off. Decoding again would eat a literal `&` the user - * typed and undercount the post. - * - Everything else, X included, is already plain text: the defused form is - * literally what gets posted, so it counts as-is. + * - Everything else, X and Mastodon included, is already plain text: the + * defused form is literally what gets posted, so it counts as-is. */ public function displayText(string $content, Platform $platform): string { @@ -70,7 +64,6 @@ public function displayText(string $content, Platform $platform): string return match ($platform) { Platform::Telegram => html_entity_decode(strip_tags($sanitized), ENT_QUOTES | ENT_HTML5, 'UTF-8'), - Platform::Mastodon => preg_replace(self::TAG_PATTERN, '', $sanitized) ?? $sanitized, default => $sanitized, }; } @@ -180,17 +173,6 @@ private function blocksToLines(string $content): string ); } - private function stripUnsafeHtml(string $content): string - { - // Mastodon accepts a subset of HTML: p, strong, em, a, br, span - $content = strip_tags($content, ['p', 'strong', 'em', 'b', 'i', 'a', 'br', 'span']); - - // Decode HTML entities that aren't part of allowed tags - $content = html_entity_decode($content, ENT_QUOTES | ENT_HTML5, 'UTF-8'); - - return trim($content); - } - private function convertBoldAndStrip(string $content): string { // Convert / to Unicode bold characters for LinkedIn diff --git a/resources/js/components/posts/composer/PostComposerDialog.vue b/resources/js/components/posts/composer/PostComposerDialog.vue index d1adc14ef5..9171fe5878 100644 --- a/resources/js/components/posts/composer/PostComposerDialog.vue +++ b/resources/js/components/posts/composer/PostComposerDialog.vue @@ -1041,10 +1041,7 @@ const remainingCharacters = (account: ComposerAccount): number | null => { return ( limit - contentWarningLength(account) - - displayLength( - contentFor(destination.content, account.platform), - account.platform, - ) + displayLength(contentFor(destination.content, account.platform)) ); }; const groupRemaining = (group: NetworkGroup): number | null => { @@ -1118,7 +1115,7 @@ const activeRemaining = (group: NetworkGroup): number | null => { return limit === null ? null : limit - - displayLength(contentFor(reply.text, group.platform), group.platform); + displayLength(contentFor(reply.text, group.platform)); }; const threadReplyErrors = (group: NetworkGroup): Record => { const found: Record = {}; @@ -1394,10 +1391,7 @@ const destinationIssues = (account: ComposerAccount): DestinationIssue[] => { const over = limit === null ? 0 - : displayLength( - contentFor(reply.text, account.platform), - account.platform, - ) - + : displayLength(contentFor(reply.text, account.platform)) - limit; const replyMediaWarning = reply.media.length ? getMediaValidationWarning(replyContentType, reply.media) diff --git a/resources/js/components/posts/editor/ThreadRepliesField.vue b/resources/js/components/posts/editor/ThreadRepliesField.vue index 2b17ef7f1c..4b620b82af 100644 --- a/resources/js/components/posts/editor/ThreadRepliesField.vue +++ b/resources/js/components/posts/editor/ThreadRepliesField.vue @@ -37,7 +37,7 @@ const editor = ref([]); const remaining = (reply: ThreadReply): number => props.limit - - displayLength(contentFor(reply.text, props.platform), props.platform); + displayLength(contentFor(reply.text, props.platform)); const isEmpty = (reply: ThreadReply | undefined): boolean => reply !== undefined && reply.text === '' && reply.media.length === 0; diff --git a/resources/js/lib/characters.ts b/resources/js/lib/characters.ts index 32a5800711..cecd3cc5be 100644 --- a/resources/js/lib/characters.ts +++ b/resources/js/lib/characters.ts @@ -1,5 +1,3 @@ -import { Platform } from '@/types/platform'; - /** Characters as the server counts them (`mb_strlen`): one per code point, so an emoji is one. */ export const characterCount = (text: string): number => Array.from(text).length; @@ -7,19 +5,15 @@ export const characterCount = (text: string): number => Array.from(text).length; const PHP_TRIM = /^[ \t\n\r\0\x0B]+|[ \t\n\r\0\x0B]+$/g; /** - * The plain text the server measures for a network - * (`ContentSanitizer::displayText()`): edges trimmed and, except on Mastodon, - * three or more line breaks collapsed to two. + * The plain text the server measures (`ContentSanitizer::displayText()`): + * edges trimmed and three or more line breaks collapsed to two. */ -export const displayText = (text: string, platform: string): string => - (platform === Platform.Mastodon ? text : text.replace(/\n{3,}/g, '\n\n')).replace( - PHP_TRIM, - '', - ); +export const displayText = (text: string): string => + text.replace(/\n{3,}/g, '\n\n').replace(PHP_TRIM, ''); /** Characters a network's limit counts for this text. */ -export const displayLength = (text: string, platform: string): number => - characterCount(displayText(text, platform)); +export const displayLength = (text: string): number => + characterCount(displayText(text)); /** Mirrors Laravel `Str::trim()`: whitespace plus `Str::INVISIBLE_CHARACTERS` count as nothing. */ const INVISIBLE = diff --git a/tests/Feature/Services/Social/ContentSanitizerTest.php b/tests/Feature/Services/Social/ContentSanitizerTest.php index 3a97874154..cc2777b612 100644 --- a/tests/Feature/Services/Social/ContentSanitizerTest.php +++ b/tests/Feature/Services/Social/ContentSanitizerTest.php @@ -74,27 +74,19 @@ function platformsThatKeepLinks(): array expect($result)->toContain('- Item two'); }); -test('it preserves safe html for mastodon', function () { +test('mastodon gets plain text, because it escapes any html it receives', function (string $content, string $status) { $sanitizer = new ContentSanitizer; - $result = $sanitizer->sanitize('

Hello world and italic

', Platform::Mastodon); - expect($result)->toContain('world'); - expect($result)->toContain('italic'); - expect($result)->toContain('

'); -}); - -test('it strips unsafe html for mastodon', function () { - $sanitizer = new ContentSanitizer; - $result = $sanitizer->sanitize('

Hello

block
', Platform::Mastodon); - expect($result)->toContain('

Hello

'); - expect($result)->not->toContain('
block
', "Hello\nalert(\"xss\")block"], + 'entities are decoded once' => ['

Tom &amp; Jerry

', 'Tom & Jerry'], + 'plain text is untouched' => ["Plain #tag https://example.com @user@mastodon.social\nnext line", "Plain #tag https://example.com @user@mastodon.social\nnext line"], + 'line breaks collapse like every network' => ["a\n\n\n\nb", "a\n\nb"], +]); test('it keeps telegram-allowed html and converts strong/em', function () { $sanitizer = new ContentSanitizer; @@ -406,13 +398,6 @@ function platformsThatKeepLinks(): array ->and($sanitizer->displayText($content, Platform::Telegram))->toBe($content); }); -test('it strips mastodon markup for length without decoding its entities twice', function () { - $sanitizer = new ContentSanitizer; - $content = '

Tom &amp; Jerry

'; - - expect($sanitizer->displayText($content, Platform::Mastodon))->toBe('Tom & Jerry'); -}); - test('a less-than sign typed as text is published, not read as a tag', function (Platform $platform) { expect(app(ContentSanitizer::class)->displayText("I <3 TryPost\nprice < 10 and > 5", $platform)) ->toBe("I <3 TryPost\nprice < 10 and > 5"); diff --git a/tests/Feature/Services/Social/MastodonPublisherTest.php b/tests/Feature/Services/Social/MastodonPublisherTest.php index 85926a24cf..bcee94f9c7 100644 --- a/tests/Feature/Services/Social/MastodonPublisherTest.php +++ b/tests/Feature/Services/Social/MastodonPublisherTest.php @@ -645,6 +645,24 @@ Http::assertNothingSent(); }); +test('mastodon publisher sends plain text for a post and a reply that still hold editor html', function () { + $this->post->update([ + 'content' => '

Teste TryPost negrito e itálico

Segunda linha #teste https://example.com

', + 'meta' => ['thread_replies' => ['

Reply two

', ['text' => '

Reply three

', 'media' => []]]], + ]); + $instance = data_get($this->socialAccount->meta, 'instance'); + Http::fake(["{$instance}/api/v1/statuses" => Http::sequence() + ->push(['id' => '1', 'url' => "{$instance}/@t/1"]) + ->push(['id' => '2', 'url' => "{$instance}/@t/2"]) + ->push(['id' => '3', 'url' => "{$instance}/@t/3"])]); + + $this->publisher->publish($this->post->fresh()); + + Http::assertSent(fn ($request): bool => $request['status'] === "Teste TryPost negrito e itálico\nSegunda linha #teste https://example.com"); + Http::assertSent(fn ($request): bool => $request['status'] === 'Reply two' && $request['in_reply_to_id'] === '1'); + Http::assertSent(fn ($request): bool => $request['status'] === 'Reply three' && $request['in_reply_to_id'] === '2'); +}); + test('a mastodon thread chains each reply to the previous one with the root content warning', function () { $this->post->update(['meta' => ['thread_replies' => ['Two', 'Three'], 'spoiler_text' => 'CW']]); $instance = data_get($this->socialAccount->meta, 'instance'); diff --git a/tests/fixtures/display-length-harness.js b/tests/fixtures/display-length-harness.js index 27d7664976..8713764d9d 100644 --- a/tests/fixtures/display-length-harness.js +++ b/tests/fixtures/display-length-harness.js @@ -21,4 +21,4 @@ const { displayLength } = await import(new URL('lib/characters.ts', base).href); const corpus = JSON.parse(fs.readFileSync(corpusFile, 'utf8')); const platforms = JSON.parse(fs.readFileSync(platformsFile, 'utf8')); -console.log(JSON.stringify(Object.fromEntries(platforms.map((platform) => [platform, corpus.map((text) => displayLength(text, platform))])))); +console.log(JSON.stringify(Object.fromEntries(platforms.map((platform) => [platform, corpus.map((text) => displayLength(text))]))));