Skip to main content

nula_core/nips/
nip92.rs

1//! [NIP-92] Media Attachments.
2//!
3//! `imeta` ("inline metadata") tags attach NIP-94-style metadata to
4//! URLs that appear inside an event's `.content`. Each tag is a
5//! variadic list of space-delimited `key value` pairs that mirror the
6//! NIP-94 tag shape:
7//!
8//! ```text
9//! ["imeta",
10//!  "url https://nostr.build/i/picture.jpg",
11//!  "m image/jpeg",
12//!  "blurhash <code>",
13//!  "dim 3024x4032",
14//!  "alt A scenic photo",
15//!  "x <sha256-hex>",
16//!  "fallback https://void.cat/alt1.jpg"]
17//! ```
18//!
19//! Spec invariants enforced by the parser/builder:
20//!
21//! - exactly one `url` per `imeta` tag (required);
22//! - at least one *other* field per `imeta` tag (required).
23//!
24//! Forward compatibility:
25//!
26//! - Unknown keys round-trip through [`MediaAttachment::extra_fields`].
27//! - Multiple `fallback`s are concatenated into [`MediaAttachment::fallback_urls`].
28//!
29//! # Cross-NIP
30//!
31//! The set of keys is intentionally aligned with NIP-94: any field on
32//! [`FileMetadata`] can appear inside an `imeta` tag. The
33//! two-direction converters
34//! [`MediaAttachment::from_file_metadata`] /
35//! [`MediaAttachment::to_file_metadata`] make crossing the boundary
36//! cheap.
37//!
38//! [NIP-92]: https://github.com/nostr-protocol/nips/blob/master/92.md
39
40use thiserror::Error;
41
42use crate::event::{Tag, TagKind, Tags};
43use crate::nips::nip94::{
44    ALT_TAG, BLURHASH_TAG, DIM_TAG, FALLBACK_TAG, FileMetadata, FileVariant, IMAGE_TAG, MAGNET_TAG,
45    OX_TAG, SERVICE_TAG, SIZE_TAG, SUMMARY_TAG, THUMB_TAG, URL_TAG,
46};
47use crate::types::{ImageDimensions, ImageError, Url, UrlError};
48use crate::util::hex::{self, HexError};
49
50/// Wire name of the `imeta` tag.
51pub const IMETA_TAG: &str = "imeta";
52
53/// One inline media attachment.
54///
55/// Field semantics match the NIP-94 columns of the same name; see
56/// [`FileMetadata`] for documentation that applies one-to-one.
57#[derive(Debug, Clone, PartialEq, Eq, Default)]
58pub struct MediaAttachment {
59    /// Required `url` field.
60    pub url: Option<Url>,
61    /// `m` MIME type.
62    pub mime_type: Option<String>,
63    /// `x` SHA-256 of served bytes.
64    pub hash: Option<[u8; 32]>,
65    /// `ox` SHA-256 of original bytes.
66    pub original_hash: Option<[u8; 32]>,
67    /// `size` in bytes.
68    pub size: Option<u64>,
69    /// `dim` pixel dimensions.
70    pub dim: Option<ImageDimensions>,
71    /// `magnet` URI.
72    pub magnet: Option<String>,
73    /// `blurhash` placeholder.
74    pub blurhash: Option<String>,
75    /// `alt` accessibility description.
76    pub alt: Option<String>,
77    /// `summary` excerpt.
78    pub summary: Option<String>,
79    /// `thumb` variant.
80    pub thumb: Option<FileVariant>,
81    /// `image` preview variant.
82    pub image: Option<FileVariant>,
83    /// `fallback` URLs.
84    pub fallback_urls: Vec<Url>,
85    /// `service` identifier.
86    pub service: Option<String>,
87    /// Unknown keys carried through verbatim for forward
88    /// compatibility. `(key, value)` pairs in insertion order.
89    pub extra_fields: Vec<(String, String)>,
90}
91
92impl MediaAttachment {
93    /// Construct an attachment seeded with the required `url` field.
94    #[must_use]
95    pub fn new(url: Url) -> Self {
96        Self {
97            url: Some(url),
98            ..Self::default()
99        }
100    }
101
102    /// Set [`Self::mime_type`].
103    #[must_use]
104    pub fn mime_type(mut self, mime_type: impl Into<String>) -> Self {
105        self.mime_type = Some(mime_type.into());
106        self
107    }
108
109    /// Set [`Self::hash`].
110    #[must_use]
111    pub const fn hash(mut self, hash: [u8; 32]) -> Self {
112        self.hash = Some(hash);
113        self
114    }
115
116    /// Set [`Self::original_hash`].
117    #[must_use]
118    pub const fn original_hash(mut self, hash: [u8; 32]) -> Self {
119        self.original_hash = Some(hash);
120        self
121    }
122
123    /// Set [`Self::size`].
124    #[must_use]
125    pub const fn size(mut self, size: u64) -> Self {
126        self.size = Some(size);
127        self
128    }
129
130    /// Set [`Self::dim`].
131    #[must_use]
132    pub const fn dim(mut self, dim: ImageDimensions) -> Self {
133        self.dim = Some(dim);
134        self
135    }
136
137    /// Set [`Self::magnet`].
138    #[must_use]
139    pub fn magnet(mut self, magnet: impl Into<String>) -> Self {
140        self.magnet = Some(magnet.into());
141        self
142    }
143
144    /// Set [`Self::blurhash`].
145    #[must_use]
146    pub fn blurhash(mut self, blurhash: impl Into<String>) -> Self {
147        self.blurhash = Some(blurhash.into());
148        self
149    }
150
151    /// Set [`Self::alt`].
152    #[must_use]
153    pub fn alt(mut self, alt: impl Into<String>) -> Self {
154        self.alt = Some(alt.into());
155        self
156    }
157
158    /// Set [`Self::summary`].
159    #[must_use]
160    pub fn summary(mut self, summary: impl Into<String>) -> Self {
161        self.summary = Some(summary.into());
162        self
163    }
164
165    /// Set [`Self::thumb`].
166    #[must_use]
167    pub fn thumb(mut self, thumb: FileVariant) -> Self {
168        self.thumb = Some(thumb);
169        self
170    }
171
172    /// Set [`Self::image`].
173    #[must_use]
174    pub fn image(mut self, image: FileVariant) -> Self {
175        self.image = Some(image);
176        self
177    }
178
179    /// Append a fallback URL.
180    #[must_use]
181    pub fn fallback(mut self, url: Url) -> Self {
182        self.fallback_urls.push(url);
183        self
184    }
185
186    /// Set [`Self::service`].
187    #[must_use]
188    pub fn service(mut self, service: impl Into<String>) -> Self {
189        self.service = Some(service.into());
190        self
191    }
192
193    /// Append a forward-compatible `(key, value)` pair.
194    ///
195    /// Known NIP-94 keys submitted via this method are silently
196    /// dropped — they live on the typed fields.
197    #[must_use]
198    pub fn extra(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
199        let key = key.into();
200        if !is_known_key(&key) {
201            self.extra_fields.push((key, value.into()));
202        }
203        self
204    }
205
206    /// Render to an `imeta` [`Tag`].
207    ///
208    /// # Errors
209    ///
210    /// Returns [`MediaAttachmentError::MissingUrl`] when [`Self::url`]
211    /// is `None`, or [`MediaAttachmentError::MissingOtherField`]
212    /// when no other field is populated (spec invariants).
213    pub fn to_tag(&self) -> Result<Tag, MediaAttachmentError> {
214        let url = self.url.as_ref().ok_or(MediaAttachmentError::MissingUrl)?;
215        let mut fields: Vec<String> = Vec::new();
216        fields.push(format!("{URL_TAG} {}", url.as_str()));
217        let other_count_before = fields.len();
218        push_optional(&mut fields, "m", self.mime_type.as_deref());
219        if let Some(hash) = self.hash {
220            fields.push(format!("x {}", hex::encode(hash)));
221        }
222        if let Some(hash) = self.original_hash {
223            fields.push(format!("{OX_TAG} {}", hex::encode(hash)));
224        }
225        if let Some(size) = self.size {
226            fields.push(format!("{SIZE_TAG} {size}"));
227        }
228        if let Some(dim) = self.dim {
229            fields.push(format!("{DIM_TAG} {dim}"));
230        }
231        push_optional(&mut fields, MAGNET_TAG, self.magnet.as_deref());
232        push_optional(&mut fields, BLURHASH_TAG, self.blurhash.as_deref());
233        push_optional(&mut fields, ALT_TAG, self.alt.as_deref());
234        push_optional(&mut fields, SUMMARY_TAG, self.summary.as_deref());
235        if let Some(thumb) = &self.thumb {
236            fields.push(format!("{THUMB_TAG} {}", thumb.url.as_str()));
237        }
238        if let Some(image) = &self.image {
239            fields.push(format!("{IMAGE_TAG} {}", image.url.as_str()));
240        }
241        for fb in &self.fallback_urls {
242            fields.push(format!("{FALLBACK_TAG} {}", fb.as_str()));
243        }
244        push_optional(&mut fields, SERVICE_TAG, self.service.as_deref());
245        for (k, v) in &self.extra_fields {
246            fields.push(format!("{k} {v}"));
247        }
248        if fields.len() == other_count_before {
249            return Err(MediaAttachmentError::MissingOtherField);
250        }
251        Ok(Tag::with(&TagKind::from_wire(IMETA_TAG), fields))
252    }
253
254    /// Parse one `imeta` [`Tag`] back into a typed attachment.
255    ///
256    /// # Errors
257    ///
258    /// - [`MediaAttachmentError::WrongTag`] when the tag is not an
259    ///   `imeta` tag.
260    /// - [`MediaAttachmentError::MissingUrl`] when no `url` field is
261    ///   present.
262    /// - [`MediaAttachmentError::MissingOtherField`] when only `url`
263    ///   is present.
264    /// - [`MediaAttachmentError::MalformedField`] when a field has
265    ///   no separator.
266    /// - Various typed errors for malformed individual fields.
267    pub fn from_tag(tag: &Tag) -> Result<Self, MediaAttachmentError> {
268        if tag.name() != IMETA_TAG {
269            return Err(MediaAttachmentError::WrongTag);
270        }
271        let mut attachment = Self::default();
272        let mut other_field_count = 0_usize;
273        for entry in tag.values().iter().skip(1) {
274            let (key, value) = split_field(entry)?;
275            if key != URL_TAG {
276                other_field_count += 1;
277            }
278            apply_field(&mut attachment, key, value)?;
279        }
280        if attachment.url.is_none() {
281            return Err(MediaAttachmentError::MissingUrl);
282        }
283        if other_field_count == 0 {
284            return Err(MediaAttachmentError::MissingOtherField);
285        }
286        Ok(attachment)
287    }
288
289    /// Build an attachment from a [`FileMetadata`] bundle.
290    ///
291    /// The `url` field is required for an `imeta` tag; if the
292    /// metadata bundle has no URL the returned attachment will fail
293    /// [`Self::to_tag`].
294    #[must_use]
295    pub fn from_file_metadata(meta: &FileMetadata) -> Self {
296        Self {
297            url: meta.url.clone(),
298            mime_type: meta.mime_type.clone(),
299            hash: meta.hash,
300            original_hash: meta.original_hash,
301            size: meta.size,
302            dim: meta.dim,
303            magnet: meta.magnet.clone(),
304            blurhash: meta.blurhash.clone(),
305            alt: meta.alt.clone(),
306            summary: meta.summary.clone(),
307            thumb: meta.thumb.clone(),
308            image: meta.preview_image.clone(),
309            fallback_urls: meta.fallback_urls.clone(),
310            service: meta.service.clone(),
311            extra_fields: Vec::new(),
312        }
313    }
314
315    /// Promote this attachment to a typed [`FileMetadata`].
316    ///
317    /// Unknown extra fields are dropped — they have no NIP-94 home.
318    #[must_use]
319    pub fn to_file_metadata(&self) -> FileMetadata {
320        FileMetadata {
321            url: self.url.clone(),
322            mime_type: self.mime_type.clone(),
323            hash: self.hash,
324            original_hash: self.original_hash,
325            size: self.size,
326            dim: self.dim,
327            magnet: self.magnet.clone(),
328            torrent_infohash: None,
329            blurhash: self.blurhash.clone(),
330            thumb: self.thumb.clone(),
331            preview_image: self.image.clone(),
332            summary: self.summary.clone(),
333            alt: self.alt.clone(),
334            fallback_urls: self.fallback_urls.clone(),
335            service: self.service.clone(),
336        }
337    }
338}
339
340/// Read every `imeta` tag off the given event tags.
341///
342/// # Errors
343///
344/// Returns the first error encountered while parsing a tag. To
345/// tolerate malformed individual tags, walk the list manually with
346/// [`MediaAttachment::from_tag`].
347pub fn attachments_from_tags(tags: &Tags) -> Result<Vec<MediaAttachment>, MediaAttachmentError> {
348    let head = TagKind::from_wire(IMETA_TAG);
349    let mut out: Vec<MediaAttachment> = Vec::new();
350    for tag in tags.find_all(&head) {
351        out.push(MediaAttachment::from_tag(tag)?);
352    }
353    Ok(out)
354}
355
356fn push_optional(out: &mut Vec<String>, key: &str, value: Option<&str>) {
357    if let Some(value) = value {
358        out.push(format!("{key} {value}"));
359    }
360}
361
362fn split_field(raw: &str) -> Result<(&str, &str), MediaAttachmentError> {
363    raw.split_once(' ')
364        .ok_or_else(|| MediaAttachmentError::MalformedField(raw.to_owned()))
365}
366
367fn apply_field(
368    out: &mut MediaAttachment,
369    key: &str,
370    value: &str,
371) -> Result<(), MediaAttachmentError> {
372    match key {
373        URL_TAG => out.url = Some(Url::parse(value)?),
374        "m" => out.mime_type = Some(value.to_owned()),
375        "x" => out.hash = Some(parse_sha256(value)?),
376        OX_TAG => out.original_hash = Some(parse_sha256(value)?),
377        SIZE_TAG => {
378            out.size = Some(
379                value
380                    .parse::<u64>()
381                    .map_err(|_| MediaAttachmentError::InvalidSize(value.to_owned()))?,
382            );
383        }
384        DIM_TAG => {
385            out.dim = Some(
386                value
387                    .parse::<ImageDimensions>()
388                    .map_err(MediaAttachmentError::InvalidDim)?,
389            );
390        }
391        MAGNET_TAG => out.magnet = Some(value.to_owned()),
392        BLURHASH_TAG => out.blurhash = Some(value.to_owned()),
393        ALT_TAG => out.alt = Some(value.to_owned()),
394        SUMMARY_TAG => out.summary = Some(value.to_owned()),
395        THUMB_TAG => out.thumb = Some(FileVariant::new(Url::parse(value)?)),
396        IMAGE_TAG => out.image = Some(FileVariant::new(Url::parse(value)?)),
397        FALLBACK_TAG => out.fallback_urls.push(Url::parse(value)?),
398        SERVICE_TAG => out.service = Some(value.to_owned()),
399        other => out.extra_fields.push((other.to_owned(), value.to_owned())),
400    }
401    Ok(())
402}
403
404fn is_known_key(key: &str) -> bool {
405    matches!(
406        key,
407        URL_TAG
408            | "m"
409            | "x"
410            | OX_TAG
411            | SIZE_TAG
412            | DIM_TAG
413            | MAGNET_TAG
414            | BLURHASH_TAG
415            | ALT_TAG
416            | SUMMARY_TAG
417            | THUMB_TAG
418            | IMAGE_TAG
419            | FALLBACK_TAG
420            | SERVICE_TAG
421    )
422}
423
424fn parse_sha256(input: &str) -> Result<[u8; 32], MediaAttachmentError> {
425    if input.len() != 64 {
426        return Err(MediaAttachmentError::InvalidHashLength(input.len()));
427    }
428    let mut bytes = [0_u8; 32];
429    hex::decode_to_slice(input, &mut bytes).map_err(MediaAttachmentError::InvalidHashHex)?;
430    Ok(bytes)
431}
432
433/// Errors raised by [`MediaAttachment`] parsers / builders.
434#[derive(Debug, Error)]
435#[non_exhaustive]
436pub enum MediaAttachmentError {
437    /// The tag was not an `imeta` tag.
438    #[error("expected `imeta` tag")]
439    WrongTag,
440    /// `url` field is missing.
441    #[error("`imeta` tag must include a `url` field")]
442    MissingUrl,
443    /// No other field besides `url` is present.
444    #[error("`imeta` tag must include at least one field besides `url`")]
445    MissingOtherField,
446    /// A field has no space separator.
447    #[error("malformed imeta field `{0}`: expected `key value`")]
448    MalformedField(String),
449    /// The `size` value is not a `u64`.
450    #[error("invalid size value: `{0}`")]
451    InvalidSize(String),
452    /// SHA-256 hash has the wrong length.
453    #[error("invalid SHA-256 hash length: {0} chars (expected 64)")]
454    InvalidHashLength(usize),
455    /// SHA-256 hash hex decoding failed.
456    #[error(transparent)]
457    InvalidHashHex(#[from] HexError),
458    /// `url` / `fallback` / `thumb` / `image` URL parse error.
459    #[error(transparent)]
460    InvalidUrl(#[from] UrlError),
461    /// `dim` value parse error.
462    #[error(transparent)]
463    InvalidDim(#[from] ImageError),
464}
465
466impl Tag {
467    /// Build a NIP-92 `imeta` tag from a [`MediaAttachment`].
468    ///
469    /// # Errors
470    ///
471    /// See [`MediaAttachment::to_tag`].
472    pub fn imeta(attachment: &MediaAttachment) -> Result<Self, MediaAttachmentError> {
473        attachment.to_tag()
474    }
475}
476
477#[cfg(test)]
478mod tests {
479    use super::*;
480    use crate::EventBuilder;
481    use crate::Keys;
482
483    fn keys() -> Keys {
484        Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
485    }
486
487    fn url() -> Url {
488        Url::parse("https://nostr.build/i/picture.jpg").unwrap()
489    }
490
491    #[test]
492    fn round_trip_full_attachment() {
493        let attachment = MediaAttachment::new(url())
494            .mime_type("image/jpeg")
495            .hash([0xab; 32])
496            .size(1024)
497            .dim("3024x4032".parse().unwrap())
498            .blurhash("eVF$^OI:")
499            .alt("scenic")
500            .fallback(Url::parse("https://void.cat/alt1.jpg").unwrap())
501            .fallback(Url::parse("https://nostrcheck.me/alt2.jpg").unwrap())
502            .service("nip96");
503        let tag = attachment.to_tag().unwrap();
504        let parsed = MediaAttachment::from_tag(&tag).unwrap();
505        assert_eq!(parsed, attachment);
506    }
507
508    #[test]
509    fn round_trip_with_extras() {
510        let attachment = MediaAttachment::new(url())
511            .mime_type("image/jpeg")
512            .extra("aspect", "16:9")
513            .extra("custom", "extension");
514        let tag = attachment.to_tag().unwrap();
515        let parsed = MediaAttachment::from_tag(&tag).unwrap();
516        assert_eq!(parsed.extra_fields, attachment.extra_fields);
517    }
518
519    #[test]
520    fn missing_url_is_rejected() {
521        let attachment = MediaAttachment::default();
522        assert!(matches!(
523            attachment.to_tag(),
524            Err(MediaAttachmentError::MissingUrl)
525        ));
526    }
527
528    #[test]
529    fn missing_other_field_is_rejected() {
530        let attachment = MediaAttachment::new(url());
531        assert!(matches!(
532            attachment.to_tag(),
533            Err(MediaAttachmentError::MissingOtherField)
534        ));
535    }
536
537    #[test]
538    fn wrong_tag_is_rejected() {
539        let tag = Tag::title("not imeta");
540        assert!(matches!(
541            MediaAttachment::from_tag(&tag),
542            Err(MediaAttachmentError::WrongTag)
543        ));
544    }
545
546    #[test]
547    fn malformed_field_is_rejected() {
548        let tag = Tag::with(&TagKind::from_wire(IMETA_TAG), ["no-separator"]);
549        assert!(matches!(
550            MediaAttachment::from_tag(&tag),
551            Err(MediaAttachmentError::MalformedField(_))
552        ));
553    }
554
555    #[test]
556    fn known_key_submitted_via_extra_is_dropped() {
557        let attachment = MediaAttachment::new(url())
558            .mime_type("image/jpeg")
559            .extra("alt", "should-be-ignored");
560        assert!(attachment.extra_fields.is_empty());
561    }
562
563    #[test]
564    fn attachments_from_tags_reads_event_tags() {
565        let attachment = MediaAttachment::new(url()).mime_type("image/jpeg");
566        let tag = attachment.to_tag().unwrap();
567        let event = EventBuilder::text_note("hi")
568            .tag(tag)
569            .sign_with_keys(&keys())
570            .unwrap();
571        let parsed = attachments_from_tags(&event.tags).unwrap();
572        assert_eq!(parsed, vec![attachment]);
573    }
574
575    #[test]
576    fn cross_conversion_with_file_metadata() {
577        let meta = FileMetadata::new(url(), "image/jpeg", [0x11; 32])
578            .size(42)
579            .alt("alt-text");
580        let attachment = MediaAttachment::from_file_metadata(&meta);
581        let back = attachment.to_file_metadata();
582        assert_eq!(back, meta);
583    }
584}