Skip to content

Commit 46391de

Browse files
committed
Add nostr-http-file-storage crate
Closes #943 Signed-off-by: Yuki Kishimoto <[email protected]>
1 parent e153339 commit 46391de

File tree

9 files changed

+338
-3
lines changed

9 files changed

+338
-3
lines changed

Cargo.lock

Lines changed: 27 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ members = [
1616

1717
# Remote File Storage implementations
1818
"rfs/nostr-blossom",
19+
"rfs/nostr-http-file-storage",
1920
]
2021
default-members = ["crates/*"]
2122
resolver = "2"

README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ The project is split up into several crates in the `crates/` directory:
66

77
* Libraries:
88
* [**nostr**](./crates/nostr): Rust implementation of Nostr protocol
9-
* [**nostr-connect**](./crates/nostr-connect): Nostr Connect (NIP46)
9+
* [**nostr-connect**](./crates/nostr-connect): Nostr Connect (NIP-46)
1010
* [**nostr-database**](./database/nostr-database): Events database abstraction and in-memory implementation
1111
* [**nostr-lmdb**](./database/nostr-lmdb): LMDB storage backend
1212
* [**nostr-ndb**](./database/nostr-ndb): [nostrdb](https://github.com/damus-io/nostrdb) storage backend
@@ -16,11 +16,12 @@ The project is split up into several crates in the `crates/` directory:
1616
* [**nostr-mls-memory-storage**](./mls/nostr-mls-memory-storage): In-memory storage for nostr-mls
1717
* [**nostr-mls-sqlite-storage**](./mls/nostr-mls-sqlite-storage): Sqlite storage for nostr-mls
1818
* Remote File Storage implementations:
19-
* [**nostr-blossom**](./crates/nostr-blossom): A library for interacting with the Blossom protocol
19+
* [**nostr-blossom**](./rfs/nostr-blossom): A library for interacting with the Blossom protocol
20+
* [**nostr-http-file-storage**](./rfs/nostr-http-file-storage): HTTP File Storage client (NIP-96)
2021
* [**nostr-keyring**](./crates/nostr-keyring): Nostr Keyring
2122
* [**nostr-relay-pool**](./crates/nostr-relay-pool): Nostr Relay Pool
2223
* [**nostr-sdk**](./crates/nostr-sdk): High level client library
23-
* [**nwc**](./crates/nwc): Nostr Wallet Connect (NWC) client
24+
* [**nwc**](./crates/nwc): Nostr Wallet Connect (NWC) client (NIP-47)
2425
* Binaries (tools):
2526
* [**nostr-cli**](./crates/nostr-cli): Nostr CLI
2627

contrib/scripts/check-crates.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ buildargs=(
5656
"-p nostr-sdk --features all-nips" # Only NIPs features
5757
"-p nostr-sdk --features tor" # Embedded tor client
5858
"-p nostr-sdk --all-features" # All features
59+
"-p nostr-http-file-storage"
5960
"-p nostr-cli"
6061
)
6162

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
[package]
2+
name = "nostr-http-file-storage"
3+
version = "0.42.0"
4+
edition = "2021"
5+
description = "Nostr HTTP File Storage client (NIP-96)."
6+
authors.workspace = true
7+
homepage.workspace = true
8+
repository.workspace = true
9+
license.workspace = true
10+
readme = "README.md"
11+
rust-version.workspace = true
12+
keywords = ["nostr", "nip96", "storage", "http"]
13+
14+
[features]
15+
default = []
16+
# Enable support for SOCKS proxy
17+
socks = ["reqwest/socks"]
18+
19+
[dependencies]
20+
nostr = { workspace = true, features = ["std", "nip96"] }
21+
reqwest = { workspace = true, features = ["json", "multipart", "rustls-tls"] }
22+
tokio = { workspace = true, features = ["sync"] }
23+
24+
[dev-dependencies]
25+
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Nostr HTTP File Storage client (NIP-96)
2+
3+
## Description
4+
5+
Nostr HTTP File Storage client ([NIP-96](https://github.com/nostr-protocol/nips/blob/master/96.md)).
6+
7+
## State
8+
9+
**This library is in an ALPHA state**, things that are implemented generally work but the API will change in breaking ways.
10+
11+
## Donations
12+
13+
`rust-nostr` is free and open-source. This means we do not earn any revenue by selling it. Instead, we rely on your financial support. If you actively use any of the `rust-nostr` libs/software/services, then please [donate](https://rust-nostr.org/donate).
14+
15+
## License
16+
17+
This project is distributed under the MIT software license - see the [LICENSE](../../LICENSE) file for details
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// Copyright (c) 2022-2023 Yuki Kishimoto
2+
// Copyright (c) 2023-2025 Rust Nostr Developers
3+
// Distributed under the MIT software license
4+
5+
use nostr_http_file_storage::prelude::*;
6+
7+
const FILE: &[u8] = &[
8+
137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0,
9+
0, 0, 31, 21, 196, 137, 0, 0, 0, 1, 115, 82, 71, 66, 0, 174, 206, 28, 233, 0, 0, 0, 4, 103, 65,
10+
77, 65, 0, 0, 177, 143, 11, 252, 97, 5, 0, 0, 0, 9, 112, 72, 89, 115, 0, 0, 28, 35, 0, 0, 28,
11+
35, 1, 199, 111, 168, 100, 0, 0, 0, 12, 73, 68, 65, 84, 8, 29, 99, 248, 255, 255, 63, 0, 5,
12+
254, 2, 254, 135, 150, 28, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130,
13+
];
14+
15+
#[tokio::main]
16+
async fn main() -> Result<()> {
17+
let keys = Keys::parse("nsec1j4c6269y9w0q2er2xjw8sv2ehyrtfxq3jwgdlxj6qfn8z4gjsq5qfvfk99")?;
18+
19+
let client = NostrHttpFileStorageClient::new();
20+
21+
let server_url: Url = Url::parse("https://NostrMedia.com")?;
22+
23+
// Get config
24+
let config = client.get_server_config(&server_url).await?;
25+
26+
// Upload
27+
let url: Url = client.upload(&keys, &config, FILE.to_vec(), None).await?;
28+
29+
println!("File uploaded: {url}");
30+
31+
Ok(())
32+
}
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
// Copyright (c) 2022-2023 Yuki Kishimoto
2+
// Copyright (c) 2023-2025 Rust Nostr Developers
3+
// Distributed under the MIT software license
4+
5+
//! Nostr HTTP File Storage client (NIP-96)
6+
//!
7+
//! <https://github.com/nostr-protocol/nips/blob/master/96.md>
8+
9+
#![forbid(unsafe_code)]
10+
#![warn(missing_docs)]
11+
#![warn(rustdoc::bare_urls)]
12+
#![warn(clippy::large_futures)]
13+
14+
use std::fmt;
15+
#[cfg(all(feature = "socks", not(target_arch = "wasm32")))]
16+
use std::net::SocketAddr;
17+
use std::time::Duration;
18+
19+
use nostr::nips::nip96::{self, ServerConfig, UploadRequest, UploadResponse};
20+
use nostr::signer::NostrSigner;
21+
use nostr::types::url::Url;
22+
#[cfg(all(feature = "socks", not(target_arch = "wasm32")))]
23+
use reqwest::Proxy;
24+
use reqwest::{multipart, Client, ClientBuilder, Response};
25+
26+
pub mod prelude;
27+
28+
/// Nostr HTTP File Storage client error
29+
#[derive(Debug)]
30+
pub enum Error {
31+
/// Reqwest error
32+
Reqwest(reqwest::Error),
33+
/// NIP-96 error
34+
NIP96(nip96::Error),
35+
/// Multipart MIME error
36+
MultipartMime,
37+
}
38+
39+
impl std::error::Error for Error {}
40+
41+
impl fmt::Display for Error {
42+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
43+
match self {
44+
Self::Reqwest(e) => write!(f, "{e}"),
45+
Self::NIP96(e) => write!(f, "{e}"),
46+
Self::MultipartMime => write!(f, "Invalid MIME type for the multipart form"),
47+
}
48+
}
49+
}
50+
51+
impl From<reqwest::Error> for Error {
52+
fn from(e: reqwest::Error) -> Self {
53+
Self::Reqwest(e)
54+
}
55+
}
56+
57+
impl From<nip96::Error> for Error {
58+
fn from(e: nip96::Error) -> Self {
59+
Self::NIP96(e)
60+
}
61+
}
62+
63+
/// Nostr HTTP File Storage client
64+
#[derive(Debug, Clone)]
65+
pub struct NostrHttpFileStorageClientBuilder {
66+
/// Socks5 proxy
67+
#[cfg(all(feature = "socks", not(target_arch = "wasm32")))]
68+
pub proxy: Option<SocketAddr>,
69+
/// Timeout
70+
pub timeout: Duration,
71+
}
72+
73+
impl Default for NostrHttpFileStorageClientBuilder {
74+
fn default() -> Self {
75+
Self {
76+
#[cfg(all(feature = "socks", not(target_arch = "wasm32")))]
77+
proxy: None,
78+
timeout: Duration::from_secs(60),
79+
}
80+
}
81+
}
82+
83+
impl NostrHttpFileStorageClientBuilder {
84+
/// New default builder
85+
#[inline]
86+
pub fn new() -> Self {
87+
Self::default()
88+
}
89+
90+
/// Set proxy
91+
#[inline]
92+
#[cfg(all(feature = "socks", not(target_arch = "wasm32")))]
93+
pub fn proxy(mut self, addr: SocketAddr) -> Self {
94+
self.proxy = Some(addr);
95+
self
96+
}
97+
98+
/// Set timeout
99+
#[inline]
100+
pub fn timeout(mut self, timeout: Duration) -> Self {
101+
self.timeout = timeout;
102+
self
103+
}
104+
105+
/// Build the client
106+
pub fn build(self) -> Result<NostrHttpFileStorageClient, Error> {
107+
// Construct builder
108+
let mut builder: ClientBuilder = Client::builder();
109+
110+
// Set proxy
111+
#[cfg(all(feature = "socks", not(target_arch = "wasm32")))]
112+
if let Some(proxy) = self.proxy {
113+
let proxy: String = format!("socks5h://{proxy}");
114+
builder = builder.proxy(Proxy::all(proxy)?);
115+
}
116+
117+
// Set timeout
118+
builder = builder.timeout(self.timeout);
119+
120+
// Build client
121+
let client: Client = builder.build()?;
122+
123+
// Construct client
124+
Ok(NostrHttpFileStorageClient::from_client(client))
125+
}
126+
}
127+
128+
/// Nostr HTTP File Storage client
129+
#[derive(Debug, Clone)]
130+
pub struct NostrHttpFileStorageClient {
131+
client: Client,
132+
}
133+
134+
impl Default for NostrHttpFileStorageClient {
135+
fn default() -> Self {
136+
Self::new()
137+
}
138+
}
139+
140+
impl NostrHttpFileStorageClient {
141+
/// Construct a default client
142+
#[inline]
143+
pub fn new() -> Self {
144+
Self::builder().build().expect("Failed to build client")
145+
}
146+
147+
/// Construct from reqwest [`Client`].
148+
#[inline]
149+
pub fn from_client(client: Client) -> Self {
150+
Self { client }
151+
}
152+
153+
/// Get a builder
154+
#[inline]
155+
pub fn builder() -> NostrHttpFileStorageClientBuilder {
156+
NostrHttpFileStorageClientBuilder::default()
157+
}
158+
159+
/// Get the nip96.json file on the server and return the JSON as a [`ServerConfig`]
160+
pub async fn get_server_config(&self, server_url: &Url) -> Result<ServerConfig, Error> {
161+
let nip96_url: Url = nip96::get_server_config_url(server_url)?;
162+
163+
let response = self.client.get(nip96_url).send().await?;
164+
165+
// Deserialize response
166+
Ok(response.json().await?)
167+
}
168+
169+
/// Uploads some data to a NIP-96 server and returns the file's download URL
170+
pub async fn upload<T>(
171+
&self,
172+
signer: &T,
173+
config: &ServerConfig,
174+
file_data: Vec<u8>,
175+
mime_type: Option<&str>,
176+
) -> Result<Url, Error>
177+
where
178+
T: NostrSigner,
179+
{
180+
// Create new request
181+
let req: UploadRequest = UploadRequest::new(signer, config, &file_data).await?;
182+
183+
// Make form
184+
let form: multipart::Form = make_multipart_form(file_data, mime_type)?;
185+
186+
// Send
187+
let response: Response = self
188+
.client
189+
.post(config.api_url.clone())
190+
.header("Authorization", req.authorization())
191+
.multipart(form)
192+
.send()
193+
.await?;
194+
195+
// Decode response
196+
let res: UploadResponse = response.json().await?;
197+
198+
// Try to extract download URL
199+
Ok(res.download_url().cloned()?)
200+
}
201+
}
202+
203+
fn make_multipart_form(
204+
file_data: Vec<u8>,
205+
mime_type: Option<&str>,
206+
) -> Result<multipart::Form, Error> {
207+
let form_file_part = multipart::Part::bytes(file_data).file_name("filename");
208+
209+
// Set the part's MIME type, or leave it as is if mime_type is None
210+
let part = match mime_type {
211+
Some(mime) => form_file_part
212+
.mime_str(mime)
213+
.map_err(|_| Error::MultipartMime)?,
214+
None => form_file_part,
215+
};
216+
217+
Ok(multipart::Form::new().part("file", part))
218+
}

0 commit comments

Comments
 (0)