From eac0daed9c3422b0eb5927c88d1a17e77e4eb4cb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 22:40:28 +0000 Subject: [PATCH] refactor(blossom): centralize Blossom protocol strings in quartz The Blossom auth-header encoding (`Nostr `), the `/upload` endpoint path, and the `X-Reason` failure header were each re-derived in both the commons JVM `BlossomClient`/`BlossomAuth` and the Android `BlossomUploader`, using two different Base64 APIs. Move these protocol-level facts into the quartz `nipB7Blossom` package, where the rest of the Blossom protocol lives: - `BlossomAuthorizationEvent.toAuthorizationHeader()` / `rawToken()` + `AUTH_HEADER_SCHEME`, mirroring NIP-98's `HTTPAuthorizationEvent.toAuthToken()` that Blossom auth reuses. - new `BlossomServerUrl` with `upload()` / `blob()` endpoint builders and the `REASON_HEADER` constant. Both transports now call these helpers instead of hand-building strings. No behavior change for upload (existing desktop BlossomClientTest still green); the Android delete URL now omits the trailing dot when no file extension is known, matching BUD-02's `DELETE /`. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01JJgwV4Y99brVa97v7p3jJb --- .../uploads/blossom/BlossomUploader.kt | 17 ++---- .../commons/service/upload/BlossomAuth.kt | 11 +--- .../commons/service/upload/BlossomClient.kt | 9 +-- .../nipB7Blossom/BlossomAuthorizationEvent.kt | 15 +++++ .../quartz/nipB7Blossom/BlossomServerUrl.kt | 56 +++++++++++++++++++ 5 files changed, 84 insertions(+), 24 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomServerUrl.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt index f5a792034d..3d5248207c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt @@ -36,6 +36,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.JsonMapper import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent +import com.vitorpamplona.quartz.nipB7Blossom.BlossomServerUrl import com.vitorpamplona.quartz.nipB7Blossom.BlossomUploadResult import com.vitorpamplona.quartz.utils.RandomInstance import com.vitorpamplona.quartz.utils.sha256.sha256StreamWithCount @@ -52,7 +53,6 @@ import okio.ForwardingSource import okio.source import java.io.File import java.io.InputStream -import java.util.Base64 class BlossomUploader { fun Context.getFileName(uri: Uri): String? = @@ -120,11 +120,6 @@ class BlossomUploader { }.mergeLocalMetadata(localMetadata) } - fun encodeAuth(event: BlossomAuthorizationEvent): String { - val encodedNIP98Event = Base64.getEncoder().encodeToString(event.toJson().toByteArray()) - return "Nostr $encodedNIP98Event" - } - suspend fun upload( inputStream: InputStream, hash: HexKey, @@ -147,7 +142,7 @@ class BlossomUploader { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) ?: extensionFromMimeType(it) } ?: "" - val apiUrl = serverBaseUrl.removeSuffix("/") + "/upload" + val apiUrl = BlossomServerUrl.upload(serverBaseUrl) val client = okHttpClient(apiUrl) val requestBuilder = Request.Builder() @@ -187,7 +182,7 @@ class BlossomUploader { } httpAuth(hash, length, alt?.let { "Uploading $it" } ?: "Uploading $fileName")?.let { - requestBuilder.addHeader("Authorization", encodeAuth(it)) + requestBuilder.addHeader("Authorization", it.toAuthorizationHeader()) } contentType?.let { requestBuilder.addHeader("Content-Type", it) } @@ -206,7 +201,7 @@ class BlossomUploader { convertToMediaResult(parseResults(body.string())) } } else { - val errorMessage = response.headers.get("X-Reason") + val errorMessage = response.headers.get(BlossomServerUrl.REASON_HEADER) val explanation = HttpStatusMessages.resourceIdFor(response.code) if (errorMessage != null) { @@ -253,12 +248,12 @@ class BlossomUploader { val requestBuilder = Request.Builder() httpAuth(hash, "Deleting $hash")?.let { - requestBuilder.addHeader("Authorization", encodeAuth(it)) + requestBuilder.addHeader("Authorization", it.toAuthorizationHeader()) } val request = requestBuilder - .url(apiUrl.removeSuffix("/") + "/$hash.$extension") + .url(BlossomServerUrl.blob(apiUrl, hash, extension)) .delete() .build() diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/BlossomAuth.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/BlossomAuth.kt index 9f02b737e6..0bda515f8a 100644 --- a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/BlossomAuth.kt +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/BlossomAuth.kt @@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.commons.service.upload import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent -import java.util.Base64 object BlossomAuth { suspend fun createUploadAuth( @@ -31,13 +30,7 @@ object BlossomAuth { size: Long, alt: String, signer: NostrSigner, - ): String { - val event = BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, signer) - return encodeAuthHeader(event) - } + ): String = BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, signer).toAuthorizationHeader() - fun encodeAuthHeader(event: BlossomAuthorizationEvent): String { - val b64 = Base64.getEncoder().encodeToString(event.toJson().toByteArray()) - return "Nostr $b64" - } + fun encodeAuthHeader(event: BlossomAuthorizationEvent): String = event.toAuthorizationHeader() } diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/BlossomClient.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/BlossomClient.kt index 32078036c5..edc9702643 100644 --- a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/BlossomClient.kt +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/BlossomClient.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.commons.service.upload import com.vitorpamplona.quartz.nip01Core.core.JsonMapper +import com.vitorpamplona.quartz.nipB7Blossom.BlossomServerUrl import com.vitorpamplona.quartz.nipB7Blossom.BlossomUploadResult import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -50,7 +51,7 @@ open class BlossomClient( authHeader: String?, ): BlossomUploadResult = withContext(Dispatchers.IO) { - val apiUrl = serverBaseUrl.removeSuffix("/") + "/upload" + val apiUrl = BlossomServerUrl.upload(serverBaseUrl) val requestBody = object : RequestBody() { override fun contentType() = contentType.toMediaType() @@ -73,7 +74,7 @@ open class BlossomClient( val response = okHttpClient.newCall(requestBuilder.build()).execute() response.use { if (!it.isSuccessful) { - val reason = it.headers["X-Reason"] ?: it.code.toString() + val reason = it.headers[BlossomServerUrl.REASON_HEADER] ?: it.code.toString() throw RuntimeException("Upload failed ($serverBaseUrl): $reason") } val body = it.body.string().ifBlank { throw RuntimeException("Upload to $serverBaseUrl returned no body") } @@ -115,7 +116,7 @@ open class BlossomClient( authHeader: String?, ): BlossomUploadResult = withContext(Dispatchers.IO) { - val apiUrl = serverBaseUrl.removeSuffix("/") + "/upload" + val apiUrl = BlossomServerUrl.upload(serverBaseUrl) val requestBody = bytes.toRequestBody(contentType.toMediaType()) val requestBuilder = @@ -129,7 +130,7 @@ open class BlossomClient( val response = okHttpClient.newCall(requestBuilder.build()).execute() response.use { if (!it.isSuccessful) { - val reason = it.headers["X-Reason"] ?: it.code.toString() + val reason = it.headers[BlossomServerUrl.REASON_HEADER] ?: it.code.toString() throw RuntimeException("Upload failed ($serverBaseUrl): $reason") } val body = it.body.string().ifBlank { throw RuntimeException("Upload to $serverBaseUrl returned no body") } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomAuthorizationEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomAuthorizationEvent.kt index 87c8a59a89..dcf9fe9e85 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomAuthorizationEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomAuthorizationEvent.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.io.encoding.Base64 @Immutable class BlossomAuthorizationEvent( @@ -35,9 +36,23 @@ class BlossomAuthorizationEvent( content: String, sig: HexKey, ) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + /** Base64 of this event's JSON, as carried in the `Authorization` header value. */ + fun rawToken() = Base64.encode(toJson().encodeToByteArray()) + + /** + * The full `Authorization` header value for a BUD-01/BUD-02 request: + * `Nostr `. Mirrors NIP-98's + * [com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent.toAuthToken], + * which Blossom auth reuses. + */ + fun toAuthorizationHeader() = "$AUTH_HEADER_SCHEME${rawToken()}" + companion object { const val KIND = 24242 + /** Scheme prefix for the `Authorization` header value (BUD-01). */ + const val AUTH_HEADER_SCHEME = "Nostr " + suspend fun createGetAuth( hash: HexKey, alt: String, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomServerUrl.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomServerUrl.kt new file mode 100644 index 0000000000..3444b78052 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomServerUrl.kt @@ -0,0 +1,56 @@ +/* + * 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 + +/** + * Endpoint helpers for the Blossom HTTP API (BUD-01 / BUD-02). Centralizes the + * protocol's URL shapes and well-known header names so every transport — the + * commons JVM `BlossomClient`, the Android uploader, the CLI — builds them the + * same way instead of re-deriving `/upload` and `X-Reason` by hand. + */ +object BlossomServerUrl { + /** BUD-01 upload endpoint path: `PUT /upload`. */ + const val UPLOAD_PATH = "/upload" + + /** + * Header a Blossom server SHOULD set with a human-readable failure reason on + * a non-2xx response (BUD-01). + */ + const val REASON_HEADER = "X-Reason" + + /** `/upload`, collapsing any trailing slash on [serverBaseUrl]. */ + fun upload(serverBaseUrl: String): String = serverBaseUrl.removeSuffix("/") + UPLOAD_PATH + + /** + * BUD-01 blob endpoint `/[.]`, used for GET and DELETE. + * A blank [extension] omits the suffix. + */ + fun blob( + serverBaseUrl: String, + hash: HexKey, + extension: String = "", + ): String { + val suffix = if (extension.isBlank()) "" else ".$extension" + return serverBaseUrl.removeSuffix("/") + "/" + hash + suffix + } +}