feat(js): export AuthRequest in js as a wrapper around native

This commit is contained in:
nazeh
2025-02-09 22:45:58 +03:00
parent 5fbbdb577b
commit 62ddf0fbe6
25 changed files with 1539 additions and 1579 deletions

2
Cargo.lock generated
View File

@@ -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",

View File

@@ -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:");

8
pubky/build.rs Normal file
View File

@@ -0,0 +1,8 @@
use cfg_aliases::cfg_aliases;
fn main() {
// Convenience aliases
cfg_aliases! {
wasm_browser: { all(target_family = "wasm", target_os = "unknown") },
}
}

View File

@@ -1 +0,0 @@
allow-unwrap-in-tests = true

View File

@@ -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;

View File

@@ -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<native::CookieJar>,
#[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;

View File

@@ -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<F>(&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<Client, BuildError> {
pub fn build(&self) -> Result<Client, BuildError> {
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<internal::cookies::CookieJar>,
#[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<Self, BuildError> {
@@ -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),
}

View File

@@ -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<Session> {
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<Option<Session>> {
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<Session> {
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<T: IntoUrl>(
&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<String, String> =
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::<Vec<_>>()
})
.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<Session> {
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<T: IntoUrl>(
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<Result<PublicKey>>,
) -> anyhow::Result<PublicKey> {
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()));
}
}

View File

@@ -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<T: IntoUrl>(&self, method: Method, url: T) -> RequestBuilder {
pub(crate) async fn cross_request<U: IntoUrl>(&self, method: Method, url: U) -> RequestBuilder {
self.request(method, url)
}
}

View File

@@ -1,5 +0,0 @@
pub mod recovery_file;
// TODO: put the Homeserver API behind a feature flag
pub mod auth;
pub mod public;

View File

@@ -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<T: IntoUrl>(&self, url: T) -> Result<ListBuilder> {
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<u16>,
cursor: Option<&'a str>,
client: &'a Client,
shallow: bool,
}
impl<'a> ListBuilder<'a> {
/// Create a new List request builder
pub(crate) fn new<T: IntoUrl>(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<Vec<String>> {
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::<Vec<&str>>();
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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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);
}
}

View File

@@ -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<Vec<u8>> {
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<Keypair> {
Ok(decrypt_recovery_file(recovery_file, passphrase)?)
}
}

View File

@@ -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<HashMap<String, String>>,
normal_jar: RwLock<cookie_store::CookieStore>,

View File

@@ -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.<public_key>`.
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(())
}
}

View File

@@ -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<Session> {
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<Option<Session>> {
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<Session> {
let token = AuthToken::sign(keypair, vec![Capability::root()]);
self.signin_with_authtoken(&token).await
}
pub(crate) async fn inner_send_auth_token<T: IntoUrl>(
&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<String, String> =
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::<Vec<_>>()
})
.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<Session> {
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<PublicKey> {
// 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()));
}
}

View File

@@ -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<u16>,
cursor: Option<&'a str>,
client: &'a Client,
shallow: bool,
}
impl<'a> ListBuilder<'a> {
/// Create a new List request builder
pub(crate) fn new<T: IntoUrl>(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<Vec<String>> {
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::<Vec<&str>>();
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())
}
}

View File

@@ -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}")),
};
}
};
}

View File

@@ -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.<public_key>`.
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://<pubky> to https://_pubky.<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<Endpoint> = 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()
// }
}

View File

@@ -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<T: IntoUrl>(&self, url: T) -> Result<ListBuilder> {
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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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);
}
}

View File

@@ -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)
}
}

View File

@@ -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<Session, JsValue> {
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<Option<Session>, 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<Session>]
/// Returns a [AuthRequest]
#[wasm_bindgen(js_name = "authRequest")]
pub fn auth_request(&self, relay: &str, capabilities: &str) -> Result<js_sys::Array, JsValue> {
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<AuthRequest, JsValue> {
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<PublicKey, JsValue> {
self.0
.response()
.await
.map(PublicKey::from)
.map_err(|e| JsValue::from_str(&e.to_string()))
}
}

View File

@@ -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<T: IntoUrl>(&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<T: IntoUrl>(&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<String> {
pub(crate) async fn prepare_request(&self, url: &mut Url) -> Option<String> {
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);
}
}
}

View File

@@ -1,5 +0,0 @@
pub mod recovery_file;
// TODO: put the Homeserver API behind a feature flag
pub mod auth;
pub mod public;

View File

@@ -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))

View File

@@ -1,5 +0,0 @@
//! Wasm wrappers around structs that we need to be turned into Classes
//! in JavaScript.
pub mod keys;
pub mod session;