diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomUri.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomUri.kt new file mode 100644 index 0000000000..4796916157 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomUri.kt @@ -0,0 +1,169 @@ +/* + * 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.nipB7Blossom + +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * Parsed representation of a BUD-10 Blossom URI. + * + * Format: `blossom:.[?parameters]` + * + * @param sha256 64-character lowercase hex hash of the blob. + * @param extension File extension (e.g. "jpg", "mp4"). Defaults to "bin" when unknown. + * @param servers Server base-URL hints where the blob may exist (`xs` params, tried first). + * @param authors Hex pubkeys of blob uploaders used for BUD-03 server-list lookup (`as` params). + * @param size Blob size in bytes for verification and progress display (`sz` param). + */ +data class BlossomUri( + val sha256: HexKey, + val extension: String, + val servers: List, + val authors: List, + val size: Long?, +) { + /** + * Serialises back to a canonical `blossom:` URI string. + * Server URLs are percent-encoded so that `&`, `=`, and `#` inside them + * do not break the query string. + */ + fun toUriString(): String = + buildString { + append("blossom:") + append(sha256) + append('.') + append(extension) + val params = + buildList { + servers.forEach { add("xs=${percentEncodeQueryValue(it)}") } + authors.forEach { add("as=$it") } + size?.let { add("sz=$it") } + } + if (params.isNotEmpty()) { + append('?') + append(params.joinToString("&")) + } + } + + companion object { + private const val SCHEME = "blossom:" + private val SHA256_REGEX = Regex("^[0-9a-f]{64}$") + + /** + * Parses a BUD-10 URI string into a [BlossomUri], or returns `null` if the + * input is not a valid blossom URI (wrong scheme or malformed SHA-256). + * + * `xs` and `as` parameters may appear multiple times; all values are collected. + */ + fun parse(uri: String): BlossomUri? { + if (!uri.startsWith(SCHEME, ignoreCase = true)) return null + val rest = uri.substring(SCHEME.length) + + val queryIndex = rest.indexOf('?') + val pathPart = if (queryIndex >= 0) rest.substring(0, queryIndex) else rest + val queryPart = if (queryIndex >= 0) rest.substring(queryIndex + 1) else "" + + // Separate sha256 from extension at the last dot. + val dotIndex = pathPart.lastIndexOf('.') + val sha256: String + val extension: String + if (dotIndex > 0) { + sha256 = pathPart.substring(0, dotIndex).lowercase() + extension = pathPart.substring(dotIndex + 1).ifEmpty { "bin" } + } else { + sha256 = pathPart.lowercase() + extension = "bin" + } + + if (!SHA256_REGEX.matches(sha256)) return null + + // Collect repeated query parameters. + val servers = mutableListOf() + val authors = mutableListOf() + var size: Long? = null + + if (queryPart.isNotEmpty()) { + for (param in queryPart.split('&')) { + val eqIndex = param.indexOf('=') + if (eqIndex < 0) continue + val key = param.substring(0, eqIndex) + val value = percentDecode(param.substring(eqIndex + 1)) + when (key) { + "xs" -> if (value.isNotEmpty()) servers.add(value) + "as" -> if (value.isNotEmpty()) authors.add(value) + "sz" -> value.toLongOrNull()?.let { size = it } + } + } + } + + return BlossomUri( + sha256 = sha256, + extension = extension, + servers = servers, + authors = authors, + size = size, + ) + } + + /** Decodes percent-encoded sequences (e.g. `%3A` → `:`). */ + private fun percentDecode(input: String): String { + if ('%' !in input) return input + val sb = StringBuilder(input.length) + var i = 0 + while (i < input.length) { + val c = input[i] + if (c == '%' && i + 2 < input.length) { + val hex = input.substring(i + 1, i + 3) + val code = hex.toIntOrNull(16) + if (code != null) { + sb.append(code.toChar()) + i += 3 + continue + } + } + sb.append(c) + i++ + } + return sb.toString() + } + + /** + * Percent-encodes characters that are not safe inside a query-string value. + * Letters, digits, and unreserved chars (`-._~`) pass through unchanged. + * Everything else (notably `&`, `=`, `#`, and non-ASCII) is encoded. + */ + private fun percentEncodeQueryValue(input: String): String { + val sb = StringBuilder(input.length) + for (c in input) { + if (c.isLetterOrDigit() || c in "-._~:/?@!$'()*+,;") { + sb.append(c) + } else { + for (b in c.toString().encodeToByteArray()) { + sb.append('%') + sb.append((b.toInt() and 0xFF).toString(16).padStart(2, '0').uppercase()) + } + } + } + return sb.toString() + } + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomUriTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomUriTest.kt new file mode 100644 index 0000000000..42c43e6cc7 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomUriTest.kt @@ -0,0 +1,133 @@ +/* + * 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.nipB7Blossom + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class BlossomUriTest { + private val sha256 = "b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553" + private val authorPubkey = "a8f3721a0dc1b4d5c12f4cc7c54ae14071eb9c1b4f9b2cf0d4ab22c0e9f0c7e" + + @Test + fun parsesMinimalUri() { + val uri = "blossom:${sha256}.jpg" + val result = BlossomUri.parse(uri) + + assertNotNull(result) + assertEquals(sha256, result.sha256) + assertEquals("jpg", result.extension) + assertTrue(result.servers.isEmpty()) + assertTrue(result.authors.isEmpty()) + assertNull(result.size) + } + + @Test + fun parsesFullUri() { + val uri = "blossom:${sha256}.mp4" + + "?xs=https%3A%2F%2Fcdn.example.com" + + "&xs=https%3A%2F%2Fbackup.example.com" + + "&as=$authorPubkey" + + "&sz=1048576" + val result = BlossomUri.parse(uri) + + assertNotNull(result) + assertEquals(sha256, result.sha256) + assertEquals("mp4", result.extension) + assertEquals(listOf("https://cdn.example.com", "https://backup.example.com"), result.servers) + assertEquals(listOf(authorPubkey), result.authors) + assertEquals(1048576L, result.size) + } + + @Test + fun parsesUriWithUnencodedServerUrls() { + // xs values with unencoded colons/slashes should still parse + val uri = "blossom:${sha256}.bin?xs=https://cdn.example.com&sz=512" + val result = BlossomUri.parse(uri) + + assertNotNull(result) + assertEquals(listOf("https://cdn.example.com"), result.servers) + assertEquals(512L, result.size) + } + + @Test + fun defaultsExtensionToBinWhenMissing() { + val uri = "blossom:$sha256" + val result = BlossomUri.parse(uri) + + assertNotNull(result) + assertEquals("bin", result.extension) + } + + @Test + fun isCaseInsensitiveForScheme() { + val result = BlossomUri.parse("BLOSSOM:${sha256}.png") + assertNotNull(result) + assertEquals(sha256, result.sha256) + } + + @Test + fun normalisesUpperCaseSha256ToLowercase() { + val result = BlossomUri.parse("blossom:${sha256.uppercase()}.jpg") + assertNotNull(result) + assertEquals(sha256, result.sha256) + } + + @Test + fun returnsNullForWrongScheme() { + assertNull(BlossomUri.parse("magnet:?xt=urn:btih:$sha256")) + assertNull(BlossomUri.parse("https://example.com/$sha256")) + } + + @Test + fun returnsNullForMalformedSha256() { + assertNull(BlossomUri.parse("blossom:tooshort.jpg")) + assertNull(BlossomUri.parse("blossom:gggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg.jpg")) + } + + @Test + fun roundTrip() { + val original = BlossomUri( + sha256 = sha256, + extension = "jpg", + servers = listOf("https://cdn.example.com", "https://backup.example.com"), + authors = listOf(authorPubkey), + size = 204800L, + ) + val reparsed = BlossomUri.parse(original.toUriString()) + + assertEquals(original, reparsed) + } + + @Test + fun handlesMultipleAuthors() { + val author2 = "b8f3721a0dc1b4d5c12f4cc7c54ae14071eb9c1b4f9b2cf0d4ab22c0e9f0c7f" + val uri = "blossom:${sha256}.jpg?as=$authorPubkey&as=$author2" + val result = BlossomUri.parse(uri) + + assertNotNull(result) + assertEquals(listOf(authorPubkey, author2), result.authors) + } +}