Merge pull request #3298 from vitorpamplona/claude/blossom-refactor-consolidate-9z9yxw

Centralize Blossom URL and auth header construction
This commit is contained in:
Vitor Pamplona
2026-06-19 18:49:18 -04:00
committed by GitHub
5 changed files with 84 additions and 24 deletions
@@ -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()
@@ -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()
}
@@ -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") }
@@ -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 <base64-event>`. 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,
@@ -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 `<server>/upload` and `X-Reason` by hand.
*/
object BlossomServerUrl {
/** BUD-01 upload endpoint path: `PUT <server>/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"
/** `<server>/upload`, collapsing any trailing slash on [serverBaseUrl]. */
fun upload(serverBaseUrl: String): String = serverBaseUrl.removeSuffix("/") + UPLOAD_PATH
/**
* BUD-01 blob endpoint `<server>/<sha256>[.<ext>]`, 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
}
}