diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt index 9be31f4..7730b2c 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt @@ -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) } ) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index dc494c5..d2a1b5d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" } diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 796f8a2..bce9a38 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -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) diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt index ca6d014..b2d6269 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt @@ -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) diff --git a/shared/src/commonMain/kotlin/su/reya/coop/blossom/BlossomClient.kt b/shared/src/commonMain/kotlin/su/reya/coop/blossom/BlossomClient.kt new file mode 100644 index 0000000..42255a3 --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/blossom/BlossomClient.kt @@ -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() + 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) + } +} diff --git a/shared/src/commonMain/kotlin/su/reya/coop/blossom/Bud01.kt b/shared/src/commonMain/kotlin/su/reya/coop/blossom/Bud01.kt new file mode 100644 index 0000000..073d629 --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/blossom/Bud01.kt @@ -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) : BlossomAuthorizationScope() + + /** + * Authorizes access to the given server URL. + */ + data class ServerUrl(val url: String) : BlossomAuthorizationScope() + + fun toTags(): List { + 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 { + val tags = mutableListOf() + 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()) +} diff --git a/shared/src/commonMain/kotlin/su/reya/coop/blossom/Bud02.kt b/shared/src/commonMain/kotlin/su/reya/coop/blossom/Bud02.kt new file mode 100644 index 0000000..b4eab0d --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/blossom/Bud02.kt @@ -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 +) \ No newline at end of file