mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
feat(marmot): group avatars behind a plain https link
`marmot.group.avatar-url.v1` (0x8007) is the lightweight alternative to the encrypted Blossom blob: a link, two opaque render hints, no key material. We had neither the codec nor a carrier for it, so a group MDK gave a URL avatar rendered as if it had none. The hard part is not the struct, it is that the URL is canonical state. The spec makes normalization a producer-side encoding rule and requires a decoder to re-run the WHATWG parse-and-serialize and REJECT bytes that differ — never repair them, because two members repairing differently hold different bytes for the same group. So `MarmotHttpsUrl` is a WHATWG serializer, not a validator with a regex: lowercased scheme and host, the default port dropped, dot-segments resolved against a segment list (a trailing slash is a final empty segment, which is also why `/a/.` keeps one), percent-encoding normalized with existing triplets left verbatim. The vectors in the test come from the Rust `url` crate the reference implementation uses, so the two agree byte for byte. Non-ASCII hosts are refused rather than guessed at: IDNA is not implemented here, and a wrong punycode encoding would be worse than a refusal. Contact safety is deliberately a separate function. A URL can be perfectly valid group state and still be somewhere this client refuses to go, and the spec is explicit that the fetch decision "MUST NOT affect component or commit validity" — so the SSRF check lives at the renderer, where an unsafe destination falls back to the Blossom image instead of erroring. Clearing writes the canonical empty state rather than removing the component. Removal is not a free substitute: a component MUST NOT be removed while `app_components` still lists it as required, so a remove is only legal in the same Commit that stops requiring it — and the empty state is what the reference implementation writes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
This commit is contained in:
+34
@@ -28,6 +28,8 @@ import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupImage
|
||||
import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.marmot.appComponents.GroupAvatarUrlV1
|
||||
import com.vitorpamplona.quartz.marmot.appComponents.MarmotHttpsUrl
|
||||
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupImageCipher
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
@@ -92,6 +94,38 @@ fun rememberMarmotGroupIconUrl(
|
||||
return url
|
||||
}
|
||||
|
||||
/**
|
||||
* The avatar URL for a group that may carry either avatar carrier, applying the
|
||||
* components' precedence: `marmot.group.avatar-url.v1` wins over
|
||||
* `marmot.group.blossom.image.v1`, and clearing the URL one falls back to the
|
||||
* Blossom blob.
|
||||
*
|
||||
* The URL avatar is a plain link with no key material, so there is no cipher to
|
||||
* register — it just goes to Coil. It does get a contact check first: a URL can
|
||||
* be valid group state and still be somewhere we refuse to fetch from, and the
|
||||
* spec puts that decision squarely on the client. An unsafe destination renders
|
||||
* as no URL avatar rather than as an error, which lets the Blossom image (or the
|
||||
* relay icon) take over.
|
||||
*
|
||||
* Returns null when the group has neither carrier.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberMarmotGroupAvatarUrl(
|
||||
avatarUrl: GroupAvatarUrlV1?,
|
||||
image: MarmotGroupImage?,
|
||||
accountViewModel: AccountViewModel,
|
||||
adminPubkeys: List<HexKey> = emptyList(),
|
||||
): String? {
|
||||
val link =
|
||||
remember(avatarUrl) {
|
||||
avatarUrl?.url?.takeIf { it.isNotEmpty() && MarmotHttpsUrl.isSafeToContact(it) }
|
||||
}
|
||||
// Branch rather than resolving both: the Blossom path registers a
|
||||
// decryption cipher and probes servers as a side effect, and neither is
|
||||
// worth doing for an avatar the renderer is not going to show.
|
||||
return if (link != null) link else rememberMarmotGroupIconUrl(image, accountViewModel, adminPubkeys)
|
||||
}
|
||||
|
||||
/**
|
||||
* The NIP-11 icon of the group's first resolvable relay, used as a fallback avatar
|
||||
* when the group has no image of its own. Fetches the relay's NIP-11 document on a
|
||||
|
||||
+7
-5
@@ -103,7 +103,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.buzzTimeli
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.observeUserNameByHex
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.loadMarmotRelayIcon
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.marmotGroupLastReadRoute
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.rememberMarmotGroupIconUrl
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.rememberMarmotGroupAvatarUrl
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.RoomNameDisplay
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.reportWarningContentDescription
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordCommunityPill
|
||||
@@ -466,6 +466,7 @@ private fun MarmotGroupRoomCompose(
|
||||
) {
|
||||
val displayName by chatroom.displayName.collectAsStateWithLifecycle()
|
||||
val image by chatroom.image.collectAsStateWithLifecycle()
|
||||
val avatarUrl by chatroom.avatarUrl.collectAsStateWithLifecycle()
|
||||
val relays by chatroom.relays.collectAsStateWithLifecycle()
|
||||
val adminPubkeys by chatroom.adminPubkeys.collectAsStateWithLifecycle()
|
||||
|
||||
@@ -473,11 +474,12 @@ private fun MarmotGroupRoomCompose(
|
||||
val noteEvent = lastMessage.event
|
||||
val groupName = displayName?.takeIf { it.isNotBlank() } ?: "Group ${chatroom.nostrGroupId.take(8)}"
|
||||
|
||||
// Prefer the group's own (encrypted) avatar; when it has none, fall back to the
|
||||
// NIP-11 icon of one of the group's relays (fetched on a cache miss).
|
||||
// Prefer the group's own avatar — the plain https link first, then the
|
||||
// encrypted Blossom blob; when it has neither, fall back to the NIP-11 icon
|
||||
// of one of the group's relays (fetched on a cache miss).
|
||||
val channelPicture =
|
||||
if (image != null) {
|
||||
rememberMarmotGroupIconUrl(image, accountViewModel, adminPubkeys)
|
||||
if (avatarUrl != null || image != null) {
|
||||
rememberMarmotGroupAvatarUrl(avatarUrl, image, accountViewModel, adminPubkeys)
|
||||
} else {
|
||||
loadMarmotRelayIcon(relays)
|
||||
}
|
||||
|
||||
+4
-3
@@ -90,7 +90,7 @@ import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.loadMarmotRelayIcon
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.rememberMarmotGroupIconUrl
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.rememberMarmotGroupAvatarUrl
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.rememberConcordImageModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.SubPurposeLabels
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
@@ -593,14 +593,15 @@ private fun rememberMarmotEntity(
|
||||
|
||||
val displayName by chatroom.displayName.collectAsStateWithLifecycle()
|
||||
val image by chatroom.image.collectAsStateWithLifecycle()
|
||||
val avatarUrl by chatroom.avatarUrl.collectAsStateWithLifecycle()
|
||||
val relays by chatroom.relays.collectAsStateWithLifecycle()
|
||||
val adminPubkeys by chatroom.adminPubkeys.collectAsStateWithLifecycle()
|
||||
|
||||
// Same name/icon precedence the chat-rooms list uses, so a group reads identically in both places.
|
||||
val name = displayName?.takeIf { it.isNotBlank() } ?: stringRes(Res.string.marmot_group_fallback_name, id.take(8))
|
||||
val picture =
|
||||
if (image != null) {
|
||||
rememberMarmotGroupIconUrl(image, accountViewModel, adminPubkeys)
|
||||
if (avatarUrl != null || image != null) {
|
||||
rememberMarmotGroupAvatarUrl(avatarUrl, image, accountViewModel, adminPubkeys)
|
||||
} else {
|
||||
loadMarmotRelayIcon(relays)
|
||||
}
|
||||
|
||||
@@ -41,6 +41,9 @@ object GroupCommands {
|
||||
| marmot group set-image GID FILE encrypt + commit a group avatar
|
||||
| [--server URL] (--server uploads the ciphertext to Blossom)
|
||||
| marmot group clear-image GID remove the group avatar
|
||||
| marmot group set-avatar-url GID URL commit a plain https avatar link
|
||||
| [--dim WxH] [--thumbhash TEXT] (optional opaque render hints)
|
||||
| marmot group clear-avatar-url GID remove the https avatar link
|
||||
| marmot group remove GID NPUB remove member
|
||||
| marmot group leave GID self-remove
|
||||
""".trimMargin()
|
||||
@@ -65,6 +68,8 @@ object GroupCommands {
|
||||
"demote" to { rest -> GroupMetadataCommands.demote(dataDir, rest) },
|
||||
"set-image" to { rest -> GroupMetadataCommands.setImage(dataDir, rest) },
|
||||
"clear-image" to { rest -> GroupMetadataCommands.clearImage(dataDir, rest) },
|
||||
"set-avatar-url" to { rest -> GroupMetadataCommands.setAvatarUrl(dataDir, rest) },
|
||||
"clear-avatar-url" to { rest -> GroupMetadataCommands.clearAvatarUrl(dataDir, rest) },
|
||||
"remove" to { rest -> GroupMembershipCommands.remove(dataDir, rest) },
|
||||
"leave" to { rest -> GroupMembershipCommands.leave(dataDir, rest) },
|
||||
),
|
||||
|
||||
@@ -29,7 +29,9 @@ import com.vitorpamplona.amethyst.commons.service.upload.BlossomAuth
|
||||
import com.vitorpamplona.amethyst.commons.service.upload.BlossomClient
|
||||
import com.vitorpamplona.amethyst.commons.util.deleteOrWarn
|
||||
import com.vitorpamplona.quartz.marmot.OutboundGroupEvent
|
||||
import com.vitorpamplona.quartz.marmot.appComponents.GroupAvatarUrlV1
|
||||
import com.vitorpamplona.quartz.marmot.appComponents.GroupBlossomImageV1
|
||||
import com.vitorpamplona.quartz.marmot.appComponents.MarmotHttpsUrl
|
||||
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupImageEncryption
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
@@ -139,6 +141,51 @@ object GroupMetadataCommands {
|
||||
return commit(dataDir, rest[0]) { ctx, gid, _ -> ctx.marmot.setGroupImage(gid, null) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Point the group avatar at a plain `https` URL (`0x8007`).
|
||||
*
|
||||
* The URL is normalized by the component's encoder, so what gets committed
|
||||
* may differ from what was typed — the emitted `avatar_url` is the stored
|
||||
* form, not the argument.
|
||||
*
|
||||
* `group set-avatar-url <gid> <https-url> [--dim WIDTHxHEIGHT] [--thumbhash TEXT]`
|
||||
*/
|
||||
suspend fun setAvatarUrl(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val gid = args.positional(0, "gid")
|
||||
val url = args.positional(1, "url")
|
||||
val dim = args.flag("dim")
|
||||
val thumbhash = args.flag("thumbhash")
|
||||
args.rejectUnknown()
|
||||
|
||||
val avatar =
|
||||
try {
|
||||
GroupAvatarUrlV1(
|
||||
url = MarmotHttpsUrl.normalize(url),
|
||||
dim = dim?.encodeToByteArray() ?: ByteArray(0),
|
||||
thumbhash = thumbhash?.encodeToByteArray() ?: ByteArray(0),
|
||||
)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
return Output.error("bad_args", e.message ?: "invalid avatar URL")
|
||||
}
|
||||
|
||||
return commit(dataDir, gid, mapOf("avatar_url" to avatar.url)) { ctx, resolved, _ ->
|
||||
ctx.marmot.setGroupAvatarUrl(resolved, avatar)
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove the https avatar link. `group clear-avatar-url <gid>` */
|
||||
suspend fun clearAvatarUrl(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
if (rest.isEmpty()) return Output.error("bad_args", "group clear-avatar-url <gid>")
|
||||
return commit(dataDir, rest[0]) { ctx, gid, _ -> ctx.marmot.setGroupAvatarUrl(gid, null) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one metadata commit and report it.
|
||||
*
|
||||
|
||||
@@ -72,6 +72,21 @@ object GroupReadCommands {
|
||||
"epoch" to ctx.marmot.groupEpoch(gid),
|
||||
"admins" to (meta?.adminPubkeys ?: emptyList()),
|
||||
"relays" to (meta?.relays ?: emptyList()),
|
||||
"avatar_url" to meta?.avatarUrl?.url,
|
||||
// Hints are opaque bytes by contract; render them as text
|
||||
// only for the conventional UTF-8 case an operator can read.
|
||||
"avatar_dim" to
|
||||
meta
|
||||
?.avatarUrl
|
||||
?.dim
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?.decodeToString(),
|
||||
"avatar_thumbhash" to
|
||||
meta
|
||||
?.avatarUrl
|
||||
?.thumbhash
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?.decodeToString(),
|
||||
"members" to members,
|
||||
"is_admin" to (meta?.adminPubkeys?.contains(ctx.identity.pubKeyHex) == true),
|
||||
),
|
||||
|
||||
+48
@@ -33,6 +33,7 @@ import com.vitorpamplona.quartz.marmot.WelcomeDelivery
|
||||
import com.vitorpamplona.quartz.marmot.WelcomeResult
|
||||
import com.vitorpamplona.quartz.marmot.appComponents.AdminPolicyV1
|
||||
import com.vitorpamplona.quartz.marmot.appComponents.CurrentProfileGroupFactory
|
||||
import com.vitorpamplona.quartz.marmot.appComponents.GroupAvatarUrlV1
|
||||
import com.vitorpamplona.quartz.marmot.appComponents.GroupBlossomImageV1
|
||||
import com.vitorpamplona.quartz.marmot.appComponents.GroupProfileV1
|
||||
import com.vitorpamplona.quartz.marmot.appComponents.MarmotGroupState
|
||||
@@ -1116,6 +1117,16 @@ class MarmotManager(
|
||||
val adminPubkeys: List<HexKey>,
|
||||
val relays: List<String>,
|
||||
val image: MarmotGroupImage?,
|
||||
/**
|
||||
* The plain-https avatar (`0x8007`), or null when the group carries
|
||||
* none. Absent-but-present state reads as null here: a cleared avatar
|
||||
* and no avatar look identical to a renderer, and the difference only
|
||||
* matters to the codec.
|
||||
*
|
||||
* When this and [image] are both set, this one wins — see
|
||||
* [MarmotGroupState.preferredAvatar].
|
||||
*/
|
||||
val avatarUrl: GroupAvatarUrlV1?,
|
||||
/** True when the group requires `0x8009` — see [MarmotGroupState.isCurrentProfile]. */
|
||||
val isCurrentProfile: Boolean,
|
||||
)
|
||||
@@ -1140,6 +1151,7 @@ class MarmotManager(
|
||||
|
||||
else -> null
|
||||
},
|
||||
avatarUrl = state.avatarUrl?.takeIf { !it.isAbsent },
|
||||
isCurrentProfile = state.isCurrentProfile,
|
||||
)
|
||||
}
|
||||
@@ -1242,6 +1254,41 @@ class MarmotManager(
|
||||
}.event
|
||||
}
|
||||
|
||||
/**
|
||||
* Set or clear the group's plain-https avatar (`marmot.group.avatar-url.v1`).
|
||||
*
|
||||
* [avatar] null clears it by writing the canonical EMPTY state rather than
|
||||
* removing the component. That is the spec's own clear ("Clearing the
|
||||
* avatar sends the empty state"), and removal is not a free substitute for
|
||||
* it: a component MUST NOT be removed while `app_components` still lists it
|
||||
* as required, so a remove would only be legal in the same Commit that
|
||||
* stopped requiring it.
|
||||
*
|
||||
* There is no legacy carrier for this. MIP-01's `0xF2EE` blob had only the
|
||||
* encrypted-Blossom fields, so a legacy group genuinely cannot hold a URL
|
||||
* avatar and this refuses rather than silently writing somewhere else.
|
||||
*/
|
||||
suspend fun setGroupAvatarUrl(
|
||||
nostrGroupId: HexKey,
|
||||
avatar: GroupAvatarUrlV1?,
|
||||
relays: List<NormalizedRelayUrl> = groupRelays(nostrGroupId),
|
||||
): OutboundGroupEvent {
|
||||
val view = groupView(nostrGroupId) ?: throw IllegalStateException("Not a member of group $nostrGroupId")
|
||||
check(view.isCurrentProfile) {
|
||||
"Group $nostrGroupId is a legacy MIP-01 group and has no carrier for a URL avatar"
|
||||
}
|
||||
// Encode before staging: an invalid or non-normalizable URL should fail
|
||||
// the caller here, not halfway through building a Commit.
|
||||
val encoded = (avatar?.takeIf { !it.isAbsent } ?: GroupAvatarUrlV1.ABSENT).encode()
|
||||
return commitAndPublish(nostrGroupId, relays) {
|
||||
groupManager.stageAppDataUpdate(
|
||||
nostrGroupId,
|
||||
GroupAvatarUrlV1.COMPONENT_ID,
|
||||
encoded,
|
||||
)
|
||||
}.event
|
||||
}
|
||||
|
||||
// --- KeyPackage Management ---
|
||||
|
||||
/**
|
||||
@@ -1473,6 +1520,7 @@ class MarmotManager(
|
||||
chatroom.adminPubkeys.value = view.adminPubkeys
|
||||
chatroom.relays.value = view.relays
|
||||
chatroom.image.value = view.image
|
||||
chatroom.avatarUrl.value = view.avatarUrl
|
||||
}
|
||||
val previousCount = chatroom.members.value.size
|
||||
val members = memberPubkeys(nostrGroupId)
|
||||
|
||||
+9
@@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.commons.model.NotesGatherer
|
||||
import com.vitorpamplona.amethyst.commons.util.KmpLock
|
||||
import com.vitorpamplona.amethyst.commons.util.WeakReference
|
||||
import com.vitorpamplona.amethyst.commons.util.withLock
|
||||
import com.vitorpamplona.quartz.marmot.appComponents.GroupAvatarUrlV1
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
@@ -55,6 +56,14 @@ class MarmotGroupChatroom(
|
||||
* it; when null they fall back to the host relay's NIP-11 icon.
|
||||
*/
|
||||
var image = MutableStateFlow<MarmotGroupImage?>(null)
|
||||
|
||||
/**
|
||||
* The group's plain-https avatar (`marmot.group.avatar-url.v1`), or null
|
||||
* when it has none. It takes precedence over [image]: a group carrying
|
||||
* both shows this one, and only falls back to the encrypted Blossom blob
|
||||
* once this is cleared.
|
||||
*/
|
||||
var avatarUrl = MutableStateFlow<GroupAvatarUrlV1?>(null)
|
||||
var adminPubkeys = MutableStateFlow<List<HexKey>>(emptyList())
|
||||
var relays = MutableStateFlow<List<String>>(emptyList())
|
||||
var memberCount = MutableStateFlow(0)
|
||||
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
/*
|
||||
* 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.quartz.marmot.appComponents
|
||||
|
||||
import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader
|
||||
import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter
|
||||
|
||||
/**
|
||||
* `marmot.group.avatar-url.v1`, component `0x8007` — a group avatar behind an
|
||||
* ordinary `https` URL.
|
||||
*
|
||||
* ```text
|
||||
* struct {
|
||||
* opaque url<0..2048>;
|
||||
* opaque dim<0..256>;
|
||||
* opaque thumbhash<0..256>;
|
||||
* } MarmotGroupAvatarUrlV1;
|
||||
* ```
|
||||
*
|
||||
* The lightweight alternative to [GroupBlossomImageV1]: no key material, no
|
||||
* encrypted blob, just a link. An absent avatar is the empty state — an empty
|
||||
* `url` AND empty hints; a partially-empty state is invalid, so "no avatar" has
|
||||
* exactly one encoding.
|
||||
*
|
||||
* [dim] and [thumbhash] are opaque by contract. A decoder checks only their
|
||||
* length, and a hint it cannot interpret is treated as absent rather than
|
||||
* invalidating otherwise-valid group state — which is why they are kept as raw
|
||||
* bytes here and interpreted only at render time ([dimensions]).
|
||||
*
|
||||
* **Precedence:** a group may carry this AND [GroupBlossomImageV1]. When both
|
||||
* are present the URL avatar wins; clearing this one falls back to the Blossom
|
||||
* image.
|
||||
*/
|
||||
data class GroupAvatarUrlV1(
|
||||
val url: String,
|
||||
val dim: ByteArray = ByteArray(0),
|
||||
val thumbhash: ByteArray = ByteArray(0),
|
||||
) {
|
||||
/** True for the cleared/absent avatar. */
|
||||
val isAbsent: Boolean get() = url.isEmpty()
|
||||
|
||||
/**
|
||||
* `dim` read as the conventional `WIDTHxHEIGHT`, or null when it is absent
|
||||
* or in a shape this renderer does not understand. Never an error: an
|
||||
* uninterpretable hint is not a validity problem.
|
||||
*/
|
||||
val dimensions: Pair<Int, Int>?
|
||||
get() {
|
||||
if (dim.isEmpty()) return null
|
||||
val text =
|
||||
try {
|
||||
dim.decodeToString(throwOnInvalidSequence = true)
|
||||
} catch (_: Exception) {
|
||||
return null
|
||||
}
|
||||
val parts = text.split('x', 'X')
|
||||
if (parts.size != 2) return null
|
||||
val w = parts[0].toIntOrNull() ?: return null
|
||||
val h = parts[1].toIntOrNull() ?: return null
|
||||
if (w <= 0 || h <= 0) return null
|
||||
return w to h
|
||||
}
|
||||
|
||||
fun encode(): ByteArray {
|
||||
require(!(isAbsent && (dim.isNotEmpty() || thumbhash.isNotEmpty()))) {
|
||||
"group avatar absent state must not carry hints"
|
||||
}
|
||||
require(dim.size <= HINT_MAX_BYTES) { "group avatar dim exceeds $HINT_MAX_BYTES bytes" }
|
||||
require(thumbhash.size <= HINT_MAX_BYTES) { "group avatar thumbhash exceeds $HINT_MAX_BYTES bytes" }
|
||||
|
||||
// Normalizing at encode is the producer's job: the stored bytes ARE the
|
||||
// serialized form, and every decoder re-derives them to check.
|
||||
val stored = if (isAbsent) "" else MarmotHttpsUrl.normalize(url)
|
||||
|
||||
val writer = TlsWriter()
|
||||
writer.putOpaqueVarInt(stored.encodeToByteArray())
|
||||
writer.putOpaqueVarInt(dim)
|
||||
writer.putOpaqueVarInt(thumbhash)
|
||||
return writer.toByteArray()
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is GroupAvatarUrlV1) return false
|
||||
return url == other.url && dim.contentEquals(other.dim) && thumbhash.contentEquals(other.thumbhash)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = url.hashCode()
|
||||
result = 31 * result + dim.contentHashCode()
|
||||
result = 31 * result + thumbhash.contentHashCode()
|
||||
return result
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val COMPONENT_ID = AppComponentIds.GROUP_AVATAR_URL_V1
|
||||
const val URL_MAX_BYTES = MarmotHttpsUrl.MAX_BYTES
|
||||
const val HINT_MAX_BYTES = 256
|
||||
|
||||
/** The cleared avatar: every field empty. */
|
||||
val ABSENT = GroupAvatarUrlV1("")
|
||||
|
||||
fun decode(bytes: ByteArray): GroupAvatarUrlV1 {
|
||||
val reader = TlsReader(bytes)
|
||||
val url = reader.readOpaqueVarInt()
|
||||
val dim = reader.readOpaqueVarInt()
|
||||
val thumbhash = reader.readOpaqueVarInt()
|
||||
require(!reader.hasRemaining) { "group avatar component has trailing bytes" }
|
||||
require(url.size <= URL_MAX_BYTES) { "group avatar URL exceeds $URL_MAX_BYTES bytes" }
|
||||
require(dim.size <= HINT_MAX_BYTES) { "group avatar dim exceeds $HINT_MAX_BYTES bytes" }
|
||||
require(thumbhash.size <= HINT_MAX_BYTES) { "group avatar thumbhash exceeds $HINT_MAX_BYTES bytes" }
|
||||
|
||||
val text = url.decodeToString()
|
||||
// Presence is decided on the bytes, before anything is parsed.
|
||||
require(!(text.isEmpty() && (dim.isNotEmpty() || thumbhash.isNotEmpty()))) {
|
||||
"group avatar absent state must not carry hints"
|
||||
}
|
||||
if (text.isNotEmpty()) {
|
||||
// "A decoder re-runs validation and the WHATWG parse-and-serialize
|
||||
// on the decoded url and MUST reject state whose stored URL bytes
|
||||
// differ from the serializer's output." A decoder never repairs a
|
||||
// non-normalized URL into canonical state — two members would then
|
||||
// hold different bytes for the same group.
|
||||
require(MarmotHttpsUrl.normalize(text) == text) { "group avatar URL is not normalized" }
|
||||
}
|
||||
return GroupAvatarUrlV1(text, dim, thumbhash)
|
||||
}
|
||||
}
|
||||
}
|
||||
+34
@@ -56,10 +56,25 @@ data class MarmotGroupState(
|
||||
val adminPolicy: AdminPolicyV1?,
|
||||
val routing: NostrRoutingV1?,
|
||||
val image: GroupBlossomImageV1?,
|
||||
val avatarUrl: GroupAvatarUrlV1?,
|
||||
val retention: MessageRetentionV1?,
|
||||
val lifecycle: GroupLifecycleV1?,
|
||||
val agentTextStream: AgentTextStreamQuicPolicyV1?,
|
||||
) {
|
||||
/**
|
||||
* The avatar a renderer should show, honouring the components' documented
|
||||
* precedence: "When both are present, the URL avatar wins."
|
||||
*
|
||||
* A cleared URL avatar (present but empty) is not the same as an absent
|
||||
* one — it explicitly falls back to the Blossom image, which is why this
|
||||
* checks [GroupAvatarUrlV1.isAbsent] rather than nullness alone.
|
||||
*/
|
||||
val preferredAvatar: MarmotGroupAvatar?
|
||||
get() {
|
||||
avatarUrl?.takeIf { !it.isAbsent }?.let { return MarmotGroupAvatar.Url(it) }
|
||||
return image?.let { MarmotGroupAvatar.Blossom(it) }
|
||||
}
|
||||
|
||||
/** True once a disband Commit has been applied. Absorbing and terminal. */
|
||||
val isDisbanded: Boolean get() = lifecycle == GroupLifecycleV1.DISBANDED
|
||||
|
||||
@@ -99,6 +114,7 @@ data class MarmotGroupState(
|
||||
adminPolicy = dictionary[AdminPolicyV1.COMPONENT_ID]?.let { AdminPolicyV1.decode(it) },
|
||||
routing = dictionary[NostrRoutingV1.COMPONENT_ID]?.let { NostrRoutingV1.decode(it) },
|
||||
image = dictionary[GroupBlossomImageV1.COMPONENT_ID]?.let { GroupBlossomImageV1.decode(it) },
|
||||
avatarUrl = dictionary[GroupAvatarUrlV1.COMPONENT_ID]?.let { GroupAvatarUrlV1.decode(it) },
|
||||
retention = dictionary[MessageRetentionV1.COMPONENT_ID]?.let { MessageRetentionV1.decode(it) },
|
||||
lifecycle = dictionary[GroupLifecycleV1.COMPONENT_ID]?.let { GroupLifecycleV1.decode(it) },
|
||||
agentTextStream =
|
||||
@@ -121,6 +137,7 @@ data class MarmotGroupState(
|
||||
routing: NostrRoutingV1? = null,
|
||||
profile: GroupProfileV1? = null,
|
||||
image: GroupBlossomImageV1? = null,
|
||||
avatarUrl: GroupAvatarUrlV1? = null,
|
||||
retention: MessageRetentionV1? = null,
|
||||
lifecycle: GroupLifecycleV1? = GroupLifecycleV1.ACTIVE,
|
||||
agentTextStream: AgentTextStreamQuicPolicyV1? = null,
|
||||
@@ -144,6 +161,10 @@ data class MarmotGroupState(
|
||||
required.add(GroupBlossomImageV1.COMPONENT_ID)
|
||||
dictionary = dictionary.with(GroupBlossomImageV1.COMPONENT_ID, it.encode())
|
||||
}
|
||||
avatarUrl?.let {
|
||||
required.add(GroupAvatarUrlV1.COMPONENT_ID)
|
||||
dictionary = dictionary.with(GroupAvatarUrlV1.COMPONENT_ID, it.encode())
|
||||
}
|
||||
retention?.let {
|
||||
required.add(MessageRetentionV1.COMPONENT_ID)
|
||||
dictionary = dictionary.with(MessageRetentionV1.COMPONENT_ID, it.encode())
|
||||
@@ -161,3 +182,16 @@ data class MarmotGroupState(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Which avatar surface a group's state resolves to, after precedence. */
|
||||
sealed class MarmotGroupAvatar {
|
||||
/** `marmot.group.avatar-url.v1` — a plain https link, no key material. */
|
||||
data class Url(
|
||||
val avatar: GroupAvatarUrlV1,
|
||||
) : MarmotGroupAvatar()
|
||||
|
||||
/** `marmot.group.blossom.image.v1` — an encrypted blob on a Blossom server. */
|
||||
data class Blossom(
|
||||
val image: GroupBlossomImageV1,
|
||||
) : MarmotGroupAvatar()
|
||||
}
|
||||
|
||||
+266
@@ -0,0 +1,266 @@
|
||||
/*
|
||||
* 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.quartz.marmot.appComponents
|
||||
|
||||
/**
|
||||
* The `https`-only WHATWG URL normalizer Marmot group state needs.
|
||||
*
|
||||
* This exists because normalization is part of the wire format, not a
|
||||
* convenience: `marmot.group.avatar-url.v1` stores the serialized form, and a
|
||||
* decoder "MUST reject state whose stored URL bytes differ from the
|
||||
* serializer's output". So this has to agree with every other implementation
|
||||
* byte for byte — too lax and we accept state a peer rejects, too strict and we
|
||||
* reject a group somebody else made.
|
||||
*
|
||||
* It is not a general URL library. It handles exactly the shape the component
|
||||
* allows — `https`, a host, no userinfo, no fragment — and refuses everything
|
||||
* else rather than guessing.
|
||||
*
|
||||
* **Known limit: no IDNA.** A host with non-ASCII characters is refused instead
|
||||
* of punycoded. That costs nothing on the decode side, where it matters: a
|
||||
* conformant producer already stored the punycoded form (which is ASCII and
|
||||
* passes through untouched), and a stored raw-Unicode host is non-normalized
|
||||
* and must be rejected anyway. It only stops us from *accepting* a
|
||||
* Unicode-typed host from our own user, who can paste the punycode form.
|
||||
*/
|
||||
object MarmotHttpsUrl {
|
||||
const val MAX_BYTES = 2048
|
||||
|
||||
private const val SCHEME = "https://"
|
||||
private const val DEFAULT_PORT = "443"
|
||||
|
||||
/**
|
||||
* Parse [raw] and return its WHATWG serialization.
|
||||
*
|
||||
* @throws IllegalArgumentException when the URL is not a valid group-avatar
|
||||
* URL, or when normalizing it would need something this does not do.
|
||||
*/
|
||||
fun normalize(raw: String): String {
|
||||
require(raw.isNotEmpty()) { "avatar URL must not be empty" }
|
||||
require(raw.encodeToByteArray().size <= MAX_BYTES) { "avatar URL exceeds $MAX_BYTES bytes" }
|
||||
|
||||
val schemeEnd = raw.indexOf("://")
|
||||
require(schemeEnd > 0) { "avatar URL must be an absolute https URL" }
|
||||
require(raw.substring(0, schemeEnd).lowercase() == "https") { "avatar URL scheme must be https" }
|
||||
|
||||
var rest = raw.substring(schemeEnd + 3)
|
||||
require(!rest.contains('#')) { "avatar URL must not include a fragment" }
|
||||
|
||||
// The authority runs to the first "/" or "?" — everything after is path
|
||||
// and query.
|
||||
val authorityEnd = rest.indexOfFirst { it == '/' || it == '?' }.let { if (it < 0) rest.length else it }
|
||||
val authority = rest.substring(0, authorityEnd)
|
||||
rest = rest.substring(authorityEnd)
|
||||
require(!authority.contains('@')) { "avatar URL must not include credentials" }
|
||||
require(authority.isNotEmpty()) { "avatar URL must include a host" }
|
||||
|
||||
val (host, port) = splitHostPort(authority)
|
||||
require(host.isNotEmpty()) { "avatar URL must include a host" }
|
||||
require(host.all { it.code < 0x80 }) {
|
||||
"avatar URL host must be ASCII — encode an international host as punycode first"
|
||||
}
|
||||
|
||||
val queryStart = rest.indexOf('?')
|
||||
val rawPath = if (queryStart < 0) rest else rest.substring(0, queryStart)
|
||||
val rawQuery = if (queryStart < 0) null else rest.substring(queryStart + 1)
|
||||
|
||||
val out = StringBuilder(SCHEME)
|
||||
out.append(host.lowercase())
|
||||
if (port != null && port != DEFAULT_PORT) out.append(':').append(port)
|
||||
out.append(normalizePath(rawPath))
|
||||
if (rawQuery != null) out.append('?').append(percentEncode(rawQuery, QUERY_KEEP))
|
||||
|
||||
val normalized = out.toString()
|
||||
require(normalized.encodeToByteArray().size <= MAX_BYTES) { "avatar URL exceeds $MAX_BYTES bytes" }
|
||||
return normalized
|
||||
}
|
||||
|
||||
/** True when [normalize] accepts [raw] and returns it unchanged. */
|
||||
fun isNormalized(raw: String): Boolean =
|
||||
try {
|
||||
normalize(raw) == raw
|
||||
} catch (_: IllegalArgumentException) {
|
||||
false
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this client should be willing to FETCH [raw].
|
||||
*
|
||||
* Deliberately separate from [normalize]: a URL can be perfectly valid group
|
||||
* state and still be somewhere we refuse to go. Validity is the group's
|
||||
* business and is the same for every member; contact is ours alone, and the
|
||||
* spec is explicit that it "MUST NOT affect component or commit validity".
|
||||
*
|
||||
* The rule is the ordinary SSRF one — no loopback, no link-local, no private
|
||||
* range — so a group avatar cannot make a member probe its own network.
|
||||
*/
|
||||
fun isSafeToContact(raw: String): Boolean {
|
||||
val host =
|
||||
try {
|
||||
hostOf(normalize(raw))
|
||||
} catch (_: IllegalArgumentException) {
|
||||
return false
|
||||
}
|
||||
if (host == "localhost" || host.endsWith(".localhost")) return false
|
||||
if (host.startsWith("[")) return !isNonRoutableIpv6(host.trim('[', ']'))
|
||||
val v4 = host.split('.').mapNotNull { it.toIntOrNull() }
|
||||
if (v4.size == 4 && v4.all { it in 0..255 }) return !isNonRoutableIpv4(v4)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun hostOf(normalized: String): String {
|
||||
val rest = normalized.substring(SCHEME.length)
|
||||
val end = rest.indexOfFirst { it == '/' || it == '?' }.let { if (it < 0) rest.length else it }
|
||||
return splitHostPort(rest.substring(0, end)).first
|
||||
}
|
||||
|
||||
private fun isNonRoutableIpv4(o: List<Int>): Boolean =
|
||||
o[0] == 0 ||
|
||||
o[0] == 127 ||
|
||||
o[0] == 10 ||
|
||||
(o[0] == 172 && o[1] in 16..31) ||
|
||||
(o[0] == 192 && o[1] == 168) ||
|
||||
(o[0] == 169 && o[1] == 254) ||
|
||||
(o[0] == 100 && o[1] in 64..127) ||
|
||||
o[0] >= 224
|
||||
|
||||
private fun isNonRoutableIpv6(addr: String): Boolean {
|
||||
val a = addr.lowercase()
|
||||
if (a == "::1" || a == "::") return true
|
||||
// Unique-local (fc00::/7) and link-local (fe80::/10).
|
||||
return a.startsWith("fc") || a.startsWith("fd") || a.startsWith("fe8") ||
|
||||
a.startsWith("fe9") || a.startsWith("fea") || a.startsWith("feb")
|
||||
}
|
||||
|
||||
/** Splits `host:port`, keeping an IPv6 literal's brackets on the host. */
|
||||
private fun splitHostPort(authority: String): Pair<String, String?> {
|
||||
if (authority.startsWith("[")) {
|
||||
val close = authority.indexOf(']')
|
||||
require(close > 0) { "avatar URL has an unterminated IPv6 host" }
|
||||
val host = authority.substring(0, close + 1)
|
||||
val tail = authority.substring(close + 1)
|
||||
if (tail.isEmpty()) return host to null
|
||||
require(tail.startsWith(":")) { "avatar URL has a malformed IPv6 authority" }
|
||||
return host to validPort(tail.substring(1))
|
||||
}
|
||||
val colon = authority.lastIndexOf(':')
|
||||
if (colon < 0) return authority to null
|
||||
return authority.substring(0, colon) to validPort(authority.substring(colon + 1))
|
||||
}
|
||||
|
||||
private fun validPort(port: String): String {
|
||||
require(port.isNotEmpty() && port.all { it.isDigit() }) { "avatar URL has a malformed port" }
|
||||
val value = port.toIntOrNull()
|
||||
require(value != null && value in 1..65535) { "avatar URL port is out of range" }
|
||||
// WHATWG serializes the port as a decimal number, so "0443" is "443".
|
||||
return value.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve dot segments and percent-encode what the path set demands. An
|
||||
* empty path serializes as `/`.
|
||||
*/
|
||||
private fun normalizePath(rawPath: String): String {
|
||||
if (rawPath.isEmpty()) return "/"
|
||||
// WHATWG keeps the path as a segment LIST and serializes it as "/" +
|
||||
// segments joined by "/". A trailing slash is therefore a final EMPTY
|
||||
// segment, not a suffix — which is also why "/a/." ends in a slash: the
|
||||
// dot segment is dropped and an empty one takes its place.
|
||||
val segments = rawPath.removePrefix("/").split('/')
|
||||
val out = ArrayList<String>()
|
||||
segments.forEachIndexed { index, segment ->
|
||||
val isLast = index == segments.size - 1
|
||||
when {
|
||||
isDoubleDot(segment) -> {
|
||||
if (out.isNotEmpty()) out.removeAt(out.size - 1)
|
||||
if (isLast) out.add("")
|
||||
}
|
||||
|
||||
isSingleDot(segment) -> if (isLast) out.add("")
|
||||
|
||||
else -> out.add(percentEncode(segment, PATH_KEEP))
|
||||
}
|
||||
}
|
||||
return "/" + out.joinToString("/")
|
||||
}
|
||||
|
||||
/** WHATWG counts `%2e` as a dot for segment resolution, case-insensitively. */
|
||||
private fun isSingleDot(segment: String) = segment == "." || segment.equals("%2e", ignoreCase = true)
|
||||
|
||||
private fun isDoubleDot(segment: String): Boolean {
|
||||
val s = segment.lowercase()
|
||||
return s == ".." || s == ".%2e" || s == "%2e." || s == "%2e%2e"
|
||||
}
|
||||
|
||||
/**
|
||||
* Percent-encode every byte outside [keep], leaving an existing `%XX`
|
||||
* sequence exactly as it was found.
|
||||
*
|
||||
* Preserving is not laziness: the reference serializer does not re-case or
|
||||
* decode what is already encoded (`%7e` stays `%7e`, `%7E` stays `%7E`), and
|
||||
* "canonicalising" either way would make our bytes differ from a peer's for
|
||||
* the same URL.
|
||||
*/
|
||||
private fun percentEncode(
|
||||
value: String,
|
||||
keep: (Char) -> Boolean,
|
||||
): String {
|
||||
val out = StringBuilder(value.length)
|
||||
var i = 0
|
||||
while (i < value.length) {
|
||||
val c = value[i]
|
||||
if (c == '%' && i + 2 < value.length && value[i + 1].isHex() && value[i + 2].isHex()) {
|
||||
out.append(value, i, i + 3)
|
||||
i += 3
|
||||
continue
|
||||
}
|
||||
if (keep(c)) {
|
||||
out.append(c)
|
||||
} else {
|
||||
for (b in c.toString().encodeToByteArray()) {
|
||||
out.append('%').append(HEX[(b.toInt() shr 4) and 0xf]).append(HEX[b.toInt() and 0xf])
|
||||
}
|
||||
}
|
||||
i++
|
||||
}
|
||||
return out.toString()
|
||||
}
|
||||
|
||||
private fun Char.isHex() = this in '0'..'9' || this in 'a'..'f' || this in 'A'..'F'
|
||||
|
||||
private const val HEX = "0123456789ABCDEF"
|
||||
|
||||
/**
|
||||
* WHATWG "path percent-encode set": the C0 set (below 0x20, above 0x7E)
|
||||
* plus space, `"`, `<`, `>`, backtick, `#`, `?`, `{`, `}`.
|
||||
*/
|
||||
private val PATH_KEEP: (Char) -> Boolean = { c ->
|
||||
c.code in 0x20..0x7e && c != ' ' && c != '"' && c != '<' && c != '>' && c != '`' && c != '#' && c != '?' && c != '{' && c != '}'
|
||||
}
|
||||
|
||||
/**
|
||||
* WHATWG "special-query percent-encode set": the C0 set plus space, `"`,
|
||||
* `#`, `<`, `>`, and — because `https` is a special scheme — `'`.
|
||||
*/
|
||||
private val QUERY_KEEP: (Char) -> Boolean = { c ->
|
||||
c.code in 0x20..0x7e && c != ' ' && c != '"' && c != '#' && c != '<' && c != '>' && c != '\''
|
||||
}
|
||||
}
|
||||
+235
@@ -0,0 +1,235 @@
|
||||
/*
|
||||
* 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.quartz.marmot.appComponents
|
||||
|
||||
import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* `marmot.group.avatar-url.v1` (`0x8007`) — the plain-https alternative to a
|
||||
* Blossom group image.
|
||||
*
|
||||
* The load-bearing rule is normalization. The spec says a producer stores the
|
||||
* WHATWG-serialized form and "a decoder re-runs validation and the WHATWG
|
||||
* parse-and-serialize on the decoded `url` and MUST reject state whose stored
|
||||
* URL bytes differ from the serializer's output". So our normalizer has to
|
||||
* agree with everybody else's byte for byte: too lax and we accept state a peer
|
||||
* rejects, too strict and we reject a group MDK made.
|
||||
*
|
||||
* The expectations below are not invented. They are the output of the Rust
|
||||
* `url` 2.5.8 crate — the one MDK calls through
|
||||
* `validate_and_normalize_group_avatar_url` — run over each input.
|
||||
*/
|
||||
class GroupAvatarUrlV1Test {
|
||||
@Test
|
||||
fun normalizationMatchesTheReferenceSerializerCaseForCase() {
|
||||
val vectors =
|
||||
listOf(
|
||||
// scheme + host lowercased, default port dropped
|
||||
"https://CDN.Example.COM:443/a.png" to "https://cdn.example.com/a.png",
|
||||
// an empty path serializes as "/"
|
||||
"https://cdn.example.com" to "https://cdn.example.com/",
|
||||
"https://cdn.example.com/" to "https://cdn.example.com/",
|
||||
// dot segments resolve
|
||||
"https://cdn.example.com/a/./b/../c.png" to "https://cdn.example.com/a/c.png",
|
||||
// percent-encoding case is PRESERVED, not canonicalised, and an
|
||||
// already-encoded sequence is never decoded
|
||||
"https://cdn.example.com/%7euser/a.png" to "https://cdn.example.com/%7euser/a.png",
|
||||
"https://cdn.example.com/%7Euser/a.png" to "https://cdn.example.com/%7Euser/a.png",
|
||||
"https://cdn.example.com/A%2fB.png" to "https://cdn.example.com/A%2fB.png",
|
||||
// characters outside the path set are encoded, in UPPERCASE hex
|
||||
"https://cdn.example.com/a b.png" to "https://cdn.example.com/a%20b.png",
|
||||
"https://cdn.example.com/ünïcode.png" to "https://cdn.example.com/%C3%BCn%C3%AFcode.png",
|
||||
// a non-default port stays
|
||||
"https://cdn.example.com:8443/a.png" to "https://cdn.example.com:8443/a.png",
|
||||
// the query is carried through, including a bare trailing "?"
|
||||
"https://cdn.example.com/a.png?x=1&y=2" to "https://cdn.example.com/a.png?x=1&y=2",
|
||||
"https://cdn.example.com/a.png?" to "https://cdn.example.com/a.png?",
|
||||
// an IPv6 literal lowercases inside its brackets
|
||||
"https://[2001:DB8::1]/a.png" to "https://[2001:db8::1]/a.png",
|
||||
// an already-punycoded host is left alone
|
||||
"https://xn--bcher-kva.example/a.png" to "https://xn--bcher-kva.example/a.png",
|
||||
)
|
||||
|
||||
for ((raw, expected) in vectors) {
|
||||
assertEquals("normalizing $raw", expected, MarmotHttpsUrl.normalize(raw))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun normalizationIsIdempotent() {
|
||||
// A decoder compares stored bytes against its own serialization, so a
|
||||
// normalizer that moves on the second pass would reject its own output.
|
||||
for (raw in listOf(
|
||||
"https://CDN.Example.COM:443/a/./b/../c.png?x=1",
|
||||
"https://cdn.example.com/%7euser/a b.png",
|
||||
"https://[2001:DB8::1]:8443/",
|
||||
)) {
|
||||
val once = MarmotHttpsUrl.normalize(raw)
|
||||
assertEquals(once, MarmotHttpsUrl.normalize(once))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onlyHttpsWithAHostAndNoUserinfoOrFragmentIsValid() {
|
||||
for (bad in listOf(
|
||||
"http://cdn.example.com/a.png",
|
||||
"ftp://cdn.example.com/a.png",
|
||||
"blossom://cdn.example.com/a.png",
|
||||
"https://user:pass@cdn.example.com/a.png",
|
||||
"https://user@cdn.example.com/a.png",
|
||||
"https://cdn.example.com/a.png#frag",
|
||||
"https:///a.png",
|
||||
"https://",
|
||||
"cdn.example.com/a.png",
|
||||
"",
|
||||
)) {
|
||||
assertThrows("must reject $bad", IllegalArgumentException::class.java) {
|
||||
MarmotHttpsUrl.normalize(bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun contactSafetyIsNotAValidityQuestion() {
|
||||
// "Whether a client contacts or renders the parsed destination is local
|
||||
// application policy and MUST NOT affect component or commit validity."
|
||||
// A group whose avatar points at localhost is still a valid group.
|
||||
for (raw in listOf(
|
||||
"https://localhost/avatar.png",
|
||||
"https://127.0.0.1/avatar.png",
|
||||
"https://10.0.0.1/avatar.png",
|
||||
"https://[::1]/avatar.png",
|
||||
)) {
|
||||
MarmotHttpsUrl.normalize(raw)
|
||||
}
|
||||
assertTrue(MarmotHttpsUrl.isSafeToContact("https://cdn.example.com/a.png"))
|
||||
assertTrue(!MarmotHttpsUrl.isSafeToContact("https://localhost/a.png"))
|
||||
assertTrue(!MarmotHttpsUrl.isSafeToContact("https://127.0.0.1/a.png"))
|
||||
assertTrue(!MarmotHttpsUrl.isSafeToContact("https://10.0.0.1/a.png"))
|
||||
assertTrue(!MarmotHttpsUrl.isSafeToContact("https://192.168.1.1/a.png"))
|
||||
assertTrue(!MarmotHttpsUrl.isSafeToContact("https://[::1]/a.png"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aHostThatWouldNeedIdnaIsRefusedRatherThanGuessedAt() {
|
||||
// We do not implement IDNA/punycode, so we cannot produce the encoded
|
||||
// form a peer expects. Refusing at the producer is safe; it costs
|
||||
// nothing on decode, because a conformant producer already stored
|
||||
// punycode and a raw Unicode host is non-normalized anyway.
|
||||
val failure =
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
MarmotHttpsUrl.normalize("https://bücher.example/a.png")
|
||||
}
|
||||
assertTrue(failure.message.orEmpty().contains("punycode"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stateRoundTripsWithAndWithoutHints() {
|
||||
val full =
|
||||
GroupAvatarUrlV1(
|
||||
url = "https://cdn.example.com/avatar.png",
|
||||
dim = "512x512".encodeToByteArray(),
|
||||
thumbhash = byteArrayOf(1, 2, 3),
|
||||
)
|
||||
assertEquals(full, GroupAvatarUrlV1.decode(full.encode()))
|
||||
|
||||
val urlOnly = GroupAvatarUrlV1(url = "https://cdn.example.com/avatar.png")
|
||||
assertEquals(urlOnly, GroupAvatarUrlV1.decode(urlOnly.encode()))
|
||||
|
||||
val absent = GroupAvatarUrlV1.ABSENT
|
||||
assertEquals(absent, GroupAvatarUrlV1.decode(absent.encode()))
|
||||
assertTrue(GroupAvatarUrlV1.decode(absent.encode()).isAbsent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anAbsentAvatarCannotCarryHints() {
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
GroupAvatarUrlV1(url = "", dim = "512x512".encodeToByteArray()).encode()
|
||||
}
|
||||
// …and the same state rejected on the way in, hand-built.
|
||||
val writer = TlsWriter()
|
||||
writer.putOpaqueVarInt(ByteArray(0))
|
||||
writer.putOpaqueVarInt("512x512".encodeToByteArray())
|
||||
writer.putOpaqueVarInt(ByteArray(0))
|
||||
assertThrows(IllegalArgumentException::class.java) { GroupAvatarUrlV1.decode(writer.toByteArray()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decodeRejectsAStoredUrlThatIsNotNormalized() {
|
||||
val writer = TlsWriter()
|
||||
writer.putOpaqueVarInt("https://CDN.EXAMPLE.COM/a.png".encodeToByteArray())
|
||||
writer.putOpaqueVarInt(ByteArray(0))
|
||||
writer.putOpaqueVarInt(ByteArray(0))
|
||||
val failure = assertThrows(IllegalArgumentException::class.java) { GroupAvatarUrlV1.decode(writer.toByteArray()) }
|
||||
assertTrue(failure.message.orEmpty().contains("normalized"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decodeRejectsTrailingBytes() {
|
||||
val encoded = GroupAvatarUrlV1(url = "https://cdn.example.com/a.png").encode()
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
GroupAvatarUrlV1.decode(encoded + byteArrayOf(0))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theBoundsAreEnforcedOnBothFields() {
|
||||
val longPath = "https://cdn.example.com/" + "a".repeat(2100)
|
||||
assertThrows(IllegalArgumentException::class.java) { MarmotHttpsUrl.normalize(longPath) }
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
GroupAvatarUrlV1(url = "https://cdn.example.com/a.png", dim = ByteArray(257)).encode()
|
||||
}
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
GroupAvatarUrlV1(url = "https://cdn.example.com/a.png", thumbhash = ByteArray(257)).encode()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aHintTheRendererCannotReadIsNotAValidityProblem() {
|
||||
// "A hint the renderer cannot interpret is treated as absent and MUST
|
||||
// NOT invalidate otherwise-valid group state."
|
||||
val weird =
|
||||
GroupAvatarUrlV1(
|
||||
url = "https://cdn.example.com/a.png",
|
||||
dim = byteArrayOf(0xff.toByte(), 0xfe.toByte()),
|
||||
thumbhash = byteArrayOf(0x00),
|
||||
)
|
||||
val decoded = GroupAvatarUrlV1.decode(weird.encode())
|
||||
assertEquals(weird, decoded)
|
||||
assertEquals(null, decoded.dimensions)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dimensionsAreParsedOnlyWhenTheyAreTheConventionalShape() {
|
||||
assertEquals(
|
||||
512 to 512,
|
||||
GroupAvatarUrlV1("https://cdn.example.com/a.png", dim = "512x512".encodeToByteArray()).dimensions,
|
||||
)
|
||||
assertEquals(
|
||||
null,
|
||||
GroupAvatarUrlV1("https://cdn.example.com/a.png", dim = "not-a-size".encodeToByteArray()).dimensions,
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user