mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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:
+5
-3
@@ -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,
|
||||
|
||||
+2
-1
@@ -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,
|
||||
|
||||
+8
-5
@@ -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)
|
||||
|
||||
+26
-12
@@ -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)?,
|
||||
)
|
||||
|
||||
+2
-2
@@ -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(
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ internal actual fun AvatarImage(
|
||||
loadProfilePicture: Boolean,
|
||||
loadRobohash: Boolean,
|
||||
autoPlayGif: Boolean,
|
||||
onError: () -> Unit,
|
||||
onError: (() -> Unit)?,
|
||||
) {
|
||||
RobohashFallbackAsyncImage(
|
||||
robot = userHex,
|
||||
|
||||
+2
-20
@@ -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,
|
||||
|
||||
+11
-5
@@ -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,
|
||||
)
|
||||
|
||||
+45
-21
@@ -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()) }
|
||||
|
||||
+33
@@ -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" })
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user