mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 16:33:27 +00:00
fix(richtext): don't markdown-detect cashu tokens
A cashuB token pasted into a chat rendered as raw base64 instead of the redeem card, because the message was routed through the markdown renderer rather than the rich-text renderer. Root cause: computeIsMarkdown treats any content containing `__` as markdown (bold). cashuB is base64url-encoded CBOR, and the base64url alphabet uses `_`; a token whose CBOR ends with the indefinite-length break marker (0xff) easily produces a trailing run like `______`. The user's actual token did. The markdown renderer has no CashuSegment support, so it just dumped the raw text. The previous truncation patch was unrelated — the cutoff guard already returned content.length for this 398-char token, so truncation never fired. Fix: when content contains `cashuA` or `cashuB` (case-insensitive), short-circuit isMarkdown to false. The trade-off is that a chat message mixing markdown formatting AND a cashu token will lose the markdown rendering, but the cashu card showing up matters more — and pure-markdown messages are unaffected. Tests cover the exact user-reported token, the same token embedded in a longer message, a synthetic cashuA, and a control case proving plain `__bold__` is still classified as markdown.
This commit is contained in:
@@ -81,14 +81,28 @@ object CachedRichTextParser {
|
||||
return result
|
||||
}
|
||||
|
||||
private fun computeIsMarkdown(content: String): Boolean =
|
||||
content.startsWith("> ") ||
|
||||
private fun computeIsMarkdown(content: String): Boolean {
|
||||
// Cashu v2/cashuB tokens are base64url payloads that routinely
|
||||
// contain '__' (the base64url alphabet uses '_'; a token whose
|
||||
// CBOR ends with a fixed-bytes break easily produces a tail
|
||||
// like `______`). Without this exemption, the markdown detector
|
||||
// tags any chat message carrying a cashuB token as markdown,
|
||||
// the content is routed through RenderContentAsMarkdown, which
|
||||
// has no knowledge of CashuSegment, and the user sees the raw
|
||||
// base64 instead of the redeem card. cashuA (v3 JSON+base64url)
|
||||
// shares the same alphabet and the same risk. Force the
|
||||
// rich-text path whenever a cashu token is present so the
|
||||
// inline CashuPreview renders.
|
||||
if (content.contains("cashuA", true) || content.contains("cashuB", true)) return false
|
||||
|
||||
return content.startsWith("> ") ||
|
||||
content.startsWith("# ") ||
|
||||
content.contains("##") ||
|
||||
content.contains("__") ||
|
||||
content.contains("**") ||
|
||||
content.contains("```") ||
|
||||
content.contains("](")
|
||||
}
|
||||
}
|
||||
|
||||
object CachedUrlParser {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import com.vitorpamplona.amethyst.service.CachedRichTextParser
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class CachedRichTextParserMarkdownTest {
|
||||
private val cashuBToken =
|
||||
"cashuBv2FteCJodHRwczovL21pbnQubWluaWJpdHMuY2FzaC9CaXRjb2luYXVjc2F0YWRkVEVTVGF0n79haUgAEHk32wzI" +
|
||||
"ZWFwn79hYQJhc3hAZWM1YWI3Yjc1NjViYjBjZTZhNzg2NzBkMDA0OGExMjVlZGQzMjJhYmVjMTEzYWMwZTBmZGVkZmE3NTQ4Mzg3OWFj" +
|
||||
"WCED0-ops8Ta6NjKChNJPe_jgIbXLlyxg2KSy2WaSTADo5D_v2FhCGFzeEBkNDZlODU5MDExNjU0NmNjZjAwNTE3ZTQ1NmU0MTY0N2Fm" +
|
||||
"ZWUxOWNlMzY2N2IzYTcxODZkMzEwZDY1MjM3OTM4YWNYIQMTDGTY943O4ojhKopoYdemsUE2rSLfzwNBODL8WgOX0v______"
|
||||
|
||||
@Test
|
||||
fun cashuBTokenWithTrailingUnderscoresIsNotMarkdown() {
|
||||
// Bug repro: the user's token tail is `______`, which the
|
||||
// markdown detector tagged as bold via the `contains("__")`
|
||||
// check. RenderContentAsMarkdown has no CashuSegment support,
|
||||
// so the chat bubble rendered the raw base64 instead of the
|
||||
// redeem card. The exemption forces the rich-text path.
|
||||
assertFalse(CachedRichTextParser.isMarkdown(cashuBToken))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cashuBTokenInLongerMessageIsNotMarkdown() {
|
||||
val text = "Here, send to a friend: $cashuBToken"
|
||||
assertFalse(CachedRichTextParser.isMarkdown(text))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cashuATokenIsNotMarkdown() {
|
||||
// cashuA payloads share the base64url alphabet, so the same
|
||||
// false-positive risk applies. Pre-empt it.
|
||||
val fakeCashuA = "cashuAeyJ0b2tlbiI6W3sicHJvb2ZzIjpbXX1dfQ___"
|
||||
assertFalse(CachedRichTextParser.isMarkdown(fakeCashuA))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun plainBoldStillRecognizedAsMarkdown() {
|
||||
// Make sure the exemption is targeted — content without a cashu
|
||||
// prefix still triggers the markdown branch.
|
||||
assertTrue(CachedRichTextParser.isMarkdown("hello __world__"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import com.vitorpamplona.amethyst.service.cashu.v4.V4Parser
|
||||
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Test
|
||||
|
||||
class CashuV4ParserTest {
|
||||
@Test
|
||||
fun decodesMinibitsTestToken() {
|
||||
val token =
|
||||
"cashuBv2FteCJodHRwczovL21pbnQubWluaWJpdHMuY2FzaC9CaXRjb2luYXVjc2F0YWRkVEVTVGF0n79haUgAEHk32wzI" +
|
||||
"ZWFwn79hYQJhc3hAZWM1YWI3Yjc1NjViYjBjZTZhNzg2NzBkMDA0OGExMjVlZGQzMjJhYmVjMTEzYWMwZTBmZGVkZmE3NTQ4Mzg3OWFj" +
|
||||
"WCED0-ops8Ta6NjKChNJPe_jgIbXLlyxg2KSy2WaSTADo5D_v2FhCGFzeEBkNDZlODU5MDExNjU0NmNjZjAwNTE3ZTQ1NmU0MTY0N2Fm" +
|
||||
"ZWUxOWNlMzY2N2IzYTcxODZkMzEwZDY1MjM3OTM4YWNYIQMTDGTY943O4ojhKopoYdemsUE2rSLfzwNBODL8WgOX0v______"
|
||||
|
||||
val outcome = V4Parser.parseCashuB(token)
|
||||
when (outcome) {
|
||||
is GenericLoadable.Loaded -> {
|
||||
val list = outcome.loaded
|
||||
assertTrue("Expected at least one CashuToken", list.isNotEmpty())
|
||||
val first = list.first()
|
||||
println("mint=${first.mint} totalAmount=${first.totalAmount} proofs=${first.proofs.size}")
|
||||
}
|
||||
is GenericLoadable.Error -> fail("V4Parser failed: ${outcome.errorMessage}")
|
||||
else -> fail("V4Parser returned $outcome")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user