From 5de55741a28de68bb7c0a41bccc562673440bd3d Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 13 Jul 2026 18:22:33 -0400 Subject: [PATCH] feat(concord): reuse the standard compressed/encrypted upload pipeline for community images MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ConcordImageUploader now drives UploadOrchestrator.uploadEncrypted — the same path DM/chat encrypted media uses — instead of a hand-rolled BlossomUploader call. A community icon now gets image compression, EXIF/metadata stripping, and the account's configured Blossom server, keeping the simple photo picker. It hands the orchestrator a fresh AESGCM cipher and maps the result — ciphertext url + plaintext hashBeforeEncryption — into the CORD-02 §6 ImagePointer, which the read path (rememberConcordImageModel) round-trips. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../concord/ConcordImageUploader.kt | 124 +++++++++--------- 1 file changed, 65 insertions(+), 59 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordImageUploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordImageUploader.kt index 5b3c7af3be..6ec70492a0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordImageUploader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordImageUploader.kt @@ -22,80 +22,86 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.conco import android.content.Context import android.net.Uri -import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader +import com.vitorpamplona.amethyst.service.uploads.CompressorQuality +import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator +import com.vitorpamplona.amethyst.service.uploads.UploadingState import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS +import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType +import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.utils.ciphers.AESGCM -import com.vitorpamplona.quartz.utils.sha256.sha256 -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import java.io.ByteArrayInputStream /** - * Authors a CORD-02 §6 encrypted community image: AES-256-GCM-encrypts the plaintext under a fresh - * random key/nonce (same scheme as NIP-17 DM encrypted media), uploads the *ciphertext* as an opaque - * blob to the account's Blossom server, and returns the [ImagePointer] to seal in the community - * metadata — the exact inverse of [rememberConcordImageModel]'s read path. Mirrors Armada's - * `encryptImageBlob` + Blossom upload in `concord-v2/lib/image.ts`. + * Authors a CORD-02 §6 encrypted community image and returns the [ImagePointer] to seal in the + * community metadata — the exact inverse of [rememberConcordImageModel]'s read path, and the same + * scheme Armada's `encryptImageBlob` + Blossom upload uses in `concord-v2/lib/image.ts`. + * + * Rather than hand-roll the upload, this drives the **standard** [UploadOrchestrator.uploadEncrypted] + * pipeline that DM/chat encrypted media uses, so a community icon gets the same treatment as any + * other attachment: image compression ([CompressorQuality]), EXIF/metadata stripping, and the + * account's configured Blossom server. The only Concord-specific parts are the fresh [AESGCM] cipher + * (whose key/nonce we keep to build the pointer) and mapping the orchestrator's result — the + * ciphertext `url` plus the *plaintext* `hashBeforeEncryption` — into the [ImagePointer] shape. */ class ConcordImageUploader( private val account: Account, ) { - suspend fun uploadEncrypted( - plaintext: ByteArray, - context: Context, - ): ImagePointer { - val serverBaseUrl = - account.blossomServers - .getBlossomServersList() - ?.servers() - ?.firstOrNull() - ?: DEFAULT_MEDIA_SERVERS.first { it.type == ServerType.Blossom }.baseUrl - - val cipher = AESGCM() - val ciphertext = cipher.encrypt(plaintext) - - val result = - withContext(Dispatchers.IO) { - BlossomUploader().upload( - // The blob is content-addressed by the SHA-256 of the *uploaded* (encrypted) bytes; - // the pointer's own hash below is over the *plaintext* for integrity on read. - inputStream = ByteArrayInputStream(ciphertext), - hash = sha256(ciphertext).toHexKey(), - length = ciphertext.size.toLong(), - baseFileName = "concord-image", - contentType = "application/octet-stream", - alt = "Encrypted Concord community image", - sensitiveContent = null, - serverBaseUrl = serverBaseUrl, - okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads, - httpAuth = account::createBlossomUploadAuth, - context = context, - ) - } - - val url = result.url ?: throw IllegalStateException("Blossom upload returned no URL") - return ImagePointer( - url = url, - key = cipher.keyBytes.toHexKey(), - nonce = cipher.nonce.toHexKey(), - hash = sha256(plaintext).toHexKey(), - ) - } - - /** Reads the picked [uri]'s bytes then [uploadEncrypted]s them. */ + /** Compresses, strips, AES-256-GCM-encrypts and uploads the picked [uri], returning its pointer. */ suspend fun uploadEncrypted( uri: Uri, context: Context, ): ImagePointer { - val bytes = - withContext(Dispatchers.IO) { - context.contentResolver.openInputStream(uri)?.use { it.readBytes() } - } ?: throw IllegalStateException("Could not read the selected image") - return uploadEncrypted(bytes, context) + // Fresh random key + nonce per image; we hold onto them to build the pointer below since the + // orchestrator only surfaces the ciphertext URL, not the cipher it was handed. + val cipher = AESGCM() + + val finalState = + UploadOrchestrator().uploadEncrypted( + uri = uri, + mimeType = context.contentResolver.getType(uri), + alt = null, + contentWarningReason = null, + compressionQuality = CompressorQuality.MEDIUM, + encrypt = cipher, + server = resolveBlossomServer(), + account = account, + context = context, + ) + + val result = + when (finalState) { + is UploadingState.Finished -> finalState.result + is UploadingState.Error -> throw IllegalStateException(stringRes(context, finalState.errorResource, *finalState.params)) + } + + val server = + result as? UploadOrchestrator.OrchestratorResult.ServerResult + ?: throw IllegalStateException("Encrypted community image upload did not return a server URL") + + return ImagePointer( + url = server.url, + key = cipher.keyBytes.toHexKey(), + nonce = cipher.nonce.toHexKey(), + // hash is over the *plaintext* (post-compression/strip) bytes — the read path verifies it + // after decrypting, so it must match what was actually encrypted, not the original file. + hash = server.hashBeforeEncryption ?: throw IllegalStateException("Upload pipeline did not report the plaintext hash"), + ) + } + + /** The account's first configured Blossom server, wrapped as a [ServerName], else the default. */ + private fun resolveBlossomServer(): ServerName { + val configured = + account.blossomServers + .getBlossomServersList() + ?.servers() + ?.firstOrNull() + return if (configured != null) { + ServerName(configured, configured, ServerType.Blossom) + } else { + DEFAULT_MEDIA_SERVERS.first { it.type == ServerType.Blossom } + } } }