feat(concord): reuse the standard compressed/encrypted upload pipeline for community images

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) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-07-13 18:22:33 -04:00
co-authored by Claude Opus 4.8
parent 66a5e111d6
commit 5de55741a2
@@ -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 }
}
}
}