Skip to main content

nula_core/nips/
nipb7.rs

1//! [NIP-B7] Blossom media — typed event bundle for the user's
2//! preferred [Blossom] server list (kind `10063`) plus helpers for
3//! the SHA-256-addressed blob URLs Blossom servers expose.
4//!
5//! # Blossom in one paragraph
6//!
7//! [Blossom] is a family of HTTP standards ("BUDs") for content-
8//! addressed file storage: every blob is uploaded under its
9//! SHA-256 digest and fetched back at `<server>/<64-char-hex>`
10//! (optionally with a file extension). Nostr clients publish a
11//! kind-10063 replaceable event listing the servers they trust;
12//! peers fetch that list to discover alternate origins when a
13//! quoted blob URL goes 404.
14//!
15//! # Why a typed module
16//!
17//! Upstream `rust-nostr` ships nothing for NIP-B7 / BUD-03. We
18//! model:
19//!
20//! 1. [`BlossomServerList`] — the typed `kind:10063` advert with
21//!    a `to_event` / `from_event` round trip and an
22//!    [`EventBuilder::blossom_servers`] constructor.
23//! 2. [`BlossomBlobRef`] — the parsed `<64-hex>[.<ext>]` blob
24//!    locator. [`BlossomBlobRef::from_url`] extracts the digest
25//!    from any quoted Blossom URL, and [`BlossomBlobRef::to_url`]
26//!    rebuilds a URL on a different server so callers can iterate
27//!    the user's mirror list when a primary origin disappears.
28//!
29//! [NIP-B7]: https://github.com/nostr-protocol/nips/blob/master/B7.md
30//! [Blossom]: https://github.com/hzrd149/blossom
31//! [BUD-03]: https://github.com/hzrd149/blossom/blob/master/buds/03.md
32
33use thiserror::Error;
34
35use crate::event::{Event, EventBuilder, Kind, Tag, TagError, TagKind};
36use crate::types::{Url, UrlError};
37
38/// `kind: 10063` — Blossom user-server list (BUD-03).
39pub const KIND_BLOSSOM_SERVERS: Kind = Kind::BLOSSOM_SERVERS;
40
41const SERVER_TAG: &str = "server";
42const SHA256_HEX_LEN: usize = 64;
43
44/// Errors raised by the NIP-B7 typed bundle / blob-ref helpers.
45#[derive(Debug, Error)]
46#[non_exhaustive]
47pub enum NipB7Error {
48    /// Event kind did not match `10063`.
49    #[error("expected kind 10063, got {0}")]
50    WrongKind(Kind),
51    /// A server URL was malformed.
52    #[error(transparent)]
53    Url(#[from] UrlError),
54    /// A typed [`Tag`] could not be constructed.
55    #[error(transparent)]
56    Tag(#[from] TagError),
57}
58
59/// Typed bundle for the `kind: 10063` Blossom user-server list.
60///
61/// The list is intentionally ordered: clients SHOULD try the
62/// servers in the order the user published them so the head of the
63/// list acts as the user's preferred origin and the rest as
64/// fallback mirrors.
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct BlossomServerList {
67    /// Server URLs the user trusts to host their blobs.
68    pub servers: Vec<Url>,
69}
70
71impl BlossomServerList {
72    /// Construct a server list from an iterable of URLs.
73    #[must_use]
74    pub fn new<I>(servers: I) -> Self
75    where
76        I: IntoIterator<Item = Url>,
77    {
78        Self {
79            servers: servers.into_iter().collect(),
80        }
81    }
82
83    /// Render the typed bundle to the public tag list.
84    #[must_use]
85    pub fn to_tags(&self) -> Vec<Tag> {
86        self.servers
87            .iter()
88            .map(|server| Tag::with(&TagKind::custom(SERVER_TAG), [server.as_str().to_owned()]))
89            .collect()
90    }
91
92    /// Parse a signed `kind:10063` event into a typed bundle.
93    ///
94    /// # Errors
95    ///
96    /// Returns [`NipB7Error::WrongKind`] when the event's kind is
97    /// not `10063`; otherwise forwards every per-tag URL parse
98    /// error.
99    pub fn from_event(event: &Event) -> Result<Self, NipB7Error> {
100        if event.kind != KIND_BLOSSOM_SERVERS {
101            return Err(NipB7Error::WrongKind(event.kind));
102        }
103        let mut servers: Vec<Url> = Vec::new();
104        for tag in &event.tags {
105            if tag.name() != SERVER_TAG {
106                continue;
107            }
108            // `values()` includes the tag head; the URL is at
109            // index 1.
110            let Some(url) = tag.values().get(1) else {
111                continue;
112            };
113            servers.push(Url::parse(url)?);
114        }
115        Ok(Self { servers })
116    }
117}
118
119/// Parsed Blossom blob locator: a 32-byte SHA-256 digest plus an
120/// optional file-extension hint preserved verbatim from the source
121/// URL.
122///
123/// The digest is the spec-mandated 64-character lowercase hex of the
124/// blob's content; the extension is purely a content-type hint
125/// (`png`, `mp4`, …) carried through to the rebuilt URL.
126#[derive(Debug, Clone, PartialEq, Eq)]
127pub struct BlossomBlobRef {
128    /// Lowercase hex of the blob's SHA-256 digest (64 chars).
129    pub hash_hex: String,
130    /// Optional file-extension hint (without the leading dot).
131    pub extension: Option<String>,
132}
133
134impl BlossomBlobRef {
135    /// Try to parse a Blossom blob locator out of an arbitrary URL.
136    ///
137    /// Returns `Some` when the URL's final path segment matches
138    /// `<64-hex>` or `<64-hex>.<ext>` and the hex is well-formed;
139    /// otherwise returns `None`.
140    #[must_use]
141    pub fn from_url(url: &Url) -> Option<Self> {
142        let path = url.as_url().path();
143        // The final path segment carries the digest (the spec only
144        // examines the trailing component, not the whole path).
145        let segment = path.rsplit('/').find(|s| !s.is_empty())?;
146        Self::from_segment(segment)
147    }
148
149    /// Try to parse a Blossom blob locator out of a raw path
150    /// segment (`<64-hex>` or `<64-hex>.<ext>`).
151    #[must_use]
152    pub fn from_segment(segment: &str) -> Option<Self> {
153        let (hash, extension) = segment.split_once('.').map_or_else(
154            || (segment, None),
155            |(prefix, ext)| (prefix, Some(ext.to_owned())),
156        );
157        if hash.len() != SHA256_HEX_LEN {
158            return None;
159        }
160        if !hash.bytes().all(|b| b.is_ascii_hexdigit()) {
161            return None;
162        }
163        Some(Self {
164            hash_hex: hash.to_ascii_lowercase(),
165            extension,
166        })
167    }
168
169    /// Build a fully-qualified Blossom URL on `server` for this
170    /// blob, preserving the optional extension hint.
171    ///
172    /// # Errors
173    ///
174    /// Forwards [`UrlError`] when the resulting URL is malformed
175    /// (which should never happen for a well-formed `server` plus a
176    /// valid hex digest, but is surfaced for completeness).
177    pub fn to_url(&self, server: &Url) -> Result<Url, NipB7Error> {
178        let mut raw = server.as_str().trim_end_matches('/').to_owned();
179        raw.push('/');
180        raw.push_str(&self.hash_hex);
181        if let Some(ext) = &self.extension {
182            raw.push('.');
183            raw.push_str(ext);
184        }
185        Ok(Url::parse(&raw)?)
186    }
187}
188
189impl EventBuilder {
190    /// Author a NIP-B7 / BUD-03 `kind: 10063` user-server list event
191    /// from a typed [`BlossomServerList`].
192    #[must_use]
193    pub fn blossom_servers(list: &BlossomServerList) -> Self {
194        let mut builder = Self::new(KIND_BLOSSOM_SERVERS, "");
195        for tag in list.to_tags() {
196            builder = builder.tag(tag);
197        }
198        builder
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205    use crate::Keys;
206
207    fn keys() -> Keys {
208        Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
209    }
210
211    fn primary() -> Url {
212        Url::parse("https://blossom.self.hosted").unwrap()
213    }
214
215    fn fallback() -> Url {
216        Url::parse("https://cdn.blossom.cloud").unwrap()
217    }
218
219    fn sample_hash_hex() -> &'static str {
220        "e4bee088334cb5d38cff1616e964369c37b6081be997962ab289d6c671975d71"
221    }
222
223    #[test]
224    fn server_list_round_trips_through_event() {
225        let list = BlossomServerList::new([primary(), fallback()]);
226        let event = EventBuilder::blossom_servers(&list)
227            .sign_with_keys(&keys())
228            .unwrap();
229        assert_eq!(event.kind, KIND_BLOSSOM_SERVERS);
230        let recovered = BlossomServerList::from_event(&event).unwrap();
231        assert_eq!(recovered, list);
232    }
233
234    #[test]
235    fn server_list_from_event_rejects_wrong_kind() {
236        let event = EventBuilder::text_note("not a server list")
237            .sign_with_keys(&keys())
238            .unwrap();
239        assert!(matches!(
240            BlossomServerList::from_event(&event),
241            Err(NipB7Error::WrongKind(_)),
242        ));
243    }
244
245    #[test]
246    fn blob_ref_parses_64_hex_with_and_without_extension() {
247        let no_ext =
248            Url::parse(format!("https://blossom.self.hosted/{}", sample_hash_hex())).unwrap();
249        let with_ext = Url::parse(format!(
250            "https://blossom.self.hosted/{}.png",
251            sample_hash_hex()
252        ))
253        .unwrap();
254
255        let parsed_no_ext = BlossomBlobRef::from_url(&no_ext).unwrap();
256        assert_eq!(parsed_no_ext.hash_hex, sample_hash_hex());
257        assert_eq!(parsed_no_ext.extension, None);
258
259        let parsed_with_ext = BlossomBlobRef::from_url(&with_ext).unwrap();
260        assert_eq!(parsed_with_ext.hash_hex, sample_hash_hex());
261        assert_eq!(parsed_with_ext.extension.as_deref(), Some("png"));
262    }
263
264    #[test]
265    fn blob_ref_rejects_non_64_hex_paths() {
266        let too_short = Url::parse("https://blossom.self.hosted/deadbeef").unwrap();
267        let non_hex = Url::parse(
268            "https://blossom.self.hosted/zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
269        )
270        .unwrap();
271        assert!(BlossomBlobRef::from_url(&too_short).is_none());
272        assert!(BlossomBlobRef::from_url(&non_hex).is_none());
273    }
274
275    #[test]
276    fn blob_ref_to_url_preserves_extension() {
277        let blob = BlossomBlobRef {
278            hash_hex: sample_hash_hex().to_owned(),
279            extension: Some("png".to_owned()),
280        };
281        let url = blob.to_url(&fallback()).unwrap();
282        assert_eq!(
283            url.as_str(),
284            "https://cdn.blossom.cloud/e4bee088334cb5d38cff1616e964369c37b6081be997962ab289d6c671975d71.png",
285        );
286    }
287
288    #[test]
289    fn blob_ref_to_url_with_no_extension_omits_dot() {
290        let blob = BlossomBlobRef {
291            hash_hex: sample_hash_hex().to_owned(),
292            extension: None,
293        };
294        let url = blob.to_url(&primary()).unwrap();
295        assert_eq!(
296            url.as_str(),
297            "https://blossom.self.hosted/e4bee088334cb5d38cff1616e964369c37b6081be997962ab289d6c671975d71",
298        );
299    }
300
301    #[test]
302    fn blob_ref_to_url_strips_trailing_slash_on_server() {
303        let blob = BlossomBlobRef {
304            hash_hex: sample_hash_hex().to_owned(),
305            extension: None,
306        };
307        let server = Url::parse("https://blossom.self.hosted/").unwrap();
308        let url = blob.to_url(&server).unwrap();
309        assert_eq!(
310            url.as_str(),
311            "https://blossom.self.hosted/e4bee088334cb5d38cff1616e964369c37b6081be997962ab289d6c671975d71",
312        );
313    }
314}