From 443f7c6a09886ce950292f12f9f1d799c1681925 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Mon, 16 Mar 2026 10:42:59 +0200 Subject: [PATCH] =?UTF-8?q?feat(media):=20Phase=202=20=E2=80=94=20Desktop?= =?UTF-8?q?=20Blossom=20upload=20client?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DesktopBlossomClient: PUT /upload, DELETE, HEAD /upload (BUD-06) - DesktopBlossomAuth: kind 24242 auth with base64 Nostr header - DesktopMediaMetadata: SHA-256, dimensions, blurhash via ImageIO - DesktopMediaCompressor: lossless EXIF stripping (Commons Imaging) - DesktopUploadOrchestrator: strip EXIF → metadata → auth → upload - DesktopUploadTracker: StateFlow-based upload state tracking Co-Authored-By: Claude Opus 4.6 --- .../service/upload/DesktopBlossomAuth.kt | 52 ++++++++ .../service/upload/DesktopBlossomClient.kt | 113 ++++++++++++++++++ .../service/upload/DesktopMediaCompressor.kt | 50 ++++++++ .../service/upload/DesktopMediaMetadata.kt | 89 ++++++++++++++ .../upload/DesktopUploadOrchestrator.kt | 78 ++++++++++++ .../service/upload/DesktopUploadTracker.kt | 52 ++++++++ 6 files changed, 434 insertions(+) create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopBlossomAuth.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopBlossomClient.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopMediaCompressor.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopMediaMetadata.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopUploadOrchestrator.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopUploadTracker.kt diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopBlossomAuth.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopBlossomAuth.kt new file mode 100644 index 000000000..1ee16b2f6 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopBlossomAuth.kt @@ -0,0 +1,52 @@ +/* + * 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.desktop.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 DesktopBlossomAuth { + suspend fun createUploadAuth( + hash: HexKey, + size: Long, + alt: String, + signer: NostrSigner, + ): String { + val event = BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, signer) + return encodeAuthHeader(event) + } + + suspend fun createDeleteAuth( + hash: HexKey, + alt: String, + signer: NostrSigner, + ): String { + val event = BlossomAuthorizationEvent.createDeleteAuth(hash, alt, signer) + return encodeAuthHeader(event) + } + + fun encodeAuthHeader(event: BlossomAuthorizationEvent): String { + val b64 = Base64.getEncoder().encodeToString(event.toJson().toByteArray()) + return "Nostr $b64" + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopBlossomClient.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopBlossomClient.kt new file mode 100644 index 000000000..9634c342f --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopBlossomClient.kt @@ -0,0 +1,113 @@ +/* + * 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.desktop.service.upload + +import com.vitorpamplona.quartz.nip01Core.core.JsonMapper +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 okio.BufferedSink +import okio.source +import java.io.File + +class DesktopBlossomClient( + private val okHttpClient: OkHttpClient = OkHttpClient(), +) { + suspend fun upload( + file: File, + contentType: String, + serverBaseUrl: String, + authHeader: String?, + ): BlossomUploadResult = + withContext(Dispatchers.IO) { + val apiUrl = serverBaseUrl.removeSuffix("/") + "/upload" + 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) + .addHeader("Content-Length", file.length().toString()) + .addHeader("Content-Type", contentType) + + authHeader?.let { requestBuilder.addHeader("Authorization", it) } + + val response = okHttpClient.newCall(requestBuilder.build()).execute() + response.use { + if (!it.isSuccessful) { + val reason = it.headers["X-Reason"] ?: it.code.toString() + throw RuntimeException("Upload failed ($serverBaseUrl): $reason") + } + JsonMapper.fromJson(it.body.string()) + } + } + + suspend fun delete( + hash: String, + serverBaseUrl: String, + authHeader: String?, + ): Boolean = + withContext(Dispatchers.IO) { + val apiUrl = serverBaseUrl.removeSuffix("/") + "/$hash" + val requestBuilder = Request.Builder().url(apiUrl).delete() + authHeader?.let { requestBuilder.addHeader("Authorization", it) } + + val response = okHttpClient.newCall(requestBuilder.build()).execute() + response.use { it.isSuccessful } + } + + suspend fun headUpload( + contentType: String, + contentLength: Long, + sha256: String, + serverBaseUrl: String, + authHeader: String?, + ): Boolean = + withContext(Dispatchers.IO) { + val apiUrl = serverBaseUrl.removeSuffix("/") + "/upload" + val requestBuilder = + Request + .Builder() + .url(apiUrl) + .head() + .addHeader("X-Content-Type", contentType) + .addHeader("X-Content-Length", contentLength.toString()) + .addHeader("X-SHA-256", sha256) + authHeader?.let { requestBuilder.addHeader("Authorization", it) } + + val response = okHttpClient.newCall(requestBuilder.build()).execute() + response.use { it.isSuccessful } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopMediaCompressor.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopMediaCompressor.kt new file mode 100644 index 000000000..c197441d7 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopMediaCompressor.kt @@ -0,0 +1,50 @@ +/* + * 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.desktop.service.upload + +import org.apache.commons.imaging.Imaging +import org.apache.commons.imaging.formats.jpeg.exif.ExifRewriter +import java.io.ByteArrayOutputStream +import java.io.File + +object DesktopMediaCompressor { + fun stripExif(file: File): File { + if (!file.name.lowercase().let { it.endsWith(".jpg") || it.endsWith(".jpeg") }) { + return file + } + + return try { + val bytes = file.readBytes() + // Check if it has EXIF data + val metadata = Imaging.getMetadata(bytes) + if (metadata == null) return file + + val baos = ByteArrayOutputStream() + ExifRewriter().removeExifMetadata(bytes, baos) + val stripped = File.createTempFile("stripped_", ".jpg") + stripped.writeBytes(baos.toByteArray()) + stripped.deleteOnExit() + stripped + } catch (_: Exception) { + file + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopMediaMetadata.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopMediaMetadata.kt new file mode 100644 index 000000000..d43b3cfd3 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopMediaMetadata.kt @@ -0,0 +1,89 @@ +/* + * 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.desktop.service.upload + +import com.vitorpamplona.amethyst.commons.blurhash.toBlurhash +import com.vitorpamplona.amethyst.commons.blurhash.toPlatformImage +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.sha256.sha256 +import java.io.File +import javax.imageio.ImageIO + +data class MediaMetadata( + val sha256: String, + val size: Long, + val mimeType: String, + val width: Int? = null, + val height: Int? = null, + val blurhash: String? = null, +) + +object DesktopMediaMetadata { + fun compute(file: File): MediaMetadata { + val bytes = file.readBytes() + val hash = sha256(bytes).toHexKey() + val mimeType = guessMimeType(file) + var width: Int? = null + var height: Int? = null + var blurhash: String? = null + + if (mimeType.startsWith("image/")) { + try { + val image = ImageIO.read(file) + if (image != null) { + width = image.width + height = image.height + blurhash = image.toPlatformImage().toBlurhash() + } + } catch (_: Exception) { + } + } + + return MediaMetadata( + sha256 = hash, + size = bytes.size.toLong(), + mimeType = mimeType, + width = width, + height = height, + blurhash = blurhash, + ) + } + + fun guessMimeType(file: File): String { + val ext = file.extension.lowercase() + return when (ext) { + "jpg", "jpeg" -> "image/jpeg" + "png" -> "image/png" + "gif" -> "image/gif" + "webp" -> "image/webp" + "svg" -> "image/svg+xml" + "avif" -> "image/avif" + "mp4" -> "video/mp4" + "webm" -> "video/webm" + "mov" -> "video/quicktime" + "mp3" -> "audio/mpeg" + "ogg" -> "audio/ogg" + "wav" -> "audio/wav" + "flac" -> "audio/flac" + else -> "application/octet-stream" + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopUploadOrchestrator.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopUploadOrchestrator.kt new file mode 100644 index 000000000..467fd528d --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopUploadOrchestrator.kt @@ -0,0 +1,78 @@ +/* + * 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.desktop.service.upload + +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nipB7Blossom.BlossomUploadResult +import java.io.File + +data class UploadResult( + val blossom: BlossomUploadResult, + val metadata: MediaMetadata, +) + +class DesktopUploadOrchestrator( + private val client: DesktopBlossomClient = DesktopBlossomClient(), +) { + suspend fun upload( + file: File, + alt: String?, + serverBaseUrl: String, + signer: NostrSigner, + stripExif: Boolean = true, + ): UploadResult { + // 1. Strip EXIF if requested (JPEG only) + val processedFile = + if (stripExif) { + DesktopMediaCompressor.stripExif(file) + } else { + file + } + + // 2. Compute metadata (hash, dimensions, blurhash) + val metadata = DesktopMediaMetadata.compute(processedFile) + + // 3. Create auth header + val authHeader = + DesktopBlossomAuth.createUploadAuth( + hash = metadata.sha256, + size = metadata.size, + alt = alt ?: "Uploading ${file.name}", + signer = signer, + ) + + // 4. Upload + val result = + client.upload( + file = processedFile, + contentType = metadata.mimeType, + serverBaseUrl = serverBaseUrl, + authHeader = authHeader, + ) + + // 5. Clean up temp file if we stripped EXIF + if (processedFile != file) { + processedFile.delete() + } + + return UploadResult(blossom = result, metadata = metadata) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopUploadTracker.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopUploadTracker.kt new file mode 100644 index 000000000..e79244ecf --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopUploadTracker.kt @@ -0,0 +1,52 @@ +/* + * 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.desktop.service.upload + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + +data class UploadState( + val isUploading: Boolean = false, + val fileName: String? = null, + val error: String? = null, + val result: UploadResult? = null, +) + +class DesktopUploadTracker { + private val _state = MutableStateFlow(UploadState()) + val state = _state.asStateFlow() + + fun startUpload(fileName: String) { + _state.value = UploadState(isUploading = true, fileName = fileName) + } + + fun onSuccess(result: UploadResult) { + _state.value = UploadState(isUploading = false, result = result) + } + + fun onError(error: String) { + _state.value = UploadState(isUploading = false, error = error) + } + + fun reset() { + _state.value = UploadState() + } +}