diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupCommands.kt index fe8bf98aef..7f3dd85719 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupCommands.kt @@ -41,6 +41,8 @@ object GroupCommands { "rename" to { rest -> GroupMetadataCommands.rename(dataDir, rest) }, "promote" to { rest -> GroupMetadataCommands.promote(dataDir, rest) }, "demote" to { rest -> GroupMetadataCommands.demote(dataDir, rest) }, + "set-image" to { rest -> GroupMetadataCommands.setImage(dataDir, rest) }, + "clear-image" to { rest -> GroupMetadataCommands.clearImage(dataDir, rest) }, "remove" to { rest -> GroupMembershipCommands.remove(dataDir, rest) }, "leave" to { rest -> GroupMembershipCommands.leave(dataDir, rest) }, ), diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupMetadataCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupMetadataCommands.kt index 841adf2c37..fee26ab50a 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupMetadataCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupMetadataCommands.kt @@ -20,15 +20,22 @@ */ package com.vitorpamplona.amethyst.cli.commands +import com.vitorpamplona.amethyst.cli.Args import com.vitorpamplona.amethyst.cli.Context import com.vitorpamplona.amethyst.cli.DataDir import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.amethyst.commons.service.upload.BlossomAuth +import com.vitorpamplona.amethyst.commons.service.upload.BlossomClient import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData +import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupImageEncryption import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import java.io.File /** - * Metadata-only commits: rename, promote/demote. Each loads current metadata, - * edits the right field, publishes a GCE commit to the group relays. + * Metadata-only commits: rename, promote/demote, set/clear image. Each loads current + * metadata, edits the right field, publishes a GCE commit to the group relays. */ object GroupMetadataCommands { suspend fun rename( @@ -64,9 +71,65 @@ object GroupMetadataCommands { } } + /** + * Set the group avatar (MIP-01 v2): encrypt the image, optionally push the + * ciphertext to Blossom (`--server`), and commit the image fields into the group + * metadata. The encryption is byte-for-byte interoperable with mdk/whitenoise. + * + * `group set-image [--server URL] [--mime TYPE]` + */ + suspend fun setImage( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val gid = args.positional(0, "gid") + val path = args.positional(1, "image-file") + val server = args.flag("server") + val file = File(path) + if (!file.isFile) return Output.error("bad_args", "no such file: $path") + + val plaintext = file.readBytes() + val enc = MarmotGroupImageEncryption.encrypt(plaintext) + val uploadKeySeed = MarmotGroupImageEncryption.generateUploadKey() + + // Optionally push the encrypted blob to Blossom, signed by the keypair derived + // from image_upload_key so an admin holding the seed can later replace/delete it. + var uploadedUrl: String? = null + if (server != null) { + val uploadSigner = NostrSignerInternal(KeyPair(privKey = MarmotGroupImageEncryption.deriveUploadKeypairSecret(uploadKeySeed))) + val tmp = File.createTempFile("marmot-icon", ".bin") + try { + tmp.writeBytes(enc.ciphertext) + val auth = BlossomAuth.createUploadAuth(enc.imageHash, enc.ciphertext.size.toLong(), "Group image", uploadSigner) + val result = BlossomClient().upload(tmp, "application/octet-stream", server, auth) + if (result.sha256 != null && result.sha256 != enc.imageHash) { + return Output.error("hash_mismatch", "blossom returned ${result.sha256}, expected ${enc.imageHash}") + } + uploadedUrl = result.url + } finally { + tmp.delete() + } + } + + return edit(dataDir, gid, mapOf("image_hash" to enc.imageHash, "image_url" to uploadedUrl)) { _, cur -> + cur.withImage(enc.imageHash, enc.imageKey, enc.imageNonce, uploadKeySeed) + } + } + + /** Remove the group avatar. `group clear-image ` */ + suspend fun clearImage( + dataDir: DataDir, + rest: Array, + ): Int { + if (rest.isEmpty()) return Output.error("bad_args", "group clear-image ") + return edit(dataDir, rest[0]) { _, cur -> cur.withoutImage() } + } + private suspend fun edit( dataDir: DataDir, rawGid: HexKey, + extra: Map = emptyMap(), mutate: suspend (Context, MarmotGroupData) -> MarmotGroupData, ): Int { Context.open(dataDir).use { ctx -> @@ -96,7 +159,7 @@ object GroupMetadataCommands { "epoch" to ctx.marmot.groupEpoch(gid), "commit_event_id" to commit.signedEvent.id, "published_to" to ack.filterValues { it }.keys.map { it.url }, - ), + ) + extra, ) return 0 } diff --git a/cli/tests/marmot/marmot-interop-headless.sh b/cli/tests/marmot/marmot-interop-headless.sh index 04b8334512..15a49f79a2 100755 --- a/cli/tests/marmot/marmot-interop-headless.sh +++ b/cli/tests/marmot/marmot-interop-headless.sh @@ -128,6 +128,7 @@ test_05_b_adds_a_existing test_06_member_removal test_07_metadata_rename test_08_admin_promote_demote +test_17_group_image_commit test_09_reply_react_unreact test_10_concurrent_commits test_11_leave_group diff --git a/cli/tests/marmot/tests-manage.sh b/cli/tests/marmot/tests-manage.sh index 03763d4207..450ca84f63 100644 --- a/cli/tests/marmot/tests-manage.sh +++ b/cli/tests/marmot/tests-manage.sh @@ -189,3 +189,49 @@ test_11_leave_group() { record_result "$id" fail "A still in B's member list after leave" fi } + +# Regression guard for the Marmot group-icon feature: setting a group image +# writes the MIP-01 image fields into the NostrGroupData extension. mdk-core (the +# library whitenoise uses) rejects ANY trailing bytes in that extension at a known +# version, so a mis-encoded image commit would make the whole group unprocessable +# for wn. We verify end-to-end that an amy-authored image commit stays parseable: +# A sets an image, then renames — the rename is a LATER epoch, so wn can only +# observe the new name if it first applied the image-bearing GCE commit. If the +# image extension were rejected, wn would stall at the pre-image epoch and never +# see the rename. +test_17_group_image_commit() { + banner "Test 17 — Group image commit stays parseable on whitenoise (MIP-01 v2)" + local id="17 group image" + + local gid mls_gid + gid=$(load_state GROUP_02 || true) + mls_gid=$(load_state GROUP_02_MLS || true) + if [[ -z "${gid:-}" || -z "${mls_gid:-}" ]]; then + record_result "$id" skip "no GROUP_02"; return + fi + + # Contents are irrelevant — amy encrypts whatever bytes it's given; the interop + # question is purely whether the resulting image extension parses on wn. + local img="$STATE_DIR/marmot-icon.bin" + head -c 1024 /dev/urandom >"$img" 2>/dev/null || printf 'fake-avatar-bytes-for-interop' >"$img" + + if ! amy_json marmot group set-image "$gid" "$img" >/dev/null; then + record_result "$id" fail "amy set-image failed"; return + fi + sleep 3 + if ! amy_json marmot group rename "$gid" "Interop-02-iconecho" >/dev/null; then + record_result "$id" fail "amy rename after set-image failed"; return + fi + + local deadline=$(( $(date +%s) + 120 )) seen="" + while [[ $(date +%s) -lt $deadline ]]; do + seen=$(wn_b --json groups show "$mls_gid" 2>/dev/null | jq -r '(.result // .) | (.group // .) | .name // empty') + [[ "$seen" == "Interop-02-iconecho" ]] && break + sleep 3 + done + if [[ "$seen" == "Interop-02-iconecho" ]]; then + record_result "$id" pass + else + record_result "$id" fail "wn did not advance past the image commit (name=\"$seen\")" + fi +}