1use 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
50pub const IMETA_TAG: &str = "imeta";
52
53#[derive(Debug, Clone, PartialEq, Eq, Default)]
58pub struct MediaAttachment {
59 pub url: Option<Url>,
61 pub mime_type: Option<String>,
63 pub hash: Option<[u8; 32]>,
65 pub original_hash: Option<[u8; 32]>,
67 pub size: Option<u64>,
69 pub dim: Option<ImageDimensions>,
71 pub magnet: Option<String>,
73 pub blurhash: Option<String>,
75 pub alt: Option<String>,
77 pub summary: Option<String>,
79 pub thumb: Option<FileVariant>,
81 pub image: Option<FileVariant>,
83 pub fallback_urls: Vec<Url>,
85 pub service: Option<String>,
87 pub extra_fields: Vec<(String, String)>,
90}
91
92impl MediaAttachment {
93 #[must_use]
95 pub fn new(url: Url) -> Self {
96 Self {
97 url: Some(url),
98 ..Self::default()
99 }
100 }
101
102 #[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 #[must_use]
111 pub const fn hash(mut self, hash: [u8; 32]) -> Self {
112 self.hash = Some(hash);
113 self
114 }
115
116 #[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 #[must_use]
125 pub const fn size(mut self, size: u64) -> Self {
126 self.size = Some(size);
127 self
128 }
129
130 #[must_use]
132 pub const fn dim(mut self, dim: ImageDimensions) -> Self {
133 self.dim = Some(dim);
134 self
135 }
136
137 #[must_use]
139 pub fn magnet(mut self, magnet: impl Into<String>) -> Self {
140 self.magnet = Some(magnet.into());
141 self
142 }
143
144 #[must_use]
146 pub fn blurhash(mut self, blurhash: impl Into<String>) -> Self {
147 self.blurhash = Some(blurhash.into());
148 self
149 }
150
151 #[must_use]
153 pub fn alt(mut self, alt: impl Into<String>) -> Self {
154 self.alt = Some(alt.into());
155 self
156 }
157
158 #[must_use]
160 pub fn summary(mut self, summary: impl Into<String>) -> Self {
161 self.summary = Some(summary.into());
162 self
163 }
164
165 #[must_use]
167 pub fn thumb(mut self, thumb: FileVariant) -> Self {
168 self.thumb = Some(thumb);
169 self
170 }
171
172 #[must_use]
174 pub fn image(mut self, image: FileVariant) -> Self {
175 self.image = Some(image);
176 self
177 }
178
179 #[must_use]
181 pub fn fallback(mut self, url: Url) -> Self {
182 self.fallback_urls.push(url);
183 self
184 }
185
186 #[must_use]
188 pub fn service(mut self, service: impl Into<String>) -> Self {
189 self.service = Some(service.into());
190 self
191 }
192
193 #[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 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 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 #[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 #[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
340pub 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#[derive(Debug, Error)]
435#[non_exhaustive]
436pub enum MediaAttachmentError {
437 #[error("expected `imeta` tag")]
439 WrongTag,
440 #[error("`imeta` tag must include a `url` field")]
442 MissingUrl,
443 #[error("`imeta` tag must include at least one field besides `url`")]
445 MissingOtherField,
446 #[error("malformed imeta field `{0}`: expected `key value`")]
448 MalformedField(String),
449 #[error("invalid size value: `{0}`")]
451 InvalidSize(String),
452 #[error("invalid SHA-256 hash length: {0} chars (expected 64)")]
454 InvalidHashLength(usize),
455 #[error(transparent)]
457 InvalidHashHex(#[from] HexError),
458 #[error(transparent)]
460 InvalidUrl(#[from] UrlError),
461 #[error(transparent)]
463 InvalidDim(#[from] ImageError),
464}
465
466impl Tag {
467 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}