mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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:
@@ -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) {
|
||||
|
||||
+1
-1
@@ -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
|
||||
},
|
||||
|
||||
+61
-10
@@ -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()
|
||||
}
|
||||
|
||||
+99
@@ -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])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user