add blossom
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
92
shared/src/commonMain/kotlin/su/reya/coop/blossom/Bud01.kt
Normal file
92
shared/src/commonMain/kotlin/su/reya/coop/blossom/Bud01.kt
Normal 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())
|
||||
}
|
||||
27
shared/src/commonMain/kotlin/su/reya/coop/blossom/Bud02.kt
Normal file
27
shared/src/commonMain/kotlin/su/reya/coop/blossom/Bud02.kt
Normal 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
|
||||
)
|
||||
Reference in New Issue
Block a user