diff --git a/Cargo.lock b/Cargo.lock index 613c9c4..85d39a1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,6 +26,55 @@ dependencies = [ "memchr", ] +[[package]] +name = "anstream" +version = "0.6.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64e15c1ab1f89faffbf04a634d5e1962e9074f2741eef6d97f3c4e322426d526" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bec1de6f59aedf83baf9ff929c98f2ad654b97c9510f4e70cf6f661d49fd5b1" + +[[package]] +name = "anstyle-parse" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb47de1e80c2b463c735db5b217a0ddc39d612e7ac9e2e96a5aed1f57616c1cb" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d36fc52c7f6c869915e99412912f22093507da8d9e942ceaf66fe4b7c14422a" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bf74e1b6e971609db8ca7a9ce79fd5768ab6ae46441c572e46cf596f59e57f8" +dependencies = [ + "anstyle", + "windows-sys 0.52.0", +] + [[package]] name = "anyhow" version = "1.0.86" @@ -78,6 +127,7 @@ checksum = "3a6c9af12842a67734c9a2e355436e5d03b22383ed60cf13cd0c18fbfe3dcbcf" dependencies = [ "async-trait", "axum-core", + "axum-macros", "bytes", "futures-util", "http", @@ -150,6 +200,18 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00c055ee2d014ae5981ce1016374e8213682aa14d9bf40e48ab48b5f3ef20eaa" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "backtrace" version = "0.3.73" @@ -259,12 +321,58 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "clap" +version = "4.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35723e6a11662c2afb578bcf0b88bf6ea8e21282a953428f240574fcc3a2b5b3" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49eb96cbfa7cfa35017b7cd548c75b14c3118c98b423041d70562665e07fb0fa" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d029b67f89d30bbb547c89fd5161293c0aec155fc691d7924b64550662db93e" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1462739cb27611015575c0c11df5df7601141071f07518d56fcc1be504cbec97" + [[package]] name = "cobs" version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67ba02a97a2bd10f4b59b25c7973101c79642302776489e030cd13cdab09ed15" +[[package]] +name = "colorchoice" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fd119d74b830634cea2a0f58bbd0d54540518a14397557951e79340abc28c0" + [[package]] name = "const-oid" version = "0.9.6" @@ -295,9 +403,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4934e6b7e8419148b6ef56950d277af8561060b56afd59e2aadf98b59fce6baa" dependencies = [ "cookie", - "idna", - "indexmap", + "idna 0.5.0", "log", + "publicsuffix", "serde", "serde_derive", "serde_json", @@ -329,15 +437,6 @@ version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" -[[package]] -name = "crc32fast" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" -dependencies = [ - "cfg-if", -] - [[package]] name = "critical-section" version = "1.1.2" @@ -501,28 +600,12 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" -[[package]] -name = "equivalent" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" - [[package]] name = "fiat-crypto" version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" -[[package]] -name = "flate2" -version = "1.0.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f54427cfd1c7829e2a139fcefea601bf088ebca651d2bf53ebc600eac295dae" -dependencies = [ - "crc32fast", - "miniz_oxide", -] - [[package]] name = "flume" version = "0.11.0" @@ -677,12 +760,6 @@ dependencies = [ "byteorder", ] -[[package]] -name = "hashbrown" -version = "0.14.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" - [[package]] name = "headers" version = "0.4.0" @@ -721,6 +798,18 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "heed" version = "0.20.3" @@ -828,6 +917,7 @@ dependencies = [ "pin-project-lite", "smallvec", "tokio", + "want", ] [[package]] @@ -837,12 +927,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ab92f4f49ee4fb4f997c784b7a2e0fa70050211e0b6a287f898c3c9785ca956" dependencies = [ "bytes", + "futures-channel", "futures-util", "http", "http-body", "hyper", "pin-project-lite", + "socket2", "tokio", + "tower", + "tower-service", + "tracing", +] + +[[package]] +name = "idna" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14ddfc70884202db2244c223200c204c2bda1bc6e0998d11b5e024d657209e6" +dependencies = [ + "unicode-bidi", + "unicode-normalization", ] [[package]] @@ -856,14 +961,16 @@ dependencies = [ ] [[package]] -name = "indexmap" -version = "2.2.6" +name = "ipnet" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "168fb715dda47215e360912c096649d23d58bf392ac62f73919e831745e40f26" -dependencies = [ - "equivalent", - "hashbrown", -] +checksum = "8f518f335dce6725a761382244631d86cf0ccb2863413590b31338feb467f9c3" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" [[package]] name = "itoa" @@ -1246,19 +1353,27 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + [[package]] name = "pubky" version = "0.1.0" dependencies = [ "bytes", - "flume", + "js-sys", "pkarr", "pubky-common", "pubky_homeserver", + "reqwest", "thiserror", "tokio", - "ureq", "url", + "wasm-bindgen", + "wasm-bindgen-futures", ] [[package]] @@ -1268,6 +1383,7 @@ dependencies = [ "base32", "blake3", "ed25519-dalek", + "js-sys", "once_cell", "pkarr", "postcard", @@ -1285,6 +1401,7 @@ dependencies = [ "axum-extra", "base32", "bytes", + "clap", "dirs-next", "flume", "futures-util", @@ -1300,6 +1417,16 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "publicsuffix" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96a8c1bda5ae1af7f99a2962e49df150414a43d62404644d98dd5c3a93d07457" +dependencies = [ + "idna 0.3.0", + "psl-types", +] + [[package]] name = "quote" version = "1.0.36" @@ -1404,18 +1531,40 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b" [[package]] -name = "ring" -version = "0.17.8" +name = "reqwest" +version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d" +checksum = "c7d6d2a27d57148378eb5e111173f4276ad26340ecc5c49a4a2152167a2d6a37" dependencies = [ - "cc", - "cfg-if", - "getrandom", - "libc", - "spin", - "untrusted", - "windows-sys 0.52.0", + "base64 0.22.1", + "bytes", + "cookie", + "cookie_store", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "ipnet", + "js-sys", + "log", + "mime", + "once_cell", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper 1.0.1", + "tokio", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "winreg", ] [[package]] @@ -1433,38 +1582,6 @@ dependencies = [ "semver", ] -[[package]] -name = "rustls" -version = "0.23.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4828ea528154ae444e5a642dbb7d5623354030dc9822b83fd9bb79683c7399d0" -dependencies = [ - "log", - "once_cell", - "ring", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-pki-types" -version = "1.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "976295e77ce332211c0d24d92c0e83e50f5c5f046d11082cea19f3df13a3562d" - -[[package]] -name = "rustls-webpki" -version = "0.102.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9a6fccd794a42c2c105b513a2f62bc3fd8f3ba57a4593677ceb0bd035164d78" -dependencies = [ - "ring", - "rustls-pki-types", - "untrusted", -] - [[package]] name = "rustversion" version = "1.0.17" @@ -1687,6 +1804,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "subtle" version = "2.6.1" @@ -1968,6 +2091,12 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "typenum" version = "1.17.0" @@ -1995,30 +2124,6 @@ dependencies = [ "tinyvec", ] -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - -[[package]] -name = "ureq" -version = "2.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72139d247e5f97a3eff96229a7ae85ead5328a39efe76f8bf5a06313d505b6ea" -dependencies = [ - "base64 0.22.1", - "cookie", - "cookie_store", - "flate2", - "log", - "once_cell", - "rustls", - "rustls-pki-types", - "url", - "webpki-roots", -] - [[package]] name = "url" version = "2.5.2" @@ -2026,10 +2131,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22784dbdf76fdde8af1aeda5622b546b422b6fc585325248a2bf9f5e41e94d6c" dependencies = [ "form_urlencoded", - "idna", + "idna 0.5.0", "percent-encoding", ] +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "valuable" version = "0.1.0" @@ -2042,6 +2153,15 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" @@ -2124,15 +2244,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "webpki-roots" -version = "0.26.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd7c23921eeb1713a4e851530e9b9756e4fb0e89978582942612524cf09f01cd" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "winapi" version = "0.3.9" @@ -2294,6 +2405,16 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winreg" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + [[package]] name = "z32" version = "1.1.1" diff --git a/Cargo.toml b/Cargo.toml index 85e44a1..9e2e527 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,3 +3,7 @@ members = [ "pubky","pubky-*"] # See: https://github.com/rust-lang/rust/issues/90148#issuecomment-949194352 resolver = "2" + +[profile.release] +lto = true +opt-level = 'z' diff --git a/pubky-common/Cargo.toml b/pubky-common/Cargo.toml index 1b7111c..6855f97 100644 --- a/pubky-common/Cargo.toml +++ b/pubky-common/Cargo.toml @@ -15,3 +15,6 @@ rand = "0.8.5" thiserror = "1.0.60" postcard = { version = "1.0.8", features = ["alloc"] } serde = { version = "1.0.204", features = ["derive"] } + +[target.'cfg(target_arch = "wasm32")'.dependencies] +js-sys = "0.3.69" diff --git a/pubky-common/src/auth.rs b/pubky-common/src/auth.rs index 7fc2a02..5d5ebba 100644 --- a/pubky-common/src/auth.rs +++ b/pubky-common/src/auth.rs @@ -206,7 +206,7 @@ mod tests { let mut invalid = authn_signature.as_bytes().to_vec(); invalid[64..].copy_from_slice(&[0; 32]); - assert!(!verifier.verify(&invalid, &signer).is_ok()) + assert!(verifier.verify(&invalid, &signer).is_err()) } { @@ -214,7 +214,7 @@ mod tests { let mut invalid = authn_signature.as_bytes().to_vec(); invalid[0..32].copy_from_slice(&[0; 32]); - assert!(!verifier.verify(&invalid, &signer).is_ok()) + assert!(verifier.verify(&invalid, &signer).is_err()) } } } diff --git a/pubky-common/src/session.rs b/pubky-common/src/session.rs index 9ef3c9d..5a35e14 100644 --- a/pubky-common/src/session.rs +++ b/pubky-common/src/session.rs @@ -69,9 +69,10 @@ mod tests { #[test] fn serialize() { - let mut session = Session::default(); - - session.user_agent = "foo".to_string(); + let session = Session { + user_agent: "foo".to_string(), + ..Default::default() + }; let serialized = session.serialize(); diff --git a/pubky-common/src/timestamp.rs b/pubky-common/src/timestamp.rs index f850661..4c546d5 100644 --- a/pubky-common/src/timestamp.rs +++ b/pubky-common/src/timestamp.rs @@ -1,7 +1,6 @@ //! Monotonic unix timestamp in microseconds use std::fmt::Display; -use std::time::SystemTime; use std::{ ops::{Add, Sub}, sync::Mutex, @@ -10,6 +9,9 @@ use std::{ use once_cell::sync::Lazy; use rand::Rng; +#[cfg(not(target_arch = "wasm32"))] +use std::time::SystemTime; + /// ~4% chance of none of 10 clocks have matching id. const CLOCK_MASK: u64 = (1 << 8) - 1; const TIME_MASK: u64 = !0 >> 8; @@ -162,6 +164,15 @@ fn system_time() -> u64 { .as_micros() as u64 } +#[cfg(target_arch = "wasm32")] +/// Return the number of microseconds since [SystemTime::UNIX_EPOCH] +pub fn system_time() -> u64 { + // Won't be an issue for more than 5000 years! + (js_sys::Date::now() as u64 ) + // Turn miliseconds to microseconds + * 1000 +} + #[derive(thiserror::Error, Debug)] pub enum TimestampError { #[error("Invalid bytes length, Timestamp should be encoded as 8 bytes, got {0}")] diff --git a/pubky-homeserver/Cargo.toml b/pubky-homeserver/Cargo.toml index da0c5c7..eaba493 100644 --- a/pubky-homeserver/Cargo.toml +++ b/pubky-homeserver/Cargo.toml @@ -5,10 +5,11 @@ edition = "2021" [dependencies] anyhow = "1.0.82" -axum = "0.7.5" +axum = { version = "0.7.5", features = ["macros"] } axum-extra = { version = "0.9.3", features = ["typed-header", "async-read-body"] } base32 = "0.5.1" bytes = "1.6.1" +clap = { version = "4.5.11", features = ["derive"] } dirs-next = "2.0.0" flume = "0.11.0" futures-util = "0.3.30" diff --git a/pubky-homeserver/src/config.rs b/pubky-homeserver/src/config.rs index 3657ecd..2136e0c 100644 --- a/pubky-homeserver/src/config.rs +++ b/pubky-homeserver/src/config.rs @@ -1,9 +1,9 @@ //! Configuration for the server use anyhow::{anyhow, Result}; -use pkarr::Keypair; +use pkarr::{mainline::dht::DhtSettings, Keypair}; // use serde::{Deserialize, Serialize}; -use std::{fmt::Debug, path::PathBuf}; +use std::{fmt::Debug, path::PathBuf, time::Duration}; use pubky_common::timestamp::Timestamp; @@ -11,21 +11,20 @@ const DEFAULT_HOMESERVER_PORT: u16 = 6287; const DEFAULT_STORAGE_DIR: &str = "pubky"; /// Server configuration -/// -/// The config is usually loaded from a file with [`Self::load`]. #[derive( // Serialize, Deserialize, Clone, )] pub struct Config { - port: Option, - bootstrap: Option>, - domain: String, + pub port: Option, + pub bootstrap: Option>, + pub domain: String, /// Path to the storage directory /// /// Defaults to a directory in the OS data directory - storage: Option, - keypair: Keypair, + pub storage: Option, + pub keypair: Keypair, + pub request_timeout: Option, } impl Config { @@ -50,6 +49,7 @@ impl Config { Self { bootstrap, storage, + request_timeout: Some(Duration::from_millis(10)), ..Default::default() } } @@ -93,6 +93,7 @@ impl Default for Config { domain: "localhost".to_string(), storage: None, keypair: Keypair::random(), + request_timeout: None, } } } diff --git a/pubky-homeserver/src/main.rs b/pubky-homeserver/src/main.rs index a54fba4..77b3382 100644 --- a/pubky-homeserver/src/main.rs +++ b/pubky-homeserver/src/main.rs @@ -1,13 +1,41 @@ use anyhow::Result; -use pubky_homeserver::Homeserver; +use pkarr::{mainline::Testnet, Keypair}; +use pubky_homeserver::{config::Config, Homeserver}; + +use clap::Parser; + +#[derive(Parser, Debug)] +struct Cli { + /// [tracing_subscriber::EnvFilter] + #[clap(short, long)] + tracing_env_filter: Option, + #[clap(long)] + testnet: bool, +} #[tokio::main] async fn main() -> Result<()> { + let args = Cli::parse(); + tracing_subscriber::fmt() - .with_env_filter("pubky_homeserver=debug,tower_http=debug") + .with_env_filter( + args.tracing_env_filter + .unwrap_or("pubky_homeserver=debug,tower_http=debug".to_string()), + ) .init(); - let server = Homeserver::start(Default::default()).await?; + let server = if args.testnet { + let testnet = Testnet::new(3); + + Homeserver::start(Config { + port: Some(15411), + keypair: Keypair::from_secret_key(&[0_u8; 32]), + ..Config::test(&testnet) + }) + .await? + } else { + Homeserver::start(Default::default()).await? + }; server.run_until_done().await?; diff --git a/pubky-homeserver/src/routes.rs b/pubky-homeserver/src/routes.rs index 3b872c1..0d3b7e3 100644 --- a/pubky-homeserver/src/routes.rs +++ b/pubky-homeserver/src/routes.rs @@ -1,18 +1,27 @@ +use std::sync::Arc; + use axum::{ extract::DefaultBodyLimit, + http::Method, routing::{delete, get, post, put}, Router, }; use tower_cookies::CookieManagerLayer; -use tower_http::trace::TraceLayer; +use tower_http::{ + cors::{self, CorsLayer}, + trace::TraceLayer, +}; use crate::server::AppState; +use self::pkarr::pkarr_router; + mod auth; +mod pkarr; mod public; mod root; -pub fn create_app(state: AppState) -> Router { +fn base(state: AppState) -> Router { Router::new() .route("/", get(root::handler)) .route("/:pubky", put(auth::signup)) @@ -21,10 +30,17 @@ pub fn create_app(state: AppState) -> Router { .route("/:pubky/session", delete(auth::signout)) .route("/:pubky/*path", put(public::put)) .route("/:pubky/*path", get(public::get)) - .layer(TraceLayer::new_for_http()) .layer(CookieManagerLayer::new()) // TODO: revisit if we enable streaming big payloads // TODO: maybe add to a separate router (drive router?). .layer(DefaultBodyLimit::max(16 * 1024)) .with_state(state) } + +pub fn create_app(state: AppState) -> Router { + base(state.clone()) + // TODO: Only enable this for test environments? + .nest("/pkarr", pkarr_router(state)) + .layer(CorsLayer::very_permissive()) + .layer(TraceLayer::new_for_http()) +} diff --git a/pubky-homeserver/src/routes/auth.rs b/pubky-homeserver/src/routes/auth.rs index c38aa38..60761fb 100644 --- a/pubky-homeserver/src/routes/auth.rs +++ b/pubky-homeserver/src/routes/auth.rs @@ -1,6 +1,7 @@ use axum::{ + debug_handler, extract::{Request, State}, - http::{HeaderMap, StatusCode}, + http::{uri::Scheme, HeaderMap, StatusCode, Uri}, response::IntoResponse, Router, }; @@ -8,7 +9,7 @@ use axum_extra::{headers::UserAgent, TypedHeader}; use bytes::Bytes; use heed::BytesEncode; use postcard::to_allocvec; -use tower_cookies::{Cookie, Cookies}; +use tower_cookies::{cookie::SameSite, Cookie, Cookies}; use pubky_common::{ crypto::{random_bytes, random_hash}, @@ -26,16 +27,26 @@ use crate::{ server::AppState, }; +#[debug_handler] pub async fn signup( State(state): State, TypedHeader(user_agent): TypedHeader, cookies: Cookies, pubky: Pubky, + uri: Uri, body: Bytes, ) -> Result { // TODO: Verify invitation link. // TODO: add errors in case of already axisting user. - signin(State(state), TypedHeader(user_agent), cookies, pubky, body).await + signin( + State(state), + TypedHeader(user_agent), + cookies, + pubky, + uri, + body, + ) + .await } pub async fn session( @@ -57,6 +68,7 @@ pub async fn session( let session = session.to_owned(); rtxn.commit()?; + // TODO: add content-type return Ok(session); }; @@ -95,6 +107,7 @@ pub async fn signin( TypedHeader(user_agent): TypedHeader, cookies: Cookies, pubky: Pubky, + uri: Uri, body: Bytes, ) -> Result { let public_key = pubky.public_key(); @@ -135,7 +148,15 @@ pub async fn signin( sessions.put(&mut wtxn, &session_secret, &session.serialize())?; - cookies.add(Cookie::new(public_key.to_string(), session_secret)); + let mut cookie = Cookie::new(public_key.to_string(), session_secret); + cookie.set_path("/"); + if *uri.scheme().unwrap_or(&Scheme::HTTP) == Scheme::HTTPS { + cookie.set_secure(true); + cookie.set_same_site(SameSite::None); + } + cookie.set_http_only(true); + + cookies.add(cookie); wtxn.commit()?; diff --git a/pubky-homeserver/src/routes/pkarr.rs b/pubky-homeserver/src/routes/pkarr.rs new file mode 100644 index 0000000..977a129 --- /dev/null +++ b/pubky-homeserver/src/routes/pkarr.rs @@ -0,0 +1,61 @@ +use std::{collections::HashMap, sync::RwLock}; + +use axum::{ + body::{Body, Bytes}, + extract::State, + http::StatusCode, + response::IntoResponse, + routing::{get, put}, + Router, +}; +use futures_util::stream::StreamExt; + +use pkarr::{PublicKey, SignedPacket}; +use tracing::debug; + +use crate::{ + error::{Error, Result}, + extractors::Pubky, + server::AppState, +}; + +/// Pkarr relay, helpful for testing. +/// +/// For real productioin, you should use a [production ready +/// relay](https://github.com/pubky/pkarr/server). +pub fn pkarr_router(state: AppState) -> Router { + Router::new() + .route("/:pubky", put(pkarr_put)) + .route("/:pubky", get(pkarr_get)) + .with_state(state) +} + +pub async fn pkarr_put( + State(mut state): State, + pubky: Pubky, + body: Body, +) -> Result { + let mut bytes = Vec::with_capacity(1104); + + let mut stream = body.into_data_stream(); + + while let Some(chunk) = stream.next().await { + bytes.extend_from_slice(&chunk?) + } + + let public_key = pubky.public_key().to_owned(); + + let signed_packet = SignedPacket::from_relay_payload(&public_key, &Bytes::from(bytes))?; + + state.pkarr_client.publish(&signed_packet).await?; + + Ok(()) +} + +pub async fn pkarr_get(State(state): State, pubky: Pubky) -> Result { + if let Some(signed_packet) = state.pkarr_client.resolve(pubky.public_key()).await? { + return Ok(signed_packet.to_relay_payload()); + } + + Err(Error::with_status(StatusCode::NOT_FOUND)) +} diff --git a/pubky-homeserver/src/server.rs b/pubky-homeserver/src/server.rs index 0a2f3ae..12d497c 100644 --- a/pubky-homeserver/src/server.rs +++ b/pubky-homeserver/src/server.rs @@ -1,13 +1,15 @@ -use std::{future::IntoFuture, net::SocketAddr}; +use std::{ + collections::HashMap, future::IntoFuture, net::SocketAddr, num::NonZeroUsize, sync::Arc, +}; use anyhow::{Error, Result}; use pubky_common::auth::AuthnVerifier; -use tokio::{net::TcpListener, signal, task::JoinSet}; +use tokio::{net::TcpListener, signal, sync::Mutex, task::JoinSet}; use tracing::{debug, info, warn}; use pkarr::{ mainline::dht::{DhtSettings, Testnet}, - PkarrClient, PublicKey, Settings, + PkarrClient, PkarrClientAsync, PublicKey, Settings, SignedPacket, }; use crate::{config::Config, database::DB, pkarr::publish_server_packet}; @@ -23,6 +25,7 @@ pub struct Homeserver { pub(crate) struct AppState { pub verifier: AuthnVerifier, pub db: DB, + pub pkarr_client: PkarrClientAsync, } impl Homeserver { @@ -33,9 +36,20 @@ impl Homeserver { let db = DB::open(&config.storage()?)?; + let pkarr_client = PkarrClient::new(Settings { + dht: DhtSettings { + bootstrap: config.bootstsrap(), + request_timeout: config.request_timeout, + ..Default::default() + }, + ..Default::default() + })? + .as_async(); + let state = AppState { verifier: AuthnVerifier::new(public_key.clone()), db, + pkarr_client: pkarr_client.clone(), }; let app = crate::routes::create_app(state); @@ -60,15 +74,6 @@ impl Homeserver { info!("Homeserver listening on http://localhost:{port}"); - let pkarr_client = PkarrClient::new(Settings { - dht: DhtSettings { - bootstrap: config.bootstsrap(), - ..Default::default() - }, - ..Default::default() - })? - .as_async(); - publish_server_packet(pkarr_client, config.keypair(), config.domain(), port).await?; info!("Homeserver listening on pubky://{public_key}"); @@ -82,6 +87,8 @@ impl Homeserver { /// Test version of [Homeserver::start], using mainline Testnet, and a temporary storage. pub async fn start_test(testnet: &Testnet) -> Result { + info!("Running testnet.."); + Homeserver::start(Config::test(testnet)).await } diff --git a/pubky/Cargo.toml b/pubky/Cargo.toml index c40cd9c..392402e 100644 --- a/pubky/Cargo.toml +++ b/pubky/Cargo.toml @@ -2,22 +2,42 @@ name = "pubky" version = "0.1.0" edition = "2021" +description = "Pubky client" +license = "MIT" +repository = "https://github.com/pubky/pubky" +keywords = ["web", "dht", "dns", "decentralized", "identity"] + +[lib] +crate-type = ["cdylib", "rlib"] [dependencies] +thiserror = "1.0.62" +wasm-bindgen = "0.2.92" +url = "2.5.2" +bytes = "1.6.1" + pubky-common = { version = "0.1.0", path = "../pubky-common" } -pkarr = "2.1.0" -ureq = { version = "2.10.0", features = ["cookies"] } -thiserror = "1.0.62" -url = "2.5.2" -flume = { version = "0.11.0", features = ["select", "eventual-fairness"], default-features = false } -bytes = "1.6.1" +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +pkarr = { version="2.1.0", features = ["async"] } +reqwest = { version = "0.12.5", features = ["cookies"], default-features = false } + +[target.'cfg(target_arch = "wasm32")'.dependencies] +pkarr = { version = "2.1.0", default-features = false } +reqwest = { version = "0.12.5", default-features = false } + +js-sys = "0.3.69" +wasm-bindgen = "0.2.92" +wasm-bindgen-futures = "0.4.42" [dev-dependencies] pubky_homeserver = { path = "../pubky-homeserver" } tokio = "1.37.0" [features] -async = ["flume/async"] -default = ["async"] +[package.metadata.docs.rs] +all-features = true + +# [package.metadata.wasm-pack.profile.release] +# wasm-opt = ['-g', '-O'] diff --git a/pubky/pkg/.gitignore b/pubky/pkg/.gitignore new file mode 100644 index 0000000..bc0022f --- /dev/null +++ b/pubky/pkg/.gitignore @@ -0,0 +1,5 @@ +nodejs/* +browser.js +coverage +node_modules +package-lock.json diff --git a/pubky/pkg/LICENSE b/pubky/pkg/LICENSE new file mode 100644 index 0000000..a0e67c5 --- /dev/null +++ b/pubky/pkg/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2023 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/pubky/pkg/README.md b/pubky/pkg/README.md new file mode 100644 index 0000000..784834a --- /dev/null +++ b/pubky/pkg/README.md @@ -0,0 +1,76 @@ +# Pubky + +JavaScript implementation of [Pubky](https://github.com/pubky/pubky). + +## Install + +```bash +npm install @synonymdev/pubky +``` + +## Getting started + +```js +import { PubkyClient, Keypair, PublicKey } from '../index.js' + +// Initialize PubkyClient with Pkarr relay(s). +let client = new PubkyClient(); + +// Generate a keypair +let keypair = Keypair.random(); + +// Create a new account +let homeserver = PublicKey.from("8pinxxgqs41n4aididenw5apqp1urfmzdztr8jt4abrkdn435ewo"); + +await client.signup(keypair, homeserver) + +// Verify that you are signed in. +const session = await client.session(publicKey) + +const publicKey = keypair.public_key(); + +const body = Buffer.from(JSON.stringify({ foo: 'bar' })) + +// PUT public data, by authorized client +await client.put(publicKey, "/pub/example.com/arbitrary", body); + +// GET public data without signup or signin +{ + const client = new PubkyClient(); + + let response = await client.get(publicKey, "/pub/example.com/arbitrary"); +} +``` + +## Test and Development + +For test and development, you can run a local homeserver in a test network. + +If you don't have Cargo Installed, start by installing it: + +```bash +curl https://sh.rustup.rs -sSf | sh +``` + +Clone the Pubky repository: + +```bash +git clone https://github.com/pubky/pubky +cd pubky/pkg +``` + +Run the local testnet server + +```bash +npm run testnet +``` + +Pass the logged addresses as inputs to `PubkyClient` + +```js +import { PubkyClient, PublicKey } from '../index.js' + +const client = new PubkyClient().setPkarrRelays(["http://localhost:15411/pkarr"]); + +let homeserver = PublicKey.from("8pinxxgqs41n4aididenw5apqp1urfmzdztr8jt4abrkdn435ewo"); +``` diff --git a/pubky/pkg/index.js b/pubky/pkg/index.js new file mode 100644 index 0000000..0af4fe0 --- /dev/null +++ b/pubky/pkg/index.js @@ -0,0 +1 @@ +export * from './nodejs/pubky.js' diff --git a/pubky/pkg/package.json b/pubky/pkg/package.json new file mode 100644 index 0000000..0adc9b9 --- /dev/null +++ b/pubky/pkg/package.json @@ -0,0 +1,44 @@ +{ + "name": "@synonymdev/pubky", + "type": "module", + "description": "Pubky client", + "version": "0.0.2", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/pubky/pubky" + }, + "scripts": { + "testnet": "cargo run -p pubky_homeserver -- --testnet", + "test": "npm run test-nodejs && npm run test-browser", + "test-nodejs": "tape test/*.js -cov", + "test-browser": "browserify test/*.js -p esmify | npx tape-run", + "build": "cargo run --bin bundle_pubky_npm", + "preinstall": "npm run build", + "prepublishOnly": "npm run build && npm run test" + }, + "files": [ + "nodejs/*", + "index.js", + "browser.js" + ], + "main": "index.js", + "browser": "browser.js", + "types": "pubky.d.ts", + "sideEffects": [ + "./snippets/*" + ], + "keywords": [ + "web", + "dht", + "dns", + "decentralized", + "identity" + ], + "devDependencies": { + "browser-resolve": "^2.0.0", + "esmify": "^2.1.1", + "tape": "^5.8.1", + "tape-run": "^11.0.0" + } +} diff --git a/pubky/pkg/test/auth.js b/pubky/pkg/test/auth.js new file mode 100644 index 0000000..cd54e06 --- /dev/null +++ b/pubky/pkg/test/auth.js @@ -0,0 +1,30 @@ +import test from 'tape' + +import { PubkyClient, Keypair, PublicKey } from '../index.js' + +test('auth', async (t) => { + const client = new PubkyClient().setPkarrRelays(["http://localhost:15411/pkarr"]) + + const keypair = Keypair.random() + const publicKey = keypair.public_key() + + const homeserver = PublicKey.from('8pinxxgqs41n4aididenw5apqp1urfmzdztr8jt4abrkdn435ewo') + await client.signup(keypair, homeserver) + + const session = await client.session(publicKey) + t.ok(session, "signup") + + { + await client.signout(publicKey) + + const session = await client.session(publicKey) + t.notOk(session, "singout") + } + + { + await client.signin(keypair) + + const session = await client.session(publicKey) + t.ok(session, "signin") + } +}) diff --git a/pubky/pkg/test/keys.js b/pubky/pkg/test/keys.js new file mode 100644 index 0000000..306e21e --- /dev/null +++ b/pubky/pkg/test/keys.js @@ -0,0 +1,13 @@ +import test from 'tape' + +import { Keypair } from '../index.js' + +test('generate keys from a seed', async (t) => { + const secretkey = Buffer.from('5aa93b299a343aa2691739771f2b5b85e740ca14c685793d67870f88fa89dc51', 'hex') + + const keypair = Keypair.from_secret_key(secretkey) + + const publicKey = keypair.public_key() + + t.is(publicKey.z32(), 'gcumbhd7sqit6nn457jxmrwqx9pyymqwamnarekgo3xppqo6a19o') +}) diff --git a/pubky/pkg/test/public.js b/pubky/pkg/test/public.js new file mode 100644 index 0000000..ddf02a4 --- /dev/null +++ b/pubky/pkg/test/public.js @@ -0,0 +1,46 @@ +import test from 'tape' + +import { PubkyClient, Keypair, PublicKey } from '../index.js' + +test('public: put/get', async (t) => { + const client = new PubkyClient().setPkarrRelays(["http://localhost:15411/pkarr"]) + + const keypair = Keypair.random(); + + const homeserver = PublicKey.from('8pinxxgqs41n4aididenw5apqp1urfmzdztr8jt4abrkdn435ewo'); + await client.signup(keypair, homeserver); + + const publicKey = keypair.public_key(); + + const body = Buffer.from(JSON.stringify({ foo: 'bar' })) + + // PUT public data, by authorized client + await client.put(publicKey, "/pub/example.com/arbitrary", body); + + + // GET public data without signup or signin + { + const client = new PubkyClient().setPkarrRelays(["http://localhost:15411/pkarr"]) + + let response = await client.get(publicKey, "/pub/example.com/arbitrary"); + + t.ok(Buffer.from(response).equals(body)) + } + + // // DELETE public data, by authorized client + // await client.delete(publicKey, "/pub/example.com/arbitrary"); + // + // + // // GET public data without signup or signin + // { + // const client = new PubkyClient(); + // + // let response = await client.get(publicKey, "/pub/example.com/arbitrary"); + // + // t.notOk(response) + // } +}) + +test.skip("not found") + +test.skip("unauthorized") diff --git a/pubky/src/bin/bundle_pubky_npm.rs b/pubky/src/bin/bundle_pubky_npm.rs new file mode 100644 index 0000000..40e9b90 --- /dev/null +++ b/pubky/src/bin/bundle_pubky_npm.rs @@ -0,0 +1,65 @@ +use std::env; +use std::io; +use std::process::{Command, ExitStatus}; + +// If the process hangs, try `cargo clean` to remove all locks. + +fn main() { + println!("Building wasm for pubky..."); + + build_wasm("nodejs").unwrap(); + patch().unwrap(); +} + +fn build_wasm(target: &str) -> io::Result { + let manifest_dir = env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR not set"); + + let output = Command::new("wasm-pack") + .args([ + "build", + &manifest_dir, + "--release", + "--target", + target, + "--out-dir", + &format!("pkg/{}", target), + ]) + .output()?; + + println!( + "wasm-pack {target} output: {}", + String::from_utf8_lossy(&output.stdout) + ); + + if !output.status.success() { + eprintln!( + "wasm-pack failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + Ok(output.status) +} + +fn patch() -> io::Result { + let manifest_dir = env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR not set"); + + println!("{manifest_dir}/src/bin/patch.mjs"); + let output = Command::new("node") + .args([format!("{manifest_dir}/src/bin/patch.mjs")]) + .output()?; + + println!( + "patch.mjs output: {}", + String::from_utf8_lossy(&output.stdout) + ); + + if !output.status.success() { + eprintln!( + "patch.mjs failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + Ok(output.status) +} diff --git a/pubky/src/bin/patch.mjs b/pubky/src/bin/patch.mjs new file mode 100644 index 0000000..ebbf13b --- /dev/null +++ b/pubky/src/bin/patch.mjs @@ -0,0 +1,59 @@ +// This script is used to generate isomorphic code for web and nodejs +// +// Based on hacks from [this issue](https://github.com/rustwasm/wasm-pack/issues/1334) + +import { readFile, writeFile } from "node:fs/promises"; +import { fileURLToPath } from 'node:url'; +import path, { dirname } from 'node:path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const cargoTomlContent = await readFile(path.join(__dirname, "../../Cargo.toml"), "utf8"); +const cargoPackageName = /\[package\]\nname = "(.*?)"/.exec(cargoTomlContent)[1] +const name = cargoPackageName.replace(/-/g, '_') + +const content = await readFile(path.join(__dirname, `../../pkg/nodejs/${name}.js`), "utf8"); + +const patched = content + // use global TextDecoder TextEncoder + .replace("require(`util`)", "globalThis") + // attach to `imports` instead of module.exports + .replace("= module.exports", "= imports") + + // add suffix Class + .replace(/\nclass (.*?) \{/g, "\nclass $1Class {") + .replace(/\nmodule\.exports\.(.*?) = (.*?);/g, "\nexport const $1 = imports.$1 = $1Class") + + // quick and dirty fix for a bug caused by the previous replace + .replace(/__wasmClass/g, "wasm") + + .replace(/\nmodule\.exports\.(.*?)\s+/g, "\nexport const $1 = imports.$1 ") + .replace(/$/, 'export default imports') + // inline bytes Uint8Array + .replace( + /\nconst path.*\nconst bytes.*\n/, + ` +var __toBinary = /* @__PURE__ */ (() => { + var table = new Uint8Array(128); + for (var i = 0; i < 64; i++) + table[i < 26 ? i + 65 : i < 52 ? i + 71 : i < 62 ? i - 4 : i * 4 - 205] = i; + return (base64) => { + var n = base64.length, bytes = new Uint8Array((n - (base64[n - 1] == "=") - (base64[n - 2] == "=")) * 3 / 4 | 0); + for (var i2 = 0, j = 0; i2 < n; ) { + var c0 = table[base64.charCodeAt(i2++)], c1 = table[base64.charCodeAt(i2++)]; + var c2 = table[base64.charCodeAt(i2++)], c3 = table[base64.charCodeAt(i2++)]; + bytes[j++] = c0 << 2 | c1 >> 4; + bytes[j++] = c1 << 4 | c2 >> 2; + bytes[j++] = c2 << 6 | c3; + } + return bytes; + }; +})(); + +const bytes = __toBinary(${JSON.stringify(await readFile(path.join(__dirname, `../../pkg/nodejs/${name}_bg.wasm`), "base64")) + }); +`, + ); + +await writeFile(path.join(__dirname, `../../pkg/browser.js`), patched); diff --git a/pubky/src/client.rs b/pubky/src/client.rs deleted file mode 100644 index 95742df..0000000 --- a/pubky/src/client.rs +++ /dev/null @@ -1,75 +0,0 @@ -mod auth; -mod pkarr; -mod public; - -use std::{collections::HashMap, fmt::format, time::Duration}; - -use ureq::{Agent, Response}; -use url::Url; - -use crate::error::{Error, Result}; - -use pkarr::{DhtSettings, PkarrClient, Settings, Testnet}; - -#[derive(Debug, Clone)] -pub struct PubkyClient { - agent: Agent, - pkarr: PkarrClient, -} - -impl PubkyClient { - pub fn new() -> Self { - Self { - agent: Agent::new(), - pkarr: PkarrClient::new(Default::default()).unwrap(), - } - } - - pub fn test(testnet: &Testnet) -> Self { - Self { - agent: Agent::new(), - pkarr: PkarrClient::new(Settings { - dht: DhtSettings { - request_timeout: Some(Duration::from_millis(10)), - bootstrap: Some(testnet.bootstrap.to_owned()), - ..DhtSettings::default() - }, - ..Settings::default() - }) - .unwrap(), - } - } - - // === Public Methods === - - // === Private Methods === - - fn request(&self, method: HttpMethod, url: &Url) -> ureq::Request { - self.agent.request_url(method.into(), url) - } -} - -impl Default for PubkyClient { - fn default() -> Self { - Self::new() - } -} - -#[derive(Debug, Clone)] -pub enum HttpMethod { - Get, - Put, - Post, - Delete, -} - -impl From for &str { - fn from(value: HttpMethod) -> Self { - match value { - HttpMethod::Get => "GET", - HttpMethod::Put => "PUT", - HttpMethod::Post => "POST", - HttpMethod::Delete => "DELETE", - } - } -} diff --git a/pubky/src/client/auth.rs b/pubky/src/client/auth.rs deleted file mode 100644 index 8445640..0000000 --- a/pubky/src/client/auth.rs +++ /dev/null @@ -1,128 +0,0 @@ -use crate::PubkyClient; - -use pubky_common::{auth::AuthnSignature, session::Session}; - -use super::{Error, HttpMethod, Result}; -use pkarr::{Keypair, PublicKey}; - -impl PubkyClient { - /// 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 fn signup(&self, keypair: &Keypair, homeserver: &str) -> Result<()> { - let (audience, mut url) = self.resolve_endpoint(homeserver)?; - - url.set_path(&format!("/{}", keypair.public_key())); - - self.request(HttpMethod::Put, &url) - .send_bytes(AuthnSignature::generate(keypair, &audience).as_bytes())?; - - self.publish_pubky_homeserver(keypair, homeserver); - - Ok(()) - } - - /// Check the current sesison for a given Pubky in its homeserver. - /// - /// Returns an [Error::NotSignedIn] if so, or [ureq::Error] if - /// the response has any other `>=400` status code. - pub fn session(&self, pubky: &PublicKey) -> Result { - let (homeserver, mut url) = self.resolve_pubky_homeserver(pubky)?; - - url.set_path(&format!("/{}/session", pubky)); - - let mut bytes = vec![]; - - let result = self.request(HttpMethod::Get, &url).call().map_err(Box::new); - - let reader = self.request(HttpMethod::Get, &url).call().map_err(|err| { - match err { - ureq::Error::Status(404, _) => Error::NotSignedIn, - // TODO: handle other types of errors - _ => err.into(), - } - })?; - - reader.into_reader().read_to_end(&mut bytes); - - Ok(Session::deserialize(&bytes)?) - } - - /// Signout from a homeserver. - pub fn signout(&self, pubky: &PublicKey) -> Result<()> { - let (homeserver, mut url) = self.resolve_pubky_homeserver(pubky)?; - - url.set_path(&format!("/{}/session", pubky)); - - self.request(HttpMethod::Delete, &url) - .call() - .map_err(Box::new)?; - - Ok(()) - } - - /// Signin to a homeserver. - pub fn signin(&self, keypair: &Keypair) -> Result<()> { - let pubky = keypair.public_key(); - - let (audience, mut url) = self.resolve_pubky_homeserver(&pubky)?; - - url.set_path(&format!("/{}/session", &pubky)); - - self.request(HttpMethod::Post, &url) - .send_bytes(AuthnSignature::generate(keypair, &audience).as_bytes()) - .map_err(Box::new)?; - - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use crate::*; - - use pkarr::{mainline::Testnet, Keypair}; - use pubky_common::session::Session; - use pubky_homeserver::Homeserver; - - #[tokio::test] - async fn basic_authn() { - let testnet = Testnet::new(3); - let server = Homeserver::start_test(&testnet).await.unwrap(); - - let client = PubkyClient::test(&testnet).as_async(); - - let keypair = Keypair::random(); - - client - .signup(&keypair, &server.public_key().to_string()) - .await - .unwrap(); - - let session = client.session(&keypair.public_key()).await.unwrap(); - - assert_eq!(session, Session { ..session.clone() }); - - client.signout(&keypair.public_key()).await.unwrap(); - - { - let session = client.session(&keypair.public_key()).await; - - assert!(session.is_err()); - - match session { - Err(Error::NotSignedIn) => {} - _ => assert!(false, "expected NotSignedInt error"), - } - } - - client.signin(&keypair).await.unwrap(); - - { - let session = client.session(&keypair.public_key()).await.unwrap(); - - assert_eq!(session, Session { ..session.clone() }); - } - } -} diff --git a/pubky/src/client/public.rs b/pubky/src/client/public.rs deleted file mode 100644 index b54cd21..0000000 --- a/pubky/src/client/public.rs +++ /dev/null @@ -1,104 +0,0 @@ -use bytes::Bytes; - -use pkarr::PublicKey; - -use crate::PubkyClient; - -use super::Result; - -impl PubkyClient { - pub fn put(&self, pubky: &PublicKey, path: &str, content: &[u8]) -> Result<()> { - let path = normalize_path(path); - - let (_, mut url) = self.resolve_pubky_homeserver(pubky)?; - - url.set_path(&format!("/{pubky}/{path}")); - - self.request(super::HttpMethod::Put, &url) - .send_bytes(content)?; - - Ok(()) - } - - pub fn get(&self, pubky: &PublicKey, path: &str) -> Result { - let path = normalize_path(path); - - let (_, mut url) = self.resolve_pubky_homeserver(pubky)?; - - url.set_path(&format!("/{pubky}/{path}")); - - let response = self.request(super::HttpMethod::Get, &url).call()?; - - let len = response - .header("Content-Length") - .and_then(|s| s.parse::().ok()) - // TODO: return an error in case content-length header is missing - .unwrap_or(0); - - // TODO: bail on too large files. - - let mut bytes = vec![0; len as usize]; - - response.into_reader().read_exact(&mut bytes); - - Ok(bytes.into()) - } -} - -fn normalize_path(path: &str) -> String { - let mut path = path.to_string(); - - if path.starts_with('/') { - path = path[1..].to_string() - } - - // TODO: should we return error instead? - if path.ends_with('/') { - path = path[..path.len()].to_string() - } - - path -} - -#[cfg(test)] -mod tests { - use std::ops::Deref; - - use crate::*; - - use pkarr::{mainline::Testnet, Keypair}; - use pubky_common::session::Session; - use pubky_homeserver::Homeserver; - - #[tokio::test] - async fn put_get() { - let testnet = Testnet::new(3); - let server = Homeserver::start_test(&testnet).await.unwrap(); - - let client = PubkyClient::test(&testnet).as_async(); - - let keypair = Keypair::random(); - - client - .signup(&keypair, &server.public_key().to_string()) - .await - .unwrap(); - - let response = client - .put(&keypair.public_key(), "/pub/foo.txt", &[0, 1, 2, 3, 4]) - .await; - - if let Err(Error::Ureq(ureqerror)) = response { - if let Some(r) = ureqerror.into_response() { - dbg!(r.into_string()); - } - } - - let response = client - .get(&keypair.public_key(), "/pub/foo.txt") - .await - .unwrap(); - - assert_eq!(response, bytes::Bytes::from(vec![0, 1, 2, 3, 4])) - } -} diff --git a/pubky/src/client_async.rs b/pubky/src/client_async.rs deleted file mode 100644 index 2fb7bd5..0000000 --- a/pubky/src/client_async.rs +++ /dev/null @@ -1,94 +0,0 @@ -use std::thread; - -use bytes::Bytes; - -use pkarr::{Keypair, PublicKey}; -use pubky_common::session::Session; - -use crate::{error::Result, PubkyClient}; - -pub struct PubkyClientAsync(PubkyClient); - -impl PubkyClient { - pub fn as_async(&self) -> PubkyClientAsync { - PubkyClientAsync(self.clone()) - } -} - -impl PubkyClientAsync { - /// Async version of [PubkyClient::signup] - pub async fn signup(&self, keypair: &Keypair, homeserver: &str) -> Result<()> { - let (sender, receiver) = flume::bounded::>(1); - - let client = self.0.clone(); - let keypair = keypair.clone(); - let homeserver = homeserver.to_string(); - - thread::spawn(move || sender.send(client.signup(&keypair, &homeserver))); - - receiver.recv_async().await? - } - - /// Async version of [PubkyClient::session] - pub async fn session(&self, pubky: &PublicKey) -> Result { - let (sender, receiver) = flume::bounded::>(1); - - let client = self.0.clone(); - let pubky = pubky.clone(); - - thread::spawn(move || sender.send(client.session(&pubky))); - - receiver.recv_async().await? - } - - /// Async version of [PubkyClient::signout] - pub async fn signout(&self, pubky: &PublicKey) -> Result<()> { - let (sender, receiver) = flume::bounded::>(1); - - let client = self.0.clone(); - let pubky = pubky.clone(); - - thread::spawn(move || sender.send(client.signout(&pubky))); - - receiver.recv_async().await? - } - - /// Async version of [PubkyClient::signin] - pub async fn signin(&self, keypair: &Keypair) -> Result<()> { - let (sender, receiver) = flume::bounded::>(1); - - let client = self.0.clone(); - let keypair = keypair.clone(); - - thread::spawn(move || sender.send(client.signin(&keypair))); - - receiver.recv_async().await? - } - - /// Async version of [PubkyClient::put] - pub async fn put(&self, pubky: &PublicKey, path: &str, content: &[u8]) -> Result<()> { - let (sender, receiver) = flume::bounded::>(1); - - let client = self.0.clone(); - let pubky = pubky.clone(); - let path = path.to_string(); - let content = content.to_vec(); - - thread::spawn(move || sender.send(client.put(&pubky, &path, &content))); - - receiver.recv_async().await? - } - - /// Async version of [PubkyClient::get] - pub async fn get(&self, pubky: &PublicKey, path: &str) -> Result { - let (sender, receiver) = flume::bounded::>(1); - - let client = self.0.clone(); - let pubky = pubky.clone(); - let path = path.to_string(); - - thread::spawn(move || sender.send(client.get(&pubky, &path))); - - receiver.recv_async().await? - } -} diff --git a/pubky/src/error.rs b/pubky/src/error.rs index acbad4b..501168d 100644 --- a/pubky/src/error.rs +++ b/pubky/src/error.rs @@ -12,9 +12,6 @@ pub enum Error { #[error("Generic error: {0}")] Generic(String), - #[error("Not signed in")] - NotSignedIn, - // === Transparent === #[error(transparent)] Dns(#[from] SimpleDnsError), @@ -22,21 +19,26 @@ pub enum Error { #[error(transparent)] Pkarr(#[from] pkarr::Error), - #[error(transparent)] - Flume(#[from] flume::RecvError), - - #[error(transparent)] - Ureq(#[from] Box), - #[error(transparent)] Url(#[from] url::ParseError), + #[error(transparent)] + Reqwest(#[from] reqwest::Error), + #[error(transparent)] Session(#[from] pubky_common::session::Error), + + #[error("Could not resolve endpoint for {0}")] + ResolveEndpoint(String), } -impl From for Error { - fn from(error: ureq::Error) -> Self { - Error::Ureq(Box::new(error)) +#[cfg(target_arch = "wasm32")] +use wasm_bindgen::JsValue; + +#[cfg(target_arch = "wasm32")] +impl From for JsValue { + fn from(error: Error) -> JsValue { + let error_message = error.to_string(); + js_sys::Error::new(&error_message).into() } } diff --git a/pubky/src/lib.rs b/pubky/src/lib.rs index 7125ca1..ab6732b 100644 --- a/pubky/src/lib.rs +++ b/pubky/src/lib.rs @@ -1,8 +1,35 @@ #![allow(unused)] -mod client; -mod client_async; mod error; +mod shared; + +#[cfg(not(target_arch = "wasm32"))] +mod native; + +#[cfg(target_arch = "wasm32")] +mod wasm; +#[cfg(target_arch = "wasm32")] +use std::{ + collections::HashSet, + sync::{Arc, RwLock}, +}; + +use wasm_bindgen::prelude::*; + +#[cfg(not(target_arch = "wasm32"))] +use ::pkarr::PkarrClientAsync; -pub use client::PubkyClient; pub use error::Error; + +#[derive(Debug, Clone)] +#[wasm_bindgen] +pub struct PubkyClient { + http: reqwest::Client, + #[cfg(not(target_arch = "wasm32"))] + pub(crate) pkarr: PkarrClientAsync, + /// A cookie jar for nodejs fetch. + #[cfg(target_arch = "wasm32")] + pub(crate) session_cookies: Arc>>, + #[cfg(target_arch = "wasm32")] + pub(crate) pkarr_relays: Vec, +} diff --git a/pubky/src/native.rs b/pubky/src/native.rs new file mode 100644 index 0000000..b63c844 --- /dev/null +++ b/pubky/src/native.rs @@ -0,0 +1,128 @@ +use std::time::Duration; + +use ::pkarr::{ + mainline::dht::{DhtSettings, Testnet}, + PkarrClient, PublicKey, Settings, SignedPacket, +}; +use bytes::Bytes; +use pkarr::Keypair; +use pubky_common::session::Session; +use reqwest::{Method, RequestBuilder, Response}; +use url::Url; + +use crate::{error::Result, PubkyClient}; + +static DEFAULT_USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"),); + +impl Default for PubkyClient { + fn default() -> Self { + Self::new() + } +} + +// === Public API === + +impl PubkyClient { + pub fn new() -> Self { + Self { + http: reqwest::Client::builder() + .cookie_store(true) + .user_agent(DEFAULT_USER_AGENT) + .build() + .unwrap(), + #[cfg(not(target_arch = "wasm32"))] + pkarr: PkarrClient::new(Default::default()).unwrap().as_async(), + } + } + + pub fn test(testnet: &Testnet) -> Self { + Self { + http: reqwest::Client::builder() + .cookie_store(true) + .user_agent(DEFAULT_USER_AGENT) + .build() + .unwrap(), + pkarr: PkarrClient::new(Settings { + dht: DhtSettings { + request_timeout: Some(Duration::from_millis(10)), + bootstrap: Some(testnet.bootstrap.to_owned()), + ..DhtSettings::default() + }, + ..Settings::default() + }) + .unwrap() + .as_async(), + } + } + + // === Auth === + + /// 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 async fn signup(&self, keypair: &Keypair, homeserver: &PublicKey) -> Result<()> { + self.inner_signup(keypair, homeserver).await + } + + /// Check the current sesison for a given Pubky in its homeserver. + /// + /// Returns [Session] or `None` (if recieved `404 NOT_FOUND`), + /// or [reqwest::Error] if the response has any other `>=400` status code. + pub async fn session(&self, pubky: &PublicKey) -> Result> { + self.inner_session(pubky).await + } + + /// Signout from a homeserver. + pub async fn signout(&self, pubky: &PublicKey) -> Result<()> { + self.inner_signout(pubky).await + } + + /// Signin to a homeserver. + pub async fn signin(&self, keypair: &Keypair) -> Result<()> { + self.inner_signin(keypair).await + } + + // === Public data === + + /// Upload a small payload to a given path. + pub async fn put(&self, pubky: &PublicKey, path: &str, content: &[u8]) -> Result<()> { + self.inner_put(pubky, path, content).await + } + + /// Download a small payload from a given path relative to a pubky author. + pub async fn get(&self, pubky: &PublicKey, path: &str) -> Result> { + self.inner_get(pubky, path).await + } + + // /// Delete a file at a path relative to a pubky author. + // pub async fn delete(&self, pubky: &PublicKey, path: &str) -> Result<()> { + // self.inner_delete(pubky, path).await + // } +} + +// === Internals === + +impl PubkyClient { + // === Pkarr === + + pub(crate) async fn pkarr_resolve( + &self, + public_key: &PublicKey, + ) -> Result> { + Ok(self.pkarr.resolve(public_key).await?) + } + + pub(crate) async fn pkarr_publish(&self, signed_packet: &SignedPacket) -> Result<()> { + Ok(self.pkarr.publish(signed_packet).await?) + } + + // === HTTP === + + pub(crate) fn request(&self, method: reqwest::Method, url: Url) -> RequestBuilder { + self.http.request(method, url) + } + + pub(crate) fn store_session(&self, response: Response) {} + pub(crate) fn remove_session(&self, pubky: &PublicKey) {} +} diff --git a/pubky/src/shared/auth.rs b/pubky/src/shared/auth.rs new file mode 100644 index 0000000..276ab1d --- /dev/null +++ b/pubky/src/shared/auth.rs @@ -0,0 +1,150 @@ +use reqwest::{Method, StatusCode}; + +use pkarr::{Keypair, PublicKey}; +use pubky_common::{auth::AuthnSignature, session::Session}; + +use crate::{ + error::{Error, Result}, + PubkyClient, +}; + +impl PubkyClient { + /// 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 homeserver = homeserver.to_string(); + + let public_key = &keypair.public_key(); + + let (audience, mut url) = self.resolve_endpoint(&homeserver).await?; + + url.set_path(&format!("/{}", public_key)); + + let body = AuthnSignature::generate(keypair, &audience) + .as_bytes() + .to_owned(); + + let response = self.request(Method::PUT, url).body(body).send().await?; + + self.store_session(response); + + self.publish_pubky_homeserver(keypair, &homeserver).await?; + + Ok(()) + } + + /// 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 (_, mut url) = self.resolve_pubky_homeserver(pubky).await?; + + url.set_path(&format!("/{}/session", pubky)); + + let res = self.request(Method::GET, url).send().await?; + + if res.status() == StatusCode::NOT_FOUND { + return Ok(None); + } + + if !res.status().is_success() { + res.error_for_status_ref()?; + }; + + let bytes = res.bytes().await?; + + Ok(Some(Session::deserialize(&bytes)?)) + } + + /// Signout from a homeserver. + pub async fn inner_signout(&self, pubky: &PublicKey) -> Result<()> { + let (_, mut url) = self.resolve_pubky_homeserver(pubky).await?; + + url.set_path(&format!("/{}/session", pubky)); + + self.request(Method::DELETE, url).send().await?; + + self.remove_session(pubky); + + Ok(()) + } + + /// Signin to a homeserver. + pub async fn inner_signin(&self, keypair: &Keypair) -> Result<()> { + let pubky = keypair.public_key(); + + let (audience, mut url) = self.resolve_pubky_homeserver(&pubky).await?; + + url.set_path(&format!("/{}/session", &pubky)); + + let body = AuthnSignature::generate(keypair, &audience) + .as_bytes() + .to_owned(); + + let response = self.request(Method::POST, url).body(body).send().await?; + + self.store_session(response); + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + + use std::time::Duration; + + use crate::*; + + use pkarr::{mainline::Testnet, Keypair}; + use pubky_common::session::Session; + use pubky_homeserver::Homeserver; + use tokio::time::sleep; + + #[tokio::test] + async fn basic_authn() { + let testnet = Testnet::new(3); + let server = Homeserver::start_test(&testnet).await.unwrap(); + + let client = PubkyClient::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_eq!(session, Session { ..session.clone() }); + + 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, Session { ..session.clone() }); + } + } +} diff --git a/pubky/src/shared/mod.rs b/pubky/src/shared/mod.rs new file mode 100644 index 0000000..ec9bd27 --- /dev/null +++ b/pubky/src/shared/mod.rs @@ -0,0 +1,3 @@ +pub mod auth; +pub mod pkarr; +pub mod public; diff --git a/pubky/src/client/pkarr.rs b/pubky/src/shared/pkarr.rs similarity index 77% rename from pubky/src/client/pkarr.rs rename to pubky/src/shared/pkarr.rs index c036527..879e901 100644 --- a/pubky/src/client/pkarr.rs +++ b/pubky/src/shared/pkarr.rs @@ -1,19 +1,29 @@ -pub use pkarr::{ +use url::Url; + +use pkarr::{ dns::{rdata::SVCB, Packet}, - mainline::{dht::DhtSettings, Testnet}, - Keypair, PkarrClient, PublicKey, Settings, SignedPacket, + Keypair, PublicKey, SignedPacket, }; -use super::{Error, PubkyClient, Result, Url}; +use crate::{ + error::{Error, Result}, + PubkyClient, +}; const MAX_RECURSIVE_PUBKY_HOMESERVER_RESOLUTION: u8 = 3; impl PubkyClient { /// Publish the SVCB record for `_pubky.`. - pub(crate) fn publish_pubky_homeserver(&self, keypair: &Keypair, host: &str) -> Result<()> { + pub(crate) async fn publish_pubky_homeserver( + &self, + keypair: &Keypair, + host: &str, + ) -> Result<()> { + let existing = self.pkarr_resolve(&keypair.public_key()).await?; + let mut packet = Packet::new_reply(0); - if let Some(existing) = self.pkarr.resolve(&keypair.public_key())? { + if let Some(existing) = existing { for answer in existing.packet().answers.iter().cloned() { if !answer.name.to_string().starts_with("_pubky") { packet.answers.push(answer.into_owned()) @@ -32,34 +42,46 @@ impl PubkyClient { let signed_packet = SignedPacket::from_packet(keypair, &packet)?; - self.pkarr.publish(&signed_packet)?; + self.pkarr_publish(&signed_packet).await?; Ok(()) } /// Resolve the homeserver for a pubky. - pub(crate) fn resolve_pubky_homeserver(&self, pubky: &PublicKey) -> Result<(PublicKey, Url)> { + pub(crate) async fn resolve_pubky_homeserver( + &self, + pubky: &PublicKey, + ) -> Result<(PublicKey, Url)> { let target = format!("_pubky.{}", pubky); self.resolve_endpoint(&target) + .await .map_err(|_| Error::Generic("Could not resolve homeserver".to_string())) } /// Resolve a service's public_key and clearnet url from a Pubky domain - pub(crate) fn resolve_endpoint(&self, target: &str) -> Result<(PublicKey, Url)> { + pub(crate) async fn resolve_endpoint(&self, target: &str) -> Result<(PublicKey, Url)> { + let original_target = target; // TODO: cache the result of this function? - // TODO: use MAX_RECURSIVE_PUBKY_HOMESERVER_RESOLUTION - // TODO: move to common? let mut target = target.to_string(); let mut homeserver_public_key = None; let mut host = target.clone(); + let mut step = 0; + // PublicKey is very good at extracting the Pkarr TLD from a string. while let Ok(public_key) = PublicKey::try_from(target.clone()) { - if let Some(signed_packet) = self.pkarr.resolve(&public_key)? { - let mut prior = None; + step += 1; + let response = self + .pkarr_resolve(&public_key) + .await + .map_err(|_| Error::ResolveEndpoint(original_target.into()))?; + + let mut prior = None; + + if let Some(signed_packet) = response { for answer in signed_packet.resource_records(&target) { if let pkarr::dns::rdata::RData::SVCB(svcb) = &answer.rdata { if svcb.priority == 0 { @@ -76,7 +98,7 @@ impl PubkyClient { } if let Some(svcb) = prior { - homeserver_public_key = Some(public_key); + homeserver_public_key = Some(public_key.clone()); target = svcb.target.to_string(); if let Some(port) = svcb.get_param(pkarr::dns::rdata::SVCB::PORT) { @@ -90,11 +112,11 @@ impl PubkyClient { host.clone_from(&target); }; - continue; + if step >= MAX_RECURSIVE_PUBKY_HOMESERVER_RESOLUTION { + continue; + }; } - }; - - break; + } } if let Some(homeserver) = homeserver_public_key { @@ -107,7 +129,7 @@ impl PubkyClient { return Ok((homeserver, Url::parse(&url)?)); } - Err(Error::Generic("Could not resolve endpoint".to_string())) + Err(Error::ResolveEndpoint(original_target.into())) } } @@ -157,23 +179,24 @@ mod tests { pkarr_client.publish(&signed_packet).await.unwrap(); - tokio::task::spawn_blocking(move || { + { let client = PubkyClient::test(&testnet); let pubky = Keypair::random(); client - .publish_pubky_homeserver(&pubky, &format!("pubky.{}", &intermediate.public_key())); + .publish_pubky_homeserver(&pubky, &format!("pubky.{}", &intermediate.public_key())) + .await + .unwrap(); let (public_key, url) = client .resolve_pubky_homeserver(&pubky.public_key()) + .await .unwrap(); assert_eq!(public_key, server.public_key()); assert_eq!(url.host_str(), Some("localhost")); assert_eq!(url.port(), Some(server.port())); - }) - .await - .expect("task failed") + } } } diff --git a/pubky/src/shared/public.rs b/pubky/src/shared/public.rs new file mode 100644 index 0000000..2b1edaa --- /dev/null +++ b/pubky/src/shared/public.rs @@ -0,0 +1,114 @@ +use bytes::Bytes; + +use pkarr::PublicKey; +use reqwest::{Method, Response, StatusCode}; +use url::Url; + +use crate::{error::Result, PubkyClient}; + +impl PubkyClient { + pub async fn inner_put(&self, pubky: &PublicKey, path: &str, content: &[u8]) -> Result<()> { + let url = self.url(pubky, path).await?; + + self.request(Method::PUT, url) + .body(content.to_owned()) + .send() + .await?; + + Ok(()) + } + + pub async fn inner_get(&self, pubky: &PublicKey, path: &str) -> Result> { + let url = self.url(pubky, path).await?; + + let res = self.request(Method::GET, url).send().await?; + + if res.status() == StatusCode::NOT_FOUND { + return Ok(None); + } + + // TODO: bail on too large files. + let bytes = res.bytes().await?; + + Ok(Some(bytes)) + } + + pub async fn inner_delete(&self, pubky: &PublicKey, path: &str) -> Result<()> { + let url = self.url(pubky, path).await?; + + self.request(Method::DELETE, url).send().await?; + + Ok(()) + } + + async fn url(&self, pubky: &PublicKey, path: &str) -> Result { + let path = normalize_path(path)?; + + let (_, mut url) = self.resolve_pubky_homeserver(pubky).await?; + + url.set_path(&format!("/{pubky}/{path}")); + + Ok(url) + } +} + +fn normalize_path(path: &str) -> Result { + let mut path = path.to_string(); + + if path.starts_with('/') { + path = path[1..].to_string() + } + + // TODO: should we return error instead? + if path.ends_with('/') { + path = path[..path.len()].to_string() + } + + Ok(path) +} + +#[cfg(test)] +mod tests { + + use crate::*; + + use pkarr::{mainline::Testnet, Keypair}; + use pubky_homeserver::Homeserver; + + #[tokio::test] + async fn put_get_delete() { + let testnet = Testnet::new(3); + let server = Homeserver::start_test(&testnet).await.unwrap(); + + let client = PubkyClient::test(&testnet); + + let keypair = Keypair::random(); + + client.signup(&keypair, &server.public_key()).await.unwrap(); + + client + .put(&keypair.public_key(), "/pub/foo.txt", &[0, 1, 2, 3, 4]) + .await + .unwrap(); + + let response = client + .get(&keypair.public_key(), "/pub/foo.txt") + .await + .unwrap() + .unwrap(); + + assert_eq!(response, bytes::Bytes::from(vec![0, 1, 2, 3, 4])); + + // client + // .delete(&keypair.public_key(), "/pub/foo.txt") + // .await + // .unwrap(); + // + // let response = client + // .get(&keypair.public_key(), "/pub/foo.txt") + // .await + // .unwrap(); + // + // assert_eq!(response, None); + } +} diff --git a/pubky/src/wasm.rs b/pubky/src/wasm.rs new file mode 100644 index 0000000..b04de3b --- /dev/null +++ b/pubky/src/wasm.rs @@ -0,0 +1,124 @@ +use std::{ + collections::HashSet, + sync::{Arc, RwLock}, +}; + +use wasm_bindgen::prelude::*; + +use reqwest::{Method, RequestBuilder, Response}; +use url::Url; + +use crate::PubkyClient; + +mod http; +mod keys; +mod pkarr; +mod session; + +use keys::{Keypair, PublicKey}; +use session::Session; + +impl Default for PubkyClient { + fn default() -> Self { + Self::new() + } +} + +static DEFAULT_RELAYS: [&str; 1] = ["https://relay.pkarr.org"]; + +#[wasm_bindgen] +impl PubkyClient { + #[wasm_bindgen(constructor)] + pub fn new() -> Self { + Self { + http: reqwest::Client::builder().build().unwrap(), + session_cookies: Arc::new(RwLock::new(HashSet::new())), + pkarr_relays: DEFAULT_RELAYS.into_iter().map(|s| s.to_string()).collect(), + } + } + + /// Set the relays used for publishing and resolving Pkarr packets. + /// + /// By default, [PubkyClient] will use `["https://relay.pkarr.org"]` + #[wasm_bindgen(js_name = "setPkarrRelays")] + pub fn set_pkarr_relays(mut self, relays: Vec) -> Self { + let relays: Vec = relays + .into_iter() + .filter_map(|name| name.as_string()) + .collect(); + + self.pkarr_relays = relays; + self + } + + #[wasm_bindgen(js_name = "getPkarrRelays")] + pub fn get_pkarr_relays(&self) -> Vec { + self.pkarr_relays + .clone() + .into_iter() + .map(JsValue::from) + .collect() + } + + /// 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" + #[wasm_bindgen] + pub async fn signup(&self, keypair: &Keypair, homeserver: &PublicKey) -> Result<(), JsValue> { + self.inner_signup(keypair.as_inner(), homeserver.as_inner()) + .await + .map_err(|e| e.into()) + } + + /// Check the current sesison 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. + #[wasm_bindgen] + pub async fn session(&self, pubky: &PublicKey) -> Result, JsValue> { + self.inner_session(pubky.as_inner()) + .await + .map(|s| s.map(Session)) + .map_err(|e| e.into()) + } + + /// Signout from a homeserver. + #[wasm_bindgen] + pub async fn signout(&self, pubky: &PublicKey) -> Result<(), JsValue> { + self.inner_signout(pubky.as_inner()) + .await + .map_err(|e| e.into()) + } + + /// Signin to a homeserver. + #[wasm_bindgen] + pub async fn signin(&self, keypair: &Keypair) -> Result<(), JsValue> { + self.inner_signin(keypair.as_inner()) + .await + .map_err(|e| e.into()) + } + + // === Public data === + + #[wasm_bindgen] + /// Upload a small payload to a given path. + pub async fn put(&self, pubky: &PublicKey, path: &str, content: &[u8]) -> Result<(), JsValue> { + self.inner_put(pubky.as_inner(), path, content) + .await + .map_err(|e| e.into()) + } + + #[wasm_bindgen] + /// Download a small payload from a given path relative to a pubky author. + pub async fn get( + &self, + pubky: &PublicKey, + path: &str, + ) -> Result, JsValue> { + self.inner_get(pubky.as_inner(), path) + .await + .map(|b| b.map(|b| (&*b).into())) + .map_err(|e| e.into()) + } +} diff --git a/pubky/src/wasm/http.rs b/pubky/src/wasm/http.rs new file mode 100644 index 0000000..d89d4ce --- /dev/null +++ b/pubky/src/wasm/http.rs @@ -0,0 +1,42 @@ +use crate::PubkyClient; + +use reqwest::{Method, RequestBuilder, Response}; +use url::Url; + +use ::pkarr::PublicKey; + +impl PubkyClient { + pub(crate) fn request(&self, method: Method, url: Url) -> RequestBuilder { + let mut request = self.http.request(method, url).fetch_credentials_include(); + + for cookie in self.session_cookies.read().unwrap().iter() { + request = request.header("Cookie", cookie); + } + + request + } + + // Support cookies for nodejs + + pub(crate) fn store_session(&self, response: Response) { + if let Some(cookie) = response + .headers() + .get("set-cookie") + .and_then(|h| h.to_str().ok()) + .and_then(|s| s.split(';').next()) + { + self.session_cookies + .write() + .unwrap() + .insert(cookie.to_string()); + } + } + pub(crate) fn remove_session(&self, pubky: &pkarr::PublicKey) { + let key = pubky.to_string(); + + self.session_cookies + .write() + .unwrap() + .retain(|cookie| !cookie.starts_with(&key)); + } +} diff --git a/pubky/src/wasm/keys.rs b/pubky/src/wasm/keys.rs new file mode 100644 index 0000000..d1ef078 --- /dev/null +++ b/pubky/src/wasm/keys.rs @@ -0,0 +1,72 @@ +use wasm_bindgen::prelude::*; + +use crate::Error; + +#[wasm_bindgen] +pub struct Keypair(pkarr::Keypair); + +#[wasm_bindgen] +impl Keypair { + #[wasm_bindgen] + /// Generate a random [Keypair] + pub fn random() -> Self { + Self(pkarr::Keypair::random()) + } + + #[wasm_bindgen] + /// Generate a [Keypair] from a secret key. + pub fn from_secret_key(secret_key: js_sys::Uint8Array) -> Self { + let mut bytes = [0; 32]; + secret_key.copy_to(&mut bytes); + + Self(pkarr::Keypair::from_secret_key(&bytes)) + } + + #[wasm_bindgen] + /// Returns the [PublicKey] of this keypair. + pub fn public_key(&self) -> PublicKey { + PublicKey(self.0.public_key()) + } +} + +impl Keypair { + pub fn as_inner(&self) -> &pkarr::Keypair { + &self.0 + } +} + +#[wasm_bindgen] +pub struct PublicKey(pkarr::PublicKey); + +#[wasm_bindgen] +impl PublicKey { + #[wasm_bindgen] + /// Convert the PublicKey to Uint8Array + pub fn to_uint8array(&self) -> js_sys::Uint8Array { + js_sys::Uint8Array::from(self.0.as_bytes().as_slice()) + } + + #[wasm_bindgen] + /// Returns the z-base32 encoding of this public key + pub fn z32(&self) -> String { + self.0.to_string() + } + + #[wasm_bindgen(js_name = "from")] + /// @throws + pub fn try_from(value: JsValue) -> Result { + let string = value.as_string().ok_or(Error::Generic( + "Couldn't create a PublicKey from this type of value".to_string(), + ))?; + + Ok(PublicKey( + pkarr::PublicKey::try_from(string).map_err(Error::Pkarr)?, + )) + } +} + +impl PublicKey { + pub fn as_inner(&self) -> &pkarr::PublicKey { + &self.0 + } +} diff --git a/pubky/src/wasm/pkarr.rs b/pubky/src/wasm/pkarr.rs new file mode 100644 index 0000000..49726f6 --- /dev/null +++ b/pubky/src/wasm/pkarr.rs @@ -0,0 +1,48 @@ +use reqwest::StatusCode; + +pub use pkarr::{PublicKey, SignedPacket}; + +use crate::error::Result; +use crate::PubkyClient; + +// TODO: Add an in memory cache of packets + +impl PubkyClient { + //TODO: migrate to pkarr::PkarrRelayClient + pub(crate) async fn pkarr_resolve( + &self, + public_key: &PublicKey, + ) -> Result> { + //TODO: Allow multiple relays in parallel + let relay = self.pkarr_relays.first().expect("initialized with relays"); + + let res = self + .http + .get(format!("{relay}/{}", public_key)) + .send() + .await?; + + if res.status() == StatusCode::NOT_FOUND { + return Ok(None); + }; + + // TODO: guard against too large responses. + let bytes = res.bytes().await?; + + let existing = SignedPacket::from_relay_payload(public_key, &bytes)?; + + Ok(Some(existing)) + } + + pub(crate) async fn pkarr_publish(&self, signed_packet: &SignedPacket) -> Result<()> { + let relay = self.pkarr_relays.first().expect("initialized with relays"); + + self.http + .put(format!("{relay}/{}", signed_packet.public_key())) + .body(signed_packet.to_relay_payload()) + .send() + .await?; + + Ok(()) + } +} diff --git a/pubky/src/wasm/session.rs b/pubky/src/wasm/session.rs new file mode 100644 index 0000000..ec2e8ca --- /dev/null +++ b/pubky/src/wasm/session.rs @@ -0,0 +1,6 @@ +use pubky_common::session; + +use wasm_bindgen::prelude::*; + +#[wasm_bindgen] +pub struct Session(pub(crate) session::Session);