refactor: promote desktop upload classes to commons/jvmMain
Moves five JVM-only upload helpers from desktopApp into commons so
the CLI can reuse the same Blossom upload + AES-GCM encryption path
without depending on :desktopApp:
desktop/service/upload/DesktopBlossomAuth.kt → commons/service/upload/BlossomAuth.kt
desktop/service/upload/DesktopBlossomClient.kt → commons/service/upload/BlossomClient.kt
desktop/service/upload/DesktopMediaCompressor.kt → commons/service/upload/MediaCompressor.kt
desktop/service/upload/DesktopMediaMetadata.kt → commons/service/upload/MediaMetadata.kt
desktop/service/upload/DesktopUploadOrchestrator.kt → commons/service/upload/UploadOrchestrator.kt
API tweaks:
- Drop the `Desktop` prefix on every class.
- Rename the `DesktopMediaMetadata` object to `MediaMetadataReader`
to avoid colliding with the `MediaMetadata` data class in the same
package.
- BlossomClient no longer reaches into desktop's `DesktopHttpClient`.
Callers pass an `OkHttpClient` (or use the default one-shot client);
desktop continues to inject its Tor-aware client.
- Tighten BlossomClient's response handling — the OkHttp version on
commons' classpath has a nullable `Response.body`, so explicitly
null-check it.
Mechanical updates to keep desktop building:
- ComposeNoteDialog / ChatPane / DesktopUploadTracker switch to the
new commons imports.
- DimensionTag-from-MediaMetadata: cross-module smart cast no longer
works on the public `width`/`height` properties — replace with
`?.let { … }` chains.
- commons/jvmMain now declares `commons-imaging` (used only by
MediaCompressor's EXIF stripping).
Tests come along too: file names follow the renamed classes
(spotless ktlint enforces this).
Next commit will add `amy dm send-file --file PATH --server URL`,
which calls `UploadOrchestrator.uploadEncrypted(...)` directly.
This commit is contained in:
@@ -104,6 +104,9 @@ kotlin {
|
||||
|
||||
// Secure key storage via OS keychain (macOS/Windows/Linux)
|
||||
implementation(libs.java.keyring)
|
||||
|
||||
// EXIF stripping for image uploads (used by service/upload/MediaCompressor).
|
||||
implementation(libs.commons.imaging)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* 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.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent
|
||||
import java.util.Base64
|
||||
|
||||
object BlossomAuth {
|
||||
suspend fun createUploadAuth(
|
||||
hash: HexKey,
|
||||
size: Long,
|
||||
alt: String,
|
||||
signer: NostrSigner,
|
||||
): String {
|
||||
val event = BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, signer)
|
||||
return encodeAuthHeader(event)
|
||||
}
|
||||
|
||||
fun encodeAuthHeader(event: BlossomAuthorizationEvent): String {
|
||||
val b64 = Base64.getEncoder().encodeToString(event.toJson().toByteArray())
|
||||
return "Nostr $b64"
|
||||
}
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
* 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.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.
|
||||
*/
|
||||
class BlossomClient(
|
||||
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)
|
||||
|
||||
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")
|
||||
}
|
||||
val body = it.body ?: throw RuntimeException("Upload to $serverBaseUrl returned no body")
|
||||
JsonMapper.fromJson<BlossomUploadResult>(body.string())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload raw bytes (e.g. encrypted blobs) to a Blossom server.
|
||||
*/
|
||||
suspend fun upload(
|
||||
bytes: ByteArray,
|
||||
contentType: String,
|
||||
serverBaseUrl: String,
|
||||
authHeader: String?,
|
||||
): BlossomUploadResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
val apiUrl = serverBaseUrl.removeSuffix("/") + "/upload"
|
||||
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["X-Reason"] ?: it.code.toString()
|
||||
throw RuntimeException("Upload failed ($serverBaseUrl): $reason")
|
||||
}
|
||||
val body = it.body ?: throw RuntimeException("Upload to $serverBaseUrl returned no body")
|
||||
JsonMapper.fromJson<BlossomUploadResult>(body.string())
|
||||
}
|
||||
}
|
||||
}
|
||||
+50
@@ -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.commons.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 MediaCompressor {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
* 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.amethyst.commons.blurhash.toBlurhash
|
||||
import com.vitorpamplona.amethyst.commons.blurhash.toPlatformImage
|
||||
import com.vitorpamplona.amethyst.commons.thumbhash.toThumbhash
|
||||
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,
|
||||
val thumbhash: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Reads media metadata (sha256, size, mime-type, dimensions, blurhash, thumbhash)
|
||||
* from a JVM [File]. Named to avoid colliding with the [MediaMetadata] data class
|
||||
* in the same package.
|
||||
*/
|
||||
object MediaMetadataReader {
|
||||
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
|
||||
var thumbhash: String? = null
|
||||
|
||||
if (mimeType.startsWith("image/")) {
|
||||
try {
|
||||
val image = ImageIO.read(file)
|
||||
if (image != null) {
|
||||
width = image.width
|
||||
height = image.height
|
||||
val platformImage = image.toPlatformImage()
|
||||
blurhash = runCatching { platformImage.toBlurhash() }.getOrNull()
|
||||
thumbhash = runCatching { platformImage.toThumbhash() }.getOrNull()
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
|
||||
return MediaMetadata(
|
||||
sha256 = hash,
|
||||
size = bytes.size.toLong(),
|
||||
mimeType = mimeType,
|
||||
width = width,
|
||||
height = height,
|
||||
blurhash = blurhash,
|
||||
thumbhash = thumbhash,
|
||||
)
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
/*
|
||||
* 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.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomUploadResult
|
||||
import com.vitorpamplona.quartz.utils.ciphers.AESGCM
|
||||
import com.vitorpamplona.quartz.utils.sha256.sha256
|
||||
import java.io.File
|
||||
|
||||
data class UploadResult(
|
||||
val blossom: BlossomUploadResult,
|
||||
val metadata: MediaMetadata,
|
||||
)
|
||||
|
||||
data class EncryptedUploadResult(
|
||||
val blossom: BlossomUploadResult,
|
||||
val metadata: MediaMetadata,
|
||||
val encryptedHash: String,
|
||||
val encryptedSize: Int,
|
||||
)
|
||||
|
||||
class UploadOrchestrator(
|
||||
private val client: BlossomClient = BlossomClient(),
|
||||
) {
|
||||
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) {
|
||||
MediaCompressor.stripExif(file)
|
||||
} else {
|
||||
file
|
||||
}
|
||||
|
||||
// 2. Compute metadata (hash, dimensions, blurhash)
|
||||
val metadata = MediaMetadataReader.compute(processedFile)
|
||||
|
||||
// 3. Create auth header
|
||||
val authHeader =
|
||||
BlossomAuth.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)
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a file encrypted with AES-GCM for NIP-17 DM file sharing.
|
||||
* Computes pre-encryption metadata (dimensions, blurhash), encrypts bytes,
|
||||
* then uploads the encrypted blob to Blossom.
|
||||
*/
|
||||
suspend fun uploadEncrypted(
|
||||
file: File,
|
||||
cipher: AESGCM,
|
||||
serverBaseUrl: String,
|
||||
signer: NostrSigner,
|
||||
): EncryptedUploadResult {
|
||||
// 1. Compute pre-encryption metadata (dimensions, blurhash, mime, originalHash)
|
||||
val metadata = MediaMetadataReader.compute(file)
|
||||
|
||||
// 2. Read file bytes and encrypt
|
||||
val plaintext = file.readBytes()
|
||||
val encrypted = cipher.encrypt(plaintext)
|
||||
|
||||
// 3. Compute SHA256 of ENCRYPTED blob (not plaintext)
|
||||
val encryptedHash = sha256(encrypted).toHexKey()
|
||||
val encryptedSize = encrypted.size
|
||||
|
||||
// 4. Create Blossom auth with encrypted hash and size
|
||||
val authHeader =
|
||||
BlossomAuth.createUploadAuth(
|
||||
hash = encryptedHash,
|
||||
size = encryptedSize.toLong(),
|
||||
alt = "Encrypted upload",
|
||||
signer = signer,
|
||||
)
|
||||
|
||||
// 5. Upload encrypted blob as opaque binary
|
||||
val result =
|
||||
client.upload(
|
||||
bytes = encrypted,
|
||||
contentType = "application/octet-stream",
|
||||
serverBaseUrl = serverBaseUrl,
|
||||
authHeader = authHeader,
|
||||
)
|
||||
|
||||
return EncryptedUploadResult(
|
||||
blossom = result,
|
||||
metadata = metadata,
|
||||
encryptedHash = encryptedHash,
|
||||
encryptedSize = encryptedSize,
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user