diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserGroupLinkTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserGroupLinkTest.kt index 453e3d9def..2d34e019d3 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserGroupLinkTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserGroupLinkTest.kt @@ -99,4 +99,70 @@ class RichTextParserGroupLinkTest { assertTrue(links.groupLinks.isEmpty()) assertNull(groupSegmentsOf("odd wss://relay.damus.io' end").firstOrNull()) } + + // ---- weird apostrophe placements after non-relay URLs: none may become a group link ---- + + @Test + fun possessiveAfterRelayUrlIsNotAGroupLink() { + // "wss://relay.damus.io's uptime" — the possessive `'s` must not linkify group "s". + val text = "how is wss://relay.damus.io's uptime today" + val links = UrlParser().parseValidUrls(text) + assertTrue(links.groupLinks.isEmpty()) + assertTrue(links.relayUrls.contains("wss://relay.damus.io")) + assertTrue(groupSegmentsOf(text).isEmpty()) + } + + @Test + fun apostropheAfterHttpUrlIsNotAGroupLink() { + val links = UrlParser().parseValidUrls("see https://example.com'abc123 here") + assertTrue(links.groupLinks.isEmpty()) + assertTrue(links.withScheme.contains("https://example.com")) + assertTrue(groupSegmentsOf("see https://example.com'abc123 here").isEmpty()) + } + + @Test + fun apostropheAfterNostrUriIsNotAGroupLink() { + val text = "ping nostr:npub1sn0wdenkukak0d9dfczzeacvhkrgz92ak56egt7vdgzn8pv2wfqqhrjdv9'abc there" + val links = UrlParser().parseValidUrls(text) + assertTrue(links.groupLinks.isEmpty()) + } + + @Test + fun apostropheAfterBlossomUriIsNotAGroupLink() { + val text = "file blossom://b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553'abc" + assertTrue(UrlParser().parseValidUrls(text).groupLinks.isEmpty()) + } + + @Test + fun apostropheAfterEmailIsNotAGroupLink() { + val links = UrlParser().parseValidUrls("mail vitor@example.com'abc please") + assertTrue(links.groupLinks.isEmpty()) + } + + @Test + fun insecureWsSchemeAlsoLinkifies() { + // `ws://` (not just `wss://`) is a relay scheme and must peek the same way. + val links = UrlParser().parseValidUrls("dev ws://relay.example.com'abc123 test") + assertEquals(setOf("ws://relay.example.com'abc123"), links.groupLinks) + } + + @Test + fun secondApostropheEndsTheGroupId() { + // `wss://relay.example.com'abc'def` — the group id is `abc`; `'def` is left as text. + val segs = groupSegmentsOf("go wss://relay.example.com'abc'def now") + assertEquals(1, segs.size) + assertEquals("wss://relay.example.com'abc", segs.first().segmentText) + } + + @Test + fun multipleGroupLinksInOnePostAreAllDetected() { + val links = + UrlParser().parseValidUrls( + "two groups wss://groups.0xchat.com'aaaa and wss://chat.wisp.talk'bbbb here", + ) + assertEquals( + setOf("wss://groups.0xchat.com'aaaa", "wss://chat.wisp.talk'bbbb"), + links.groupLinks, + ) + } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/GroupInviteLink.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/GroupInviteLink.kt index 298b32301f..e2b62e8fc6 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/GroupInviteLink.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/GroupInviteLink.kt @@ -47,6 +47,16 @@ data class GroupInviteLink( // swallowing trailing prose punctuation into the link. private fun isIdChar(c: Char): Boolean = c in 'a'..'z' || c in 'A'..'Z' || c in '0'..'9' || c == '-' || c == '_' + /** + * A single-character group id is only accepted when it is the relay-wide default + * group `_`. Every real group id from the reference clients (relay29 random ids, + * Wisp/0xchat 12+ chars) is longer, whereas a lone char after an apostrophe is + * almost always an English possessive/contraction glued to a bare relay URL + * (`wss://relay.damus.io's uptime`). Rejecting it kills that false positive without + * dropping any real link. + */ + private fun isAcceptableGroupId(id: String): Boolean = id.length >= 2 || id == "_" + private const val CODE_PREFIX = "?code=" /** @@ -64,6 +74,7 @@ data class GroupInviteLink( var i = from while (i < content.length && isIdChar(content[i])) i++ if (i == from) return 0 // empty group id — not a link + if (!isAcceptableGroupId(content.substring(from, i))) return 0 // possessive/contraction guard // Optional `?code=`; only extend the span when a non-empty code follows. if (content.startsWith(CODE_PREFIX, i)) { @@ -89,7 +100,7 @@ data class GroupInviteLink( val rest = link.substring(apos + 1) val queryIdx = rest.indexOf('?') val groupId = if (queryIdx < 0) rest else rest.substring(0, queryIdx) - if (groupId.isEmpty() || !groupId.all { isIdChar(it) }) return null + if (!groupId.all { isIdChar(it) } || !isAcceptableGroupId(groupId)) return null val code = if (queryIdx >= 0) { diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/GroupInviteLinkTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/GroupInviteLinkTest.kt index b8decd1751..032d99896a 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/GroupInviteLinkTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/GroupInviteLinkTest.kt @@ -77,6 +77,31 @@ class GroupInviteLinkTest { assertNull(GroupInviteLink.parse("'abc123")) } + @Test + fun rejectsSingleCharPossessive() { + // `wss://relay.damus.io's uptime` — the `'s` is a possessive, not a group id. + assertNull(GroupInviteLink.parse("wss://relay.damus.io's")) + } + + @Test + fun acceptsUnderscoreDefaultGroupButNotOtherSingleChars() { + // `_` is the relay-wide default group and is a legitimate 1-char id; other lone + // chars are rejected as contractions. + assertEquals("_", GroupInviteLink.parse("wss://relay.example.com'_")?.groupId) + assertNull(GroupInviteLink.parse("wss://relay.example.com'x")) + } + + @Test + fun suffixLengthRejectsPossessiveS() { + // "wss://r.com's uptime" — apostrophe at 11, `s` at 12 → not a link. + assertEquals(0, GroupInviteLink.suffixLength("wss://r.com's uptime", 12)) + } + + @Test + fun suffixLengthAcceptsUnderscore() { + assertEquals(1, GroupInviteLink.suffixLength("wss://r.com'_ hi", 12)) + } + @Test fun suffixLengthMeasuresGroupIdOnly() { // "wss://r.com'abc def" — apostrophe at index 11, group id starts at 12.