1use thiserror::Error;
56
57use crate::event::{Alphabet, Event, EventBuilder, Kind, SingleLetterTag, Tag, TagKind, Tags};
58use crate::types::{ImageDimensions, ImageError, Url, UrlError};
59use crate::util::hex::{self, HexError};
60
61pub const KIND_FILE_METADATA: Kind = Kind::FILE_METADATA;
63
64pub const URL_TAG: &str = "url";
66pub const OX_TAG: &str = "ox";
68pub const SIZE_TAG: &str = "size";
70pub const DIM_TAG: &str = "dim";
72pub const MAGNET_TAG: &str = "magnet";
74pub const BLURHASH_TAG: &str = "blurhash";
76pub const THUMB_TAG: &str = "thumb";
78pub const IMAGE_TAG: &str = "image";
80pub const SUMMARY_TAG: &str = "summary";
82pub const ALT_TAG: &str = "alt";
84pub const FALLBACK_TAG: &str = "fallback";
86pub const SERVICE_TAG: &str = "service";
88
89#[derive(Debug, Clone, PartialEq, Eq, Hash)]
93pub struct FileVariant {
94 pub url: Url,
96 pub hash: Option<[u8; 32]>,
100}
101
102impl FileVariant {
103 #[must_use]
105 pub const fn new(url: Url) -> Self {
106 Self { url, hash: None }
107 }
108
109 #[must_use]
111 pub const fn with_hash(mut self, hash: [u8; 32]) -> Self {
112 self.hash = Some(hash);
113 self
114 }
115}
116
117#[derive(Debug, Clone, PartialEq, Eq, Default)]
122pub struct FileMetadata {
123 pub url: Option<Url>,
125 pub mime_type: Option<String>,
127 pub hash: Option<[u8; 32]>,
129 pub original_hash: Option<[u8; 32]>,
132 pub size: Option<u64>,
134 pub dim: Option<ImageDimensions>,
136 pub magnet: Option<String>,
138 pub torrent_infohash: Option<String>,
140 pub blurhash: Option<String>,
142 pub thumb: Option<FileVariant>,
144 pub preview_image: Option<FileVariant>,
146 pub summary: Option<String>,
148 pub alt: Option<String>,
150 pub fallback_urls: Vec<Url>,
152 pub service: Option<String>,
154}
155
156impl FileMetadata {
157 #[must_use]
159 pub fn new(url: Url, mime_type: impl Into<String>, hash: [u8; 32]) -> Self {
160 Self {
161 url: Some(url),
162 mime_type: Some(mime_type.into()),
163 hash: Some(hash),
164 ..Self::default()
165 }
166 }
167
168 #[must_use]
170 pub const fn original_hash(mut self, hash: [u8; 32]) -> Self {
171 self.original_hash = Some(hash);
172 self
173 }
174
175 #[must_use]
177 pub const fn size(mut self, size: u64) -> Self {
178 self.size = Some(size);
179 self
180 }
181
182 #[must_use]
184 pub const fn dim(mut self, dim: ImageDimensions) -> Self {
185 self.dim = Some(dim);
186 self
187 }
188
189 #[must_use]
191 pub fn magnet(mut self, magnet: impl Into<String>) -> Self {
192 self.magnet = Some(magnet.into());
193 self
194 }
195
196 #[must_use]
198 pub fn torrent_infohash(mut self, infohash: impl Into<String>) -> Self {
199 self.torrent_infohash = Some(infohash.into());
200 self
201 }
202
203 #[must_use]
205 pub fn blurhash(mut self, blurhash: impl Into<String>) -> Self {
206 self.blurhash = Some(blurhash.into());
207 self
208 }
209
210 #[must_use]
212 pub fn thumb(mut self, thumb: FileVariant) -> Self {
213 self.thumb = Some(thumb);
214 self
215 }
216
217 #[must_use]
219 pub fn preview_image(mut self, image: FileVariant) -> Self {
220 self.preview_image = Some(image);
221 self
222 }
223
224 #[must_use]
226 pub fn summary(mut self, summary: impl Into<String>) -> Self {
227 self.summary = Some(summary.into());
228 self
229 }
230
231 #[must_use]
233 pub fn alt(mut self, alt: impl Into<String>) -> Self {
234 self.alt = Some(alt.into());
235 self
236 }
237
238 #[must_use]
240 pub fn fallback(mut self, url: Url) -> Self {
241 self.fallback_urls.push(url);
242 self
243 }
244
245 #[must_use]
247 pub fn service(mut self, service: impl Into<String>) -> Self {
248 self.service = Some(service.into());
249 self
250 }
251
252 pub fn from_event(event: &Event) -> Result<Self, FileMetadataError> {
263 if event.kind != KIND_FILE_METADATA {
264 return Err(FileMetadataError::WrongKind(event.kind));
265 }
266 Self::from_tags(&event.tags)
267 }
268
269 pub fn from_tags(tags: &Tags) -> Result<Self, FileMetadataError> {
277 let url_str = custom_value(tags, URL_TAG).ok_or(FileMetadataError::MissingUrl)?;
278 let url = Url::parse(url_str).map_err(FileMetadataError::InvalidUrl)?;
279
280 let mime_type = single_value(tags, Alphabet::M)
281 .ok_or(FileMetadataError::MissingMimeType)?
282 .to_owned();
283 let hash_hex = single_value(tags, Alphabet::X).ok_or(FileMetadataError::MissingHash)?;
284 let hash = parse_sha256(hash_hex)?;
285
286 let mut metadata = Self::new(url, mime_type, hash);
287
288 if let Some(raw) = custom_value(tags, OX_TAG) {
289 metadata.original_hash = Some(parse_sha256(raw)?);
290 }
291 if let Some(raw) = custom_value(tags, SIZE_TAG) {
292 metadata.size = Some(
293 raw.parse::<u64>()
294 .map_err(|_| FileMetadataError::InvalidSize(raw.to_owned()))?,
295 );
296 }
297 if let Some(raw) = custom_value(tags, DIM_TAG) {
298 metadata.dim = Some(
299 raw.parse::<ImageDimensions>()
300 .map_err(FileMetadataError::InvalidDim)?,
301 );
302 }
303 if let Some(raw) = custom_value(tags, MAGNET_TAG) {
304 metadata.magnet = Some(raw.to_owned());
305 }
306 if let Some(raw) = single_value(tags, Alphabet::I) {
307 metadata.torrent_infohash = Some(raw.to_owned());
308 }
309 if let Some(raw) = custom_value(tags, BLURHASH_TAG) {
310 metadata.blurhash = Some(raw.to_owned());
311 }
312 metadata.thumb = parse_variant(tags, THUMB_TAG)?;
313 metadata.preview_image = parse_variant(tags, IMAGE_TAG)?;
314 if let Some(raw) = custom_value(tags, SUMMARY_TAG) {
315 metadata.summary = Some(raw.to_owned());
316 }
317 if let Some(raw) = custom_value(tags, ALT_TAG) {
318 metadata.alt = Some(raw.to_owned());
319 }
320 for tag in tags.find_all(&TagKind::Custom(FALLBACK_TAG.to_owned())) {
321 if let Some(raw) = tag.get(1) {
322 let parsed = Url::parse(raw).map_err(FileMetadataError::InvalidUrl)?;
323 metadata.fallback_urls.push(parsed);
324 }
325 }
326 if let Some(raw) = custom_value(tags, SERVICE_TAG) {
327 metadata.service = Some(raw.to_owned());
328 }
329 Ok(metadata)
330 }
331
332 pub fn to_tags(&self) -> Result<Vec<Tag>, FileMetadataError> {
343 let url = self.url.as_ref().ok_or(FileMetadataError::MissingUrl)?;
344 let mime = self
345 .mime_type
346 .as_deref()
347 .ok_or(FileMetadataError::MissingMimeType)?;
348 let hash = self.hash.ok_or(FileMetadataError::MissingHash)?;
349
350 let mut tags: Vec<Tag> = Vec::with_capacity(3 + self.fallback_urls.len());
351 tags.push(custom(URL_TAG, [url.as_str().to_owned()]));
352 tags.push(letter(Alphabet::M, [mime.to_owned()]));
353 tags.push(letter(Alphabet::X, [hex::encode(hash)]));
354
355 if let Some(ox) = self.original_hash {
356 tags.push(custom(OX_TAG, [hex::encode(ox)]));
357 }
358 if let Some(size) = self.size {
359 tags.push(custom(SIZE_TAG, [size.to_string()]));
360 }
361 if let Some(dim) = self.dim {
362 tags.push(custom(DIM_TAG, [dim.to_string()]));
363 }
364 if let Some(magnet) = &self.magnet {
365 tags.push(custom(MAGNET_TAG, [magnet.clone()]));
366 }
367 if let Some(infohash) = &self.torrent_infohash {
368 tags.push(letter(Alphabet::I, [infohash.clone()]));
369 }
370 if let Some(blurhash) = &self.blurhash {
371 tags.push(custom(BLURHASH_TAG, [blurhash.clone()]));
372 }
373 if let Some(thumb) = &self.thumb {
374 tags.push(variant_tag(THUMB_TAG, thumb));
375 }
376 if let Some(preview) = &self.preview_image {
377 tags.push(variant_tag(IMAGE_TAG, preview));
378 }
379 if let Some(summary) = &self.summary {
380 tags.push(custom(SUMMARY_TAG, [summary.clone()]));
381 }
382 if let Some(alt) = &self.alt {
383 tags.push(custom(ALT_TAG, [alt.clone()]));
384 }
385 for fallback in &self.fallback_urls {
386 tags.push(custom(FALLBACK_TAG, [fallback.as_str().to_owned()]));
387 }
388 if let Some(service) = &self.service {
389 tags.push(custom(SERVICE_TAG, [service.clone()]));
390 }
391 Ok(tags)
392 }
393}
394
395#[derive(Debug, Error)]
397#[non_exhaustive]
398pub enum FileMetadataError {
399 #[error("expected kind 1063 (file metadata), got kind {}", .0.as_u16())]
401 WrongKind(Kind),
402 #[error("NIP-94 event must carry a `url` tag")]
404 MissingUrl,
405 #[error("NIP-94 event must carry an `m` (MIME) tag")]
407 MissingMimeType,
408 #[error("NIP-94 event must carry an `x` (SHA-256) tag")]
410 MissingHash,
411 #[error("invalid URL: {0}")]
413 InvalidUrl(#[source] UrlError),
414 #[error("invalid SHA-256 hex: {0}")]
416 InvalidHash(#[source] HexError),
417 #[error("SHA-256 must be 64 hex chars, got {0}")]
419 InvalidHashLength(usize),
420 #[error("invalid `size` value `{0}`: must be unsigned integer bytes")]
422 InvalidSize(String),
423 #[error("invalid `dim` value: {0}")]
425 InvalidDim(#[source] ImageError),
426}
427
428fn parse_sha256(input: &str) -> Result<[u8; 32], FileMetadataError> {
429 if input.len() != 64 {
430 return Err(FileMetadataError::InvalidHashLength(input.len()));
431 }
432 let mut bytes = [0_u8; 32];
433 hex::decode_to_slice(input, &mut bytes).map_err(FileMetadataError::InvalidHash)?;
434 Ok(bytes)
435}
436
437fn parse_variant(tags: &Tags, name: &str) -> Result<Option<FileVariant>, FileMetadataError> {
438 let head = TagKind::Custom(name.to_owned());
439 let Some(tag) = tags.find_first(&head) else {
440 return Ok(None);
441 };
442 let Some(url_str) = tag.get(1) else {
443 return Ok(None);
444 };
445 let url = Url::parse(url_str).map_err(FileMetadataError::InvalidUrl)?;
446 let hash = match tag.get(2) {
447 Some(hex) if !hex.is_empty() => Some(parse_sha256(hex)?),
448 _ => None,
449 };
450 Ok(Some(FileVariant { url, hash }))
451}
452
453fn variant_tag(name: &str, variant: &FileVariant) -> Tag {
454 let mut values: Vec<String> = Vec::with_capacity(2);
455 values.push(variant.url.as_str().to_owned());
456 if let Some(hash) = variant.hash {
457 values.push(hex::encode(hash));
458 }
459 custom(name, values)
460}
461
462fn single_value(tags: &Tags, letter: Alphabet) -> Option<&str> {
463 let head = TagKind::single_letter(SingleLetterTag::lowercase(letter));
464 tags.find_first(&head).and_then(|tag| tag.get(1))
465}
466
467fn custom_value<'a>(tags: &'a Tags, name: &str) -> Option<&'a str> {
468 tags.iter()
469 .find(|tag| tag.name() == name)
470 .and_then(|tag| tag.get(1))
471}
472
473fn custom<I, S>(name: &str, args: I) -> Tag
474where
475 I: IntoIterator<Item = S>,
476 S: Into<String>,
477{
478 Tag::with(&TagKind::Custom(name.to_owned()), args)
479}
480
481fn letter<I, S>(alphabet: Alphabet, args: I) -> Tag
482where
483 I: IntoIterator<Item = S>,
484 S: Into<String>,
485{
486 let head = TagKind::single_letter(SingleLetterTag::lowercase(alphabet));
487 Tag::with(&head, args)
488}
489
490impl EventBuilder {
491 pub fn file_metadata(
501 caption: impl Into<String>,
502 metadata: &FileMetadata,
503 ) -> Result<Self, FileMetadataError> {
504 let tags = metadata.to_tags()?;
505 let mut builder = Self::new(KIND_FILE_METADATA, caption);
506 for tag in tags {
507 builder = builder.tag(tag);
508 }
509 Ok(builder)
510 }
511}
512
513#[cfg(test)]
514mod tests {
515 use super::*;
516 use crate::Keys;
517
518 const HASH_HEX: &str = "1aea8e98e0e5d969b7124f553b88dfae47d1f00472ea8c0dbf4ac4577d39ef02";
519 const ORIG_HEX: &str = "8a8c1d9c5b3e3e3a8b95d51b6f8a6f3a3a23bba1f1c5d9e7e1c0b3d8b9a0e3a4";
520 const URL: &str = "https://image.nostr.build/99a95fcb4b7a2591ad32467032c52a62d90a204d3b176bc2459ad7427a3f2b89.jpg";
521
522 fn keys() -> Keys {
523 Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
524 }
525
526 fn fixture_metadata() -> FileMetadata {
527 let url = Url::parse(URL).unwrap();
528 let hash = hex_array(HASH_HEX);
529 FileMetadata::new(url, "image/jpeg", hash)
530 .original_hash(hex_array(ORIG_HEX))
531 .size(102_400)
532 .dim(ImageDimensions::new(640, 480).unwrap())
533 .magnet("magnet:?xt=urn:btih:abcd")
534 .torrent_infohash("abcd1234")
535 .blurhash("LE@:#0R%9F00ag~q-=^7^*9F8_-V")
536 .thumb(FileVariant::new(
537 Url::parse("https://example.com/t.jpg").unwrap(),
538 ))
539 .preview_image(
540 FileVariant::new(Url::parse("https://example.com/p.jpg").unwrap())
541 .with_hash(hex_array(HASH_HEX)),
542 )
543 .summary("a sample image")
544 .alt("scenic mountain view")
545 .fallback(Url::parse("https://example.com/fallback1").unwrap())
546 .fallback(Url::parse("https://example.com/fallback2").unwrap())
547 .service("nip96")
548 }
549
550 fn hex_array(s: &str) -> [u8; 32] {
551 let mut out = [0_u8; 32];
552 hex::decode_to_slice(s, &mut out).unwrap();
553 out
554 }
555
556 #[test]
557 fn full_metadata_round_trips_through_event() {
558 let original = fixture_metadata();
559 let event = EventBuilder::file_metadata("caption text", &original)
560 .unwrap()
561 .sign_with_keys(&keys())
562 .unwrap();
563 assert_eq!(event.kind, KIND_FILE_METADATA);
564 assert_eq!(event.content, "caption text");
565
566 let parsed = FileMetadata::from_event(&event).unwrap();
567 assert_eq!(parsed, original);
568 }
569
570 #[test]
571 fn missing_url_is_rejected_when_parsing() {
572 let event = EventBuilder::new(KIND_FILE_METADATA, "")
573 .tag(letter(Alphabet::M, ["image/jpeg"]))
574 .tag(letter(Alphabet::X, [HASH_HEX]))
575 .sign_with_keys(&keys())
576 .unwrap();
577 assert!(matches!(
578 FileMetadata::from_event(&event),
579 Err(FileMetadataError::MissingUrl)
580 ));
581 }
582
583 #[test]
584 fn missing_mime_is_rejected_when_parsing() {
585 let event = EventBuilder::new(KIND_FILE_METADATA, "")
586 .tag(custom(URL_TAG, [URL]))
587 .tag(letter(Alphabet::X, [HASH_HEX]))
588 .sign_with_keys(&keys())
589 .unwrap();
590 assert!(matches!(
591 FileMetadata::from_event(&event),
592 Err(FileMetadataError::MissingMimeType)
593 ));
594 }
595
596 #[test]
597 fn missing_hash_is_rejected_when_parsing() {
598 let event = EventBuilder::new(KIND_FILE_METADATA, "")
599 .tag(custom(URL_TAG, [URL]))
600 .tag(letter(Alphabet::M, ["image/jpeg"]))
601 .sign_with_keys(&keys())
602 .unwrap();
603 assert!(matches!(
604 FileMetadata::from_event(&event),
605 Err(FileMetadataError::MissingHash)
606 ));
607 }
608
609 #[test]
610 fn malformed_hash_surfaces_typed_error() {
611 let event = EventBuilder::new(KIND_FILE_METADATA, "")
612 .tag(custom(URL_TAG, [URL]))
613 .tag(letter(Alphabet::M, ["image/jpeg"]))
614 .tag(letter(Alphabet::X, ["not-hex"]))
615 .sign_with_keys(&keys())
616 .unwrap();
617 assert!(matches!(
618 FileMetadata::from_event(&event),
619 Err(FileMetadataError::InvalidHashLength(_))
620 ));
621 }
622
623 #[test]
624 fn wrong_kind_is_rejected_when_parsing() {
625 let event = EventBuilder::text_note("nope")
626 .sign_with_keys(&keys())
627 .unwrap();
628 assert!(matches!(
629 FileMetadata::from_event(&event),
630 Err(FileMetadataError::WrongKind(_))
631 ));
632 }
633
634 #[test]
635 fn invalid_size_surfaces_typed_error() {
636 let event = EventBuilder::new(KIND_FILE_METADATA, "")
637 .tag(custom(URL_TAG, [URL]))
638 .tag(letter(Alphabet::M, ["image/jpeg"]))
639 .tag(letter(Alphabet::X, [HASH_HEX]))
640 .tag(custom(SIZE_TAG, ["abc"]))
641 .sign_with_keys(&keys())
642 .unwrap();
643 assert!(matches!(
644 FileMetadata::from_event(&event),
645 Err(FileMetadataError::InvalidSize(_))
646 ));
647 }
648
649 #[test]
650 fn invalid_dim_surfaces_typed_error() {
651 let event = EventBuilder::new(KIND_FILE_METADATA, "")
652 .tag(custom(URL_TAG, [URL]))
653 .tag(letter(Alphabet::M, ["image/jpeg"]))
654 .tag(letter(Alphabet::X, [HASH_HEX]))
655 .tag(custom(DIM_TAG, ["bad"]))
656 .sign_with_keys(&keys())
657 .unwrap();
658 assert!(matches!(
659 FileMetadata::from_event(&event),
660 Err(FileMetadataError::InvalidDim(_))
661 ));
662 }
663
664 #[test]
665 fn fallback_urls_round_trip_in_order() {
666 let metadata =
667 FileMetadata::new(Url::parse(URL).unwrap(), "image/jpeg", hex_array(HASH_HEX))
668 .fallback(Url::parse("https://a.example/").unwrap())
669 .fallback(Url::parse("https://b.example/").unwrap());
670 let event = EventBuilder::file_metadata("", &metadata)
671 .unwrap()
672 .sign_with_keys(&keys())
673 .unwrap();
674 let parsed = FileMetadata::from_event(&event).unwrap();
675 assert_eq!(parsed.fallback_urls.len(), 2);
676 assert_eq!(parsed.fallback_urls[0].as_str(), "https://a.example/");
677 assert_eq!(parsed.fallback_urls[1].as_str(), "https://b.example/");
678 }
679
680 #[test]
681 fn variant_without_hash_round_trips() {
682 let metadata =
683 FileMetadata::new(Url::parse(URL).unwrap(), "image/jpeg", hex_array(HASH_HEX))
684 .thumb(FileVariant::new(Url::parse("https://t.example/").unwrap()));
685 let event = EventBuilder::file_metadata("", &metadata)
686 .unwrap()
687 .sign_with_keys(&keys())
688 .unwrap();
689 let parsed = FileMetadata::from_event(&event).unwrap();
690 let thumb = parsed.thumb.unwrap();
691 assert_eq!(thumb.url.as_str(), "https://t.example/");
692 assert_eq!(thumb.hash, None);
693 }
694}