From a6ee8af4c151928332eeb321c85951488502a304 Mon Sep 17 00:00:00 2001 From: Prarthana Purohit Date: Tue, 31 Mar 2026 17:12:19 +0000 Subject: [PATCH] nip94: add missing tags to FileMetadata (ox, thumb, image, summary, alt, fallback) --- crates/nostr/src/event/tag/kind.rs | 12 ++ crates/nostr/src/event/tag/standard.rs | 11 ++ crates/nostr/src/nips/nip94.rs | 171 +++++++++++++++++++++++++ 3 files changed, 194 insertions(+) diff --git a/crates/nostr/src/event/tag/kind.rs b/crates/nostr/src/event/tag/kind.rs index d0a77b5c1..d83a82db9 100644 --- a/crates/nostr/src/event/tag/kind.rs +++ b/crates/nostr/src/event/tag/kind.rs @@ -79,6 +79,10 @@ pub enum TagKind<'a> { /// /// Extension, + /// Fallback file source + /// + /// + Fallback, /// File File, /// Image @@ -109,6 +113,10 @@ pub enum TagKind<'a> { Nonce, /// Option Option, + /// SHA-256 of the original file before any server-side transforms + /// + /// + Ox, /// Payload Payload, /// Poll type @@ -339,6 +347,7 @@ impl<'a> TagKind<'a> { Self::Ends => "ends", Self::Expiration => "expiration", Self::Extension => "extension", + Self::Fallback => "fallback", Self::File => "file", Self::Head => "HEAD", Self::Image => "image", @@ -354,6 +363,7 @@ impl<'a> TagKind<'a> { Self::Name => "name", Self::Nonce => "nonce", Self::Option => "option", + Self::Ox => "ox", Self::Payload => "payload", Self::PollType => "polltype", Self::Preimage => "preimage", @@ -418,6 +428,7 @@ impl<'a> From<&'a str> for TagKind<'a> { "ends" => Self::Ends, "expiration" => Self::Expiration, "extension" => Self::Extension, + "fallback" => Self::Fallback, "file" => Self::File, "image" => Self::Image, "license" => Self::License, @@ -432,6 +443,7 @@ impl<'a> From<&'a str> for TagKind<'a> { "name" => Self::Name, "nonce" => Self::Nonce, "option" => Self::Option, + "ox" => Self::Ox, "payload" => Self::Payload, "polltype" => Self::PollType, "preimage" => Self::Preimage, diff --git a/crates/nostr/src/event/tag/standard.rs b/crates/nostr/src/event/tag/standard.rs index 7d00c4213..c8a74627d 100644 --- a/crates/nostr/src/event/tag/standard.rs +++ b/crates/nostr/src/event/tag/standard.rs @@ -228,6 +228,7 @@ pub enum TagStandard { }, Server(Url), Sha256(Sha256Hash), + OriginalHash(Sha256Hash), Size(usize), Dim(ImageDimensions), Magnet(String), @@ -303,6 +304,10 @@ pub enum TagStandard { /// /// Alt(String), + /// Fallback file source URL + /// + /// + Fallback(Url), /// List of web URLs Web(Vec), Word(String), @@ -491,6 +496,7 @@ impl TagStandard { TagKind::Lnurl => Ok(Self::Lnurl(tag_1.to_string())), TagKind::Name => Ok(Self::Name(tag_1.to_string())), TagKind::Url => Ok(Self::Url(Url::parse(tag_1)?)), + TagKind::Fallback => Ok(Self::Fallback(Url::parse(tag_1)?)), TagKind::Magnet => Ok(Self::Magnet(tag_1.to_string())), TagKind::Blurhash => Ok(Self::Blurhash(tag_1.to_string())), TagKind::Streaming => Ok(Self::Streaming(Url::parse(tag_1)?)), @@ -513,6 +519,7 @@ impl TagStandard { #[cfg(feature = "nip98")] TagKind::Method => Ok(Self::Method(HttpMethod::from_str(tag_1)?)), TagKind::Payload => Ok(Self::Payload(Sha256Hash::from_str(tag_1)?)), + TagKind::Ox => Ok(Self::OriginalHash(Sha256Hash::from_str(tag_1)?)), TagKind::Request => Ok(Self::Request(Event::from_json(tag_1)?)), TagKind::Word => Ok(Self::Word(tag_1.to_string())), TagKind::Alt => Ok(Self::Alt(tag_1.to_string())), @@ -715,6 +722,7 @@ impl TagStandard { character: Alphabet::X, uppercase: false, }), + Self::OriginalHash(..) => TagKind::Ox, Self::Size(..) => TagKind::Size, Self::Dim(..) => TagKind::Dim, Self::Magnet(..) => TagKind::Magnet, @@ -749,6 +757,7 @@ impl TagStandard { }), Self::Protected => TagKind::Protected, Self::Alt(..) => TagKind::Alt, + Self::Fallback(..) => TagKind::Fallback, Self::Web(..) => TagKind::Web, } } @@ -1015,6 +1024,7 @@ impl From for Vec { TagStandard::Aes256Gcm { key, iv } => vec![tag_kind, key, iv], TagStandard::Server(url) => vec![tag_kind, url.to_string()], TagStandard::Sha256(hash) => vec![tag_kind, hash.to_string()], + TagStandard::OriginalHash(hash) => vec![tag_kind, hash.to_string()], TagStandard::Size(bytes) => vec![tag_kind, bytes.to_string()], TagStandard::Dim(dim) => vec![tag_kind, dim.to_string()], TagStandard::Magnet(uri) => vec![tag_kind, uri], @@ -1077,6 +1087,7 @@ impl From for Vec { } TagStandard::Protected => vec![tag_kind], TagStandard::Alt(summary) => vec![tag_kind, summary], + TagStandard::Fallback(url) => vec![tag_kind, url.to_string()], TagStandard::Web(urls) => { let mut tag: Vec = Vec::with_capacity(1 + urls.len()); tag.push(tag_kind); diff --git a/crates/nostr/src/nips/nip94.rs b/crates/nostr/src/nips/nip94.rs index 7387862ba..414de753c 100644 --- a/crates/nostr/src/nips/nip94.rs +++ b/crates/nostr/src/nips/nip94.rs @@ -46,6 +46,8 @@ pub struct FileMetadata { pub mime_type: String, /// SHA256 of file pub hash: Sha256Hash, + /// SHA-256 of the original file before any server-side transforms (`ox` tag) + pub original_hash: Option, /// AES 256 GCM pub aes_256_gcm: Option<(String, String)>, /// Size in bytes @@ -56,6 +58,16 @@ pub struct FileMetadata { pub magnet: Option, /// Blurhash pub blurhash: Option, + /// Thumbnail URL + pub thumb: Option, + /// Preview image URL + pub image: Option, + /// Short text summary / description + pub summary: Option, + /// Alt text for accessibility + pub alt: Option, + /// Fallback download URLs + pub fallback: Vec, } impl FileMetadata { @@ -68,11 +80,25 @@ impl FileMetadata { url, mime_type: mime_type.into(), hash, + original_hash: None, aes_256_gcm: None, size: None, dim: None, magnet: None, blurhash: None, + thumb: None, + image: None, + summary: None, + alt: None, + fallback: Vec::new(), + } + } + + /// Set SHA-256 of the original file before server-side transforms (`ox` tag) + pub fn original_hash(self, hash: Sha256Hash) -> Self { + Self { + original_hash: Some(hash), + ..self } } @@ -124,6 +150,50 @@ impl FileMetadata { ..self } } + + /// Add thumbnail URL + pub fn thumb(self, thumb: Url) -> Self { + Self { + thumb: Some(thumb), + ..self + } + } + + /// Add preview image URL + pub fn image(self, image: Url) -> Self { + Self { + image: Some(image), + ..self + } + } + + /// Add short text summary / description + pub fn summary(self, summary: S) -> Self + where + S: Into, + { + Self { + summary: Some(summary.into()), + ..self + } + } + + /// Add alt text for accessibility + pub fn alt(self, alt: S) -> Self + where + S: Into, + { + Self { + alt: Some(alt.into()), + ..self + } + } + + /// Add a fallback download URL + pub fn add_fallback(mut self, url: Url) -> Self { + self.fallback.push(url); + self + } } impl From for Vec { @@ -132,11 +202,17 @@ impl From for Vec { url, mime_type, hash, + original_hash, aes_256_gcm, size, dim, magnet, blurhash, + thumb, + image, + summary, + alt, + fallback, } = metadata; let mut tags: Vec = Vec::with_capacity(3); @@ -149,6 +225,12 @@ impl From for Vec { hash, ))); + if let Some(ox) = original_hash { + tags.push(Tag::from_standardized_without_cell( + TagStandard::OriginalHash(ox), + )); + } + if let Some((key, iv)) = aes_256_gcm { tags.push(Tag::from_standardized_without_cell( TagStandard::Aes256Gcm { key, iv }, @@ -175,6 +257,34 @@ impl From for Vec { ))); } + if let Some(thumb) = thumb { + tags.push(Tag::from_standardized_without_cell(TagStandard::Thumb( + thumb, None, + ))); + } + + if let Some(image) = image { + tags.push(Tag::from_standardized_without_cell(TagStandard::Image( + image, None, + ))); + } + + if let Some(summary) = summary { + tags.push(Tag::from_standardized_without_cell(TagStandard::Summary( + summary, + ))); + } + + if let Some(alt) = alt { + tags.push(Tag::from_standardized_without_cell(TagStandard::Alt(alt))); + } + + for url in fallback { + tags.push(Tag::from_standardized_without_cell(TagStandard::Fallback( + url, + ))); + } + tags } } @@ -218,6 +328,17 @@ impl TryFrom> for FileMetadata { let mut metadata = FileMetadata::new(url.clone(), mime, *sha256); + if let Some(TagStandard::OriginalHash(ox)) = value.iter().find_map(|t| { + let t = t.as_standardized(); + if matches!(t, Some(TagStandard::OriginalHash(..))) { + t + } else { + None + } + }) { + metadata = metadata.original_hash(*ox); + } + if let Some(TagStandard::Aes256Gcm { key, iv }) = value.iter().find_map(|t| { let t = t.as_standardized(); if matches!(t, Some(TagStandard::Aes256Gcm { .. })) { @@ -273,6 +394,56 @@ impl TryFrom> for FileMetadata { metadata = metadata.blurhash(bh); } + if let Some(TagStandard::Thumb(url, _)) = value.iter().find_map(|t| { + let t = t.as_standardized(); + if matches!(t, Some(TagStandard::Thumb(..))) { + t + } else { + None + } + }) { + metadata = metadata.thumb(url.clone()); + } + + if let Some(TagStandard::Image(url, _)) = value.iter().find_map(|t| { + let t = t.as_standardized(); + if matches!(t, Some(TagStandard::Image(..))) { + t + } else { + None + } + }) { + metadata = metadata.image(url.clone()); + } + + if let Some(TagStandard::Summary(s)) = value.iter().find_map(|t| { + let t = t.as_standardized(); + if matches!(t, Some(TagStandard::Summary(..))) { + t + } else { + None + } + }) { + metadata = metadata.summary(s); + } + + if let Some(TagStandard::Alt(s)) = value.iter().find_map(|t| { + let t = t.as_standardized(); + if matches!(t, Some(TagStandard::Alt(..))) { + t + } else { + None + } + }) { + metadata = metadata.alt(s); + } + + for tag in value.iter() { + if let Some(TagStandard::Fallback(url)) = tag.as_standardized() { + metadata = metadata.add_fallback(url.clone()); + } + } + Ok(metadata) } }