Skip to main content

nula_core/nips/
nip96.rs

1//! [NIP-96] HTTP File Storage Integration — typed bundles for the
2//! kind-10096 file-server preference list and the JSON shapes a
3//! NIP-96 server exchanges with its clients.
4//!
5//! > **Status:** upstream marks NIP-96 as `unrecommended` in favour
6//! > of [NIP-B7] (Blossom). We still ship it because a meaningful
7//! > slice of the deployed Nostr fleet (Damus, Iris, Coracle,
8//! > nostr.build, nostrcheck.me, …) only speaks NIP-96 today.
9//! > New code targeting greenfield servers should prefer
10//! > [`crate::nips::nipb7`].
11//!
12//! # What this module covers
13//!
14//! 1. **`kind: 10096`** — [`FileServerList`]: the user's typed list
15//!    of NIP-96 servers. Identical wire shape to the NIP-B7 server
16//!    list, parked at a different kind.
17//! 2. **`/.well-known/nostr/nip96.json`** — [`Nip96ServerConfig`]:
18//!    full typed parse of every field the server advertises
19//!    (`api_url`, `download_url`, `delegated_to_url`,
20//!    `supported_nips`, `tos_url`, `content_types`, and the typed
21//!    [`Nip96Plan`] map).
22//! 3. **Upload response JSON** — [`Nip96UploadResponse`] plus the
23//!    typed [`Nip96Status`] enum and the embedded
24//!    [`EmbeddedNip94Event`] sub-bundle that carries the tags +
25//!    content of the canonical NIP-94 file-metadata event the
26//!    spec embeds in the response body.
27//!
28//! # What this module deliberately does NOT do
29//!
30//! - **HTTP transport.** Wiring up `multipart/form-data` belongs in
31//!   the consumer crate; this module exposes only the typed
32//!   data shapes so callers can stay backend-agnostic (`reqwest`,
33//!   `hyper`, `surf`, …).
34//! - **NIP-98 authorization.** That lives in
35//!   [`crate::nips::nip98`]. Callers compose the `Authorization`
36//!   header at the HTTP boundary.
37//!
38//! [NIP-96]: https://github.com/nostr-protocol/nips/blob/master/96.md
39//! [NIP-B7]: https://github.com/nostr-protocol/nips/blob/master/B7.md
40
41use 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
49/// `kind: 10096` — user's NIP-96 file-server preference list.
50pub const KIND_FILE_SERVER_LIST: Kind = Kind::FILE_SERVER_LIST;
51
52/// Canonical `Content-Type` value that NIP-96 servers serve their
53/// well-known config under.
54pub const NIP96_WELL_KNOWN_MEDIA_TYPE: &str = "application/json";
55
56const SERVER_TAG: &str = "server";
57
58/// Errors raised by the NIP-96 typed bundles.
59#[derive(Debug, Error)]
60#[non_exhaustive]
61pub enum Nip96Error {
62    /// `kind:10096` server list event had the wrong kind.
63    #[error("expected kind 10096, got {0}")]
64    WrongKind(Kind),
65    /// Server URL parse failure.
66    #[error(transparent)]
67    Url(#[from] UrlError),
68    /// Typed [`Tag`] construction failure.
69    #[error(transparent)]
70    Tag(#[from] TagError),
71}
72
73/// Typed bundle for the `kind: 10096` user file-server preference
74/// list.
75///
76/// Identical wire shape to the NIP-B7 [`crate::nips::nipb7::BlossomServerList`]:
77/// one `server` tag per URL, no `.content`. Servers are listed in
78/// the user's preference order so clients SHOULD try the head of
79/// the list first.
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct FileServerList {
82    /// File-storage server URLs the user trusts.
83    pub servers: Vec<Url>,
84}
85
86impl FileServerList {
87    /// Construct a server list.
88    #[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    /// Render the typed bundle to the public tag list.
99    #[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    /// Parse a signed `kind:10096` event back into a typed bundle.
108    ///
109    /// # Errors
110    ///
111    /// Returns [`Nip96Error::WrongKind`] when the event's kind is
112    /// not `10096`; forwards URL parse errors for each `server`
113    /// tag.
114    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/// Typed parse of `/.well-known/nostr/nip96.json`.
133///
134/// Every optional field stays `Option` so callers can distinguish
135/// "absent" from "present-but-empty"; the spec leans on absent
136/// fields heavily (e.g. `download_url` absent means downloads are
137/// served from `api_url`).
138#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
139#[serde(deny_unknown_fields)]
140pub struct Nip96ServerConfig {
141    /// Required: the upload / delete API endpoint.
142    pub api_url: String,
143    /// Optional: alternate download base URL. Absent or empty
144    /// means downloads are served from [`Self::api_url`].
145    #[serde(default, skip_serializing_if = "Option::is_none")]
146    pub download_url: Option<String>,
147    /// Optional: when set, the well-known is a redirect from a
148    /// relay to another server's well-known; [`Self::api_url`]
149    /// MUST be empty in that case.
150    #[serde(default, skip_serializing_if = "Option::is_none")]
151    pub delegated_to_url: Option<String>,
152    /// Optional: NIP numbers the server explicitly supports.
153    #[serde(default, skip_serializing_if = "Option::is_none")]
154    pub supported_nips: Option<Vec<u16>>,
155    /// Optional: server's Terms-of-Service URL.
156    #[serde(default, skip_serializing_if = "Option::is_none")]
157    pub tos_url: Option<String>,
158    /// Optional: MIME types the server accepts (e.g.
159    /// `"image/jpeg"`, `"audio/*"`).
160    #[serde(default, skip_serializing_if = "Option::is_none")]
161    pub content_types: Option<Vec<String>>,
162    /// Optional: plan-name → [`Nip96Plan`] map. The key `"free"`
163    /// is spec-standardised and indicates the server offers a free
164    /// tier.
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub plans: Option<BTreeMap<String, Nip96Plan>>,
167}
168
169/// One entry in the [`Nip96ServerConfig::plans`] map.
170#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
171#[serde(deny_unknown_fields)]
172pub struct Nip96Plan {
173    /// Human-readable plan name.
174    pub name: String,
175    /// Whether the plan requires NIP-98 auth on upload. Default
176    /// `true`. The spec is explicit that all plans MUST support
177    /// NIP-98 — this toggle only relaxes whether NIP-98 is the
178    /// *only* accepted credential.
179    #[serde(default = "default_true")]
180    pub is_nip98_required: bool,
181    /// Optional: plan's landing page.
182    #[serde(default, skip_serializing_if = "Option::is_none")]
183    pub url: Option<String>,
184    /// Optional: per-file upload size limit in bytes.
185    #[serde(default, skip_serializing_if = "Option::is_none")]
186    pub max_byte_size: Option<u64>,
187    /// Optional: `[min_days, max_days]` retention range. `0`
188    /// means "no expiration", so `[0, 0]` is unlimited and
189    /// `[7, 0]` is 7 days up to unlimited.
190    #[serde(default, skip_serializing_if = "Option::is_none")]
191    pub file_expiration: Option<[u64; 2]>,
192    /// Optional: media-transformation capability map (e.g.
193    /// `"image" -> ["resizing"]`).
194    #[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/// Typed status column of a [`Nip96UploadResponse`].
203#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
204#[serde(rename_all = "lowercase")]
205#[non_exhaustive]
206pub enum Nip96Status {
207    /// `"success"` — upload accepted.
208    Success,
209    /// `"error"` — upload rejected; see
210    /// [`Nip96UploadResponse::message`] for detail.
211    Error,
212    /// `"processing"` — upload accepted, deferred processing in
213    /// progress (poll
214    /// [`Nip96UploadResponse::processing_url`]).
215    Processing,
216}
217
218/// Typed parse of a NIP-96 upload response body.
219///
220/// Spec wire shape:
221///
222/// ```jsonc
223/// {
224///   "status": "success",
225///   "message": "Upload successful.",
226///   "processing_url": "...",      // optional, deferred processing
227///   "nip94_event": { ... }        // optional, embedded NIP-94 body
228/// }
229/// ```
230#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
231pub struct Nip96UploadResponse {
232    /// `"success"` / `"error"` / `"processing"` discriminator.
233    pub status: Nip96Status,
234    /// Free-form human-readable message.
235    pub message: String,
236    /// Optional: poll URL for deferred-processing uploads.
237    #[serde(default, skip_serializing_if = "Option::is_none")]
238    pub processing_url: Option<String>,
239    /// Optional: embedded NIP-94 file-metadata body. Absent on
240    /// failure, present on success.
241    #[serde(default, skip_serializing_if = "Option::is_none")]
242    pub nip94_event: Option<EmbeddedNip94Event>,
243    /// Optional: processing percentage for `status = "processing"`
244    /// poll responses (0..=100).
245    #[serde(default, skip_serializing_if = "Option::is_none")]
246    pub percentage: Option<u8>,
247}
248
249/// Embedded NIP-94-shaped sub-bundle in an upload response.
250///
251/// This is **not** a signed event: the spec strips `id`, `pubkey`,
252/// `created_at`, and `sig` because the response body is already
253/// authoritative under HTTP. Callers who need a typed view of the
254/// tags can hydrate
255/// [`crate::nips::nip94::FileMetadata::from_tags`] (note: that
256/// helper takes the typed `Tags` collection; for the raw JSON rows
257/// here, callers typically just walk
258/// [`Self::tags`]).
259#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
260pub struct EmbeddedNip94Event {
261    /// NIP-94 tag rows (`[head, arg1, arg2, …]`).
262    pub tags: Vec<Vec<String>>,
263    /// NIP-94 content (free-form caption).
264    #[serde(default)]
265    pub content: String,
266}
267
268/// Typed view of the optional NIP-96 multipart upload form fields.
269///
270/// The `file` field is the actual upload payload and lives at the
271/// HTTP layer, so it is **not** modelled here. This struct exists
272/// to keep the rest of the spec-defined columns spell-checked at
273/// compile time.
274#[derive(Debug, Clone, Default, PartialEq, Eq)]
275pub struct Nip96UploadFields {
276    /// `caption` — loose description (RECOMMENDED).
277    pub caption: Option<String>,
278    /// `expiration` — UNIX seconds, empty string for "forever".
279    pub expiration: Option<u64>,
280    /// `size` — declared byte count (lets server short-circuit
281    /// uploads above its limit).
282    pub size: Option<u64>,
283    /// `alt` — strict alt text (RECOMMENDED for accessibility).
284    pub alt: Option<String>,
285    /// `media_type` — `"avatar"` or `"banner"` for special
286    /// handling, omitted for normal uploads.
287    pub media_type: Option<String>,
288    /// `content_type` — MIME type hint, lets server short-circuit
289    /// unsupported types.
290    pub content_type: Option<String>,
291    /// `no_transform` — when `true`, asks the server to keep the
292    /// file byte-identical (used for cross-server replication so
293    /// the resulting digest matches across mirrors).
294    pub no_transform: bool,
295}
296
297impl Nip96UploadFields {
298    /// Render the fields as `(name, value)` pairs ready to feed
299    /// into a multipart-form builder.
300    ///
301    /// Boolean values are emitted as `"true"` per the spec.
302    #[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    /// Author a NIP-96 `kind: 10096` user file-server list event
332    /// from a typed [`FileServerList`].
333    #[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}