add blossom

This commit is contained in:
2026-05-09 16:34:56 +07:00
parent e824aa7e16
commit 52ae2e521f
7 changed files with 255 additions and 3 deletions

View File

@@ -124,7 +124,14 @@ fun App(dbPath: String) {
isLoading = isCreating,
onBack = { navController.popBackStack() },
onSave = { name, bio, uri ->
viewModel.createIdentity(name, bio, uri?.toString())
val contentType = uri?.let { context.contentResolver.getType(it) }
val picture = uri?.let {
context.contentResolver.openInputStream(it)?.use { input ->
input.readBytes()
}
}
viewModel.createIdentity(name, bio, picture, contentType)
}
)
}

View File

@@ -41,6 +41,8 @@ ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" }
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
[plugins]
androidApplication = { id = "com.android.application", version.ref = "agp" }

View File

@@ -3,6 +3,7 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidLibrary)
kotlin("plugin.serialization") version libs.versions.kotlin.get()
}
kotlin {
@@ -27,8 +28,11 @@ kotlin {
implementation("org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
implementation("su.reya:nostr-sdk-kmp:0.1.5")
implementation("com.squareup.okio:okio:3.16.2")
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.websockets)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
}
androidMain.dependencies {
implementation(libs.ktor.client.okhttp)

View File

@@ -2,6 +2,9 @@ package su.reya.coop
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import io.ktor.client.HttpClient
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
@@ -11,11 +14,14 @@ import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.json.Json
import rust.nostr.sdk.Keys
import rust.nostr.sdk.Metadata
import rust.nostr.sdk.NostrConnect
import rust.nostr.sdk.NostrConnectUri
import rust.nostr.sdk.NostrSigner
import rust.nostr.sdk.PublicKey
import su.reya.coop.blossom.BlossomClient
import su.reya.coop.storage.SecretStorage
import kotlin.time.Clock
import kotlin.time.Duration
@@ -47,6 +53,10 @@ class NostrViewModel(
private fun showError(message: String) {
viewModelScope.launch {
_errorEvents.send(message)
if (isCreating.value) {
_isCreating.value = false
}
}
}
@@ -180,17 +190,47 @@ class NostrViewModel(
return keys
}
fun createIdentity(name: String, bio: String, picture: String?) {
fun createIdentity(
name: String,
bio: String,
picture: ByteArray?,
contentType: String?
) {
viewModelScope.launch {
try {
val keys = Keys.generate()
val secret = keys.secretKey().toBech32()
var avatarUrl = ""
// Set loading state
_isCreating.value = true
// Upload picture to Blossom
if (picture != null) {
val blossom = BlossomClient(
url = "https://blossom.band",
client = HttpClient {
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
prettyPrint = true
isLenient = true
})
}
}
)
val descriptor = blossom.upload(
file = picture,
contentType = contentType,
signer = NostrSigner.keys(keys)
)
avatarUrl = descriptor?.url ?: ""
}
// Create identity
nostr.createIdentity(keys, name, bio, picture)
nostr.createIdentity(keys = keys, name = name, bio = bio, picture = avatarUrl)
// Save secret to the secret storage
secretStore.set("user_signer", secret)

View File

@@ -0,0 +1,80 @@
package su.reya.coop.blossom
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.header
import io.ktor.client.request.put
import io.ktor.client.request.setBody
import io.ktor.http.HeaderValue
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.utils.io.core.toByteArray
import okio.ByteString.Companion.toByteString
import rust.nostr.sdk.EventBuilder
import rust.nostr.sdk.NostrSigner
import rust.nostr.sdk.Timestamp
import kotlin.io.encoding.Base64
import kotlin.time.Duration
class BlossomClient(
val url: String,
val client: HttpClient,
) {
suspend fun upload(
file: ByteArray,
contentType: String? = null,
signer: NostrSigner? = null
): BlobDescriptor? {
val url = "$url/upload"
val hash = file.toByteString().sha256().hex()
val fileHashes = listOf(hash)
val res = client.put(url) {
// Set body
setBody(file)
// Set the content type if provided
contentType?.let {
header(HttpHeaders.ContentType, it)
}
signer?.let {
val defaultAuth = defaultAuth(
action = BlossomAuthorizationVerb.Upload,
defaultContent = "Blossom upload authorization",
defaultScope = BlossomAuthorizationScope.BlobSha256Hashes(fileHashes)
)
val authHeader = buildAuthHeader(it, defaultAuth)
header(HttpHeaders.Authorization, authHeader.value)
}
}
return when (res.status) {
HttpStatusCode.OK -> res.body<BlobDescriptor>()
else -> {
throw Exception("Failed to upload file: ${res.status}")
}
}
}
fun defaultAuth(
action: BlossomAuthorizationVerb,
defaultContent: String,
defaultScope: BlossomAuthorizationScope
): BlossomAuthorization {
val expiration = Timestamp.now().addDuration(Duration.parse("300s"))
return BlossomAuthorization(
content = defaultContent,
expiration = expiration,
action = action,
scope = defaultScope
)
}
suspend fun buildAuthHeader(signer: NostrSigner, authz: BlossomAuthorization): HeaderValue {
val authEvent = EventBuilder.blossomAuth(authz).sign(signer)
val encodedAuth = Base64.encode(authEvent.asJson().toByteArray())
val value = "Nostr $encodedAuth"
return HeaderValue(value)
}
}

View File

@@ -0,0 +1,92 @@
package su.reya.coop.blossom
import rust.nostr.sdk.EventBuilder
import rust.nostr.sdk.Kind
import rust.nostr.sdk.Tag
import rust.nostr.sdk.Timestamp
/**
* Represents the authorization data for accessing a Blossom server.
*/
data class BlossomAuthorization(
/**
* A human readable string explaining to the user what the events intended use is
*/
val content: String,
/**
* A UNIX timestamp (in seconds) indicating when the authorization should be expired
*/
val expiration: Timestamp,
/**
* The type of action authorized by the user
*/
val action: BlossomAuthorizationVerb,
/**
* The scope of the authorization
*/
val scope: BlossomAuthorizationScope,
)
/**
* The scope of a Blossom authorization event
*/
sealed class BlossomAuthorizationScope {
/**
* Authorizes access to blobs with the given SHA256 hashes.
*/
data class BlobSha256Hashes(val hashes: List<String>) : BlossomAuthorizationScope()
/**
* Authorizes access to the given server URL.
*/
data class ServerUrl(val url: String) : BlossomAuthorizationScope()
fun toTags(): List<Tag> {
return when (this) {
is BlobSha256Hashes -> hashes.map { hash ->
// "x" tag for blob hash
Tag.parse(listOf("x", hash))
}
is ServerUrl -> listOf(
// "server" tag for server URL
Tag.parse(listOf("server", url))
)
}
}
}
/**
* Represents the possible actions that can be authorized by a Blossom authorization event.
*/
enum class BlossomAuthorizationVerb(val value: String) {
Get("get"),
Upload("upload"),
List("list"),
Delete("delete");
override fun toString(): String = value
}
/**
* Extension functions for [BlossomAuthorization] and [EventBuilder].
*/
fun BlossomAuthorization.toTags(): List<Tag> {
val tags = mutableListOf<Tag>()
tags.addAll(scope.toTags())
tags.add(Tag.expiration(expiration))
// Add the 't' tag to say what this auth is for
tags.add(Tag.hashtag(action.toString()))
return tags
}
/**
* Blossom authorization event (Kind 24242)
*
* https://github.com/hzrd149/blossom/blob/master/buds/01.md
*/
fun EventBuilder.Companion.blossomAuth(authorization: BlossomAuthorization): EventBuilder {
// Kind 24242 is used for Blossom Auth
val kind = Kind(24242u)
return EventBuilder(kind, authorization.content).tags(authorization.toTags())
}

View File

@@ -0,0 +1,27 @@
package su.reya.coop.blossom
import kotlinx.serialization.Serializable
@Serializable
data class BlobDescriptor(
/**
* The URL at which the blob/file can be accessed
*/
val url: String,
/**
* The SHA256 hash of the contents in the blob
*/
val sha256: String,
/**
* The size of the blob/file, in bytes
*/
val size: Long,
/**
* Mime type of the blob/file
*/
val mimeType: String? = null,
/**
* The date at which the blob was uploaded, as a UNIX timestamp (in seconds)
*/
val uploaded: ULong
)