diff --git a/app/Util/Lexer/Regex.php b/app/Util/Lexer/Regex.php index 04697fd00..e22c8f863 100755 --- a/app/Util/Lexer/Regex.php +++ b/app/Util/Lexer/Regex.php @@ -162,9 +162,18 @@ abstract class Regex // look-ahead capture here and don't append $after when we return. $tmp['valid_mention_preceding_chars'] = '([^a-zA-Z0-9_!#\$%&*@@\/]|^|(?:^|[^a-z0-9_+~.-])RT:?)'; - $re['valid_mentions_or_lists'] = '/'.$tmp['valid_mention_preceding_chars'].'(['.$tmp['at_signs'].'])([\p{L}0-9_\-.]{1,20})((\/[a-z][a-z0-9_\-]{0,24})?(?=(.*|$))(?:@[a-z0-9\.\-]+[a-z0-9]+)?)/iu'; - - $re['valid_reply'] = '/^(?:['.$tmp['spaces'].'])*['.$tmp['at_signs'].']([a-z0-9_\-.]{1,20})(?=(.*|$))/iu'; + // Local part (the username before any @domain) capped at 64. Local + // Pixelfed usernames max at 30 (RegisterController: max:30), but a + // REMOTE handle's username comes from other software and can be longer; + // the profiles.username column stores the full "@user@domain" as a + // VARCHAR(255). A cap that is too small does not fail cleanly, it + // matches the first N chars and drops the "@domain" suffix, turning a + // remote mention into a broken local one (#7204). 64 comfortably covers + // remote usernames while still bounding the pattern. The trailing + // "@domain" group below is matched separately and is not length-capped. + $re['valid_mentions_or_lists'] = '/'.$tmp['valid_mention_preceding_chars'].'(['.$tmp['at_signs'].'])([\p{L}0-9_\-.]{1,64})((\/[a-z][a-z0-9_\-]{0,24})?(?=(.*|$))(?:@[a-z0-9\.\-]+[a-z0-9]+)?)/iu'; + + $re['valid_reply'] = '/^(?:['.$tmp['spaces'].'])*['.$tmp['at_signs'].']([a-z0-9_\-.]{1,64})(?=(.*|$))/iu'; $re['end_mention_match'] = '/\A(?:['.$tmp['at_signs'].']|['.$tmp['latin_accents'].']|:\/\/)/iu'; // URL related hash regex collection diff --git a/tests/Unit/Lexer/StatusLexerTest.php b/tests/Unit/Lexer/StatusLexerTest.php index 8ce40b2f7..1aef8febf 100644 --- a/tests/Unit/Lexer/StatusLexerTest.php +++ b/tests/Unit/Lexer/StatusLexerTest.php @@ -111,6 +111,43 @@ class StatusLexerTest extends TestCase $this->assertEquals($actual, $expected); } + #[Test] + public function remote_mention_with_long_username(): void + { + // A remote handle whose local part is longer than the old 20-char cap + // (23 chars here) must still be extracted and autolinked, the same way + // a short remote handle is (#7204). + $handle = '@stadtlandfluchtfestival@pixelfed.de'; + + $entities = Extractor::create()->extract('hello '.$handle); + + $this->assertContains($handle, $entities['mentions']); + + // It is turned into an anchor rather than left as plain text. + $autolink = Autolink::create()->autolink('hello '.$handle); + $this->assertStringContainsString('assertStringContainsString('>'.$handle.'', $autolink); + } + + #[Test] + public function remote_mention_username_over_thirty_keeps_domain(): void + { + // A remote username longer than the local 30-char limit must not be + // truncated with its @domain dropped (which turned a remote mention + // into a broken local one). The full handle must be captured (#7204). + $handle = '@aReallyLongRemoteUsernameThatExceedsThirty@example.social'; + + $entities = Extractor::create()->extract('hi '.$handle); + + // Mentions are lowercased by the extractor. + $this->assertContains(mb_strtolower($handle), $entities['mentions']); + + // The domain is preserved (not dropped by a too-small local-part cap). + foreach ($entities['mentions'] as $mention) { + $this->assertStringContainsString('@example.social', $mention); + } + } + #[Test] public function mention_limit() { @@ -140,4 +177,289 @@ class StatusLexerTest extends TestCase $count = count($entities['urls']); $this->assertEquals(Status::MAX_LINKS, $count); } + + /* + |-------------------------------------------------------------------------- + | Local mention extraction + |-------------------------------------------------------------------------- + */ + + #[Test] + public function extracts_a_simple_local_mention(): void + { + $entities = Extractor::create()->extract('hey @dansup how are you'); + + $this->assertEquals(['dansup'], $entities['mentions']); + } + + #[Test] + public function extracts_multiple_local_mentions(): void + { + $entities = Extractor::create()->extract('@alice @bob @carol'); + + $this->assertEquals(['alice', 'bob', 'carol'], $entities['mentions']); + } + + #[Test] + public function deduplicates_repeated_mentions(): void + { + $entities = Extractor::create()->extract('@alice @alice @alice'); + + $this->assertEquals(['alice'], $entities['mentions']); + } + + #[Test] + public function lowercases_extracted_mentions(): void + { + $entities = Extractor::create()->extract('@DanSup @HELLO'); + + $this->assertEquals(['dansup', 'hello'], $entities['mentions']); + } + + #[Test] + public function extracts_mention_with_underscores_dashes_and_dots(): void + { + $entities = Extractor::create()->extract('@a_b-c.d'); + + $this->assertEquals(['a_b-c.d'], $entities['mentions']); + } + + #[Test] + public function extracts_mention_with_unicode_letters(): void + { + // The local-part class includes \p{L}, so accented usernames match. + $entities = Extractor::create()->extract('@josé'); + + $this->assertEquals(['josé'], $entities['mentions']); + } + + #[Test] + public function does_not_extract_bare_at_sign(): void + { + $entities = Extractor::create()->extract('email me @ home'); + + $this->assertEmpty($entities['mentions']); + } + + #[Test] + public function does_not_treat_email_address_as_mention(): void + { + // Preceded by a word character, so the @ is not a mention boundary. + $entities = Extractor::create()->extract('contact test@example.com please'); + + $this->assertEmpty($entities['mentions']); + } + + /* + |-------------------------------------------------------------------------- + | Local-part length boundaries (#7204) + |-------------------------------------------------------------------------- + */ + + #[Test] + public function extracts_mention_at_thirty_chars(): void + { + $name = str_repeat('a', 30); + + $entities = Extractor::create()->extract('@'.$name); + + $this->assertEquals([$name], $entities['mentions']); + } + + #[Test] + public function extracts_local_mention_between_thirty_and_sixty_four_chars(): void + { + // Previously (cap 30) this would have been truncated to 30 chars. + $name = str_repeat('a', 45); + + $entities = Extractor::create()->extract('@'.$name); + + $this->assertEquals([$name], $entities['mentions']); + } + + #[Test] + public function extracts_mention_at_sixty_four_chars(): void + { + $name = str_repeat('a', 64); + + $entities = Extractor::create()->extract('@'.$name); + + $this->assertEquals([$name], $entities['mentions']); + } + + #[Test] + public function truncates_local_part_beyond_sixty_four_chars(): void + { + // Beyond the cap the extra characters are not part of the handle. + $name = str_repeat('a', 70); + + $entities = Extractor::create()->extract('@'.$name); + + $this->assertEquals([str_repeat('a', 64)], $entities['mentions']); + } + + /* + |-------------------------------------------------------------------------- + | Remote mention extraction (@user@domain) + |-------------------------------------------------------------------------- + */ + + #[Test] + public function extracts_a_short_remote_mention(): void + { + $entities = Extractor::create()->extract('cc @shlee@aus.social'); + + $this->assertContains('@shlee@aus.social', $entities['mentions']); + } + + #[Test] + public function extracts_remote_mention_with_multi_label_domain(): void + { + $handle = '@user@sub.domain.example.co.uk'; + + $entities = Extractor::create()->extract('hi '.$handle); + + $this->assertContains($handle, $entities['mentions']); + } + + #[Test] + public function extracts_remote_mention_with_very_long_domain(): void + { + // The domain is not counted against the local-part cap. + $handle = '@user@averylongsubdomain.example.social.network.example.org'; + + $entities = Extractor::create()->extract('hi '.$handle); + + $this->assertContains($handle, $entities['mentions']); + } + + #[Test] + public function extracts_remote_mention_with_dashed_domain(): void + { + $handle = '@user@my-instance.example'; + + $entities = Extractor::create()->extract('hi '.$handle); + + $this->assertContains($handle, $entities['mentions']); + } + + #[Test] + public function extracts_mixed_local_and_remote_mentions(): void + { + $entities = Extractor::create()->extract('@localuser and @remote@example.social'); + + $this->assertContains('localuser', $entities['mentions']); + $this->assertContains('@remote@example.social', $entities['mentions']); + } + + /* + |-------------------------------------------------------------------------- + | Autolink output + |-------------------------------------------------------------------------- + */ + + #[Test] + public function autolinks_a_local_mention_with_mention_class(): void + { + $html = Autolink::create()->autolink('hi @dansup'); + + $this->assertStringContainsString('class="u-url mention"', $html); + $this->assertStringContainsString('>@dansup', $html); + $this->assertStringContainsString('href="'.config('app.url').'/dansup"', $html); + } + + #[Test] + public function autolinks_a_remote_mention_into_an_anchor(): void + { + $handle = '@shlee@aus.social'; + + $html = Autolink::create()->autolink('hi '.$handle); + + $this->assertStringContainsString('assertStringContainsString('>'.$handle.'', $html); + } + + #[Test] + public function autolinks_a_long_remote_mention_the_same_as_a_short_one(): void + { + $short = Autolink::create()->autolink('@shlee@aus.social'); + $long = Autolink::create()->autolink('@stadtlandfluchtfestival@pixelfed.de'); + + // Both remote handles produce an anchor carrying the full handle. + $this->assertStringContainsString('>@shlee@aus.social', $short); + $this->assertStringContainsString('>@stadtlandfluchtfestival@pixelfed.de', $long); + } + + #[Test] + public function leaves_text_without_mentions_untouched(): void + { + $text = 'just a plain sentence with no entities'; + + $this->assertEquals($text, Autolink::create()->autolink($text)); + } + + /* + |-------------------------------------------------------------------------- + | Reply detection + |-------------------------------------------------------------------------- + */ + + #[Test] + public function detects_reply_to_a_leading_local_mention(): void + { + $entities = Extractor::create()->extract('@dansup hello there'); + + $this->assertEquals('dansup', $entities['replyto']); + } + + #[Test] + public function detects_reply_for_a_long_leading_username(): void + { + // Reply detection shares the same local-part cap as mention detection. + $name = str_repeat('a', 45); + + $entities = Extractor::create()->extract('@'.$name.' hi'); + + $this->assertEquals($name, $entities['replyto']); + } + + #[Test] + public function no_reply_when_mention_is_not_leading(): void + { + $entities = Extractor::create()->extract('hello @dansup'); + + $this->assertNull($entities['replyto']); + } + + /* + |-------------------------------------------------------------------------- + | Mentions alongside hashtags and urls + |-------------------------------------------------------------------------- + */ + + #[Test] + public function extracts_mention_hashtag_and_url_together(): void + { + $entities = Extractor::create()->extract('@dansup check #pixelfed at https://pixelfed.org'); + + $this->assertEquals(['dansup'], $entities['mentions']); + $this->assertEquals(['pixelfed'], $entities['hashtags']); + $this->assertEquals(['https://pixelfed.org'], $entities['urls']); + } + + #[Test] + public function mention_immediately_before_punctuation_is_extracted(): void + { + $entities = Extractor::create()->extract('thanks @dansup!'); + + $this->assertEquals(['dansup'], $entities['mentions']); + } + + #[Test] + public function mention_wrapped_in_parentheses_is_extracted(): void + { + $entities = Extractor::create()->extract('(via @dansup)'); + + $this->assertEquals(['dansup'], $entities['mentions']); + } }