use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::event::{Event, EventBuilder, Kind, Tag, TagError, TagKind};
use crate::types::{Url, UrlError};
pub const KIND_FILE_SERVER_LIST: Kind = Kind::FILE_SERVER_LIST;
pub const NIP96_WELL_KNOWN_MEDIA_TYPE: &str = "application/json";
const SERVER_TAG: &str = "server";
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum Nip96Error {
#[error("expected kind 10096, got {0}")]
WrongKind(Kind),
#[error(transparent)]
Url(#[from] UrlError),
#[error(transparent)]
Tag(#[from] TagError),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileServerList {
pub servers: Vec<Url>,
}
impl FileServerList {
#[must_use]
pub fn new<I>(servers: I) -> Self
where
I: IntoIterator<Item = Url>,
{
Self {
servers: servers.into_iter().collect(),
}
}
#[must_use]
pub fn to_tags(&self) -> Vec<Tag> {
self.servers
.iter()
.map(|server| Tag::with(&TagKind::custom(SERVER_TAG), [server.as_str().to_owned()]))
.collect()
}
pub fn from_event(event: &Event) -> Result<Self, Nip96Error> {
if event.kind != KIND_FILE_SERVER_LIST {
return Err(Nip96Error::WrongKind(event.kind));
}
let mut servers: Vec<Url> = Vec::new();
for tag in &event.tags {
if tag.name() != SERVER_TAG {
continue;
}
let Some(url) = tag.values().get(1) else {
continue;
};
servers.push(Url::parse(url)?);
}
Ok(Self { servers })
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Nip96ServerConfig {
pub api_url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub download_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub delegated_to_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub supported_nips: Option<Vec<u16>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tos_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content_types: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub plans: Option<BTreeMap<String, Nip96Plan>>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Nip96Plan {
pub name: String,
#[serde(default = "default_true")]
pub is_nip98_required: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_byte_size: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub file_expiration: Option<[u64; 2]>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub media_transformations: Option<BTreeMap<String, Vec<String>>>,
}
const fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum Nip96Status {
Success,
Error,
Processing,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Nip96UploadResponse {
pub status: Nip96Status,
pub message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub processing_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub nip94_event: Option<EmbeddedNip94Event>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub percentage: Option<u8>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EmbeddedNip94Event {
pub tags: Vec<Vec<String>>,
#[serde(default)]
pub content: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Nip96UploadFields {
pub caption: Option<String>,
pub expiration: Option<u64>,
pub size: Option<u64>,
pub alt: Option<String>,
pub media_type: Option<String>,
pub content_type: Option<String>,
pub no_transform: bool,
}
impl Nip96UploadFields {
#[must_use]
pub fn to_form_pairs(&self) -> Vec<(&'static str, String)> {
let mut out: Vec<(&'static str, String)> = Vec::new();
if let Some(caption) = &self.caption {
out.push(("caption", caption.clone()));
}
if let Some(expiration) = self.expiration {
out.push(("expiration", expiration.to_string()));
}
if let Some(size) = self.size {
out.push(("size", size.to_string()));
}
if let Some(alt) = &self.alt {
out.push(("alt", alt.clone()));
}
if let Some(media_type) = &self.media_type {
out.push(("media_type", media_type.clone()));
}
if let Some(content_type) = &self.content_type {
out.push(("content_type", content_type.clone()));
}
if self.no_transform {
out.push(("no_transform", "true".to_owned()));
}
out
}
}
impl EventBuilder {
#[must_use]
pub fn nip96_file_servers(list: &FileServerList) -> Self {
let mut builder = Self::new(KIND_FILE_SERVER_LIST, "");
for tag in list.to_tags() {
builder = builder.tag(tag);
}
builder
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Keys;
fn keys() -> Keys {
Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
}
#[test]
fn server_list_round_trips_through_event() {
let list = FileServerList::new([
Url::parse("https://file.server.one").unwrap(),
Url::parse("https://file.server.two").unwrap(),
]);
let event = EventBuilder::nip96_file_servers(&list)
.sign_with_keys(&keys())
.unwrap();
assert_eq!(event.kind, KIND_FILE_SERVER_LIST);
let recovered = FileServerList::from_event(&event).unwrap();
assert_eq!(recovered, list);
}
#[test]
fn server_list_from_event_rejects_wrong_kind() {
let event = EventBuilder::text_note("nope")
.sign_with_keys(&keys())
.unwrap();
assert!(matches!(
FileServerList::from_event(&event),
Err(Nip96Error::WrongKind(_)),
));
}
#[test]
fn well_known_config_round_trips_through_json() {
let json = r#"{
"api_url": "https://your-file-server.example/custom-api-path",
"download_url": "https://a-cdn.example/a-path",
"supported_nips": [96, 98],
"tos_url": "https://your-file-server.example/terms-of-service",
"content_types": ["image/jpeg", "video/webm", "audio/*"],
"plans": {
"free": {
"name": "Free Tier",
"is_nip98_required": true,
"url": "https://example/plans/free",
"max_byte_size": 10485760,
"file_expiration": [14, 90],
"media_transformations": {
"image": ["resizing"]
}
}
}
}"#;
let config: Nip96ServerConfig = serde_json::from_str(json).unwrap();
assert_eq!(
config.api_url,
"https://your-file-server.example/custom-api-path"
);
let plans = config.plans.as_ref().unwrap();
let free = plans.get("free").unwrap();
assert_eq!(free.name, "Free Tier");
assert!(free.is_nip98_required);
assert_eq!(free.max_byte_size, Some(10_485_760));
assert_eq!(free.file_expiration, Some([14, 90]));
let reserialised = serde_json::to_string(&config).unwrap();
let round_tripped: Nip96ServerConfig = serde_json::from_str(&reserialised).unwrap();
assert_eq!(round_tripped, config);
}
#[test]
fn well_known_config_supports_delegated_form() {
let json = r#"{
"api_url": "",
"delegated_to_url": "https://your-file-server.example"
}"#;
let config: Nip96ServerConfig = serde_json::from_str(json).unwrap();
assert!(config.api_url.is_empty());
assert_eq!(
config.delegated_to_url.as_deref(),
Some("https://your-file-server.example"),
);
}
#[test]
fn plan_default_is_nip98_required_is_true() {
let json = r#"{ "name": "Bare", "url": "https://example" }"#;
let plan: Nip96Plan = serde_json::from_str(json).unwrap();
assert!(plan.is_nip98_required);
}
#[test]
fn upload_response_success_round_trips_through_json() {
let json = r#"{
"status": "success",
"message": "Upload successful.",
"nip94_event": {
"tags": [
["url", "https://srv.example/abc.png"],
["ox", "719171db19525d9d08dd69cb716a18158a249b7b3b3ec4bbdec5698dca104b7b"],
["x", "543244319525d9d08dd69cb716a18158a249b7b3b3ec4bbde5435543acb34443"],
["m", "image/png"],
["dim", "800x600"]
],
"content": ""
}
}"#;
let response: Nip96UploadResponse = serde_json::from_str(json).unwrap();
assert_eq!(response.status, Nip96Status::Success);
let embedded = response.nip94_event.as_ref().unwrap();
assert_eq!(embedded.tags.len(), 5);
assert_eq!(embedded.tags[0], vec!["url", "https://srv.example/abc.png"]);
let reserialised = serde_json::to_string(&response).unwrap();
let round_tripped: Nip96UploadResponse = serde_json::from_str(&reserialised).unwrap();
assert_eq!(round_tripped, response);
}
#[test]
fn upload_response_processing_carries_percentage() {
let json = r#"{
"status": "processing",
"message": "Processing. Please check again later for updated status.",
"percentage": 15
}"#;
let response: Nip96UploadResponse = serde_json::from_str(json).unwrap();
assert_eq!(response.status, Nip96Status::Processing);
assert_eq!(response.percentage, Some(15));
assert!(response.nip94_event.is_none());
}
#[test]
fn upload_fields_emit_only_set_pairs() {
let fields = Nip96UploadFields {
caption: Some("a meme".to_owned()),
alt: Some("a meme that makes you laugh".to_owned()),
no_transform: true,
..Default::default()
};
let pairs = fields.to_form_pairs();
assert_eq!(
pairs,
vec![
("caption", "a meme".to_owned()),
("alt", "a meme that makes you laugh".to_owned()),
("no_transform", "true".to_owned()),
],
);
}
}