fix: audit fixes for kind 0 imeta support

- Republishing a profile no longer deletes imeta tags that never
  described the picture or banner; only those of retired images go.
- Picture/banner URLs match their imeta ignoring surrounding whitespace,
  on both the read and the write side, so Android, Desktop and amy agree.
- Read previous picture/banner defensively when a wild kind 0 holds a
  non-string value there.
- Android banner: tap-to-zoom and long-press copy use the URL actually
  shown (the fallback once the primary failed); a blank blurhash no
  longer hides the default placeholder.
- Avatars without fallbacks skip the fallback state and the Coil state
  hook entirely, keeping the feed path as it was.
- Upload metadata: one UploadResult -> PictureMeta converter (Desktop
  picture posts reuse it), every field from the uploaded bytes, and a
  size of 0 is omitted instead of published.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B79haW8cjFSX6ZL64ooLZL
This commit is contained in:
Claude
2026-10-05 15:51:08 +00:00
parent 55b3d36022
commit 3305c0372f
10 changed files with 135 additions and 70 deletions
@@ -85,7 +85,9 @@ fun DrawBanner(
var sourceBounds by remember { mutableStateOf<Rect?>(null) }
val bannerUrl = rememberFallbackUrlState(banner, bannerMeta?.fallback ?: emptyList())
val hasPreviewHash = bannerMeta?.blurhash != null || bannerMeta?.thumbhash != null
// Whichever URL is on screen: the primary, or the fallback that replaced it.
val shownBanner = bannerUrl.url ?: banner
val hasPreviewHash = !bannerMeta?.blurhash.isNullOrBlank() || !bannerMeta?.thumbhash.isNullOrBlank()
Box(
modifier =
@@ -97,7 +99,7 @@ fun DrawBanner(
onClick = { zoomImageDialogOpen = true },
onLongClick = {
scope.launch {
clipboardManager.setText(banner)
clipboardManager.setText(shownBanner)
}
},
),
@@ -125,7 +127,7 @@ fun DrawBanner(
if (zoomImageDialogOpen) {
ZoomableImageDialog(
imageUrl = RichTextParser.parseImageOrVideo(banner),
imageUrl = RichTextParser.parseImageOrVideo(shownBanner),
sourceBounds = sourceBounds,
onDismiss = { zoomImageDialogOpen = false },
accountViewModel = accountViewModel,
@@ -31,7 +31,8 @@ fun UploadOrchestrator.OrchestratorResult.ServerResult.toPictureMeta(): PictureM
url = url,
mimeType = fileHeader.mimeType,
hash = fileHeader.hash,
size = fileHeader.size,
// A server that reports no size leaves it at 0, which is not a size.
size = fileHeader.size.takeIf { it > 0 },
dimension = fileHeader.dim?.takeIf { it.hasSize() },
blurhash = fileHeader.blurHash?.blurhash,
thumbhash = fileHeader.thumbHash?.thumbhash,
@@ -24,15 +24,18 @@ import com.vitorpamplona.quartz.nip68Picture.PictureMeta
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
/**
* The NIP-92 `imeta` description of an image uploaded to [url], for a kind 0 `picture` or
* `banner` (https://github.com/nostr-protocol/nips/pull/2494).
* The NIP-92 `imeta` description of an image uploaded to [url]: for a kind 0 `picture` or
* `banner` (https://github.com/nostr-protocol/nips/pull/2494) or a NIP-68 picture post.
*
* Every field comes from [UploadResult.metadata], computed on the bytes that left this machine,
* so hash, size, dimensions and blurhash all describe the same file.
*/
fun UploadResult.toPictureMeta(url: String): PictureMeta =
PictureMeta(
url = url,
mimeType = blossom.type ?: metadata.mimeType,
hash = blossom.sha256 ?: metadata.sha256,
size = (blossom.size ?: metadata.size).toInt(),
mimeType = metadata.mimeType,
hash = metadata.sha256,
size = metadata.size.takeIf { it in 1..Int.MAX_VALUE }?.toInt(),
dimension =
if (metadata.width != null && metadata.height != null && metadata.width > 0 && metadata.height > 0) {
DimensionTag(metadata.width, metadata.height)
@@ -109,18 +109,32 @@ fun UserAvatar(
.clip(shape = CircleShape)
}
val picture = rememberFallbackUrlState(pictureUrl, pictureFallbacks)
if (pictureFallbacks.isEmpty()) {
// Almost every profile: no state to keep and no error hook for the feed's avatars.
AvatarImage(
userHex = userHex,
pictureUrl = pictureUrl,
contentDescription = contentDescription,
modifier = avatarModifier,
loadProfilePicture = loadProfilePicture,
loadRobohash = loadRobohash,
autoPlayGif = autoPlayGif,
onError = null,
)
} else {
val picture = rememberFallbackUrlState(pictureUrl, pictureFallbacks)
AvatarImage(
userHex = userHex,
pictureUrl = picture.url,
contentDescription = contentDescription,
modifier = avatarModifier,
loadProfilePicture = loadProfilePicture,
loadRobohash = loadRobohash,
autoPlayGif = autoPlayGif,
onError = picture::onError,
)
AvatarImage(
userHex = userHex,
pictureUrl = picture.url,
contentDescription = contentDescription,
modifier = avatarModifier,
loadProfilePicture = loadProfilePicture,
loadRobohash = loadRobohash,
autoPlayGif = autoPlayGif,
onError = picture::onError,
)
}
}
/**
@@ -136,5 +150,5 @@ internal expect fun AvatarImage(
loadProfilePicture: Boolean,
loadRobohash: Boolean,
autoPlayGif: Boolean,
onError: () -> Unit,
onError: (() -> Unit)?,
)
@@ -43,7 +43,7 @@ internal actual fun AvatarImage(
loadProfilePicture: Boolean,
loadRobohash: Boolean,
autoPlayGif: Boolean,
onError: () -> Unit,
onError: (() -> Unit)?,
) {
if (pictureUrl != null && loadProfilePicture) {
val fallbackPainter =
@@ -61,7 +61,7 @@ internal actual fun AvatarImage(
fallback = fallbackPainter,
error = fallbackPainter,
contentScale = ContentScale.Crop,
onError = { onError() },
onError = onError?.let { onError -> { onError() } },
)
} else if (loadRobohash) {
Image(
@@ -33,7 +33,7 @@ internal actual fun AvatarImage(
loadProfilePicture: Boolean,
loadRobohash: Boolean,
autoPlayGif: Boolean,
onError: () -> Unit,
onError: (() -> Unit)?,
) {
RobohashFallbackAsyncImage(
robot = userHex,
@@ -78,6 +78,7 @@ import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPostStore
import com.vitorpamplona.amethyst.commons.service.upload.CompressionQuality
import com.vitorpamplona.amethyst.commons.service.upload.UploadOrchestrator
import com.vitorpamplona.amethyst.commons.service.upload.UploadResult
import com.vitorpamplona.amethyst.commons.service.upload.toPictureMeta
import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar
import com.vitorpamplona.amethyst.commons.util.deleteOrWarn
import com.vitorpamplona.amethyst.desktop.ImageCompressionStore
@@ -1027,26 +1028,7 @@ private fun PostTypeSelector(
}
}
private fun buildPictureMetas(results: List<UploadResult>): List<com.vitorpamplona.quartz.nip68Picture.PictureMeta> =
results.mapNotNull { result ->
val url = result.blossom.url ?: return@mapNotNull null
val meta = result.metadata
com.vitorpamplona.quartz.nip68Picture.PictureMeta(
url = url,
mimeType = meta.mimeType,
blurhash = meta.blurhash,
dimension =
meta.width?.let { w ->
meta.height?.let { h ->
com.vitorpamplona.quartz.nip94FileMetadata.tags
.DimensionTag(w, h)
}
},
hash = meta.sha256,
size = meta.size.toInt(),
thumbhash = meta.thumbhash,
)
}
private fun buildPictureMetas(results: List<UploadResult>): List<com.vitorpamplona.quartz.nip68Picture.PictureMeta> = results.mapNotNull { result -> result.blossom.url?.let { result.toPictureMeta(it) } }
private suspend fun publishPicture(
description: String,
@@ -271,8 +271,10 @@ class MetadataEvent(
updateProfileImageMetas(
previous = emptyArray(),
picture = currentMetadata[PictureTag.TAG_NAME]?.text,
banner = currentMetadata[BannerTag.TAG_NAME]?.text,
previousPicture = null,
previousBanner = null,
picture = (currentMetadata[PictureTag.TAG_NAME] as? JsonPrimitive)?.content,
banner = (currentMetadata[BannerTag.TAG_NAME] as? JsonPrimitive)?.content,
pictureMeta = pictureMeta,
bannerMeta = bannerMeta,
)
@@ -307,6 +309,8 @@ class MetadataEvent(
): EventTemplate<MetadataEvent> {
// Tries to not delete any existing attribute that we do not work with.
val currentMetadata = latest.contactMetadataJson()?.toMutableMap() ?: mutableMapOf()
val previousPicture = (currentMetadata[PictureTag.TAG_NAME] as? JsonPrimitive)?.content
val previousBanner = (currentMetadata[BannerTag.TAG_NAME] as? JsonPrimitive)?.content
updateFieldsWeWorkWith(
currentMetadata,
@@ -334,11 +338,13 @@ class MetadataEvent(
remove(IdentityClaimTag.TAG_NAME)
claims(newClaims)
// NIP-92: keep the imetas of unchanged images, drop the ones no longer in use.
// NIP-92: keep the imetas of unchanged images, drop the ones of images no longer in use.
updateProfileImageMetas(
previous = latest.tags,
picture = currentMetadata[PictureTag.TAG_NAME]?.text,
banner = currentMetadata[BannerTag.TAG_NAME]?.text,
previousPicture = previousPicture,
previousBanner = previousBanner,
picture = (currentMetadata[PictureTag.TAG_NAME] as? JsonPrimitive)?.content,
banner = (currentMetadata[BannerTag.TAG_NAME] as? JsonPrimitive)?.content,
pictureMeta = pictureMeta,
bannerMeta = bannerMeta,
)
@@ -30,8 +30,9 @@ import com.vitorpamplona.quartz.nip92IMeta.IMetaTag
* NIP-92 `imeta` descriptions of the images a kind 0 names in its `picture` and `banner`
* fields: https://github.com/nostr-protocol/nips/pull/2494
*
* An `imeta` describes a field only when its `url` is exactly that field's value; any other
* `imeta` on a kind 0 is ignored.
* An `imeta` describes a field only when its `url` is that field's value; any other `imeta` on a
* kind 0 is ignored. Both sides are compared trimmed, because profile values are trimmed
* everywhere they are read and written, and a URL cannot carry surrounding whitespace anyway.
*/
class ProfileImageMetas(
val picture: PictureMeta?,
@@ -45,16 +46,20 @@ class ProfileImageMetas(
picture: String?,
banner: String?,
): ProfileImageMetas {
if (picture.isNullOrBlank() && banner.isNullOrBlank()) return EMPTY
val pictureUrl = picture?.trim()?.ifEmpty { null }
val bannerUrl = banner?.trim()?.ifEmpty { null }
if (pictureUrl == null && bannerUrl == null) return EMPTY
var pictureMeta: PictureMeta? = null
var bannerMeta: PictureMeta? = null
tags.fastForEach { tag ->
if (pictureMeta != null && bannerMeta != null) return@fastForEach
if (tag.isEmpty() || tag[0] != IMetaTag.TAG_NAME) return@fastForEach
if ((pictureUrl == null || pictureMeta != null) && (bannerUrl == null || bannerMeta != null)) return@fastForEach
IMetaTag.parse(tag)?.forEach { imeta ->
if (pictureMeta == null && imeta.url == picture) pictureMeta = PictureMeta.parse(imeta)
if (bannerMeta == null && imeta.url == banner) bannerMeta = PictureMeta.parse(imeta)
val url = imeta.url.trim()
if (pictureMeta == null && url == pictureUrl) pictureMeta = PictureMeta.parse(imeta)
if (bannerMeta == null && url == bannerUrl) bannerMeta = PictureMeta.parse(imeta)
}
}
@@ -71,38 +76,57 @@ fun MetadataEvent.profileImageMetas(metadata: UserMetadata? = contactMetaData())
ProfileImageMetas.parse(tags, metadata.picture, metadata.banner)
}
/** The first url of an `imeta` tag, or null if [tag] is not one. */
private fun imetaUrl(tag: Array<String>): String? = IMetaTag.parse(tag)?.firstOrNull()?.url
/** The trimmed first url of an `imeta` tag, or null if [tag] is not one. */
private fun imetaUrl(tag: Array<String>): String? {
if (tag.isEmpty() || tag[0] != IMetaTag.TAG_NAME) return null
return IMetaTag
.parse(tag)
?.firstOrNull()
?.url
?.trim()
}
/**
* Rewrites the kind 0's `imeta` tags so they describe exactly the images it now names.
* Rewrites the kind 0's `imeta` tags after its `picture`/`banner` changed from
* [previousPicture]/[previousBanner] to [picture]/[banner].
*
* [previous] are the tags of the kind 0 being replaced: its `imeta`s for a `picture` or
* `banner` that did not change are carried over, and the ones for images no longer in use
* are dropped. A non-null [pictureMeta] / [bannerMeta] whose url is the current field value
* replaces whatever described that image before.
* Of the [previous] kind 0's `imeta`s, the ones for an image still in use are carried over and the
* ones for an image the profile stopped using are dropped. An `imeta` whose url was never the
* picture or banner is not ours to judge, so it is kept as is. A non-null [pictureMeta] /
* [bannerMeta] whose url is the new field value replaces whatever described that image before.
*/
fun TagArrayBuilder<MetadataEvent>.updateProfileImageMetas(
previous: TagArray,
previousPicture: String?,
previousBanner: String?,
picture: String?,
banner: String?,
pictureMeta: PictureMeta? = null,
bannerMeta: PictureMeta? = null,
) {
remove(IMetaTag.TAG_NAME)
val pictureUrl = picture?.trim()?.ifEmpty { null }
val bannerUrl = banner?.trim()?.ifEmpty { null }
val newPicture = pictureMeta?.takeIf { it.url == picture }
val newBanner = bannerMeta?.takeIf { it.url == banner && it.url != newPicture?.url }
val newPicture = pictureMeta?.takeIf { pictureUrl != null && it.url.trim() == pictureUrl }
val newBanner = bannerMeta?.takeIf { bannerUrl != null && it.url.trim() == bannerUrl && it.url.trim() != newPicture?.url?.trim() }
val replaced = setOfNotNull(newPicture?.url, newBanner?.url)
val inUse = setOfNotNull(picture?.ifBlank { null }, banner?.ifBlank { null })
val replaced = setOfNotNull(newPicture?.url?.trim(), newBanner?.url?.trim())
val inUse = setOfNotNull(pictureUrl, bannerUrl)
val retired = setOfNotNull(previousPicture?.trim(), previousBanner?.trim()) - inUse
val carried = mutableSetOf<String>()
remove(IMetaTag.TAG_NAME)
previous.fastForEach { tag ->
val url = imetaUrl(tag) ?: return@fastForEach
if (url in inUse && url !in replaced && carried.add(url)) {
add(tag)
}
val keep =
when (url) {
in replaced -> false
in inUse -> carried.add(url)
in retired -> false
else -> true
}
if (keep) add(tag)
}
newPicture?.let { add(it.toIMetaArray()) }
@@ -255,4 +255,37 @@ class ProfileImageMetasTest {
assertEquals(1, event.tags.count { it[0] == "imeta" })
assertMeta(pictureMeta, event.profileImageMetas().banner)
}
@Test
fun updateKeepsImetaThatWasNeverPictureOrBanner() {
val unrelated = arrayOf("imeta", "url https://other.example/badge.png", "dim 64x64")
val first =
event(
"{\"picture\":\"$picture\"}",
arrayOf("imeta", "url $picture", "dim 400x400"),
unrelated,
)
val second = signer.sign(MetadataEvent.updateFromPast(first, picture = "https://blossom.example/new.png", createdAt = 1740669817))
assertNull(second.profileImageMetas().picture)
val imetas = second.tags.filter { it[0] == "imeta" }
assertEquals(1, imetas.size)
assertContentEquals(unrelated, imetas[0])
}
@Test
fun matchesIgnoringSurroundingWhitespace() {
val event =
event(
"{\"picture\":\"$picture \"}",
arrayOf("imeta", "url $picture", "dim 400x400"),
)
assertNotNull(event.profileImageMetas().picture)
// A name-only edit leaves the untrimmed picture alone and must keep its imeta.
val second = signer.sign(MetadataEvent.updateFromPast(event, name = "luna", createdAt = 1740669817))
assertEquals(1, second.tags.count { it[0] == "imeta" })
}
}