1use thiserror::Error;
34
35use crate::event::{Event, EventBuilder, Kind, Tag, TagError, TagKind};
36use crate::types::{Url, UrlError};
37
38pub const KIND_BLOSSOM_SERVERS: Kind = Kind::BLOSSOM_SERVERS;
40
41const SERVER_TAG: &str = "server";
42const SHA256_HEX_LEN: usize = 64;
43
44#[derive(Debug, Error)]
46#[non_exhaustive]
47pub enum NipB7Error {
48 #[error("expected kind 10063, got {0}")]
50 WrongKind(Kind),
51 #[error(transparent)]
53 Url(#[from] UrlError),
54 #[error(transparent)]
56 Tag(#[from] TagError),
57}
58
59#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct BlossomServerList {
67 pub servers: Vec<Url>,
69}
70
71impl BlossomServerList {
72 #[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 #[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 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 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#[derive(Debug, Clone, PartialEq, Eq)]
127pub struct BlossomBlobRef {
128 pub hash_hex: String,
130 pub extension: Option<String>,
132}
133
134impl BlossomBlobRef {
135 #[must_use]
141 pub fn from_url(url: &Url) -> Option<Self> {
142 let path = url.as_url().path();
143 let segment = path.rsplit('/').find(|s| !s.is_empty())?;
146 Self::from_segment(segment)
147 }
148
149 #[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 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 #[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}