fix: NIP-B0 keeps non-https schemes in the web bookmark d tag

NIP-B0 now omits the part before the hostname only for https; http:// and
every other scheme stay in the d tag. urlToDTag no longer strips http://,
and url() adds https:// only when the d tag has no scheme (it produced
https://ftp://... before). The spec is silent on trailing slashes and case,
so the existing trailing-slash trim is kept and case is untouched.

Editing a bookmark now keeps its original published_at, and when the saved
d tag differs from the edited one (URL changed, or an http bookmark stored
scheme-less by the old rule is re-saved with http://) the old address gets a
NIP-09 deletion instead of being left behind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGR1u8SyzcUuekub39SBsc
This commit is contained in:
Claude
2026-09-27 17:24:04 +00:00
parent 7b91dd234e
commit ffadc6a807
4 changed files with 180 additions and 12 deletions
@@ -2163,14 +2163,32 @@ class Account(
title: String?,
description: String,
hashtags: List<String> = 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) {
@@ -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
},
@@ -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<String> = emptyList(),
createdAt: Long = TimeUtils.now(),
firstPublishedAt: Long = createdAt,
initializer: TagArrayBuilder<WebBookmarkEvent>.() -> Unit = {},
) = eventTemplate<WebBookmarkEvent>(KIND, description, createdAt) {
dTag(urlToDTag(url))
bookmarkTitle?.let { title(it) }
publishedAt(createdAt)
publishedAt(firstPublishedAt)
hashtags(tags)
initializer()
}
@@ -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])
}
}