Merge remote-tracking branch 'origin/main' into claude/thin-material-icons-9SFWk

# Conflicts:
#	desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt
This commit is contained in:
Claude
2026-04-23 22:12:15 +00:00
54 changed files with 1932 additions and 166 deletions
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.commons.marmot
import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.unwrapAndUnsealOrNull
import com.vitorpamplona.quartz.marmot.GroupEventResult
import com.vitorpamplona.quartz.marmot.MarmotInboundProcessor
import com.vitorpamplona.quartz.marmot.WelcomeResult
@@ -27,7 +28,6 @@ import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent
import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
/**
@@ -96,19 +96,12 @@ suspend fun MarmotManager.ingest(event: Event): MarmotIngestResult =
private suspend fun MarmotManager.ingestGiftWrap(wrap: GiftWrapEvent): MarmotIngestResult =
try {
// NIP-59 wraps contain TWO encryption layers:
// kind:1059 gift wrap → kind:13 sealed rumor → the rumor itself.
// `GiftWrapEvent.unwrapOrNull` only peels the outer layer; when the
// result is a [SealedRumorEvent] we must unseal it to reach the
// kind:444 Welcome rumor. The old code checked `isWelcomeEvent` on
// the seal (kind:13) and always took the Ignored branch, which is
// why every inbound Welcome was silently dropped by the CLI and by
// any non-Amethyst consumer.
val rumor =
when (val inner = wrap.unwrapOrNull(signer) ?: return MarmotIngestResult.Ignored) {
is SealedRumorEvent -> inner.unsealOrNull(signer) ?: return MarmotIngestResult.Ignored
else -> inner
}
// NIP-59 wraps carry two encryption layers (kind:1059 → kind:13 → rumor).
// [unwrapAndUnsealOrNull] peels both so we land directly on the inner
// kind:444 Welcome rumor. Checking `isWelcomeEvent` on the seal itself
// (the old bug) always took the Ignored branch and silently dropped
// every inbound Welcome.
val rumor = wrap.unwrapAndUnsealOrNull(signer) ?: return MarmotIngestResult.Ignored
if (!MarmotInboundProcessor.isWelcomeEvent(rumor) || rumor !is WelcomeEvent) {
return MarmotIngestResult.Ignored
}
@@ -0,0 +1,49 @@
/*
* 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.relayClient.nip17Dm
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.EphemeralGiftWrapEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.utils.TimeUtils
fun filterGiftWrapsToPubkey(
relay: NormalizedRelayUrl,
pubkey: HexKey?,
since: Long?,
): List<RelayBasedFilter> {
if (pubkey.isNullOrEmpty()) return emptyList()
return listOf(
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = listOf(GiftWrapEvent.KIND, EphemeralGiftWrapEvent.KIND),
tags = mapOf("p" to listOf(pubkey)),
since = since?.minus(TimeUtils.twoDays()),
),
),
)
}
@@ -0,0 +1,46 @@
/*
* 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.relayClient.nip17Dm
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
/**
* Fully decrypt a NIP-59 gift wrap down to the inner rumor.
*
* A gift wrap carries two encryption layers:
* kind:1059 [GiftWrapEvent] → kind:13 [SealedRumorEvent] → the rumor.
*
* [GiftWrapEvent.unwrapOrNull] peels only the outer layer. Every non-Android
* consumer that looks at the rumor directly (the CLI's `dm list`, the desktop
* chat receive path, the Marmot welcome ingest in commons) needs both peels.
*
* If the inner content isn't a [SealedRumorEvent] (malformed, or a future
* NIP-59 variant that wraps a rumor directly), the outer unwrap result is
* returned as-is so callers can still route on kind. Either unwrap returning
* null propagates as null.
*/
suspend fun GiftWrapEvent.unwrapAndUnsealOrNull(signer: NostrSigner): Event? {
val inner = unwrapOrNull(signer) ?: return null
return if (inner is SealedRumorEvent) inner.unsealOrNull(signer) else inner
}
@@ -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"
}
}
@@ -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())
}
}
}
@@ -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
}
}
}
@@ -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"
}
}
}
@@ -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,
)
}
}