1use std::collections::BTreeMap;
42
43use serde::{Deserialize, Serialize};
44use thiserror::Error;
45
46use crate::event::{Event, EventBuilder, Kind, Tag, TagError, TagKind};
47use crate::types::{Url, UrlError};
48
49pub const KIND_FILE_SERVER_LIST: Kind = Kind::FILE_SERVER_LIST;
51
52pub const NIP96_WELL_KNOWN_MEDIA_TYPE: &str = "application/json";
55
56const SERVER_TAG: &str = "server";
57
58#[derive(Debug, Error)]
60#[non_exhaustive]
61pub enum Nip96Error {
62 #[error("expected kind 10096, got {0}")]
64 WrongKind(Kind),
65 #[error(transparent)]
67 Url(#[from] UrlError),
68 #[error(transparent)]
70 Tag(#[from] TagError),
71}
72
73#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct FileServerList {
82 pub servers: Vec<Url>,
84}
85
86impl FileServerList {
87 #[must_use]
89 pub fn new<I>(servers: I) -> Self
90 where
91 I: IntoIterator<Item = Url>,
92 {
93 Self {
94 servers: servers.into_iter().collect(),
95 }
96 }
97
98 #[must_use]
100 pub fn to_tags(&self) -> Vec<Tag> {
101 self.servers
102 .iter()
103 .map(|server| Tag::with(&TagKind::custom(SERVER_TAG), [server.as_str().to_owned()]))
104 .collect()
105 }
106
107 pub fn from_event(event: &Event) -> Result<Self, Nip96Error> {
115 if event.kind != KIND_FILE_SERVER_LIST {
116 return Err(Nip96Error::WrongKind(event.kind));
117 }
118 let mut servers: Vec<Url> = Vec::new();
119 for tag in &event.tags {
120 if tag.name() != SERVER_TAG {
121 continue;
122 }
123 let Some(url) = tag.values().get(1) else {
124 continue;
125 };
126 servers.push(Url::parse(url)?);
127 }
128 Ok(Self { servers })
129 }
130}
131
132#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
139#[serde(deny_unknown_fields)]
140pub struct Nip96ServerConfig {
141 pub api_url: String,
143 #[serde(default, skip_serializing_if = "Option::is_none")]
146 pub download_url: Option<String>,
147 #[serde(default, skip_serializing_if = "Option::is_none")]
151 pub delegated_to_url: Option<String>,
152 #[serde(default, skip_serializing_if = "Option::is_none")]
154 pub supported_nips: Option<Vec<u16>>,
155 #[serde(default, skip_serializing_if = "Option::is_none")]
157 pub tos_url: Option<String>,
158 #[serde(default, skip_serializing_if = "Option::is_none")]
161 pub content_types: Option<Vec<String>>,
162 #[serde(default, skip_serializing_if = "Option::is_none")]
166 pub plans: Option<BTreeMap<String, Nip96Plan>>,
167}
168
169#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
171#[serde(deny_unknown_fields)]
172pub struct Nip96Plan {
173 pub name: String,
175 #[serde(default = "default_true")]
180 pub is_nip98_required: bool,
181 #[serde(default, skip_serializing_if = "Option::is_none")]
183 pub url: Option<String>,
184 #[serde(default, skip_serializing_if = "Option::is_none")]
186 pub max_byte_size: Option<u64>,
187 #[serde(default, skip_serializing_if = "Option::is_none")]
191 pub file_expiration: Option<[u64; 2]>,
192 #[serde(default, skip_serializing_if = "Option::is_none")]
195 pub media_transformations: Option<BTreeMap<String, Vec<String>>>,
196}
197
198const fn default_true() -> bool {
199 true
200}
201
202#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
204#[serde(rename_all = "lowercase")]
205#[non_exhaustive]
206pub enum Nip96Status {
207 Success,
209 Error,
212 Processing,
216}
217
218#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
231pub struct Nip96UploadResponse {
232 pub status: Nip96Status,
234 pub message: String,
236 #[serde(default, skip_serializing_if = "Option::is_none")]
238 pub processing_url: Option<String>,
239 #[serde(default, skip_serializing_if = "Option::is_none")]
242 pub nip94_event: Option<EmbeddedNip94Event>,
243 #[serde(default, skip_serializing_if = "Option::is_none")]
246 pub percentage: Option<u8>,
247}
248
249#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
260pub struct EmbeddedNip94Event {
261 pub tags: Vec<Vec<String>>,
263 #[serde(default)]
265 pub content: String,
266}
267
268#[derive(Debug, Clone, Default, PartialEq, Eq)]
275pub struct Nip96UploadFields {
276 pub caption: Option<String>,
278 pub expiration: Option<u64>,
280 pub size: Option<u64>,
283 pub alt: Option<String>,
285 pub media_type: Option<String>,
288 pub content_type: Option<String>,
291 pub no_transform: bool,
295}
296
297impl Nip96UploadFields {
298 #[must_use]
303 pub fn to_form_pairs(&self) -> Vec<(&'static str, String)> {
304 let mut out: Vec<(&'static str, String)> = Vec::new();
305 if let Some(caption) = &self.caption {
306 out.push(("caption", caption.clone()));
307 }
308 if let Some(expiration) = self.expiration {
309 out.push(("expiration", expiration.to_string()));
310 }
311 if let Some(size) = self.size {
312 out.push(("size", size.to_string()));
313 }
314 if let Some(alt) = &self.alt {
315 out.push(("alt", alt.clone()));
316 }
317 if let Some(media_type) = &self.media_type {
318 out.push(("media_type", media_type.clone()));
319 }
320 if let Some(content_type) = &self.content_type {
321 out.push(("content_type", content_type.clone()));
322 }
323 if self.no_transform {
324 out.push(("no_transform", "true".to_owned()));
325 }
326 out
327 }
328}
329
330impl EventBuilder {
331 #[must_use]
334 pub fn nip96_file_servers(list: &FileServerList) -> Self {
335 let mut builder = Self::new(KIND_FILE_SERVER_LIST, "");
336 for tag in list.to_tags() {
337 builder = builder.tag(tag);
338 }
339 builder
340 }
341}
342
343#[cfg(test)]
344mod tests {
345 use super::*;
346 use crate::Keys;
347
348 fn keys() -> Keys {
349 Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
350 }
351
352 #[test]
353 fn server_list_round_trips_through_event() {
354 let list = FileServerList::new([
355 Url::parse("https://file.server.one").unwrap(),
356 Url::parse("https://file.server.two").unwrap(),
357 ]);
358 let event = EventBuilder::nip96_file_servers(&list)
359 .sign_with_keys(&keys())
360 .unwrap();
361 assert_eq!(event.kind, KIND_FILE_SERVER_LIST);
362 let recovered = FileServerList::from_event(&event).unwrap();
363 assert_eq!(recovered, list);
364 }
365
366 #[test]
367 fn server_list_from_event_rejects_wrong_kind() {
368 let event = EventBuilder::text_note("nope")
369 .sign_with_keys(&keys())
370 .unwrap();
371 assert!(matches!(
372 FileServerList::from_event(&event),
373 Err(Nip96Error::WrongKind(_)),
374 ));
375 }
376
377 #[test]
378 fn well_known_config_round_trips_through_json() {
379 let json = r#"{
380 "api_url": "https://your-file-server.example/custom-api-path",
381 "download_url": "https://a-cdn.example/a-path",
382 "supported_nips": [96, 98],
383 "tos_url": "https://your-file-server.example/terms-of-service",
384 "content_types": ["image/jpeg", "video/webm", "audio/*"],
385 "plans": {
386 "free": {
387 "name": "Free Tier",
388 "is_nip98_required": true,
389 "url": "https://example/plans/free",
390 "max_byte_size": 10485760,
391 "file_expiration": [14, 90],
392 "media_transformations": {
393 "image": ["resizing"]
394 }
395 }
396 }
397 }"#;
398 let config: Nip96ServerConfig = serde_json::from_str(json).unwrap();
399 assert_eq!(
400 config.api_url,
401 "https://your-file-server.example/custom-api-path"
402 );
403 let plans = config.plans.as_ref().unwrap();
404 let free = plans.get("free").unwrap();
405 assert_eq!(free.name, "Free Tier");
406 assert!(free.is_nip98_required);
407 assert_eq!(free.max_byte_size, Some(10_485_760));
408 assert_eq!(free.file_expiration, Some([14, 90]));
409
410 let reserialised = serde_json::to_string(&config).unwrap();
411 let round_tripped: Nip96ServerConfig = serde_json::from_str(&reserialised).unwrap();
412 assert_eq!(round_tripped, config);
413 }
414
415 #[test]
416 fn well_known_config_supports_delegated_form() {
417 let json = r#"{
418 "api_url": "",
419 "delegated_to_url": "https://your-file-server.example"
420 }"#;
421 let config: Nip96ServerConfig = serde_json::from_str(json).unwrap();
422 assert!(config.api_url.is_empty());
423 assert_eq!(
424 config.delegated_to_url.as_deref(),
425 Some("https://your-file-server.example"),
426 );
427 }
428
429 #[test]
430 fn plan_default_is_nip98_required_is_true() {
431 let json = r#"{ "name": "Bare", "url": "https://example" }"#;
432 let plan: Nip96Plan = serde_json::from_str(json).unwrap();
433 assert!(plan.is_nip98_required);
434 }
435
436 #[test]
437 fn upload_response_success_round_trips_through_json() {
438 let json = r#"{
439 "status": "success",
440 "message": "Upload successful.",
441 "nip94_event": {
442 "tags": [
443 ["url", "https://srv.example/abc.png"],
444 ["ox", "719171db19525d9d08dd69cb716a18158a249b7b3b3ec4bbdec5698dca104b7b"],
445 ["x", "543244319525d9d08dd69cb716a18158a249b7b3b3ec4bbde5435543acb34443"],
446 ["m", "image/png"],
447 ["dim", "800x600"]
448 ],
449 "content": ""
450 }
451 }"#;
452 let response: Nip96UploadResponse = serde_json::from_str(json).unwrap();
453 assert_eq!(response.status, Nip96Status::Success);
454 let embedded = response.nip94_event.as_ref().unwrap();
455 assert_eq!(embedded.tags.len(), 5);
456 assert_eq!(embedded.tags[0], vec!["url", "https://srv.example/abc.png"]);
457
458 let reserialised = serde_json::to_string(&response).unwrap();
459 let round_tripped: Nip96UploadResponse = serde_json::from_str(&reserialised).unwrap();
460 assert_eq!(round_tripped, response);
461 }
462
463 #[test]
464 fn upload_response_processing_carries_percentage() {
465 let json = r#"{
466 "status": "processing",
467 "message": "Processing. Please check again later for updated status.",
468 "percentage": 15
469 }"#;
470 let response: Nip96UploadResponse = serde_json::from_str(json).unwrap();
471 assert_eq!(response.status, Nip96Status::Processing);
472 assert_eq!(response.percentage, Some(15));
473 assert!(response.nip94_event.is_none());
474 }
475
476 #[test]
477 fn upload_fields_emit_only_set_pairs() {
478 let fields = Nip96UploadFields {
479 caption: Some("a meme".to_owned()),
480 alt: Some("a meme that makes you laugh".to_owned()),
481 no_transform: true,
482 ..Default::default()
483 };
484 let pairs = fields.to_form_pairs();
485 assert_eq!(
486 pairs,
487 vec![
488 ("caption", "a meme".to_owned()),
489 ("alt", "a meme that makes you laugh".to_owned()),
490 ("no_transform", "true".to_owned()),
491 ],
492 );
493 }
494}