diff --git a/Cargo.lock b/Cargo.lock index 4343589..e6fa991 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2194,9 +2194,11 @@ dependencies = [ "axum-server", "base64 0.22.1", "bytes", + "cfg_aliases", "console_log", "cookie", "cookie_store", + "flume", "futures-lite", "futures-util", "http-relay", diff --git a/examples/authn/signup.rs b/examples/authn/signup.rs index 2db6aaf..293d673 100644 --- a/examples/authn/signup.rs +++ b/examples/authn/signup.rs @@ -1,10 +1,8 @@ use anyhow::Result; use clap::Parser; -use pubky::Client; +use pubky::{Client, PublicKey}; use std::path::PathBuf; -use pubky_common::crypto::PublicKey; - #[derive(Parser, Debug)] #[command(version, about, long_about = None)] struct Cli { @@ -29,7 +27,7 @@ async fn main() -> Result<()> { println!("Enter your recovery_file's passphrase to signup:"); let passphrase = rpassword::read_password()?; - let keypair = pubky_common::recovery_file::decrypt_recovery_file(&recovery_file, &passphrase)?; + let keypair = pubky::recovery_file::decrypt_recovery_file(&recovery_file, &passphrase)?; println!("Successfully decrypted the recovery file, signing up to the homeserver:"); diff --git a/pubky/build.rs b/pubky/build.rs new file mode 100644 index 0000000..88e794f --- /dev/null +++ b/pubky/build.rs @@ -0,0 +1,8 @@ +use cfg_aliases::cfg_aliases; + +fn main() { + // Convenience aliases + cfg_aliases! { + wasm_browser: { all(target_family = "wasm", target_os = "unknown") }, + } +} diff --git a/pubky/clippy.toml b/pubky/clippy.toml deleted file mode 100644 index 154626e..0000000 --- a/pubky/clippy.toml +++ /dev/null @@ -1 +0,0 @@ -allow-unwrap-in-tests = true diff --git a/pubky/pkg/test/auth.js b/pubky/pkg/test/auth.js index 31e4e0f..aed0e94 100644 --- a/pubky/pkg/test/auth.js +++ b/pubky/pkg/test/auth.js @@ -1,6 +1,6 @@ import test from 'tape' -import { Client, Keypair, PublicKey } from '../index.cjs' +import { Client, Keypair, PublicKey, setLogLevel } from '../index.cjs' const HOMESERVER_PUBLICKEY = PublicKey.from('8pinxxgqs41n4aididenw5apqp1urfmzdztr8jt4abrkdn435ewo') const TESTNET_HTTP_RELAY = "http://localhost:15412/link"; @@ -65,9 +65,12 @@ test("Auth: 3rd party signin", async (t) => { // Third party app side let capabilities = "/pub/pubky.app/:rw,/pub/foo.bar/file:r"; let client = Client.testnet(); - let [pubkyauth_url, pubkyauthResponse] = client + let authRequest = client .authRequest(TESTNET_HTTP_RELAY, capabilities); + let pubkyauthUrl = authRequest.url(); + let pubkyauthResponse = authRequest.response(); + if (globalThis.document) { // Skip `sendAuthToken` in browser // TODO: figure out why does it fail in browser unit tests @@ -81,7 +84,7 @@ test("Auth: 3rd party signin", async (t) => { await client.signup(keypair, HOMESERVER_PUBLICKEY); - await client.sendAuthToken(keypair, pubkyauth_url) + await client.sendAuthToken(keypair, pubkyauthUrl) } let authedPubky = await pubkyauthResponse; diff --git a/pubky/src/lib.rs b/pubky/src/lib.rs index 951bd62..cceaff8 100644 --- a/pubky/src/lib.rs +++ b/pubky/src/lib.rs @@ -1,39 +1,34 @@ #![doc = include_str!("../README.md")] //! -mod shared; +// TODO: deny missing docs. +// #![deny(missing_docs)] +#![deny(rustdoc::broken_intra_doc_links)] +// TODO: deny unwrap +#![cfg_attr(any(), deny(clippy::unwrap_used))] -#[cfg(not(target_arch = "wasm32"))] -mod native; +macro_rules! cross_debug { + ($($arg:tt)*) => { + #[cfg(target_arch = "wasm32")] + log::debug!($($arg)*); + #[cfg(not(target_arch = "wasm32"))] + tracing::debug!($($arg)*); + }; +} -#[cfg(target_arch = "wasm32")] +pub mod native; +#[cfg(wasm_browser)] mod wasm; -use std::fmt::Debug; +#[cfg(not(wasm_browser))] +pub use crate::native::Client; +pub use crate::native::{api::auth::AuthRequest, api::public::ListBuilder, ClientBuilder}; -use wasm_bindgen::prelude::*; +#[cfg(wasm_browser)] +pub use native::Client as NativeClient; +#[cfg(wasm_browser)] +pub use wasm::Client; -#[cfg(not(target_arch = "wasm32"))] -pub use crate::shared::list_builder::ListBuilder; - -/// A client for Pubky homeserver API, as well as generic HTTP requests to Pubky urls. -#[derive(Clone)] -#[wasm_bindgen] -pub struct Client { - http: reqwest::Client, - pkarr: pkarr::Client, - - #[cfg(not(target_arch = "wasm32"))] - cookie_store: std::sync::Arc, - #[cfg(not(target_arch = "wasm32"))] - icann_http: reqwest::Client, - - #[cfg(target_arch = "wasm32")] - testnet: bool, -} - -impl Debug for Client { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("Pubky Client").finish() - } -} +// Re-exports +pub use pkarr::{Keypair, PublicKey}; +pub use pubky_common::recovery_file; diff --git a/pubky/src/native.rs b/pubky/src/native.rs index ebb6851..2693377 100644 --- a/pubky/src/native.rs +++ b/pubky/src/native.rs @@ -1,28 +1,49 @@ +pub mod internal { + #[cfg(not(wasm_browser))] + pub mod cookies; + pub mod pkarr; +} +pub mod api { + pub mod auth; + #[cfg(not(wasm_browser))] + pub mod http; + pub mod public; +} + +use std::fmt::Debug; + +#[cfg(not(wasm_browser))] use std::{sync::Arc, time::Duration}; +#[cfg(not(wasm_browser))] use mainline::Testnet; -use crate::Client; +static DEFAULT_USER_AGENT: &str = concat!("pubky.org", "@", env!("CARGO_PKG_VERSION"),); -mod api; -mod cookies; -mod http; +#[macro_export] +macro_rules! handle_http_error { + ($res:expr) => { + if let Err(status) = $res.error_for_status_ref() { + return match $res.text().await { + Ok(text) => Err(anyhow::anyhow!("{status}. Error message: {text}")), + _ => Err(anyhow::anyhow!("{status}")), + }; + } + }; +} -pub(crate) use cookies::CookieJar; - -static DEFAULT_USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"),); - -#[derive(Debug, Default)] +#[derive(Debug, Default, Clone)] pub struct ClientBuilder { pkarr: pkarr::ClientBuilder, } impl ClientBuilder { + #[cfg(not(wasm_browser))] /// Sets the following: /// - Pkarr client's DHT bootstrap nodes = `testnet` bootstrap nodes. /// - Pkarr client's resolvers = `testnet` bootstrap nodes. /// - Pkarr client's DHT request timeout = 500 milliseconds. (unless in CI, then it is left as default 2000) - pub fn testnet(mut self, testnet: &Testnet) -> Self { + pub fn testnet(&mut self, testnet: &Testnet) -> &mut Self { let bootstrap = testnet.bootstrap.clone(); self.pkarr.bootstrap(&bootstrap); @@ -34,15 +55,27 @@ impl ClientBuilder { self } + /// Create a [mainline::DhtBuilder] if `None`, and allows mutating it with a callback function. + pub fn pkarr(&mut self, f: F) -> &mut Self + where + F: FnOnce(&mut pkarr::ClientBuilder) -> &mut pkarr::ClientBuilder, + { + f(&mut self.pkarr); + + self + } + /// Build [Client] - pub fn build(self) -> Result { + pub fn build(&self) -> Result { let pkarr = self.pkarr.build()?; - let cookie_store = Arc::new(CookieJar::default()); + #[cfg(not(wasm_browser))] + let cookie_store = Arc::new(internal::cookies::CookieJar::default()); // TODO: allow custom user agent, but force a Pubky user agent information let user_agent = DEFAULT_USER_AGENT; + #[cfg(not(wasm_browser))] let http = reqwest::ClientBuilder::from(pkarr.clone()) // TODO: use persistent cookie jar .cookie_provider(cookie_store.clone()) @@ -50,27 +83,61 @@ impl ClientBuilder { .build() .expect("config expected to not error"); - let icann_http = reqwest::ClientBuilder::new() - .cookie_provider(cookie_store.clone()) + #[cfg(wasm_browser)] + let http = reqwest::Client::builder() .user_agent(user_agent) .build() .expect("config expected to not error"); Ok(Client { - cookie_store, http, - icann_http, pkarr, + + #[cfg(not(wasm_browser))] + icann_http: reqwest::Client::builder() + // TODO: use persistent cookie jar + .cookie_provider(cookie_store.clone()) + .user_agent(user_agent) + .build() + .expect("config expected to not error"), + #[cfg(not(wasm_browser))] + cookie_store, + + #[cfg(wasm_browser)] + testnet: false, }) } } +#[derive(Debug, thiserror::Error)] +pub enum BuildError { + #[error(transparent)] + /// Error building Pkarr client. + PkarrBuildError(#[from] pkarr::errors::BuildError), +} + +/// A client for Pubky homeserver API, as well as generic HTTP requests to Pubky urls. +#[derive(Clone, Debug)] +pub struct Client { + pub(crate) http: reqwest::Client, + pub(crate) pkarr: pkarr::Client, + + #[cfg(not(wasm_browser))] + pub(crate) cookie_store: std::sync::Arc, + #[cfg(not(wasm_browser))] + pub(crate) icann_http: reqwest::Client, + + #[cfg(wasm_browser)] + pub(crate) testnet: bool, +} + impl Client { /// Returns a builder to edit settings before creating [Client]. pub fn builder() -> ClientBuilder { ClientBuilder::default() } + #[cfg(not(wasm_browser))] /// Create a client connected to the local network /// with the bootstrapping node: `localhost:6881` pub fn testnet() -> Result { @@ -83,15 +150,9 @@ impl Client { } #[cfg(test)] + #[cfg(not(wasm_browser))] /// Alias to `pubky::Client::builder().testnet(testnet).build().unwrap()` pub(crate) fn test(testnet: &Testnet) -> Client { Client::builder().testnet(testnet).build().unwrap() } } - -#[derive(Debug, thiserror::Error)] -pub enum BuildError { - #[error(transparent)] - /// Error building Pkarr client. - PkarrBuildError(#[from] pkarr::errors::BuildError), -} diff --git a/pubky/src/native/api/auth.rs b/pubky/src/native/api/auth.rs index 3a0bf86..dcee93c 100644 --- a/pubky/src/native/api/auth.rs +++ b/pubky/src/native/api/auth.rs @@ -1,15 +1,22 @@ -use pkarr::Keypair; -use pubky_common::session::Session; -use reqwest::IntoUrl; +use std::collections::HashMap; + +use base64::{alphabet::URL_SAFE, engine::general_purpose::NO_PAD, Engine}; +use reqwest::{IntoUrl, Method, StatusCode}; use url::Url; -use pkarr::PublicKey; - -use pubky_common::capabilities::Capabilities; +use pkarr::{Keypair, PublicKey}; +use pubky_common::{ + auth::AuthToken, + capabilities::{Capabilities, Capability}, + crypto::{decrypt, encrypt, hash, random_bytes}, + session::Session, +}; use anyhow::Result; -use crate::Client; +use crate::handle_http_error; + +use super::super::Client; impl Client { /// Signup to a homeserver and update Pkarr accordingly. @@ -17,25 +24,179 @@ impl Client { /// The homeserver is a Pkarr domain name, where the TLD is a Pkarr public key /// for example "pubky.o4dksfbqk85ogzdb5osziw6befigbuxmuxkuxq8434q89uj56uyy" pub async fn signup(&self, keypair: &Keypair, homeserver: &PublicKey) -> Result { - self.inner_signup(keypair, homeserver).await + let response = self + .cross_request(Method::POST, format!("https://{}/signup", homeserver)) + .await + .body(AuthToken::sign(keypair, vec![Capability::root()]).serialize()) + .send() + .await?; + + handle_http_error!(response); + + self.publish_homeserver(keypair, &homeserver.to_string()) + .await?; + + // Store the cookie to the correct URL. + #[cfg(not(target_arch = "wasm32"))] + self.cookie_store + .store_session_after_signup(&response, &keypair.public_key()); + + let bytes = response.bytes().await?; + + Ok(Session::deserialize(&bytes)?) } - /// Check the current sessison for a given Pubky in its homeserver. + /// Check the current session for a given Pubky in its homeserver. /// - /// Returns [Session] or `None` (if received `404 NOT_FOUND`), - /// or [reqwest::Error] if the response has any other `>=400` status code. + /// Returns None if not signed in, or [reqwest::Error] + /// if the response has any other `>=404` status code. pub async fn session(&self, pubky: &PublicKey) -> Result> { - self.inner_session(pubky).await + let response = self + .cross_request(Method::GET, format!("pubky://{}/session", pubky)) + .await + .send() + .await?; + + if response.status() == StatusCode::NOT_FOUND { + return Ok(None); + } + + handle_http_error!(response); + + let bytes = response.bytes().await?; + + Ok(Some(Session::deserialize(&bytes)?)) } /// Signout from a homeserver. pub async fn signout(&self, pubky: &PublicKey) -> Result<()> { - self.inner_signout(pubky).await + let response = self + .cross_request(Method::DELETE, format!("pubky://{}/session", pubky)) + .await + .send() + .await?; + + handle_http_error!(response); + + #[cfg(not(target_arch = "wasm32"))] + self.cookie_store.delete_session_after_signout(pubky); + + Ok(()) } /// Signin to a homeserver. pub async fn signin(&self, keypair: &Keypair) -> Result { - self.inner_signin(keypair).await + let token = AuthToken::sign(keypair, vec![Capability::root()]); + + self.signin_with_authtoken(&token).await + } + + pub async fn send_auth_token( + &self, + keypair: &Keypair, + pubkyauth_url: &T, + ) -> Result<()> { + let pubkyauth_url = Url::parse( + pubkyauth_url + .as_str() + .replace("pubkyauth_url", "http") + .as_str(), + )?; + + let query_params: HashMap = + pubkyauth_url.query_pairs().into_owned().collect(); + + let relay = query_params + .get("relay") + .map(|r| url::Url::parse(r).expect("Relay query param to be valid URL")) + .expect("Missing relay query param"); + + let client_secret = query_params + .get("secret") + .map(|s| { + let engine = base64::engine::GeneralPurpose::new(&URL_SAFE, NO_PAD); + let bytes = engine.decode(s).expect("invalid client_secret"); + let arr: [u8; 32] = bytes.try_into().expect("invalid client_secret"); + + arr + }) + .expect("Missing client secret"); + + let capabilities = query_params + .get("caps") + .map(|caps_string| { + caps_string + .split(',') + .filter_map(|cap| Capability::try_from(cap).ok()) + .collect::>() + }) + .unwrap_or_default(); + + let token = AuthToken::sign(keypair, capabilities); + + let encrypted_token = encrypt(&token.serialize(), &client_secret)?; + + let engine = base64::engine::GeneralPurpose::new(&URL_SAFE, NO_PAD); + + let mut callback_url = relay.clone(); + let mut path_segments = callback_url.path_segments_mut().unwrap(); + path_segments.pop_if_empty(); + let channel_id = engine.encode(hash(&client_secret).as_bytes()); + path_segments.push(&channel_id); + drop(path_segments); + + let response = self + .cross_request(Method::POST, callback_url) + .await + .body(encrypted_token) + .send() + .await?; + + handle_http_error!(response); + + Ok(()) + } + + pub(crate) async fn signin_with_authtoken(&self, token: &AuthToken) -> Result { + let response = self + .cross_request(Method::POST, format!("pubky://{}/session", token.pubky())) + .await + .body(token.serialize()) + .send() + .await?; + + handle_http_error!(response); + + let bytes = response.bytes().await?; + + Ok(Session::deserialize(&bytes)?) + } + + pub(crate) fn create_auth_request( + &self, + relay: &mut Url, + capabilities: &Capabilities, + ) -> Result<(Url, [u8; 32])> { + let engine = base64::engine::GeneralPurpose::new(&URL_SAFE, NO_PAD); + + let client_secret: [u8; 32] = random_bytes::<32>(); + + let pubkyauth_url = Url::parse(&format!( + "pubkyauth:///?caps={capabilities}&secret={}&relay={relay}", + engine.encode(client_secret) + ))?; + + let mut segments = relay + .path_segments_mut() + .map_err(|_| anyhow::anyhow!("Invalid relay"))?; + + // remove trailing slash if any. + segments.pop_if_empty(); + let channel_id = &engine.encode(hash(&client_secret).as_bytes()); + segments.push(channel_id); + drop(segments); + + Ok((pubkyauth_url, client_secret)) } /// Return `pubkyauth://` url and wait for the incoming [AuthToken] @@ -55,24 +216,59 @@ impl Client { let this = self.clone(); - tokio::spawn(async move { + let future = async move { let result = this .subscribe_to_auth_response(relay, &client_secret, tx.clone()) .await; - tx.send(result) - }); + let _ = tx.send(result); + }; + + #[cfg(not(wasm_browser))] + tokio::spawn(future); + #[cfg(wasm_browser)] + wasm_bindgen_futures::spawn_local(future); Ok(AuthRequest { url, rx }) } - - /// Sign an [pubky_common::auth::AuthToken], encrypt it and send it to the - /// source of the pubkyauth request url. - pub async fn send_auth_token( + pub(crate) async fn subscribe_to_auth_response( &self, - keypair: &Keypair, - pubkyauth_url: &T, - ) -> Result<()> { - self.inner_send_auth_token(keypair, pubkyauth_url).await + relay: Url, + client_secret: &[u8; 32], + tx: flume::Sender>, + ) -> anyhow::Result { + let response = loop { + match self + .cross_request(Method::GET, relay.clone()) + .await + .send() + .await + { + Ok(response) => { + break Ok(response); + } + Err(error) => { + if error.is_timeout() && !tx.is_disconnected() { + cross_debug!("Connection to HttpRelay timedout, reconnecting..."); + + continue; + } + + break Err(error); + } + } + }?; + cross_debug!("LOOPING xxx {:?}", &response); + + let encrypted_token = response.bytes().await?; + let token_bytes = decrypt(&encrypted_token, client_secret) + .map_err(|e| anyhow::anyhow!("Got invalid token: {e}"))?; + let token = AuthToken::verify(&token_bytes)?; + + if !token.capabilities().is_empty() { + self.signin_with_authtoken(&token).await?; + } + + Ok(token.pubky().clone()) } } @@ -94,3 +290,167 @@ impl AuthRequest { .expect("sender dropped unexpectedly") } } + +#[cfg(test)] +mod tests { + use crate::*; + + use http_relay::HttpRelay; + use mainline::Testnet; + use pkarr::Keypair; + use pubky_common::capabilities::{Capabilities, Capability}; + use pubky_homeserver::Homeserver; + use reqwest::StatusCode; + + #[tokio::test] + async fn basic_authn() { + let testnet = Testnet::new(10).unwrap(); + let server = Homeserver::start_test(&testnet).await.unwrap(); + + let client = Client::test(&testnet); + + let keypair = Keypair::random(); + + client.signup(&keypair, &server.public_key()).await.unwrap(); + + let session = client + .session(&keypair.public_key()) + .await + .unwrap() + .unwrap(); + + assert!(session.capabilities().contains(&Capability::root())); + + client.signout(&keypair.public_key()).await.unwrap(); + + { + let session = client.session(&keypair.public_key()).await.unwrap(); + + assert!(session.is_none()); + } + + client.signin(&keypair).await.unwrap(); + + { + let session = client + .session(&keypair.public_key()) + .await + .unwrap() + .unwrap(); + + assert_eq!(session.pubky(), &keypair.public_key()); + assert!(session.capabilities().contains(&Capability::root())); + } + } + + #[tokio::test] + async fn authz() { + let testnet = Testnet::new(10).unwrap(); + let server = Homeserver::start_test(&testnet).await.unwrap(); + + let http_relay = HttpRelay::builder().build().await.unwrap(); + let http_relay_url = http_relay.local_link_url(); + + let keypair = Keypair::random(); + let pubky = keypair.public_key(); + + // Third party app side + let capabilities: Capabilities = + "/pub/pubky.app/:rw,/pub/foo.bar/file:r".try_into().unwrap(); + + let client = Client::test(&testnet); + + let pubky_auth_request = client.auth_request(http_relay_url, &capabilities).unwrap(); + + // Authenticator side + { + let client = Client::test(&testnet); + + client.signup(&keypair, &server.public_key()).await.unwrap(); + + client + .send_auth_token(&keypair, pubky_auth_request.url()) + .await + .unwrap(); + } + + let public_key = pubky_auth_request.response().await.unwrap(); + + assert_eq!(&public_key, &pubky); + + let session = client.session(&pubky).await.unwrap().unwrap(); + assert_eq!(session.capabilities(), &capabilities.0); + + // Test access control enforcement + + client + .put(format!("pubky://{pubky}/pub/pubky.app/foo")) + .body(vec![]) + .send() + .await + .unwrap() + .error_for_status() + .unwrap(); + + assert_eq!( + client + .put(format!("pubky://{pubky}/pub/pubky.app")) + .body(vec![]) + .send() + .await + .unwrap() + .status(), + StatusCode::FORBIDDEN + ); + + assert_eq!( + client + .put(format!("pubky://{pubky}/pub/foo.bar/file")) + .body(vec![]) + .send() + .await + .unwrap() + .status(), + StatusCode::FORBIDDEN + ); + } + + #[tokio::test] + async fn multiple_users() { + let testnet = Testnet::new(10).unwrap(); + let server = Homeserver::start_test(&testnet).await.unwrap(); + + let client = Client::test(&testnet); + + let first_keypair = Keypair::random(); + let second_keypair = Keypair::random(); + + client + .signup(&first_keypair, &server.public_key()) + .await + .unwrap(); + + client + .signup(&second_keypair, &server.public_key()) + .await + .unwrap(); + + let session = client + .session(&first_keypair.public_key()) + .await + .unwrap() + .unwrap(); + + assert_eq!(session.pubky(), &first_keypair.public_key()); + assert!(session.capabilities().contains(&Capability::root())); + + let session = client + .session(&second_keypair.public_key()) + .await + .unwrap() + .unwrap(); + + assert_eq!(session.pubky(), &second_keypair.public_key()); + assert!(session.capabilities().contains(&Capability::root())); + } +} diff --git a/pubky/src/native/http.rs b/pubky/src/native/api/http.rs similarity index 95% rename from pubky/src/native/http.rs rename to pubky/src/native/api/http.rs index b724e6a..4cef351 100644 --- a/pubky/src/native/http.rs +++ b/pubky/src/native/api/http.rs @@ -3,9 +3,10 @@ use pkarr::PublicKey; use reqwest::{IntoUrl, Method, RequestBuilder}; -use crate::Client; +use super::super::Client; impl Client { + #[cfg(not(wasm_browser))] /// Start building a `Request` with the `Method` and `Url`. /// /// Returns a `RequestBuilder`, which will allow setting headers and @@ -29,6 +30,8 @@ impl Client { return self.http.request(method, url); } else if url.starts_with("https://") && PublicKey::try_from(url).is_err() { + // TODO: remove icann_http when we can control reqwest connection + // and or create a tls config per connection. return self.icann_http.request(method, url); } @@ -126,7 +129,7 @@ impl Client { // === Private Methods === - pub(crate) async fn inner_request(&self, method: Method, url: T) -> RequestBuilder { + pub(crate) async fn cross_request(&self, method: Method, url: U) -> RequestBuilder { self.request(method, url) } } diff --git a/pubky/src/native/api/mod.rs b/pubky/src/native/api/mod.rs deleted file mode 100644 index f43316f..0000000 --- a/pubky/src/native/api/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -pub mod recovery_file; - -// TODO: put the Homeserver API behind a feature flag -pub mod auth; -pub mod public; diff --git a/pubky/src/native/api/public.rs b/pubky/src/native/api/public.rs index 00d8ce6..bbe0ad9 100644 --- a/pubky/src/native/api/public.rs +++ b/pubky/src/native/api/public.rs @@ -1,14 +1,897 @@ -use reqwest::IntoUrl; +use reqwest::{IntoUrl, Method}; use anyhow::Result; -use crate::{shared::list_builder::ListBuilder, Client}; +use crate::handle_http_error; + +use super::super::Client; impl Client { /// Returns a [ListBuilder] to help pass options before calling [ListBuilder::send]. /// /// `url` sets the path you want to lest within. pub fn list(&self, url: T) -> Result { - self.inner_list(url) + Ok(ListBuilder::new(self, url)) + } +} + +/// Helper struct to edit Pubky homeserver's list API options before sending them. +#[derive(Debug)] +pub struct ListBuilder<'a> { + url: String, + reverse: bool, + limit: Option, + cursor: Option<&'a str>, + client: &'a Client, + shallow: bool, +} + +impl<'a> ListBuilder<'a> { + /// Create a new List request builder + pub(crate) fn new(client: &'a Client, url: T) -> Self { + Self { + client, + url: url.as_str().to_string(), + limit: None, + cursor: None, + reverse: false, + shallow: false, + } + } + + /// Set the `reverse` option. + pub fn reverse(mut self, reverse: bool) -> Self { + self.reverse = reverse; + self + } + + /// Set the `limit` value. + pub fn limit(mut self, limit: u16) -> Self { + self.limit = limit.into(); + self + } + + /// Set the `cursor` value. + /// + /// Either a full `pubky://` Url (from previous list response), + /// or a path (to a file or directory) relative to the `url` + pub fn cursor(mut self, cursor: &'a str) -> Self { + self.cursor = cursor.into(); + self + } + + pub fn shallow(mut self, shallow: bool) -> Self { + self.shallow = shallow; + self + } + + /// Send the list request. + /// + /// Returns a list of Pubky URLs of the files in the path of the `url` + /// respecting [ListBuilder::reverse], [ListBuilder::limit] and [ListBuilder::cursor] + /// options. + pub async fn send(self) -> Result> { + let mut url = url::Url::parse(&self.url)?; + + if !url.path().ends_with('/') { + let path = url.path().to_string(); + let mut parts = path.split('/').collect::>(); + parts.pop(); + + let path = format!("{}/", parts.join("/")); + + url.set_path(&path) + } + + let mut query = url.query_pairs_mut(); + + if self.reverse { + query.append_key_only("reverse"); + } + + if self.shallow { + query.append_key_only("shallow"); + } + + if let Some(limit) = self.limit { + query.append_pair("limit", &limit.to_string()); + } + + if let Some(cursor) = self.cursor { + query.append_pair("cursor", cursor); + } + + drop(query); + + let response = self + .client + .cross_request(Method::GET, url) + .await + .send() + .await?; + + handle_http_error!(response); + + // TODO: bail on too large files. + let bytes = response.bytes().await?; + + Ok(String::from_utf8_lossy(&bytes) + .lines() + .map(String::from) + .collect()) + } +} + +#[cfg(test)] +mod tests { + use crate::*; + + use bytes::Bytes; + use mainline::Testnet; + use pkarr::Keypair; + use pubky_homeserver::Homeserver; + use reqwest::{Method, StatusCode}; + + #[tokio::test] + async fn put_get_delete() { + let testnet = Testnet::new(10).unwrap(); + let server = Homeserver::start_test(&testnet).await.unwrap(); + + let client = Client::test(&testnet); + + let keypair = Keypair::random(); + + client.signup(&keypair, &server.public_key()).await.unwrap(); + + let url = format!("pubky://{}/pub/foo.txt", keypair.public_key()); + let url = url.as_str(); + + client + .put(url) + .body(vec![0, 1, 2, 3, 4]) + .send() + .await + .unwrap() + .error_for_status() + .unwrap(); + + let response = client.get(url).send().await.unwrap().bytes().await.unwrap(); + + assert_eq!(response, bytes::Bytes::from(vec![0, 1, 2, 3, 4])); + + client + .delete(url) + .send() + .await + .unwrap() + .error_for_status() + .unwrap(); + + let response = client.get(url).send().await.unwrap(); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn unauthorized_put_delete() { + let testnet = Testnet::new(10).unwrap(); + let server = Homeserver::start_test(&testnet).await.unwrap(); + + let client = Client::test(&testnet); + + let keypair = Keypair::random(); + + client.signup(&keypair, &server.public_key()).await.unwrap(); + + let public_key = keypair.public_key(); + + let url = format!("pubky://{public_key}/pub/foo.txt"); + let url = url.as_str(); + + let other_client = Client::test(&testnet); + { + let other = Keypair::random(); + + // TODO: remove extra client after switching to subdomains. + other_client + .signup(&other, &server.public_key()) + .await + .unwrap(); + + assert_eq!( + other_client + .put(url) + .body(vec![0, 1, 2, 3, 4]) + .send() + .await + .unwrap() + .status(), + StatusCode::UNAUTHORIZED + ); + } + + client + .put(url) + .body(vec![0, 1, 2, 3, 4]) + .send() + .await + .unwrap(); + + { + let other = Keypair::random(); + + // TODO: remove extra client after switching to subdomains. + other_client + .signup(&other, &server.public_key()) + .await + .unwrap(); + + assert_eq!( + other_client.delete(url).send().await.unwrap().status(), + StatusCode::UNAUTHORIZED + ); + } + + let response = client.get(url).send().await.unwrap().bytes().await.unwrap(); + + assert_eq!(response, bytes::Bytes::from(vec![0, 1, 2, 3, 4])); + } + + #[tokio::test] + async fn list() { + let testnet = Testnet::new(10).unwrap(); + let server = Homeserver::start_test(&testnet).await.unwrap(); + + let client = Client::test(&testnet); + + let keypair = Keypair::random(); + + client.signup(&keypair, &server.public_key()).await.unwrap(); + + let pubky = keypair.public_key(); + + let urls = vec![ + format!("pubky://{pubky}/pub/a.wrong/a.txt"), + format!("pubky://{pubky}/pub/example.com/a.txt"), + format!("pubky://{pubky}/pub/example.com/b.txt"), + format!("pubky://{pubky}/pub/example.com/cc-nested/z.txt"), + format!("pubky://{pubky}/pub/example.wrong/a.txt"), + format!("pubky://{pubky}/pub/example.com/c.txt"), + format!("pubky://{pubky}/pub/example.com/d.txt"), + format!("pubky://{pubky}/pub/z.wrong/a.txt"), + ]; + + for url in urls { + client.put(url).body(vec![0]).send().await.unwrap(); + } + + let url = format!("pubky://{pubky}/pub/example.com/extra"); + + { + let list = client.list(&url).unwrap().send().await.unwrap(); + + assert_eq!( + list, + vec![ + format!("pubky://{pubky}/pub/example.com/a.txt"), + format!("pubky://{pubky}/pub/example.com/b.txt"), + format!("pubky://{pubky}/pub/example.com/c.txt"), + format!("pubky://{pubky}/pub/example.com/cc-nested/z.txt"), + format!("pubky://{pubky}/pub/example.com/d.txt"), + ], + "normal list with no limit or cursor" + ); + } + + { + let list = client.list(&url).unwrap().limit(2).send().await.unwrap(); + + assert_eq!( + list, + vec![ + format!("pubky://{pubky}/pub/example.com/a.txt"), + format!("pubky://{pubky}/pub/example.com/b.txt"), + ], + "normal list with limit but no cursor" + ); + } + + { + let list = client + .list(&url) + .unwrap() + .limit(2) + .cursor("a.txt") + .send() + .await + .unwrap(); + + assert_eq!( + list, + vec![ + format!("pubky://{pubky}/pub/example.com/b.txt"), + format!("pubky://{pubky}/pub/example.com/c.txt"), + ], + "normal list with limit and a file cursor" + ); + } + + { + let list = client + .list(&url) + .unwrap() + .limit(2) + .cursor("cc-nested/") + .send() + .await + .unwrap(); + + assert_eq!( + list, + vec![ + format!("pubky://{pubky}/pub/example.com/cc-nested/z.txt"), + format!("pubky://{pubky}/pub/example.com/d.txt"), + ], + "normal list with limit and a directory cursor" + ); + } + + { + let list = client + .list(&url) + .unwrap() + .limit(2) + .cursor(&format!("pubky://{pubky}/pub/example.com/a.txt")) + .send() + .await + .unwrap(); + + assert_eq!( + list, + vec![ + format!("pubky://{pubky}/pub/example.com/b.txt"), + format!("pubky://{pubky}/pub/example.com/c.txt"), + ], + "normal list with limit and a full url cursor" + ); + } + + { + let list = client + .list(&url) + .unwrap() + .limit(2) + .cursor("/a.txt") + .send() + .await + .unwrap(); + + assert_eq!( + list, + vec![ + format!("pubky://{pubky}/pub/example.com/b.txt"), + format!("pubky://{pubky}/pub/example.com/c.txt"), + ], + "normal list with limit and a leading / cursor" + ); + } + + { + let list = client + .list(&url) + .unwrap() + .reverse(true) + .send() + .await + .unwrap(); + + assert_eq!( + list, + vec![ + format!("pubky://{pubky}/pub/example.com/d.txt"), + format!("pubky://{pubky}/pub/example.com/cc-nested/z.txt"), + format!("pubky://{pubky}/pub/example.com/c.txt"), + format!("pubky://{pubky}/pub/example.com/b.txt"), + format!("pubky://{pubky}/pub/example.com/a.txt"), + ], + "reverse list with no limit or cursor" + ); + } + + { + let list = client + .list(&url) + .unwrap() + .reverse(true) + .limit(2) + .send() + .await + .unwrap(); + + assert_eq!( + list, + vec![ + format!("pubky://{pubky}/pub/example.com/d.txt"), + format!("pubky://{pubky}/pub/example.com/cc-nested/z.txt"), + ], + "reverse list with limit but no cursor" + ); + } + + { + let list = client + .list(&url) + .unwrap() + .reverse(true) + .limit(2) + .cursor("d.txt") + .send() + .await + .unwrap(); + + assert_eq!( + list, + vec![ + format!("pubky://{pubky}/pub/example.com/cc-nested/z.txt"), + format!("pubky://{pubky}/pub/example.com/c.txt"), + ], + "reverse list with limit and cursor" + ); + } + } + + #[tokio::test] + async fn list_shallow() { + let testnet = Testnet::new(10).unwrap(); + let server = Homeserver::start_test(&testnet).await.unwrap(); + + let client = Client::test(&testnet); + + let keypair = Keypair::random(); + + client.signup(&keypair, &server.public_key()).await.unwrap(); + + let pubky = keypair.public_key(); + + let urls = vec![ + format!("pubky://{pubky}/pub/a.com/a.txt"), + format!("pubky://{pubky}/pub/example.com/a.txt"), + format!("pubky://{pubky}/pub/example.com/b.txt"), + format!("pubky://{pubky}/pub/example.com/c.txt"), + format!("pubky://{pubky}/pub/example.com/d.txt"), + format!("pubky://{pubky}/pub/example.con/d.txt"), + format!("pubky://{pubky}/pub/example.con"), + format!("pubky://{pubky}/pub/file"), + format!("pubky://{pubky}/pub/file2"), + format!("pubky://{pubky}/pub/z.com/a.txt"), + ]; + + for url in urls { + client.put(url).body(vec![0]).send().await.unwrap(); + } + + let url = format!("pubky://{pubky}/pub/"); + + { + let list = client + .list(&url) + .unwrap() + .shallow(true) + .send() + .await + .unwrap(); + + assert_eq!( + list, + vec![ + format!("pubky://{pubky}/pub/a.com/"), + format!("pubky://{pubky}/pub/example.com/"), + format!("pubky://{pubky}/pub/example.con"), + format!("pubky://{pubky}/pub/example.con/"), + format!("pubky://{pubky}/pub/file"), + format!("pubky://{pubky}/pub/file2"), + format!("pubky://{pubky}/pub/z.com/"), + ], + "normal list shallow" + ); + } + + { + let list = client + .list(&url) + .unwrap() + .shallow(true) + .limit(2) + .send() + .await + .unwrap(); + + assert_eq!( + list, + vec![ + format!("pubky://{pubky}/pub/a.com/"), + format!("pubky://{pubky}/pub/example.com/"), + ], + "normal list shallow with limit but no cursor" + ); + } + + { + let list = client + .list(&url) + .unwrap() + .shallow(true) + .limit(2) + .cursor("example.com/a.txt") + .send() + .await + .unwrap(); + + assert_eq!( + list, + vec![ + format!("pubky://{pubky}/pub/example.com/"), + format!("pubky://{pubky}/pub/example.con"), + ], + "normal list shallow with limit and a file cursor" + ); + } + + { + let list = client + .list(&url) + .unwrap() + .shallow(true) + .limit(3) + .cursor("example.com/") + .send() + .await + .unwrap(); + + assert_eq!( + list, + vec![ + format!("pubky://{pubky}/pub/example.con"), + format!("pubky://{pubky}/pub/example.con/"), + format!("pubky://{pubky}/pub/file"), + ], + "normal list shallow with limit and a directory cursor" + ); + } + + { + let list = client + .list(&url) + .unwrap() + .reverse(true) + .shallow(true) + .send() + .await + .unwrap(); + + assert_eq!( + list, + vec![ + format!("pubky://{pubky}/pub/z.com/"), + format!("pubky://{pubky}/pub/file2"), + format!("pubky://{pubky}/pub/file"), + format!("pubky://{pubky}/pub/example.con/"), + format!("pubky://{pubky}/pub/example.con"), + format!("pubky://{pubky}/pub/example.com/"), + format!("pubky://{pubky}/pub/a.com/"), + ], + "reverse list shallow" + ); + } + + { + let list = client + .list(&url) + .unwrap() + .reverse(true) + .shallow(true) + .limit(2) + .send() + .await + .unwrap(); + + assert_eq!( + list, + vec![ + format!("pubky://{pubky}/pub/z.com/"), + format!("pubky://{pubky}/pub/file2"), + ], + "reverse list shallow with limit but no cursor" + ); + } + + { + let list = client + .list(&url) + .unwrap() + .shallow(true) + .reverse(true) + .limit(2) + .cursor("file2") + .send() + .await + .unwrap(); + + assert_eq!( + list, + vec![ + format!("pubky://{pubky}/pub/file"), + format!("pubky://{pubky}/pub/example.con/"), + ], + "reverse list shallow with limit and a file cursor" + ); + } + + { + let list = client + .list(&url) + .unwrap() + .shallow(true) + .reverse(true) + .limit(2) + .cursor("example.con/") + .send() + .await + .unwrap(); + + assert_eq!( + list, + vec![ + format!("pubky://{pubky}/pub/example.con"), + format!("pubky://{pubky}/pub/example.com/"), + ], + "reverse list shallow with limit and a directory cursor" + ); + } + } + + #[tokio::test] + async fn list_events() { + let testnet = Testnet::new(10).unwrap(); + let server = Homeserver::start_test(&testnet).await.unwrap(); + + let client = Client::test(&testnet); + + let keypair = Keypair::random(); + + client.signup(&keypair, &server.public_key()).await.unwrap(); + + let pubky = keypair.public_key(); + + let urls = vec![ + format!("pubky://{pubky}/pub/a.com/a.txt"), + format!("pubky://{pubky}/pub/example.com/a.txt"), + format!("pubky://{pubky}/pub/example.com/b.txt"), + format!("pubky://{pubky}/pub/example.com/c.txt"), + format!("pubky://{pubky}/pub/example.com/d.txt"), + format!("pubky://{pubky}/pub/example.con/d.txt"), + format!("pubky://{pubky}/pub/example.con"), + format!("pubky://{pubky}/pub/file"), + format!("pubky://{pubky}/pub/file2"), + format!("pubky://{pubky}/pub/z.com/a.txt"), + ]; + + for url in urls { + client.put(&url).body(vec![0]).send().await.unwrap(); + client.delete(url).send().await.unwrap(); + } + + let feed_url = format!("https://{}/events/", server.public_key()); + + let client = Client::test(&testnet); + + let cursor; + + { + let response = client + .request(Method::GET, format!("{feed_url}?limit=10")) + .send() + .await + .unwrap(); + + let text = response.text().await.unwrap(); + let lines = text.split('\n').collect::>(); + + cursor = lines.last().unwrap().split(" ").last().unwrap().to_string(); + + assert_eq!( + lines, + vec![ + format!("PUT pubky://{pubky}/pub/a.com/a.txt"), + format!("DEL pubky://{pubky}/pub/a.com/a.txt"), + format!("PUT pubky://{pubky}/pub/example.com/a.txt"), + format!("DEL pubky://{pubky}/pub/example.com/a.txt"), + format!("PUT pubky://{pubky}/pub/example.com/b.txt"), + format!("DEL pubky://{pubky}/pub/example.com/b.txt"), + format!("PUT pubky://{pubky}/pub/example.com/c.txt"), + format!("DEL pubky://{pubky}/pub/example.com/c.txt"), + format!("PUT pubky://{pubky}/pub/example.com/d.txt"), + format!("DEL pubky://{pubky}/pub/example.com/d.txt"), + format!("cursor: {cursor}",) + ] + ); + } + + { + let response = client + .request(Method::GET, format!("{feed_url}?limit=10&cursor={cursor}")) + .send() + .await + .unwrap(); + + let text = response.text().await.unwrap(); + let lines = text.split('\n').collect::>(); + + assert_eq!( + lines, + vec![ + format!("PUT pubky://{pubky}/pub/example.con/d.txt"), + format!("DEL pubky://{pubky}/pub/example.con/d.txt"), + format!("PUT pubky://{pubky}/pub/example.con"), + format!("DEL pubky://{pubky}/pub/example.con"), + format!("PUT pubky://{pubky}/pub/file"), + format!("DEL pubky://{pubky}/pub/file"), + format!("PUT pubky://{pubky}/pub/file2"), + format!("DEL pubky://{pubky}/pub/file2"), + format!("PUT pubky://{pubky}/pub/z.com/a.txt"), + format!("DEL pubky://{pubky}/pub/z.com/a.txt"), + lines.last().unwrap().to_string() + ] + ) + } + } + + #[tokio::test] + async fn read_after_event() { + let testnet = Testnet::new(10).unwrap(); + let server = Homeserver::start_test(&testnet).await.unwrap(); + + let client = Client::test(&testnet); + + let keypair = Keypair::random(); + + client.signup(&keypair, &server.public_key()).await.unwrap(); + + let pubky = keypair.public_key(); + + let url = format!("pubky://{pubky}/pub/a.com/a.txt"); + + client.put(&url).body(vec![0]).send().await.unwrap(); + + let feed_url = format!("https://{}/events/", server.public_key()); + + let client = Client::test(&testnet); + + { + let response = client + .request(Method::GET, format!("{feed_url}?limit=10")) + .send() + .await + .unwrap(); + + let text = response.text().await.unwrap(); + let lines = text.split('\n').collect::>(); + + let cursor = lines.last().unwrap().split(" ").last().unwrap().to_string(); + + assert_eq!( + lines, + vec![ + format!("PUT pubky://{pubky}/pub/a.com/a.txt"), + format!("cursor: {cursor}",) + ] + ); + } + + let response = client.get(url).send().await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + + let body = response.bytes().await.unwrap(); + + assert_eq!(body.as_ref(), &[0]); + } + + #[tokio::test] + async fn dont_delete_shared_blobs() { + let testnet = Testnet::new(10).unwrap(); + let homeserver = Homeserver::start_test(&testnet).await.unwrap(); + let client = Client::test(&testnet); + + let homeserver_pubky = homeserver.public_key(); + + let user_1 = Keypair::random(); + let user_2 = Keypair::random(); + + client.signup(&user_1, &homeserver_pubky).await.unwrap(); + client.signup(&user_2, &homeserver_pubky).await.unwrap(); + + let user_1_id = user_1.public_key(); + let user_2_id = user_2.public_key(); + + let url_1 = format!("pubky://{user_1_id}/pub/pubky.app/file/file_1"); + let url_2 = format!("pubky://{user_2_id}/pub/pubky.app/file/file_1"); + + let file = vec![1]; + client.put(&url_1).body(file.clone()).send().await.unwrap(); + client.put(&url_2).body(file.clone()).send().await.unwrap(); + + // Delete file 1 + client + .delete(url_1) + .send() + .await + .unwrap() + .error_for_status() + .unwrap(); + + let blob = client + .get(url_2) + .send() + .await + .unwrap() + .bytes() + .await + .unwrap(); + + assert_eq!(blob, file); + + let feed_url = format!("https://{}/events/", homeserver.public_key()); + + let response = client + .request(Method::GET, feed_url) + .send() + .await + .unwrap() + .error_for_status() + .unwrap(); + + let text = response.text().await.unwrap(); + let lines = text.split('\n').collect::>(); + + assert_eq!( + lines, + vec![ + format!("PUT pubky://{user_1_id}/pub/pubky.app/file/file_1",), + format!("PUT pubky://{user_2_id}/pub/pubky.app/file/file_1",), + format!("DEL pubky://{user_1_id}/pub/pubky.app/file/file_1",), + lines.last().unwrap().to_string() + ] + ); + } + + #[tokio::test] + async fn stream() { + // TODO: test better streaming API + + let testnet = Testnet::new(10).unwrap(); + let server = Homeserver::start_test(&testnet).await.unwrap(); + + let client = Client::test(&testnet); + + let keypair = Keypair::random(); + + client.signup(&keypair, &server.public_key()).await.unwrap(); + + let url = format!("pubky://{}/pub/foo.txt", keypair.public_key()); + let url = url.as_str(); + + let bytes = Bytes::from(vec![0; 1024 * 1024]); + + client.put(url).body(bytes.clone()).send().await.unwrap(); + + let response = client.get(url).send().await.unwrap().bytes().await.unwrap(); + + assert_eq!(response, bytes); + + client.delete(url).send().await.unwrap(); + + let response = client.get(url).send().await.unwrap(); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); } } diff --git a/pubky/src/native/api/recovery_file.rs b/pubky/src/native/api/recovery_file.rs deleted file mode 100644 index 2d05190..0000000 --- a/pubky/src/native/api/recovery_file.rs +++ /dev/null @@ -1,21 +0,0 @@ -use pubky_common::{ - crypto::Keypair, - recovery_file::{create_recovery_file, decrypt_recovery_file}, -}; - -use anyhow::Result; - -use crate::Client; - -impl Client { - /// Create a recovery file of the `keypair`, containing the secret key encrypted - /// using the `passphrase`. - pub fn create_recovery_file(keypair: &Keypair, passphrase: &str) -> Result> { - Ok(create_recovery_file(keypair, passphrase)?) - } - - /// Recover a keypair from a recovery file by decrypting the secret key using `passphrase`. - pub fn decrypt_recovery_file(recovery_file: &[u8], passphrase: &str) -> Result { - Ok(decrypt_recovery_file(recovery_file, passphrase)?) - } -} diff --git a/pubky/src/native/cookies.rs b/pubky/src/native/internal/cookies.rs similarity index 99% rename from pubky/src/native/cookies.rs rename to pubky/src/native/internal/cookies.rs index d57bb00..8dd5872 100644 --- a/pubky/src/native/cookies.rs +++ b/pubky/src/native/internal/cookies.rs @@ -3,7 +3,7 @@ use std::{collections::HashMap, sync::RwLock}; use pkarr::PublicKey; use reqwest::{cookie::CookieStore, header::HeaderValue, Response}; -#[derive(Default)] +#[derive(Default, Debug)] pub struct CookieJar { pubky_sessions: RwLock>, normal_jar: RwLock, diff --git a/pubky/src/native/internal/pkarr.rs b/pubky/src/native/internal/pkarr.rs new file mode 100644 index 0000000..0b7dfd8 --- /dev/null +++ b/pubky/src/native/internal/pkarr.rs @@ -0,0 +1,36 @@ +use pkarr::{dns::rdata::SVCB, Keypair, SignedPacket}; + +use anyhow::Result; + +use super::super::Client; + +impl Client { + /// Publish the HTTPS record for `_pubky.`. + pub(crate) async fn publish_homeserver(&self, keypair: &Keypair, host: &str) -> Result<()> { + // TODO: Before making public, consider the effect on other records and other mirrors + + let existing = self.pkarr.resolve_most_recent(&keypair.public_key()).await; + + let mut signed_packet_builder = SignedPacket::builder(); + + if let Some(ref existing) = existing { + for answer in existing.resource_records("_pubky") { + if !answer.name.to_string().starts_with("_pubky") { + signed_packet_builder = signed_packet_builder.record(answer.to_owned()); + } + } + } + + let svcb = SVCB::new(0, host.try_into()?); + + let signed_packet = SignedPacket::builder() + .https("_pubky".try_into().unwrap(), svcb, 60 * 60) + .sign(keypair)?; + + self.pkarr + .publish(&signed_packet, existing.map(|s| s.timestamp())) + .await?; + + Ok(()) + } +} diff --git a/pubky/src/shared/auth.rs b/pubky/src/shared/auth.rs deleted file mode 100644 index d9a9e62..0000000 --- a/pubky/src/shared/auth.rs +++ /dev/null @@ -1,390 +0,0 @@ -use std::collections::HashMap; - -use base64::{alphabet::URL_SAFE, engine::general_purpose::NO_PAD, Engine}; -use reqwest::{IntoUrl, Method, StatusCode}; -use url::Url; - -use pkarr::{Keypair, PublicKey}; -use pubky_common::{ - auth::AuthToken, - capabilities::{Capabilities, Capability}, - crypto::{decrypt, encrypt, hash, random_bytes}, - session::Session, -}; - -use anyhow::Result; - -use crate::{handle_http_error, Client}; - -impl Client { - /// Signup to a homeserver and update Pkarr accordingly. - /// - /// The homeserver is a Pkarr domain name, where the TLD is a Pkarr public key - /// for example "pubky.o4dksfbqk85ogzdb5osziw6befigbuxmuxkuxq8434q89uj56uyy" - pub(crate) async fn inner_signup( - &self, - keypair: &Keypair, - homeserver: &PublicKey, - ) -> Result { - let response = self - .inner_request(Method::POST, format!("https://{}/signup", homeserver)) - .await - .body(AuthToken::sign(keypair, vec![Capability::root()]).serialize()) - .send() - .await?; - - handle_http_error!(response); - - self.publish_homeserver(keypair, &homeserver.to_string()) - .await?; - - // Store the cookie to the correct URL. - #[cfg(not(target_arch = "wasm32"))] - self.cookie_store - .store_session_after_signup(&response, &keypair.public_key()); - - let bytes = response.bytes().await?; - - Ok(Session::deserialize(&bytes)?) - } - - /// Check the current sesison for a given Pubky in its homeserver. - /// - /// Returns None if not signed in, or [reqwest::Error] - /// if the response has any other `>=404` status code. - pub(crate) async fn inner_session(&self, pubky: &PublicKey) -> Result> { - let response = self - .inner_request(Method::GET, format!("pubky://{}/session", pubky)) - .await - .send() - .await?; - - if response.status() == StatusCode::NOT_FOUND { - return Ok(None); - } - - handle_http_error!(response); - - let bytes = response.bytes().await?; - - Ok(Some(Session::deserialize(&bytes)?)) - } - - /// Signout from a homeserver. - pub(crate) async fn inner_signout(&self, pubky: &PublicKey) -> Result<()> { - let response = self - .inner_request(Method::DELETE, format!("pubky://{}/session", pubky)) - .await - .send() - .await?; - - handle_http_error!(response); - - #[cfg(not(target_arch = "wasm32"))] - self.cookie_store.delete_session_after_signout(pubky); - - Ok(()) - } - - /// Signin to a homeserver. - pub(crate) async fn inner_signin(&self, keypair: &Keypair) -> Result { - let token = AuthToken::sign(keypair, vec![Capability::root()]); - - self.signin_with_authtoken(&token).await - } - - pub(crate) async fn inner_send_auth_token( - &self, - keypair: &Keypair, - pubkyauth_url: T, - ) -> Result<()> { - let pubkyauth_url = Url::parse( - pubkyauth_url - .as_str() - .replace("pubkyauth_url", "http") - .as_str(), - )?; - - let query_params: HashMap = - pubkyauth_url.query_pairs().into_owned().collect(); - - let relay = query_params - .get("relay") - .map(|r| url::Url::parse(r).expect("Relay query param to be valid URL")) - .expect("Missing relay query param"); - - let client_secret = query_params - .get("secret") - .map(|s| { - let engine = base64::engine::GeneralPurpose::new(&URL_SAFE, NO_PAD); - let bytes = engine.decode(s).expect("invalid client_secret"); - let arr: [u8; 32] = bytes.try_into().expect("invalid client_secret"); - - arr - }) - .expect("Missing client secret"); - - let capabilities = query_params - .get("caps") - .map(|caps_string| { - caps_string - .split(',') - .filter_map(|cap| Capability::try_from(cap).ok()) - .collect::>() - }) - .unwrap_or_default(); - - let token = AuthToken::sign(keypair, capabilities); - - let encrypted_token = encrypt(&token.serialize(), &client_secret)?; - - let engine = base64::engine::GeneralPurpose::new(&URL_SAFE, NO_PAD); - - let mut callback_url = relay.clone(); - let mut path_segments = callback_url.path_segments_mut().unwrap(); - path_segments.pop_if_empty(); - let channel_id = engine.encode(hash(&client_secret).as_bytes()); - path_segments.push(&channel_id); - drop(path_segments); - - let response = self - .inner_request(Method::POST, callback_url) - .await - .body(encrypted_token) - .send() - .await?; - - handle_http_error!(response); - - Ok(()) - } - - pub(crate) async fn signin_with_authtoken(&self, token: &AuthToken) -> Result { - let response = self - .inner_request(Method::POST, format!("pubky://{}/session", token.pubky())) - .await - .body(token.serialize()) - .send() - .await?; - - handle_http_error!(response); - - let bytes = response.bytes().await?; - - Ok(Session::deserialize(&bytes)?) - } - - pub(crate) fn create_auth_request( - &self, - relay: &mut Url, - capabilities: &Capabilities, - ) -> Result<(Url, [u8; 32])> { - let engine = base64::engine::GeneralPurpose::new(&URL_SAFE, NO_PAD); - - let client_secret: [u8; 32] = random_bytes::<32>(); - - let pubkyauth_url = Url::parse(&format!( - "pubkyauth:///?caps={capabilities}&secret={}&relay={relay}", - engine.encode(client_secret) - ))?; - - let mut segments = relay - .path_segments_mut() - .map_err(|_| anyhow::anyhow!("Invalid relay"))?; - - // remove trailing slash if any. - segments.pop_if_empty(); - let channel_id = &engine.encode(hash(&client_secret).as_bytes()); - segments.push(channel_id); - drop(segments); - - Ok((pubkyauth_url, client_secret)) - } - - pub(crate) async fn subscribe_to_auth_response( - &self, - relay: Url, - client_secret: &[u8; 32], - ) -> Result { - // TODO: use a clearnet client. - let response = reqwest::get(relay).await?; - let encrypted_token = response.bytes().await?; - let token_bytes = decrypt(&encrypted_token, client_secret) - .map_err(|e| anyhow::anyhow!("Got invalid token: {e}"))?; - let token = AuthToken::verify(&token_bytes)?; - - if !token.capabilities().is_empty() { - self.signin_with_authtoken(&token).await?; - } - - Ok(token.pubky().clone()) - } -} - -#[cfg(test)] -mod tests { - use crate::*; - - use http_relay::HttpRelay; - use mainline::Testnet; - use pkarr::Keypair; - use pubky_common::capabilities::{Capabilities, Capability}; - use pubky_homeserver::Homeserver; - use reqwest::StatusCode; - - #[tokio::test] - async fn basic_authn() { - let testnet = Testnet::new(10).unwrap(); - let server = Homeserver::start_test(&testnet).await.unwrap(); - - let client = Client::test(&testnet); - - let keypair = Keypair::random(); - - client.signup(&keypair, &server.public_key()).await.unwrap(); - - let session = client - .session(&keypair.public_key()) - .await - .unwrap() - .unwrap(); - - assert!(session.capabilities().contains(&Capability::root())); - - client.signout(&keypair.public_key()).await.unwrap(); - - { - let session = client.session(&keypair.public_key()).await.unwrap(); - - assert!(session.is_none()); - } - - client.signin(&keypair).await.unwrap(); - - { - let session = client - .session(&keypair.public_key()) - .await - .unwrap() - .unwrap(); - - assert_eq!(session.pubky(), &keypair.public_key()); - assert!(session.capabilities().contains(&Capability::root())); - } - } - - #[tokio::test] - async fn authz() { - let testnet = Testnet::new(10).unwrap(); - let server = Homeserver::start_test(&testnet).await.unwrap(); - - let http_relay = HttpRelay::builder().build().await.unwrap(); - let http_relay_url = http_relay.local_link_url(); - - let keypair = Keypair::random(); - let pubky = keypair.public_key(); - - // Third party app side - let capabilities: Capabilities = - "/pub/pubky.app/:rw,/pub/foo.bar/file:r".try_into().unwrap(); - - let client = Client::test(&testnet); - - let (pubkyauth_url, pubkyauth_response) = - client.auth_request(http_relay_url, &capabilities).unwrap(); - - // Authenticator side - { - let client = Client::test(&testnet); - - client.signup(&keypair, &server.public_key()).await.unwrap(); - - client - .send_auth_token(&keypair, pubkyauth_url) - .await - .unwrap(); - } - - let public_key = pubkyauth_response - .await - .expect("sender to not be dropped") - .unwrap(); - - assert_eq!(&public_key, &pubky); - - let session = client.session(&pubky).await.unwrap().unwrap(); - assert_eq!(session.capabilities(), &capabilities.0); - - // Test access control enforcement - - client - .put(format!("pubky://{pubky}/pub/pubky.app/foo")) - .body(vec![]) - .send() - .await - .unwrap() - .error_for_status() - .unwrap(); - - assert_eq!( - client - .put(format!("pubky://{pubky}/pub/pubky.app")) - .body(vec![]) - .send() - .await - .unwrap() - .status(), - StatusCode::FORBIDDEN - ); - - assert_eq!( - client - .put(format!("pubky://{pubky}/pub/foo.bar/file")) - .body(vec![]) - .send() - .await - .unwrap() - .status(), - StatusCode::FORBIDDEN - ); - } - - #[tokio::test] - async fn multiple_users() { - let testnet = Testnet::new(10).unwrap(); - let server = Homeserver::start_test(&testnet).await.unwrap(); - - let client = Client::test(&testnet); - - let first_keypair = Keypair::random(); - let second_keypair = Keypair::random(); - - client - .signup(&first_keypair, &server.public_key()) - .await - .unwrap(); - - client - .signup(&second_keypair, &server.public_key()) - .await - .unwrap(); - - let session = client - .session(&first_keypair.public_key()) - .await - .unwrap() - .unwrap(); - - assert_eq!(session.pubky(), &first_keypair.public_key()); - assert!(session.capabilities().contains(&Capability::root())); - - let session = client - .session(&second_keypair.public_key()) - .await - .unwrap() - .unwrap(); - - assert_eq!(session.pubky(), &second_keypair.public_key()); - assert!(session.capabilities().contains(&Capability::root())); - } -} diff --git a/pubky/src/shared/list_builder.rs b/pubky/src/shared/list_builder.rs deleted file mode 100644 index cc31860..0000000 --- a/pubky/src/shared/list_builder.rs +++ /dev/null @@ -1,112 +0,0 @@ -use reqwest::{IntoUrl, Method}; - -use anyhow::Result; - -use crate::{handle_http_error, Client}; - -/// Helper struct to edit Pubky homeserver's list API options before sending them. -#[derive(Debug)] -pub struct ListBuilder<'a> { - url: String, - reverse: bool, - limit: Option, - cursor: Option<&'a str>, - client: &'a Client, - shallow: bool, -} - -impl<'a> ListBuilder<'a> { - /// Create a new List request builder - pub(crate) fn new(client: &'a Client, url: T) -> Self { - Self { - client, - url: url.as_str().to_string(), - limit: None, - cursor: None, - reverse: false, - shallow: false, - } - } - - /// Set the `reverse` option. - pub fn reverse(mut self, reverse: bool) -> Self { - self.reverse = reverse; - self - } - - /// Set the `limit` value. - pub fn limit(mut self, limit: u16) -> Self { - self.limit = limit.into(); - self - } - - /// Set the `cursor` value. - /// - /// Either a full `pubky://` Url (from previous list response), - /// or a path (to a file or directory) relative to the `url` - pub fn cursor(mut self, cursor: &'a str) -> Self { - self.cursor = cursor.into(); - self - } - - pub fn shallow(mut self, shallow: bool) -> Self { - self.shallow = shallow; - self - } - - /// Send the list request. - /// - /// Returns a list of Pubky URLs of the files in the path of the `url` - /// respecting [ListBuilder::reverse], [ListBuilder::limit] and [ListBuilder::cursor] - /// options. - pub async fn send(self) -> Result> { - let mut url = url::Url::parse(&self.url)?; - - if !url.path().ends_with('/') { - let path = url.path().to_string(); - let mut parts = path.split('/').collect::>(); - parts.pop(); - - let path = format!("{}/", parts.join("/")); - - url.set_path(&path) - } - - let mut query = url.query_pairs_mut(); - - if self.reverse { - query.append_key_only("reverse"); - } - - if self.shallow { - query.append_key_only("shallow"); - } - - if let Some(limit) = self.limit { - query.append_pair("limit", &limit.to_string()); - } - - if let Some(cursor) = self.cursor { - query.append_pair("cursor", cursor); - } - - drop(query); - - let response = self - .client - .inner_request(Method::GET, url) - .await - .send() - .await?; - - handle_http_error!(response); - - // TODO: bail on too large files. - let bytes = response.bytes().await?; - - Ok(String::from_utf8_lossy(&bytes) - .lines() - .map(String::from) - .collect()) - } -} diff --git a/pubky/src/shared/mod.rs b/pubky/src/shared/mod.rs deleted file mode 100644 index 63ca268..0000000 --- a/pubky/src/shared/mod.rs +++ /dev/null @@ -1,16 +0,0 @@ -pub mod auth; -pub mod list_builder; -pub mod pkarr; -pub mod public; - -#[macro_export] -macro_rules! handle_http_error { - ($res:expr) => { - if let Err(status) = $res.error_for_status_ref() { - return match $res.text().await { - Ok(text) => Err(anyhow::anyhow!("{status}. Error message: {text}")), - _ => Err(anyhow::anyhow!("{status}")), - }; - } - }; -} diff --git a/pubky/src/shared/pkarr.rs b/pubky/src/shared/pkarr.rs deleted file mode 100644 index 2a20fb8..0000000 --- a/pubky/src/shared/pkarr.rs +++ /dev/null @@ -1,81 +0,0 @@ -use pkarr::{dns::rdata::SVCB, Keypair, SignedPacket}; - -use anyhow::Result; - -use crate::Client; - -impl Client { - /// Publish the HTTPS record for `_pubky.`. - pub(crate) async fn publish_homeserver(&self, keypair: &Keypair, host: &str) -> Result<()> { - // TODO: Before making public, consider the effect on other records and other mirrors - - let existing = self.pkarr.resolve_most_recent(&keypair.public_key()).await; - - let mut signed_packet_builder = SignedPacket::builder(); - - if let Some(ref existing) = existing { - for answer in existing.resource_records("_pubky") { - if !answer.name.to_string().starts_with("_pubky") { - signed_packet_builder = signed_packet_builder.record(answer.to_owned()); - } - } - } - - let svcb = SVCB::new(0, host.try_into()?); - - let signed_packet = SignedPacket::builder() - .https("_pubky".try_into().unwrap(), svcb, 60 * 60) - .sign(keypair)?; - - self.pkarr - .publish(&signed_packet, existing.map(|s| s.timestamp())) - .await?; - - Ok(()) - } - - // pub(crate) resolve_icann_domain() { - // - // let original_url = url.as_str(); - // let mut url = Url::parse(original_url).expect("Invalid url in inner_request"); - // - // if url.scheme() == "pubky" { - // // TODO: use https for anything other than testnet - // url.set_scheme("http") - // .expect("couldn't replace pubky:// with http://"); - // url.set_host(Some(&format!("_pubky.{}", url.host_str().unwrap_or("")))) - // .expect("couldn't map pubk:// to https://_pubky."); - // } - // - // let qname = url.host_str().unwrap_or("").to_string(); - // - // if PublicKey::try_from(original_url).is_ok() { - // let mut stream = self.pkarr.resolve_https_endpoints(&qname); - // - // let mut so_far: Option = None; - // - // while let Some(endpoint) = stream.next().await { - // if let Some(ref e) = so_far { - // if e.domain() == "." && endpoint.domain() != "." { - // so_far = Some(endpoint); - // } - // } else { - // so_far = Some(endpoint) - // } - // } - // - // if let Some(e) = so_far { - // url.set_host(Some(e.domain())) - // .expect("coultdn't use the resolved endpoint's domain"); - // url.set_port(Some(e.port())) - // .expect("coultdn't use the resolved endpoint's port"); - // - // return self.http.request(method, url).fetch_credentials_include(); - // } else { - // // TODO: didn't find any domain, what to do? - // } - // } - // - // self.http.request(method, url).fetch_credentials_include() - // } -} diff --git a/pubky/src/shared/public.rs b/pubky/src/shared/public.rs deleted file mode 100644 index fc863f4..0000000 --- a/pubky/src/shared/public.rs +++ /dev/null @@ -1,787 +0,0 @@ -use reqwest::IntoUrl; - -use anyhow::Result; - -use crate::Client; - -use super::list_builder::ListBuilder; - -impl Client { - pub(crate) fn inner_list(&self, url: T) -> Result { - Ok(ListBuilder::new(self, url)) - } -} - -#[cfg(test)] -mod tests { - use crate::*; - - use bytes::Bytes; - use mainline::Testnet; - use pkarr::Keypair; - use pubky_homeserver::Homeserver; - use reqwest::{Method, StatusCode}; - - #[tokio::test] - async fn put_get_delete() { - let testnet = Testnet::new(10).unwrap(); - let server = Homeserver::start_test(&testnet).await.unwrap(); - - let client = Client::test(&testnet); - - let keypair = Keypair::random(); - - client.signup(&keypair, &server.public_key()).await.unwrap(); - - let url = format!("pubky://{}/pub/foo.txt", keypair.public_key()); - let url = url.as_str(); - - client - .put(url) - .body(vec![0, 1, 2, 3, 4]) - .send() - .await - .unwrap() - .error_for_status() - .unwrap(); - - let response = client.get(url).send().await.unwrap().bytes().await.unwrap(); - - assert_eq!(response, bytes::Bytes::from(vec![0, 1, 2, 3, 4])); - - client - .delete(url) - .send() - .await - .unwrap() - .error_for_status() - .unwrap(); - - let response = client.get(url).send().await.unwrap(); - - assert_eq!(response.status(), StatusCode::NOT_FOUND); - } - - #[tokio::test] - async fn unauthorized_put_delete() { - let testnet = Testnet::new(10).unwrap(); - let server = Homeserver::start_test(&testnet).await.unwrap(); - - let client = Client::test(&testnet); - - let keypair = Keypair::random(); - - client.signup(&keypair, &server.public_key()).await.unwrap(); - - let public_key = keypair.public_key(); - - let url = format!("pubky://{public_key}/pub/foo.txt"); - let url = url.as_str(); - - let other_client = Client::test(&testnet); - { - let other = Keypair::random(); - - // TODO: remove extra client after switching to subdomains. - other_client - .signup(&other, &server.public_key()) - .await - .unwrap(); - - assert_eq!( - other_client - .put(url) - .body(vec![0, 1, 2, 3, 4]) - .send() - .await - .unwrap() - .status(), - StatusCode::UNAUTHORIZED - ); - } - - client - .put(url) - .body(vec![0, 1, 2, 3, 4]) - .send() - .await - .unwrap(); - - { - let other = Keypair::random(); - - // TODO: remove extra client after switching to subdomains. - other_client - .signup(&other, &server.public_key()) - .await - .unwrap(); - - assert_eq!( - other_client.delete(url).send().await.unwrap().status(), - StatusCode::UNAUTHORIZED - ); - } - - let response = client.get(url).send().await.unwrap().bytes().await.unwrap(); - - assert_eq!(response, bytes::Bytes::from(vec![0, 1, 2, 3, 4])); - } - - #[tokio::test] - async fn list() { - let testnet = Testnet::new(10).unwrap(); - let server = Homeserver::start_test(&testnet).await.unwrap(); - - let client = Client::test(&testnet); - - let keypair = Keypair::random(); - - client.signup(&keypair, &server.public_key()).await.unwrap(); - - let pubky = keypair.public_key(); - - let urls = vec![ - format!("pubky://{pubky}/pub/a.wrong/a.txt"), - format!("pubky://{pubky}/pub/example.com/a.txt"), - format!("pubky://{pubky}/pub/example.com/b.txt"), - format!("pubky://{pubky}/pub/example.com/cc-nested/z.txt"), - format!("pubky://{pubky}/pub/example.wrong/a.txt"), - format!("pubky://{pubky}/pub/example.com/c.txt"), - format!("pubky://{pubky}/pub/example.com/d.txt"), - format!("pubky://{pubky}/pub/z.wrong/a.txt"), - ]; - - for url in urls { - client.put(url).body(vec![0]).send().await.unwrap(); - } - - let url = format!("pubky://{pubky}/pub/example.com/extra"); - - { - let list = client.list(&url).unwrap().send().await.unwrap(); - - assert_eq!( - list, - vec![ - format!("pubky://{pubky}/pub/example.com/a.txt"), - format!("pubky://{pubky}/pub/example.com/b.txt"), - format!("pubky://{pubky}/pub/example.com/c.txt"), - format!("pubky://{pubky}/pub/example.com/cc-nested/z.txt"), - format!("pubky://{pubky}/pub/example.com/d.txt"), - ], - "normal list with no limit or cursor" - ); - } - - { - let list = client.list(&url).unwrap().limit(2).send().await.unwrap(); - - assert_eq!( - list, - vec![ - format!("pubky://{pubky}/pub/example.com/a.txt"), - format!("pubky://{pubky}/pub/example.com/b.txt"), - ], - "normal list with limit but no cursor" - ); - } - - { - let list = client - .list(&url) - .unwrap() - .limit(2) - .cursor("a.txt") - .send() - .await - .unwrap(); - - assert_eq!( - list, - vec![ - format!("pubky://{pubky}/pub/example.com/b.txt"), - format!("pubky://{pubky}/pub/example.com/c.txt"), - ], - "normal list with limit and a file cursor" - ); - } - - { - let list = client - .list(&url) - .unwrap() - .limit(2) - .cursor("cc-nested/") - .send() - .await - .unwrap(); - - assert_eq!( - list, - vec![ - format!("pubky://{pubky}/pub/example.com/cc-nested/z.txt"), - format!("pubky://{pubky}/pub/example.com/d.txt"), - ], - "normal list with limit and a directory cursor" - ); - } - - { - let list = client - .list(&url) - .unwrap() - .limit(2) - .cursor(&format!("pubky://{pubky}/pub/example.com/a.txt")) - .send() - .await - .unwrap(); - - assert_eq!( - list, - vec![ - format!("pubky://{pubky}/pub/example.com/b.txt"), - format!("pubky://{pubky}/pub/example.com/c.txt"), - ], - "normal list with limit and a full url cursor" - ); - } - - { - let list = client - .list(&url) - .unwrap() - .limit(2) - .cursor("/a.txt") - .send() - .await - .unwrap(); - - assert_eq!( - list, - vec![ - format!("pubky://{pubky}/pub/example.com/b.txt"), - format!("pubky://{pubky}/pub/example.com/c.txt"), - ], - "normal list with limit and a leading / cursor" - ); - } - - { - let list = client - .list(&url) - .unwrap() - .reverse(true) - .send() - .await - .unwrap(); - - assert_eq!( - list, - vec![ - format!("pubky://{pubky}/pub/example.com/d.txt"), - format!("pubky://{pubky}/pub/example.com/cc-nested/z.txt"), - format!("pubky://{pubky}/pub/example.com/c.txt"), - format!("pubky://{pubky}/pub/example.com/b.txt"), - format!("pubky://{pubky}/pub/example.com/a.txt"), - ], - "reverse list with no limit or cursor" - ); - } - - { - let list = client - .list(&url) - .unwrap() - .reverse(true) - .limit(2) - .send() - .await - .unwrap(); - - assert_eq!( - list, - vec![ - format!("pubky://{pubky}/pub/example.com/d.txt"), - format!("pubky://{pubky}/pub/example.com/cc-nested/z.txt"), - ], - "reverse list with limit but no cursor" - ); - } - - { - let list = client - .list(&url) - .unwrap() - .reverse(true) - .limit(2) - .cursor("d.txt") - .send() - .await - .unwrap(); - - assert_eq!( - list, - vec![ - format!("pubky://{pubky}/pub/example.com/cc-nested/z.txt"), - format!("pubky://{pubky}/pub/example.com/c.txt"), - ], - "reverse list with limit and cursor" - ); - } - } - - #[tokio::test] - async fn list_shallow() { - let testnet = Testnet::new(10).unwrap(); - let server = Homeserver::start_test(&testnet).await.unwrap(); - - let client = Client::test(&testnet); - - let keypair = Keypair::random(); - - client.signup(&keypair, &server.public_key()).await.unwrap(); - - let pubky = keypair.public_key(); - - let urls = vec![ - format!("pubky://{pubky}/pub/a.com/a.txt"), - format!("pubky://{pubky}/pub/example.com/a.txt"), - format!("pubky://{pubky}/pub/example.com/b.txt"), - format!("pubky://{pubky}/pub/example.com/c.txt"), - format!("pubky://{pubky}/pub/example.com/d.txt"), - format!("pubky://{pubky}/pub/example.con/d.txt"), - format!("pubky://{pubky}/pub/example.con"), - format!("pubky://{pubky}/pub/file"), - format!("pubky://{pubky}/pub/file2"), - format!("pubky://{pubky}/pub/z.com/a.txt"), - ]; - - for url in urls { - client.put(url).body(vec![0]).send().await.unwrap(); - } - - let url = format!("pubky://{pubky}/pub/"); - - { - let list = client - .list(&url) - .unwrap() - .shallow(true) - .send() - .await - .unwrap(); - - assert_eq!( - list, - vec![ - format!("pubky://{pubky}/pub/a.com/"), - format!("pubky://{pubky}/pub/example.com/"), - format!("pubky://{pubky}/pub/example.con"), - format!("pubky://{pubky}/pub/example.con/"), - format!("pubky://{pubky}/pub/file"), - format!("pubky://{pubky}/pub/file2"), - format!("pubky://{pubky}/pub/z.com/"), - ], - "normal list shallow" - ); - } - - { - let list = client - .list(&url) - .unwrap() - .shallow(true) - .limit(2) - .send() - .await - .unwrap(); - - assert_eq!( - list, - vec![ - format!("pubky://{pubky}/pub/a.com/"), - format!("pubky://{pubky}/pub/example.com/"), - ], - "normal list shallow with limit but no cursor" - ); - } - - { - let list = client - .list(&url) - .unwrap() - .shallow(true) - .limit(2) - .cursor("example.com/a.txt") - .send() - .await - .unwrap(); - - assert_eq!( - list, - vec![ - format!("pubky://{pubky}/pub/example.com/"), - format!("pubky://{pubky}/pub/example.con"), - ], - "normal list shallow with limit and a file cursor" - ); - } - - { - let list = client - .list(&url) - .unwrap() - .shallow(true) - .limit(3) - .cursor("example.com/") - .send() - .await - .unwrap(); - - assert_eq!( - list, - vec![ - format!("pubky://{pubky}/pub/example.con"), - format!("pubky://{pubky}/pub/example.con/"), - format!("pubky://{pubky}/pub/file"), - ], - "normal list shallow with limit and a directory cursor" - ); - } - - { - let list = client - .list(&url) - .unwrap() - .reverse(true) - .shallow(true) - .send() - .await - .unwrap(); - - assert_eq!( - list, - vec![ - format!("pubky://{pubky}/pub/z.com/"), - format!("pubky://{pubky}/pub/file2"), - format!("pubky://{pubky}/pub/file"), - format!("pubky://{pubky}/pub/example.con/"), - format!("pubky://{pubky}/pub/example.con"), - format!("pubky://{pubky}/pub/example.com/"), - format!("pubky://{pubky}/pub/a.com/"), - ], - "reverse list shallow" - ); - } - - { - let list = client - .list(&url) - .unwrap() - .reverse(true) - .shallow(true) - .limit(2) - .send() - .await - .unwrap(); - - assert_eq!( - list, - vec![ - format!("pubky://{pubky}/pub/z.com/"), - format!("pubky://{pubky}/pub/file2"), - ], - "reverse list shallow with limit but no cursor" - ); - } - - { - let list = client - .list(&url) - .unwrap() - .shallow(true) - .reverse(true) - .limit(2) - .cursor("file2") - .send() - .await - .unwrap(); - - assert_eq!( - list, - vec![ - format!("pubky://{pubky}/pub/file"), - format!("pubky://{pubky}/pub/example.con/"), - ], - "reverse list shallow with limit and a file cursor" - ); - } - - { - let list = client - .list(&url) - .unwrap() - .shallow(true) - .reverse(true) - .limit(2) - .cursor("example.con/") - .send() - .await - .unwrap(); - - assert_eq!( - list, - vec![ - format!("pubky://{pubky}/pub/example.con"), - format!("pubky://{pubky}/pub/example.com/"), - ], - "reverse list shallow with limit and a directory cursor" - ); - } - } - - #[tokio::test] - async fn list_events() { - let testnet = Testnet::new(10).unwrap(); - let server = Homeserver::start_test(&testnet).await.unwrap(); - - let client = Client::test(&testnet); - - let keypair = Keypair::random(); - - client.signup(&keypair, &server.public_key()).await.unwrap(); - - let pubky = keypair.public_key(); - - let urls = vec![ - format!("pubky://{pubky}/pub/a.com/a.txt"), - format!("pubky://{pubky}/pub/example.com/a.txt"), - format!("pubky://{pubky}/pub/example.com/b.txt"), - format!("pubky://{pubky}/pub/example.com/c.txt"), - format!("pubky://{pubky}/pub/example.com/d.txt"), - format!("pubky://{pubky}/pub/example.con/d.txt"), - format!("pubky://{pubky}/pub/example.con"), - format!("pubky://{pubky}/pub/file"), - format!("pubky://{pubky}/pub/file2"), - format!("pubky://{pubky}/pub/z.com/a.txt"), - ]; - - for url in urls { - client.put(&url).body(vec![0]).send().await.unwrap(); - client.delete(url).send().await.unwrap(); - } - - let feed_url = format!("https://{}/events/", server.public_key()); - - let client = Client::test(&testnet); - - let cursor; - - { - let response = client - .request(Method::GET, format!("{feed_url}?limit=10")) - .send() - .await - .unwrap(); - - let text = response.text().await.unwrap(); - let lines = text.split('\n').collect::>(); - - cursor = lines.last().unwrap().split(" ").last().unwrap().to_string(); - - assert_eq!( - lines, - vec![ - format!("PUT pubky://{pubky}/pub/a.com/a.txt"), - format!("DEL pubky://{pubky}/pub/a.com/a.txt"), - format!("PUT pubky://{pubky}/pub/example.com/a.txt"), - format!("DEL pubky://{pubky}/pub/example.com/a.txt"), - format!("PUT pubky://{pubky}/pub/example.com/b.txt"), - format!("DEL pubky://{pubky}/pub/example.com/b.txt"), - format!("PUT pubky://{pubky}/pub/example.com/c.txt"), - format!("DEL pubky://{pubky}/pub/example.com/c.txt"), - format!("PUT pubky://{pubky}/pub/example.com/d.txt"), - format!("DEL pubky://{pubky}/pub/example.com/d.txt"), - format!("cursor: {cursor}",) - ] - ); - } - - { - let response = client - .request(Method::GET, format!("{feed_url}?limit=10&cursor={cursor}")) - .send() - .await - .unwrap(); - - let text = response.text().await.unwrap(); - let lines = text.split('\n').collect::>(); - - assert_eq!( - lines, - vec![ - format!("PUT pubky://{pubky}/pub/example.con/d.txt"), - format!("DEL pubky://{pubky}/pub/example.con/d.txt"), - format!("PUT pubky://{pubky}/pub/example.con"), - format!("DEL pubky://{pubky}/pub/example.con"), - format!("PUT pubky://{pubky}/pub/file"), - format!("DEL pubky://{pubky}/pub/file"), - format!("PUT pubky://{pubky}/pub/file2"), - format!("DEL pubky://{pubky}/pub/file2"), - format!("PUT pubky://{pubky}/pub/z.com/a.txt"), - format!("DEL pubky://{pubky}/pub/z.com/a.txt"), - lines.last().unwrap().to_string() - ] - ) - } - } - - #[tokio::test] - async fn read_after_event() { - let testnet = Testnet::new(10).unwrap(); - let server = Homeserver::start_test(&testnet).await.unwrap(); - - let client = Client::test(&testnet); - - let keypair = Keypair::random(); - - client.signup(&keypair, &server.public_key()).await.unwrap(); - - let pubky = keypair.public_key(); - - let url = format!("pubky://{pubky}/pub/a.com/a.txt"); - - client.put(&url).body(vec![0]).send().await.unwrap(); - - let feed_url = format!("https://{}/events/", server.public_key()); - - let client = Client::test(&testnet); - - { - let response = client - .request(Method::GET, format!("{feed_url}?limit=10")) - .send() - .await - .unwrap(); - - let text = response.text().await.unwrap(); - let lines = text.split('\n').collect::>(); - - let cursor = lines.last().unwrap().split(" ").last().unwrap().to_string(); - - assert_eq!( - lines, - vec![ - format!("PUT pubky://{pubky}/pub/a.com/a.txt"), - format!("cursor: {cursor}",) - ] - ); - } - - let response = client.get(url).send().await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); - - let body = response.bytes().await.unwrap(); - - assert_eq!(body.as_ref(), &[0]); - } - - #[tokio::test] - async fn dont_delete_shared_blobs() { - let testnet = Testnet::new(10).unwrap(); - let homeserver = Homeserver::start_test(&testnet).await.unwrap(); - let client = Client::test(&testnet); - - let homeserver_pubky = homeserver.public_key(); - - let user_1 = Keypair::random(); - let user_2 = Keypair::random(); - - client.signup(&user_1, &homeserver_pubky).await.unwrap(); - client.signup(&user_2, &homeserver_pubky).await.unwrap(); - - let user_1_id = user_1.public_key(); - let user_2_id = user_2.public_key(); - - let url_1 = format!("pubky://{user_1_id}/pub/pubky.app/file/file_1"); - let url_2 = format!("pubky://{user_2_id}/pub/pubky.app/file/file_1"); - - let file = vec![1]; - client.put(&url_1).body(file.clone()).send().await.unwrap(); - client.put(&url_2).body(file.clone()).send().await.unwrap(); - - // Delete file 1 - client - .delete(url_1) - .send() - .await - .unwrap() - .error_for_status() - .unwrap(); - - let blob = client - .get(url_2) - .send() - .await - .unwrap() - .bytes() - .await - .unwrap(); - - assert_eq!(blob, file); - - let feed_url = format!("https://{}/events/", homeserver.public_key()); - - let response = client - .request(Method::GET, feed_url) - .send() - .await - .unwrap() - .error_for_status() - .unwrap(); - - let text = response.text().await.unwrap(); - let lines = text.split('\n').collect::>(); - - assert_eq!( - lines, - vec![ - format!("PUT pubky://{user_1_id}/pub/pubky.app/file/file_1",), - format!("PUT pubky://{user_2_id}/pub/pubky.app/file/file_1",), - format!("DEL pubky://{user_1_id}/pub/pubky.app/file/file_1",), - lines.last().unwrap().to_string() - ] - ); - } - - #[tokio::test] - async fn stream() { - // TODO: test better streaming API - - let testnet = Testnet::new(10).unwrap(); - let server = Homeserver::start_test(&testnet).await.unwrap(); - - let client = Client::test(&testnet); - - let keypair = Keypair::random(); - - client.signup(&keypair, &server.public_key()).await.unwrap(); - - let url = format!("pubky://{}/pub/foo.txt", keypair.public_key()); - let url = url.as_str(); - - let bytes = Bytes::from(vec![0; 1024 * 1024]); - - client.put(url).body(bytes.clone()).send().await.unwrap(); - - let response = client.get(url).send().await.unwrap().bytes().await.unwrap(); - - assert_eq!(response, bytes); - - client.delete(url).send().await.unwrap(); - - let response = client.get(url).send().await.unwrap(); - - assert_eq!(response.status(), StatusCode::NOT_FOUND); - } -} diff --git a/pubky/src/wasm.rs b/pubky/src/wasm.rs index 4aba4bd..87d2629 100644 --- a/pubky/src/wasm.rs +++ b/pubky/src/wasm.rs @@ -1,10 +1,21 @@ use wasm_bindgen::prelude::*; -use crate::Client; +pub mod api { + pub mod auth; + pub mod http; + pub mod public; + pub mod recovery_file; +} -mod api; -mod http; -mod wrappers; +pub mod wrappers { + pub mod keys; + pub mod session; +} + +static TESTNET_RELAYS: [&str; 1] = ["http://localhost:15411/"]; + +#[wasm_bindgen] +pub struct Client(crate::NativeClient); impl Default for Client { fn default() -> Self { @@ -12,18 +23,16 @@ impl Default for Client { } } -static TESTNET_RELAYS: [&str; 1] = ["http://localhost:15411/"]; - #[wasm_bindgen] impl Client { #[wasm_bindgen(constructor)] /// Create Client with default Settings including default relays pub fn new() -> Self { - Self { - http: reqwest::Client::builder().build().unwrap(), - pkarr: pkarr::Client::builder().build().unwrap(), - testnet: false, - } + Self( + crate::NativeClient::builder() + .build() + .expect("building a default NativeClient should be infallible"), + ) } /// Create a client with with configurations appropriate for local testing: @@ -32,15 +41,19 @@ impl Client { /// and read the homeserver HTTP port from the [reserved service parameter key](pubky_common::constants::reserved_param_keys::HTTP_PORT) #[wasm_bindgen] pub fn testnet() -> Self { - Self { - http: reqwest::Client::builder().build().unwrap(), - pkarr: pkarr::Client::builder() + let mut builder = crate::NativeClient::builder(); + + builder.pkarr(|builder| { + builder .relays(&TESTNET_RELAYS) .expect("testnet relays are valid urls") - .build() - .unwrap(), - testnet: true, - } + }); + + let mut client = builder.build().expect("testnet build should be infallibl"); + + client.testnet = true; + + Self(client) } } diff --git a/pubky/src/wasm/api/auth.rs b/pubky/src/wasm/api/auth.rs index 931b2a6..c26aff2 100644 --- a/pubky/src/wasm/api/auth.rs +++ b/pubky/src/wasm/api/auth.rs @@ -4,10 +4,12 @@ use url::Url; use pubky_common::capabilities::Capabilities; -use crate::Client; +use crate::wasm::wrappers::{ + keys::{Keypair, PublicKey}, + session::Session, +}; -use crate::wasm::wrappers::keys::{Keypair, PublicKey}; -use crate::wasm::wrappers::session::Session; +use super::super::Client; use wasm_bindgen::prelude::*; @@ -24,19 +26,21 @@ impl Client { homeserver: &PublicKey, ) -> Result { Ok(Session( - self.inner_signup(keypair.as_inner(), homeserver.as_inner()) + self.0 + .signup(keypair.as_inner(), homeserver.as_inner()) .await .map_err(|e| JsValue::from_str(&e.to_string()))?, )) } - /// Check the current sesison for a given Pubky in its homeserver. + /// Check the current session for a given Pubky in its homeserver. /// - /// Returns [Session] or `None` (if recieved `404 NOT_FOUND`), - /// or throws the recieved error if the response has any other `>=400` status code. + /// Returns [Session] or `None` (if received `404 NOT_FOUND`), + /// or throws the received error if the response has any other `>=400` status code. #[wasm_bindgen] pub async fn session(&self, pubky: &PublicKey) -> Result, JsValue> { - self.inner_session(pubky.as_inner()) + self.0 + .session(pubky.as_inner()) .await .map(|s| s.map(Session)) .map_err(|e| JsValue::from_str(&e.to_string())) @@ -45,7 +49,8 @@ impl Client { /// Signout from a homeserver. #[wasm_bindgen] pub async fn signout(&self, pubky: &PublicKey) -> Result<(), JsValue> { - self.inner_signout(pubky.as_inner()) + self.0 + .signout(pubky.as_inner()) .await .map_err(|e| JsValue::from_str(&e.to_string())) } @@ -53,7 +58,8 @@ impl Client { /// Signin to a homeserver using the root Keypair. #[wasm_bindgen] pub async fn signin(&self, keypair: &Keypair) -> Result<(), JsValue> { - self.inner_signin(keypair.as_inner()) + self.0 + .signin(keypair.as_inner()) .await .map(|_| ()) .map_err(|e| JsValue::from_str(&e.to_string())) @@ -63,35 +69,18 @@ impl Client { /// verifying that AuthToken, and if capabilities were requested, signing in to /// the Pubky's homeserver and returning the [Session] information. /// - /// Returns a tuple of [pubkyAuthUrl, Promise] + /// Returns a [AuthRequest] #[wasm_bindgen(js_name = "authRequest")] - pub fn auth_request(&self, relay: &str, capabilities: &str) -> Result { - let mut relay: Url = relay.try_into().map_err(|_| "Invalid relay Url")?; - - let (pubkyauth_url, client_secret) = self - .create_auth_request( - &mut relay, + pub fn auth_request(&self, relay: &str, capabilities: &str) -> Result { + let auth_request = self + .0 + .auth_request( + relay, &Capabilities::try_from(capabilities).map_err(|_| "Invalid capaiblities")?, ) .map_err(|e| JsValue::from_str(&e.to_string()))?; - let this = self.clone(); - - let future = async move { - this.subscribe_to_auth_response(relay, &client_secret) - .await - .map(|pubky| JsValue::from(PublicKey(pubky))) - .map_err(|e| JsValue::from_str(&e.to_string())) - }; - - let promise = wasm_bindgen_futures::future_to_promise(future); - - // Return the URL and the promise - let js_tuple = js_sys::Array::new(); - js_tuple.push(&JsValue::from_str(pubkyauth_url.as_ref())); - js_tuple.push(&promise); - - Ok(js_tuple) + Ok(AuthRequest(auth_request)) } /// Sign an [pubky_common::auth::AuthToken], encrypt it and send it to the @@ -104,10 +93,40 @@ impl Client { ) -> Result<(), JsValue> { let pubkyauth_url: Url = pubkyauth_url.try_into().map_err(|_| "Invalid relay Url")?; - self.inner_send_auth_token(keypair.as_inner(), pubkyauth_url) + self.0 + .send_auth_token(keypair.as_inner(), &pubkyauth_url) .await .map_err(|e| JsValue::from_str(&e.to_string()))?; Ok(()) } } + +#[wasm_bindgen] +pub struct AuthRequest(crate::AuthRequest); + +#[wasm_bindgen] +impl AuthRequest { + /// Returns the Pubky Auth url, which you should show to the user + /// to request an authentication or authorization token. + /// + /// Wait for this token using `this.response()`. + #[wasm_bindgen] + pub fn url(&self) -> String { + self.0.url().as_str().to_string() + } + + /// Wait for the user to send an authentication or authorization proof. + /// + /// If successful, you should expect an instance of [PublicKey] + /// + /// Otherwise it will throw an error. + #[wasm_bindgen] + pub async fn response(&self) -> Result { + self.0 + .response() + .await + .map(PublicKey::from) + .map_err(|e| JsValue::from_str(&e.to_string())) + } +} diff --git a/pubky/src/wasm/http.rs b/pubky/src/wasm/api/http.rs similarity index 90% rename from pubky/src/wasm/http.rs rename to pubky/src/wasm/api/http.rs index 895afc7..ad62fde 100644 --- a/pubky/src/wasm/http.rs +++ b/pubky/src/wasm/api/http.rs @@ -11,7 +11,7 @@ use futures_lite::StreamExt; use pkarr::extra::endpoints::Endpoint; use pkarr::PublicKey; -use crate::Client; +use super::super::Client; #[wasm_bindgen] impl Client { @@ -27,7 +27,7 @@ impl Client { let request_init = request_init.unwrap_or_default(); - if let Some(pubky_host) = self.prepare_request(&mut url).await { + if let Some(pubky_host) = self.0.prepare_request(&mut url).await { let headers = request_init.get_headers(); let headers = if headers.is_null() || headers.is_undefined() { @@ -74,9 +74,9 @@ fn js_fetch(req: &web_sys::Request) -> Promise { } } -impl Client { - /// A wrapper around [reqwest::Client::request], with the same signature between native and wasm. - pub(crate) async fn inner_request(&self, method: Method, url: T) -> RequestBuilder { +impl crate::NativeClient { + /// A wrapper around [NativeClient::request], with the same signature between native and wasm. + pub(crate) async fn cross_request(&self, method: Method, url: T) -> RequestBuilder { let original_url = url.as_str(); let mut url = Url::parse(original_url).expect("Invalid url in inner_request"); @@ -95,7 +95,7 @@ impl Client { /// - Transforms pubky:// url to http(s):// urls /// - Resolves a clearnet host to call with fetch /// - Returns the `pubky-host` value if available - pub(super) async fn prepare_request(&self, url: &mut Url) -> Option { + pub(crate) async fn prepare_request(&self, url: &mut Url) -> Option { let host = url.host_str().unwrap_or("").to_string(); if url.scheme() == "pubky" { @@ -116,10 +116,10 @@ impl Client { pubky_host } - pub async fn transform_url(&self, url: &mut Url) { + pub(crate) async fn transform_url(&self, url: &mut Url) { let clone = url.clone(); let qname = clone.host_str().unwrap_or(""); - log::debug!("Prepare request {}", url.as_str()); + cross_debug!("Prepare request {}", url.as_str()); let mut stream = self.pkarr.resolve_https_endpoints(qname); @@ -163,7 +163,7 @@ impl Client { } else { // TODO: didn't find any domain, what to do? // return an error. - log::debug!("Could not resolve host: {}", qname); + cross_debug!("Could not resolve host: {}", qname); } } } diff --git a/pubky/src/wasm/api/mod.rs b/pubky/src/wasm/api/mod.rs deleted file mode 100644 index f43316f..0000000 --- a/pubky/src/wasm/api/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -pub mod recovery_file; - -// TODO: put the Homeserver API behind a feature flag -pub mod auth; -pub mod public; diff --git a/pubky/src/wasm/api/public.rs b/pubky/src/wasm/api/public.rs index febc952..9b23ac4 100644 --- a/pubky/src/wasm/api/public.rs +++ b/pubky/src/wasm/api/public.rs @@ -3,7 +3,7 @@ use js_sys::Array; use wasm_bindgen::prelude::*; -use crate::Client; +use super::super::Client; #[wasm_bindgen] impl Client { @@ -28,7 +28,8 @@ impl Client { if let Some(cursor) = cursor { return self - .inner_list(url) + .0 + .list(url) .map_err(|e| JsValue::from_str(&e.to_string()))? .reverse(reverse.unwrap_or(false)) .limit(limit.unwrap_or(u16::MAX)) @@ -48,7 +49,8 @@ impl Client { .map_err(|e| JsValue::from_str(&e.to_string())); } - self.inner_list(url) + self.0 + .list(url) .map_err(|e| JsValue::from_str(&e.to_string()))? .reverse(reverse.unwrap_or(false)) .limit(limit.unwrap_or(u16::MAX)) diff --git a/pubky/src/wasm/wrappers/mod.rs b/pubky/src/wasm/wrappers/mod.rs deleted file mode 100644 index f632a1f..0000000 --- a/pubky/src/wasm/wrappers/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -//! Wasm wrappers around structs that we need to be turned into Classes -//! in JavaScript. - -pub mod keys; -pub mod session;