mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-11 16:57:39 +00:00
feat(blossom): full-client protocol support across quartz, commons, CLI and Android
Extends Blossom support toward a full client on both the CLI and the mobile app. Quartz (protocol): - BlossomAuthorizationEvent: add t=media auth (BUD-05) and optional BUD-11 `server` domain scoping on every factory (stops replayable upload/delete tokens) - BlossomServerUrl: mirror/media/list/report path builders, BUD-06 preflight and BUD-07 payment header constants, and a lowercase bare-domain helper - BlossomUploadResult: parse `ox` (BUD-05 original hash) and `nip94` (BUD-08) - BlossomPaymentRequired: BUD-07 402 challenge model (Cashu/Lightning) - BlossomReport: BUD-09 kind-1984 blob report reusing NIP-56 tag builders Commons (shared JVM client, now in jvmAndroid so Android shares it too): - BlossomClient gains mirror (BUD-04), list/delete (BUD-02), media (BUD-05), preflight/has (BUD-06/01), report (BUD-09) and typed 402 handling - BlossomAuth: media/list/delete passthroughs with server scoping CLI (first-class): - amy blossom now routes all HTTP through the shared client and adds `media` and `report` verbs; auth tokens are scoped to --server Android (first-class): - uploads mirror to the user's other Blossom servers (BUD-04) best-effort - new "Manage stored files" screen: per-server presence matrix (BUD-02 list + BUD-01 HEAD), delete, mirror-to-missing, and report actions Tests: quartz URL/auth/descriptor/payment parsing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP
This commit is contained in:
+23
-1
@@ -30,7 +30,29 @@ object BlossomAuth {
|
||||
size: Long,
|
||||
alt: String,
|
||||
signer: NostrSigner,
|
||||
): String = BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, signer).toAuthorizationHeader()
|
||||
servers: List<String> = emptyList(),
|
||||
): String = BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, signer, servers).toAuthorizationHeader()
|
||||
|
||||
suspend fun createMediaAuth(
|
||||
hash: HexKey,
|
||||
size: Long,
|
||||
alt: String,
|
||||
signer: NostrSigner,
|
||||
servers: List<String> = emptyList(),
|
||||
): String = BlossomAuthorizationEvent.createMediaAuth(hash, size, alt, signer, servers).toAuthorizationHeader()
|
||||
|
||||
suspend fun createListAuth(
|
||||
alt: String,
|
||||
signer: NostrSigner,
|
||||
servers: List<String> = emptyList(),
|
||||
): String = BlossomAuthorizationEvent.createListAuth(signer, alt, servers).toAuthorizationHeader()
|
||||
|
||||
suspend fun createDeleteAuth(
|
||||
hash: HexKey,
|
||||
alt: String,
|
||||
signer: NostrSigner,
|
||||
servers: List<String> = emptyList(),
|
||||
): String = BlossomAuthorizationEvent.createDeleteAuth(hash, alt, signer, servers).toAuthorizationHeader()
|
||||
|
||||
fun encodeAuthHeader(event: BlossomAuthorizationEvent): String = event.toAuthorizationHeader()
|
||||
}
|
||||
+336
@@ -0,0 +1,336 @@
|
||||
/*
|
||||
* 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.amethyst.commons.service.upload
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
|
||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomPaymentRequired
|
||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServerUrl
|
||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomUploadResult
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import okio.BufferedSink
|
||||
import okio.source
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Thrown when a Blossom server answers with `402 Payment Required` (BUD-07). The
|
||||
* caller pays [payment] (Cashu or Lightning) and retries the request with the
|
||||
* proof attached.
|
||||
*/
|
||||
class BlossomPaymentException(
|
||||
val server: String,
|
||||
val payment: BlossomPaymentRequired,
|
||||
) : RuntimeException("Payment required by $server: ${payment.reason ?: "402 Payment Required"}")
|
||||
|
||||
/** Result of a BUD-06 `HEAD /upload` or `HEAD /media` preflight. */
|
||||
data class BlossomPreflightResult(
|
||||
val accepted: Boolean,
|
||||
val status: Int,
|
||||
val reason: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Blossom HTTP client for JVM consumers (desktop + CLI + Android's
|
||||
* shared logic). Owns no global state — pass a configured [OkHttpClient] (e.g.
|
||||
* desktop's Tor-aware `DesktopHttpClient.currentClient()`) for proxying /
|
||||
* connection pooling. The default constructor uses a fresh OkHttpClient — fine
|
||||
* for one-shot uses such as the CLI.
|
||||
*
|
||||
* Covers BUD-01 (download), BUD-02 (upload/list/delete), BUD-04 (mirror),
|
||||
* BUD-05 (media), BUD-06 (preflight), BUD-07 (402), and BUD-09 (report). Every
|
||||
* optional endpoint degrades gracefully so callers can fan out across servers of
|
||||
* varying capability.
|
||||
*/
|
||||
open class BlossomClient(
|
||||
private val okHttpClient: OkHttpClient = OkHttpClient(),
|
||||
) {
|
||||
open suspend fun upload(
|
||||
file: File,
|
||||
contentType: String,
|
||||
serverBaseUrl: String,
|
||||
authHeader: String?,
|
||||
): BlossomUploadResult = putBlob(BlossomServerUrl.upload(serverBaseUrl), fileBody(file, contentType), serverBaseUrl, authHeader)
|
||||
|
||||
/**
|
||||
* Upload raw bytes (e.g. encrypted blobs) to a Blossom server.
|
||||
*/
|
||||
open suspend fun upload(
|
||||
bytes: ByteArray,
|
||||
contentType: String,
|
||||
serverBaseUrl: String,
|
||||
authHeader: String?,
|
||||
): BlossomUploadResult = putBlob(BlossomServerUrl.upload(serverBaseUrl), bytes.toRequestBody(contentType.toMediaType()), serverBaseUrl, authHeader)
|
||||
|
||||
/**
|
||||
* BUD-05 media-optimization upload: `PUT /media`. The server MAY transform the
|
||||
* blob, so the returned descriptor's `sha256` is the *optimized* hash and `ox`
|
||||
* the original. Requires a `t=media` auth token.
|
||||
*/
|
||||
open suspend fun media(
|
||||
file: File,
|
||||
contentType: String,
|
||||
serverBaseUrl: String,
|
||||
authHeader: String?,
|
||||
): BlossomUploadResult = putBlob(BlossomServerUrl.media(serverBaseUrl), fileBody(file, contentType), serverBaseUrl, authHeader)
|
||||
|
||||
open suspend fun media(
|
||||
bytes: ByteArray,
|
||||
contentType: String,
|
||||
serverBaseUrl: String,
|
||||
authHeader: String?,
|
||||
): BlossomUploadResult = putBlob(BlossomServerUrl.media(serverBaseUrl), bytes.toRequestBody(contentType.toMediaType()), serverBaseUrl, authHeader)
|
||||
|
||||
/**
|
||||
* BUD-04 mirror: ask [serverBaseUrl] to fetch and store the blob already at
|
||||
* [sourceUrl]. The server verifies the downloaded bytes hash to the `x` tag in
|
||||
* the (upload) auth token. Returns the mirrored blob's descriptor.
|
||||
*/
|
||||
open suspend fun mirror(
|
||||
sourceUrl: String,
|
||||
serverBaseUrl: String,
|
||||
authHeader: String?,
|
||||
): BlossomUploadResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
val body = JsonMapper.toJson(MirrorRequest(sourceUrl)).toRequestBody("application/json".toMediaType())
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url(BlossomServerUrl.mirror(serverBaseUrl))
|
||||
.apply { authHeader?.let { addHeader("Authorization", it) } }
|
||||
.put(body)
|
||||
.build()
|
||||
okHttpClient.newCall(request).execute().use { parseDescriptor(it, serverBaseUrl) }
|
||||
}
|
||||
|
||||
/**
|
||||
* BUD-02 list: `GET /list/<pubkey>`. Returns the pubkey's blob descriptors on
|
||||
* this server (may be empty; servers MAY not implement it). [authHeader] is a
|
||||
* `t=list` token — some servers require it, others allow anonymous listing.
|
||||
*/
|
||||
open suspend fun list(
|
||||
serverBaseUrl: String,
|
||||
pubkey: HexKey,
|
||||
authHeader: String?,
|
||||
): List<BlossomUploadResult> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url(BlossomServerUrl.list(serverBaseUrl, pubkey))
|
||||
.apply { authHeader?.let { addHeader("Authorization", it) } }
|
||||
.get()
|
||||
.build()
|
||||
okHttpClient.newCall(request).execute().use { response ->
|
||||
check402(response, serverBaseUrl)
|
||||
if (!response.isSuccessful) {
|
||||
val reason = response.headers[BlossomServerUrl.REASON_HEADER] ?: response.code.toString()
|
||||
throw RuntimeException("List failed ($serverBaseUrl): $reason")
|
||||
}
|
||||
val body = response.body.string().ifBlank { "[]" }
|
||||
JsonMapper.fromJson<List<BlossomUploadResult>>(body)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* BUD-02 delete: `DELETE /<sha256>[.ext]`. [authHeader] is a `t=delete` token
|
||||
* scoped to the hash (and ideally to this server). Returns true on 2xx.
|
||||
*/
|
||||
open suspend fun delete(
|
||||
hash: HexKey,
|
||||
serverBaseUrl: String,
|
||||
authHeader: String?,
|
||||
extension: String = "",
|
||||
): Boolean =
|
||||
withContext(Dispatchers.IO) {
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url(BlossomServerUrl.blob(serverBaseUrl, hash, extension))
|
||||
.apply { authHeader?.let { addHeader("Authorization", it) } }
|
||||
.delete()
|
||||
.build()
|
||||
okHttpClient.newCall(request).execute().use { it.isSuccessful }
|
||||
}
|
||||
|
||||
/**
|
||||
* BUD-01 HEAD probe: does [serverBaseUrl] hold [hash]? A cheap "which server
|
||||
* has which blob" check that needs no auth on most servers.
|
||||
*/
|
||||
open suspend fun has(
|
||||
hash: HexKey,
|
||||
serverBaseUrl: String,
|
||||
): Boolean =
|
||||
withContext(Dispatchers.IO) {
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url(BlossomServerUrl.blob(serverBaseUrl, hash))
|
||||
.head()
|
||||
.build()
|
||||
try {
|
||||
okHttpClient.newCall(request).execute().use { it.isSuccessful }
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* BUD-06 preflight: `HEAD /upload` (or `/media` when [media] is true). A 200
|
||||
* means the server would accept the blob; any other status carries an optional
|
||||
* `X-Reason`. Per spec this is only a hint — never gate an upload hard on it.
|
||||
*/
|
||||
open suspend fun preflight(
|
||||
hash: HexKey,
|
||||
size: Long,
|
||||
contentType: String,
|
||||
serverBaseUrl: String,
|
||||
authHeader: String?,
|
||||
media: Boolean = false,
|
||||
): BlossomPreflightResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
val endpoint = if (media) BlossomServerUrl.media(serverBaseUrl) else BlossomServerUrl.upload(serverBaseUrl)
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url(endpoint)
|
||||
.head()
|
||||
.addHeader(BlossomServerUrl.X_SHA_256_HEADER, hash)
|
||||
.addHeader(BlossomServerUrl.X_CONTENT_LENGTH_HEADER, size.toString())
|
||||
.addHeader(BlossomServerUrl.X_CONTENT_TYPE_HEADER, contentType)
|
||||
.apply { authHeader?.let { addHeader("Authorization", it) } }
|
||||
.build()
|
||||
okHttpClient.newCall(request).execute().use { response ->
|
||||
BlossomPreflightResult(
|
||||
accepted = response.isSuccessful,
|
||||
status = response.code,
|
||||
reason = response.headers[BlossomServerUrl.REASON_HEADER],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* BUD-09 report: `PUT /report` with a signed NIP-56 (kind 1984) report event as
|
||||
* the JSON body. Returns true on 2xx.
|
||||
*/
|
||||
open suspend fun report(
|
||||
serverBaseUrl: String,
|
||||
reportEventJson: String,
|
||||
): Boolean =
|
||||
withContext(Dispatchers.IO) {
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url(BlossomServerUrl.report(serverBaseUrl))
|
||||
.put(reportEventJson.toRequestBody("application/json".toMediaType()))
|
||||
.build()
|
||||
okHttpClient.newCall(request).execute().use { it.isSuccessful }
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a blob from an absolute URL — typically a Blossom GET endpoint
|
||||
* `<server>/<sha256>`. Returns the raw bytes, or `null` when the server
|
||||
* responds with a non-2xx status. Connection-level failures (DNS, refused,
|
||||
* timeout) propagate as [java.io.IOException] so the caller can try the next
|
||||
* server.
|
||||
*
|
||||
* This does NOT verify the blob's hash — content-addressed verification is
|
||||
* the caller's responsibility (see quartz `StaticSiteResolver.verify`), since
|
||||
* a Blossom server is untrusted and may return a substituted blob.
|
||||
*/
|
||||
open suspend fun download(url: String): ByteArray? =
|
||||
withContext(Dispatchers.IO) {
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url(url)
|
||||
.get()
|
||||
.build()
|
||||
okHttpClient.newCall(request).execute().use { response ->
|
||||
if (response.isSuccessful) response.body.bytes() else null
|
||||
}
|
||||
}
|
||||
|
||||
private fun fileBody(
|
||||
file: File,
|
||||
contentType: String,
|
||||
): RequestBody =
|
||||
object : RequestBody() {
|
||||
override fun contentType() = contentType.toMediaType()
|
||||
|
||||
override fun contentLength() = file.length()
|
||||
|
||||
override fun writeTo(sink: BufferedSink) {
|
||||
file.inputStream().source().use(sink::writeAll)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun putBlob(
|
||||
endpoint: String,
|
||||
body: RequestBody,
|
||||
serverBaseUrl: String,
|
||||
authHeader: String?,
|
||||
): BlossomUploadResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url(endpoint)
|
||||
.apply { authHeader?.let { addHeader("Authorization", it) } }
|
||||
.put(body)
|
||||
.build()
|
||||
okHttpClient.newCall(request).execute().use { parseDescriptor(it, serverBaseUrl) }
|
||||
}
|
||||
|
||||
private fun parseDescriptor(
|
||||
response: Response,
|
||||
serverBaseUrl: String,
|
||||
): BlossomUploadResult {
|
||||
check402(response, serverBaseUrl)
|
||||
if (!response.isSuccessful) {
|
||||
val reason = response.headers[BlossomServerUrl.REASON_HEADER] ?: response.code.toString()
|
||||
throw RuntimeException("Request failed ($serverBaseUrl): $reason")
|
||||
}
|
||||
val body = response.body.string().ifBlank { throw RuntimeException("$serverBaseUrl returned no body") }
|
||||
return JsonMapper.fromJson<BlossomUploadResult>(body)
|
||||
}
|
||||
|
||||
/** Surfaces a BUD-07 `402 Payment Required` as a typed exception the caller can act on. */
|
||||
private fun check402(
|
||||
response: Response,
|
||||
serverBaseUrl: String,
|
||||
) {
|
||||
if (response.code == 402) {
|
||||
throw BlossomPaymentException(serverBaseUrl, BlossomPaymentRequired.fromHeaders { response.headers[it] })
|
||||
}
|
||||
}
|
||||
|
||||
@kotlinx.serialization.Serializable
|
||||
private data class MirrorRequest(
|
||||
val url: String,
|
||||
)
|
||||
}
|
||||
-140
@@ -1,140 +0,0 @@
|
||||
/*
|
||||
* 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.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
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okio.BufferedSink
|
||||
import okio.source
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Blossom HTTP client for JVM consumers (desktop + CLI). Owns no global
|
||||
* state — pass a configured [OkHttpClient] (e.g. desktop's Tor-aware
|
||||
* `DesktopHttpClient.currentClient()`) for proxying / connection pooling.
|
||||
* The default constructor uses a fresh OkHttpClient — fine for one-shot
|
||||
* uses such as the CLI.
|
||||
*/
|
||||
open class BlossomClient(
|
||||
private val okHttpClient: OkHttpClient = OkHttpClient(),
|
||||
) {
|
||||
open suspend fun upload(
|
||||
file: File,
|
||||
contentType: String,
|
||||
serverBaseUrl: String,
|
||||
authHeader: String?,
|
||||
): BlossomUploadResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
val apiUrl = BlossomServerUrl.upload(serverBaseUrl)
|
||||
val requestBody =
|
||||
object : RequestBody() {
|
||||
override fun contentType() = contentType.toMediaType()
|
||||
|
||||
override fun contentLength() = file.length()
|
||||
|
||||
override fun writeTo(sink: BufferedSink) {
|
||||
file.inputStream().source().use(sink::writeAll)
|
||||
}
|
||||
}
|
||||
|
||||
val requestBuilder =
|
||||
Request
|
||||
.Builder()
|
||||
.url(apiUrl)
|
||||
.put(requestBody)
|
||||
|
||||
authHeader?.let { requestBuilder.addHeader("Authorization", it) }
|
||||
|
||||
val response = okHttpClient.newCall(requestBuilder.build()).execute()
|
||||
response.use {
|
||||
if (!it.isSuccessful) {
|
||||
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") }
|
||||
JsonMapper.fromJson<BlossomUploadResult>(body)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a blob from an absolute URL — typically a Blossom GET endpoint
|
||||
* `<server>/<sha256>`. Returns the raw bytes, or `null` when the server
|
||||
* responds with a non-2xx status. Connection-level failures (DNS, refused,
|
||||
* timeout) propagate as [java.io.IOException] so the caller can try the next
|
||||
* server.
|
||||
*
|
||||
* This does NOT verify the blob's hash — content-addressed verification is
|
||||
* the caller's responsibility (see quartz `StaticSiteResolver.verify`), since
|
||||
* a Blossom server is untrusted and may return a substituted blob.
|
||||
*/
|
||||
open suspend fun download(url: String): ByteArray? =
|
||||
withContext(Dispatchers.IO) {
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url(url)
|
||||
.get()
|
||||
.build()
|
||||
okHttpClient.newCall(request).execute().use { response ->
|
||||
if (response.isSuccessful) response.body.bytes() else null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload raw bytes (e.g. encrypted blobs) to a Blossom server.
|
||||
*/
|
||||
open suspend fun upload(
|
||||
bytes: ByteArray,
|
||||
contentType: String,
|
||||
serverBaseUrl: String,
|
||||
authHeader: String?,
|
||||
): BlossomUploadResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
val apiUrl = BlossomServerUrl.upload(serverBaseUrl)
|
||||
val requestBody = bytes.toRequestBody(contentType.toMediaType())
|
||||
|
||||
val requestBuilder =
|
||||
Request
|
||||
.Builder()
|
||||
.url(apiUrl)
|
||||
.put(requestBody)
|
||||
|
||||
authHeader?.let { requestBuilder.addHeader("Authorization", it) }
|
||||
|
||||
val response = okHttpClient.newCall(requestBuilder.build()).execute()
|
||||
response.use {
|
||||
if (!it.isSuccessful) {
|
||||
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") }
|
||||
JsonMapper.fromJson<BlossomUploadResult>(body)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user