nostr_types/types/
file_metadata.rs

1use crate::{Event, EventKind, PreEvent, PublicKey, Tag, UncheckedUrl, Unixtime};
2
3/// NIP-92/94 File Metadata
4#[derive(Clone, Debug, Hash, PartialEq)]
5pub struct FileMetadata {
6    /// The URL this metadata applies to
7    pub url: UncheckedUrl,
8
9    /// Mime type (lowercase), see https://developer.mozilla.org/en-US/docs/Web/HTTP/MIME_types/Common_types
10    pub m: Option<String>,
11
12    /// SHA-256 hex-encoded hash
13    pub x: Option<String>,
14
15    /// original SHA-256 hex-encoded hash prior to transformations
16    pub ox: Option<String>,
17
18    /// Size of file in bytes
19    pub size: Option<u64>,
20
21    /// Dimensions of the image
22    pub dim: Option<(usize, usize)>,
23
24    /// Magnet URI
25    pub magnet: Option<UncheckedUrl>,
26
27    /// Torrent infohash
28    pub i: Option<String>,
29
30    /// Blurhash
31    pub blurhash: Option<String>,
32
33    /// Thumbnail URL
34    pub thumb: Option<UncheckedUrl>,
35
36    /// Preview image (same dimensions)
37    pub image: Option<UncheckedUrl>,
38
39    /// Summary text
40    pub summary: Option<String>,
41
42    /// Alt description
43    pub alt: Option<String>,
44
45    /// Fallback URLs
46    pub fallback: Vec<UncheckedUrl>,
47
48    /// Service
49    pub service: Option<String>,
50}
51
52impl FileMetadata {
53    /// Create a new empty (except the URL) FileMetadata
54    pub fn new(url: UncheckedUrl) -> FileMetadata {
55        FileMetadata {
56            url,
57            m: None,
58            x: None,
59            ox: None,
60            size: None,
61            dim: None,
62            magnet: None,
63            i: None,
64            blurhash: None,
65            thumb: None,
66            image: None,
67            summary: None,
68            alt: None,
69            fallback: vec![],
70            service: None,
71        }
72    }
73
74    /// Create a NIP-94 FileMetadata PreEvent from this FileMetadata
75    pub fn to_nip94_preevent(&self, pubkey: PublicKey) -> PreEvent {
76        let mut tags = vec![Tag::new(&["url", &self.url.0])];
77
78        if let Some(m) = &self.m {
79            tags.push(Tag::new(&["m", m]));
80        }
81
82        if let Some(x) = &self.x {
83            tags.push(Tag::new(&["x", x]));
84        }
85
86        if let Some(ox) = &self.ox {
87            tags.push(Tag::new(&["ox", ox]));
88        }
89
90        if let Some(size) = self.size {
91            tags.push(Tag::new(&["size", &format!("{size}")]));
92        }
93
94        if let Some(dim) = self.dim {
95            tags.push(Tag::new(&["dim", &format!("{}x{}", dim.0, dim.1)]));
96        }
97
98        if let Some(magnet) = &self.magnet {
99            tags.push(Tag::new(&["magnet", &magnet.0]));
100        }
101
102        if let Some(i) = &self.i {
103            tags.push(Tag::new(&["i", i]));
104        }
105
106        if let Some(blurhash) = &self.blurhash {
107            tags.push(Tag::new(&["blurhash", blurhash]));
108        }
109
110        if let Some(thumb) = &self.thumb {
111            tags.push(Tag::new(&["thumb", &thumb.0]));
112        }
113
114        if let Some(image) = &self.image {
115            tags.push(Tag::new(&["image", &image.0]));
116        }
117
118        if let Some(summary) = &self.summary {
119            tags.push(Tag::new(&["summary", summary]));
120        }
121
122        if let Some(alt) = &self.alt {
123            tags.push(Tag::new(&["alt", alt]));
124        }
125
126        for fallback in &self.fallback {
127            tags.push(Tag::new(&["fallback", &fallback.0]));
128        }
129
130        if let Some(service) = &self.service {
131            tags.push(Tag::new(&["service", service]));
132        }
133
134        PreEvent {
135            pubkey,
136            created_at: Unixtime::now(),
137            kind: EventKind::FileMetadata,
138            content: "".to_owned(),
139            tags,
140        }
141    }
142
143    /// Turn a kind-1063 (FileMetadata) event into a FileMetadata structure
144    pub fn from_nip94_event(event: &Event) -> Option<FileMetadata> {
145        if event.kind != EventKind::FileMetadata {
146            return None;
147        }
148
149        let mut fm = FileMetadata::new(UncheckedUrl("".to_owned()));
150
151        for tag in &event.tags {
152            match tag.tagname() {
153                "url" => fm.url = UncheckedUrl(tag.value().to_owned()),
154                "m" => fm.m = Some(tag.value().to_owned()),
155                "x" => fm.x = Some(tag.value().to_owned()),
156                "ox" => fm.ox = Some(tag.value().to_owned()),
157                "size" => {
158                    if let Ok(u) = tag.value().parse::<u64>() {
159                        fm.size = Some(u);
160                    }
161                }
162                "dim" => {
163                    let parts: Vec<&str> = tag.value().split('x').collect();
164                    if parts.len() == 2 {
165                        if let Ok(w) = parts[0].parse::<usize>() {
166                            if let Ok(h) = parts[1].parse::<usize>() {
167                                fm.dim = Some((w, h));
168                            }
169                        }
170                    }
171                }
172                "magnet" => fm.magnet = Some(UncheckedUrl(tag.value().to_owned())),
173                "i" => fm.i = Some(tag.value().to_owned()),
174                "blurhash" => fm.blurhash = Some(tag.value().to_owned()),
175                "thumb" => fm.thumb = Some(UncheckedUrl(tag.value().to_owned())),
176                "image" => fm.image = Some(UncheckedUrl(tag.value().to_owned())),
177                "summary" => fm.summary = Some(tag.value().to_owned()),
178                "alt" => fm.alt = Some(tag.value().to_owned()),
179                "fallback" => fm.fallback.push(UncheckedUrl(tag.value().to_owned())),
180                "service" => fm.service = Some(tag.value().to_owned()),
181                _ => continue,
182            }
183        }
184
185        if !fm.url.0.is_empty() {
186            Some(fm)
187        } else {
188            None
189        }
190    }
191
192    /// Convert into an 'imeta' tag
193    pub fn to_imeta_tag(&self) -> Tag {
194        let mut tag = Tag::new(&["imeta"]);
195
196        tag.push_value(format!("url {}", self.url));
197
198        if let Some(m) = &self.m {
199            tag.push_value(format!("m {}", m));
200        }
201
202        if let Some(x) = &self.x {
203            tag.push_value(format!("x {}", x));
204        }
205
206        if let Some(ox) = &self.ox {
207            tag.push_value(format!("ox {}", ox));
208        }
209
210        if let Some(size) = &self.size {
211            tag.push_value(format!("size {}", size));
212        }
213
214        if let Some(dim) = &self.dim {
215            tag.push_value(format!("dim {}x{}", dim.0, dim.1));
216        }
217
218        if let Some(magnet) = &self.magnet {
219            tag.push_value(format!("magnet {}", magnet));
220        }
221
222        if let Some(i) = &self.i {
223            tag.push_value(format!("i {}", i));
224        }
225
226        if let Some(blurhash) = &self.blurhash {
227            tag.push_value(format!("blurhash {}", blurhash));
228        }
229
230        if let Some(thumb) = &self.thumb {
231            tag.push_value(format!("thumb {}", thumb));
232        }
233
234        if let Some(image) = &self.image {
235            tag.push_value(format!("image {}", image));
236        }
237
238        if let Some(summary) = &self.summary {
239            tag.push_value(format!("summary {}", summary));
240        }
241
242        if let Some(alt) = &self.alt {
243            tag.push_value(format!("alt {}", alt));
244        }
245
246        for fallback in &self.fallback {
247            tag.push_value(format!("fallback {}", fallback));
248        }
249
250        if let Some(service) = &self.service {
251            tag.push_value(format!("service {}", service));
252        }
253
254        tag
255    }
256
257    /// Import from an 'imeta' tag
258    pub fn from_imeta_tag(tag: &Tag) -> Option<FileMetadata> {
259        let mut fm = FileMetadata::new(UncheckedUrl("".to_owned()));
260
261        for i in 0..tag.len() {
262            let parts: Vec<&str> = tag.get_index(i).splitn(2, ' ').collect();
263            if parts.len() < 2 {
264                continue;
265            }
266            match parts[0] {
267                "url" => fm.url = UncheckedUrl(parts[1].to_owned()),
268                "m" => fm.m = Some(parts[1].to_owned()),
269                "x" => fm.x = Some(parts[1].to_owned()),
270                "ox" => fm.ox = Some(parts[1].to_owned()),
271                "size" => {
272                    if let Ok(u) = parts[1].parse::<u64>() {
273                        fm.size = Some(u);
274                    }
275                }
276                "dim" => {
277                    let parts: Vec<&str> = parts[1].split('x').collect();
278                    if parts.len() == 2 {
279                        if let Ok(w) = parts[0].parse::<usize>() {
280                            if let Ok(h) = parts[1].parse::<usize>() {
281                                fm.dim = Some((w, h));
282                            }
283                        }
284                    }
285                }
286                "magnet" => fm.magnet = Some(UncheckedUrl(parts[1].to_owned())),
287                "i" => fm.i = Some(parts[1].to_owned()),
288                "blurhash" => fm.blurhash = Some(parts[1].to_owned()),
289                "thumb" => fm.thumb = Some(UncheckedUrl(parts[1].to_owned())),
290                "image" => fm.image = Some(UncheckedUrl(parts[1].to_owned())),
291                "summary" => fm.summary = Some(parts[1].to_owned()),
292                "alt" => fm.alt = Some(parts[1].to_owned()),
293                "fallback" => fm.fallback.push(UncheckedUrl(parts[1].to_owned())),
294                "service" => fm.service = Some(parts[1].to_owned()),
295                _ => continue,
296            }
297        }
298
299        if !fm.url.0.is_empty() {
300            Some(fm)
301        } else {
302            None
303        }
304    }
305}
306
307#[cfg(test)]
308mod test {
309    use super::*;
310
311    #[tokio::test]
312    async fn test_nip94_event() {
313        let mut fm = FileMetadata::new(UncheckedUrl("https://nostr.build/blahblahblah".to_owned()));
314        fm.x = Some("12345".to_owned());
315        fm.service = Some("http".to_owned());
316        fm.size = Some(10124);
317        fm.alt = Some("a crackerjack".to_owned());
318
319        use crate::{PrivateKey, Signer};
320        let private_key = PrivateKey::generate();
321        let public_key = private_key.public_key();
322
323        let pre_event = fm.to_nip94_preevent(public_key);
324        let event = private_key.sign_event(pre_event).await.unwrap();
325        let fm2 = FileMetadata::from_nip94_event(&event).unwrap();
326
327        assert_eq!(fm, fm2);
328    }
329
330    #[test]
331    fn test_imeta_tag() {
332        let mut fm = FileMetadata::new(UncheckedUrl("https://nostr.build/blahblahblah".to_owned()));
333        fm.x = Some("12345".to_owned());
334        fm.service = Some("http".to_owned());
335        fm.size = Some(10124);
336        fm.alt = Some("a crackerjack".to_owned());
337
338        let tag = fm.to_imeta_tag();
339        let fm2 = FileMetadata::from_imeta_tag(&tag).unwrap();
340        assert_eq!(fm, fm2);
341    }
342}