diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index e2a51f8237..2412c5adf3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -2163,14 +2163,32 @@ class Account( title: String?, description: String, hashtags: List = emptyList(), + editing: WebBookmarkEvent? = null, ) { if (!isWriteable()) return - val template = WebBookmarkEvent.build(url, title, description, tags = hashtags) + val now = TimeUtils.now() + val template = + WebBookmarkEvent.build( + url, + title, + description, + tags = hashtags, + createdAt = now, + firstPublishedAt = editing?.publishedAt() ?: now, + ) val signedEvent = signer.sign(template) cache.justConsumeMyOwnEvent(signedEvent) client.publish(signedEvent, computeRelayListToBroadcast(signedEvent)) + + // A different d tag is a different address, so the edit would otherwise leave the old + // bookmark behind. That happens when the URL was changed, and when re-saving a bookmark + // stored under the pre-2026 NIP-B0 rule, which also dropped `http://` (the d tag then + // reads as https, and saving the real http URL now keeps the scheme). + if (editing != null && editing.dTag() != signedEvent.dTag()) { + deleteWebBookmark(editing) + } } suspend fun deleteWebBookmark(event: WebBookmarkEvent) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/webBookmarks/WebBookmarksScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/webBookmarks/WebBookmarksScreen.kt index 149d7d79f5..d39857b4df 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/webBookmarks/WebBookmarksScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/webBookmarks/WebBookmarksScreen.kt @@ -242,7 +242,7 @@ private fun WebBookmarkCard( onDismiss = { showEditDialog = false }, onSave = { url, title, description, tags -> accountViewModel.launchSigner { - accountViewModel.account.sendWebBookmark(url, title, description, tags) + accountViewModel.account.sendWebBookmark(url, title, description, tags, editing = event) } showEditDialog = false }, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB0WebBookmarks/WebBookmarkEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB0WebBookmarks/WebBookmarkEvent.kt index 9f928defbc..97cac54110 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB0WebBookmarks/WebBookmarkEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB0WebBookmarks/WebBookmarkEvent.kt @@ -55,10 +55,11 @@ class WebBookmarkEvent( visitor.visit(description()) } - fun url(): String { - val dTagValue = dTag() - return if (dTagValue.isNotEmpty()) "https://$dTagValue" else "" - } + /** + * The bookmarked URI. NIP-B0 only drops the scheme for `https`, so a d tag without a scheme + * is an https URL and anything else (`http://`, `gemini://`, `magnet:`...) is already complete. + */ + fun url(): String = dTagToUrl(dTag()) fun title() = tags.firstNotNullOfOrNull(TitleTag::parse) @@ -71,23 +72,73 @@ class WebBookmarkEvent( companion object { const val KIND = 39701 - fun urlToDTag(url: String): String = - url - .removePrefix("https://") - .removePrefix("http://") - .trimEnd('/') + private const val HTTPS_PREFIX = "https://" + // RFC 3986: scheme = ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) followed by ":" + private val SCHEME = Regex("^([A-Za-z][A-Za-z0-9+.\\-]*):(.*)$") + + // What follows "host:" when the colon introduces a port rather than ending a scheme. + private val PORT = Regex("^[0-9]{1,5}([/?#].*)?$") + + /** + * True when [uri] starts with a scheme (`http://`, `mailto:`, `magnet:`) rather than a + * `host[:port]` of a scheme-less https d tag. `://` always means a scheme; otherwise a + * dotted or `localhost` prefix, or a numeric port after the colon, means a host. + */ + fun hasScheme(uri: String): Boolean { + val match = SCHEME.find(uri) ?: return false + val candidate = match.groupValues[1] + val rest = match.groupValues[2] + if (rest.startsWith("//")) return true + if (candidate.contains('.') || candidate.equals("localhost", ignoreCase = true)) return false + return !PORT.matches(rest) + } + + /** + * NIP-B0 d tag: the URI itself, except that for `https` everything before the hostname + * (scheme, `//` and any userinfo) is omitted. Other schemes, `http` included, are kept. + * + * The spec says nothing about trailing slashes or case; this keeps the long-standing + * trailing-slash trim (so re-saving an old bookmark lands on the same address) and does + * not change case. + */ + fun urlToDTag(url: String): String { + val trimmed = url.trim() + val uri = + if (trimmed.startsWith(HTTPS_PREFIX, ignoreCase = true)) { + val afterScheme = trimmed.substring(HTTPS_PREFIX.length) + val authorityEnd = afterScheme.indexOfAny(charArrayOf('/', '?', '#')).let { if (it < 0) afterScheme.length else it } + val userInfoEnd = afterScheme.lastIndexOf('@', authorityEnd - 1) + if (userInfoEnd >= 0) afterScheme.substring(userInfoEnd + 1) else afterScheme + } else { + trimmed + } + return uri.trimEnd('/') + } + + fun dTagToUrl(dTag: String): String = + when { + dTag.isEmpty() -> "" + hasScheme(dTag) -> dTag + else -> HTTPS_PREFIX + dTag + } + + /** + * @param firstPublishedAt when the bookmark was first published; pass the original value when + * editing so it survives the replacement. + */ fun build( url: String, bookmarkTitle: String?, description: String, tags: List = emptyList(), createdAt: Long = TimeUtils.now(), + firstPublishedAt: Long = createdAt, initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, description, createdAt) { dTag(urlToDTag(url)) bookmarkTitle?.let { title(it) } - publishedAt(createdAt) + publishedAt(firstPublishedAt) hashtags(tags) initializer() } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB0WebBookmarks/WebBookmarkEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB0WebBookmarks/WebBookmarkEventTest.kt new file mode 100644 index 0000000000..761912059d --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB0WebBookmarks/WebBookmarkEventTest.kt @@ -0,0 +1,99 @@ +/* + * 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.quartz.nipB0WebBookmarks + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class WebBookmarkEventTest { + private fun bookmark(dTag: String) = WebBookmarkEvent("0".repeat(64), "1".repeat(64), 1L, arrayOf(arrayOf("d", dTag)), "", "") + + @Test + fun httpsDropsEverythingBeforeTheHostname() { + assertEquals("alice.blog/post", WebBookmarkEvent.urlToDTag("https://alice.blog/post")) + assertEquals("alice.blog/post", WebBookmarkEvent.urlToDTag("HTTPS://alice.blog/post")) + assertEquals("alice.blog/post", WebBookmarkEvent.urlToDTag("https://user:pw@alice.blog/post")) + assertEquals("alice.blog:8443/post?q=1#f", WebBookmarkEvent.urlToDTag("https://alice.blog:8443/post?q=1#f")) + } + + @Test + fun otherSchemesAreKept() { + assertEquals("http://alice.i2p/post", WebBookmarkEvent.urlToDTag("http://alice.i2p/post")) + assertEquals("ftp://files.example.com/a.txt", WebBookmarkEvent.urlToDTag("ftp://files.example.com/a.txt")) + assertEquals("gemini://alice.space/post", WebBookmarkEvent.urlToDTag("gemini://alice.space/post")) + assertEquals("magnet:?xt=urn:btih:abc", WebBookmarkEvent.urlToDTag("magnet:?xt=urn:btih:abc")) + } + + @Test + fun schemelessInputIsAlreadyInHttpsForm() { + assertEquals("alice.blog/post", WebBookmarkEvent.urlToDTag("alice.blog/post")) + assertEquals("localhost:8080/x", WebBookmarkEvent.urlToDTag("localhost:8080/x")) + } + + @Test + fun trailingSlashIsTrimmedAsBefore() { + // NIP-B0 is silent on trailing slashes; keep the long-standing trim so existing d tags still match. + assertEquals("alice.blog", WebBookmarkEvent.urlToDTag("https://alice.blog/")) + assertEquals("http://alice.blog", WebBookmarkEvent.urlToDTag("http://alice.blog/")) + } + + @Test + fun urlRestoresHttpsOnlyWhenTheDTagHasNoScheme() { + assertEquals("https://alice.blog/post", bookmark("alice.blog/post").url()) + assertEquals("https://alice.blog:8443/post", bookmark("alice.blog:8443/post").url()) + assertEquals("https://localhost:8080/x", bookmark("localhost:8080/x").url()) + assertEquals("http://alice.i2p/post", bookmark("http://alice.i2p/post").url()) + assertEquals("gemini://alice.space/post", bookmark("gemini://alice.space/post").url()) + assertEquals("magnet:?xt=urn:btih:abc", bookmark("magnet:?xt=urn:btih:abc").url()) + assertEquals("", bookmark("").url()) + } + + @Test + fun roundTripsThroughTheDTag() { + listOf( + "https://alice.blog/post", + "http://alice.i2p/post", + "ftp://files.example.com/a.txt", + "magnet:?xt=urn:btih:abc", + ).forEach { url -> + assertEquals(url, bookmark(WebBookmarkEvent.urlToDTag(url)).url()) + } + } + + @Test + fun detectsSchemes() { + assertTrue(WebBookmarkEvent.hasScheme("http://a.b")) + assertTrue(WebBookmarkEvent.hasScheme("mailto:alice@a.b")) + assertFalse(WebBookmarkEvent.hasScheme("a.b/c")) + assertFalse(WebBookmarkEvent.hasScheme("a.b:80/c")) + assertFalse(WebBookmarkEvent.hasScheme("localhost:80")) + assertFalse(WebBookmarkEvent.hasScheme("192.168.0.1:80/c")) + } + + @Test + fun buildKeepsTheOriginalPublishedAt() { + val template = WebBookmarkEvent.build("http://alice.i2p/post", "t", "", createdAt = 200, firstPublishedAt = 100) + assertEquals("http://alice.i2p/post", template.tags.first { it[0] == "d" }[1]) + assertEquals("100", template.tags.first { it[0] == "published_at" }[1]) + } +}