Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 4 additions & 22 deletions app/Services/Social/ContentSanitizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand All @@ -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),
Expand All @@ -53,24 +50,20 @@ 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 — `&amp;` 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 `&amp;` 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
{
$sanitized = $this->sanitize($content, $platform);

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,
};
}
Expand Down Expand Up @@ -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 <strong>/<b> to Unicode bold characters for LinkedIn
Expand Down
12 changes: 3 additions & 9 deletions resources/js/components/posts/composer/PostComposerDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down Expand Up @@ -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<number, string> => {
const found: Record<number, string> = {};
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ const editor = ref<HTMLTextAreaElement[]>([]);

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;
Expand Down
18 changes: 6 additions & 12 deletions resources/js/lib/characters.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,19 @@
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;

/** PHP `trim()`'s default characters. */
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 =
Expand Down
37 changes: 11 additions & 26 deletions tests/Feature/Services/Social/ContentSanitizerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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('<p>Hello <strong>world</strong> and <em>italic</em></p>', Platform::Mastodon);
expect($result)->toContain('<strong>world</strong>');
expect($result)->toContain('<em>italic</em>');
expect($result)->toContain('<p>');
});

test('it strips unsafe html for mastodon', function () {
$sanitizer = new ContentSanitizer;
$result = $sanitizer->sanitize('<p>Hello</p><script>alert("xss")</script><div>block</div>', Platform::Mastodon);
expect($result)->toContain('<p>Hello</p>');
expect($result)->not->toContain('<script>');
expect($result)->not->toContain('<div>');
});

test('it preserves links for mastodon', function () {
$sanitizer = new ContentSanitizer;
$result = $sanitizer->sanitize('<p>Check <a href="https://example.com">this</a></p>', Platform::Mastodon);
expect($result)->toContain('<a href="https://example.com">this</a>');
});
expect($sanitizer->sanitize($content, Platform::Mastodon))->toBe($status)
->and($sanitizer->displayText($content, Platform::Mastodon))->toBe($status);
})->with([
'editor paragraphs' => ['<p>Hello <strong>world</strong> and <em>italic</em></p><p>Second</p>', "Hello world and italic\nSecond"],
'a link keeps its text' => ['<p>Check <a href="https://example.com">this</a></p>', 'Check this'],
'script and div tags come off, their text stays' => ['<p>Hello</p><script>alert("xss")</script><div>block</div>', "Hello\nalert(\"xss\")block"],
'entities are decoded once' => ['<p>Tom &amp;amp; <strong>Jerry</strong></p>', 'Tom &amp; 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;
Expand Down Expand Up @@ -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 = '<p>Tom &amp;amp; <strong>Jerry</strong></p>';

expect($sanitizer->displayText($content, Platform::Mastodon))->toBe('Tom &amp; 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");
Expand Down
18 changes: 18 additions & 0 deletions tests/Feature/Services/Social/MastodonPublisherTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' => '<p>Teste TryPost <strong>negrito</strong> e <em>itálico</em></p><p>Segunda linha #teste https://example.com</p>',
'meta' => ['thread_replies' => ['<p>Reply <strong>two</strong></p>', ['text' => '<p>Reply <em>three</em></p>', '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');
Expand Down
2 changes: 1 addition & 1 deletion tests/fixtures/display-length-harness.js
Original file line number Diff line number Diff line change
Expand Up @@ -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))]))));
Loading