mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-11 08:47:33 +00:00
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:
co-authored by
Claude Opus 4.8
parent
66a5e111d6
commit
5de55741a2
+65
-59
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user