diff --git a/Cargo.lock b/Cargo.lock index 0be02ae..ce712da 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -38,9 +38,9 @@ dependencies = [ [[package]] name = "anstream" -version = "0.6.17" +version = "0.6.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23a1e53f0f5d86382dafe1cf314783b2044280f406e7e1506368220ad11b1338" +checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" dependencies = [ "anstyle", "anstyle-parse", @@ -53,9 +53,9 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.9" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8365de52b16c035ff4fcafe0092ba9390540e3e352870ac09933bebcaa2c8c56" +checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" [[package]] name = "anstyle-parse" @@ -87,9 +87,15 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.91" +version = "1.0.94" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1fd03a028ef38ba2276dce7e33fcd6369c158a1bca17946c4b1b701891c1ff7" + +[[package]] +name = "arc-swap" +version = "1.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c042108f3ed77fd83760a5fd79b53be043192bb3b9dba91d8c574c0ada7850c8" +checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" [[package]] name = "argon2" @@ -153,6 +159,7 @@ dependencies = [ "reqwest", "rpassword", "tokio", + "tracing-subscriber", "url", ] @@ -164,9 +171,9 @@ checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" [[package]] name = "axum" -version = "0.7.7" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "504e3947307ac8326a5437504c517c4b56716c9d98fac0028c2acc7ca47d70ae" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" dependencies = [ "async-trait", "axum-core", @@ -191,7 +198,7 @@ dependencies = [ "serde_urlencoded", "sync_wrapper 1.0.1", "tokio", - "tower", + "tower 0.5.1", "tower-layer", "tower-service", "tracing", @@ -220,27 +227,28 @@ dependencies = [ [[package]] name = "axum-extra" -version = "0.9.4" +version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73c3220b188aea709cf1b6c5f9b01c3bd936bb08bd2b5184a12b35ac8131b1f9" +checksum = "c794b30c904f0a1c2fb7740f7df7f7972dfaa14ef6f57cb6178dc63e5dca2f04" dependencies = [ "axum", "axum-core", "bytes", + "fastrand", "futures-util", "headers", "http", "http-body", "http-body-util", "mime", + "multer", "pin-project-lite", "serde", "tokio", "tokio-util", - "tower", + "tower 0.5.1", "tower-layer", "tower-service", - "tracing", ] [[package]] @@ -254,6 +262,30 @@ dependencies = [ "syn", ] +[[package]] +name = "axum-server" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56bac90848f6a9393ac03c63c640925c4b7c8ca21654de40d53f55964667c7d8" +dependencies = [ + "arc-swap", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "pin-project-lite", + "rustls", + "rustls-pemfile", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower 0.4.13", + "tower-service", +] + [[package]] name = "backtrace" version = "0.3.74" @@ -322,9 +354,9 @@ dependencies = [ [[package]] name = "blake3" -version = "1.5.4" +version = "1.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d82033247fd8e890df8f740e407ad4d038debb9eb1f40533fffb32e7d17dc6f7" +checksum = "b8ee0c1824c4dea5b5f81736aff91bae041d2c07ee1192bec91054e10e3e601e" dependencies = [ "arrayref", "arrayvec", @@ -356,15 +388,15 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.8.0" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ac0150caa2ae65ca5bd83f25c7de183dea78d4d366469f148435e2acfbad0da" +checksum = "325918d6fe32f23b19878fe4b34794ae41fc19ddbe53b10571a4874d44ffd39b" [[package]] name = "cc" -version = "1.1.31" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2e7962b54006dcfcc61cb72735f4d89bb97061dd6a7ed882ec6b8ee53714c6f" +checksum = "1aeb932158bd710538c73702db6945cb68a8fb08c519e6e12706b94263b36db8" dependencies = [ "shlex", ] @@ -375,6 +407,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "cipher" version = "0.4.4" @@ -388,9 +426,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.20" +version = "4.5.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97f376d85a664d5837dbae44bf546e6477a679ff6610010f17276f686d867e8" +checksum = "3135e7ec2ef7b10c6ed8950f0f792ed96ee093fa088608f1c76e569722700c84" dependencies = [ "clap_builder", "clap_derive", @@ -398,9 +436,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.20" +version = "4.5.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19bc80abd44e4bed93ca373a0704ccbd1b710dc5749406201bb018272808dc54" +checksum = "30582fc632330df2bd26877bde0c1f4470d57c582bbc070376afcd04d8cb4838" dependencies = [ "anstream", "anstyle", @@ -422,9 +460,9 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.7.2" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1462739cb27611015575c0c11df5df7601141071f07518d56fcc1be504cbec97" +checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" [[package]] name = "cobs" @@ -438,6 +476,17 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" +[[package]] +name = "console_log" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be8aed40e4edbf4d3b4431ab260b63fdc40f5780a4766824329ea0f1eefe3c0f" +dependencies = [ + "log", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -463,12 +512,13 @@ dependencies = [ [[package]] name = "cookie_store" -version = "0.21.0" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4934e6b7e8419148b6ef56950d277af8561060b56afd59e2aadf98b59fce6baa" +checksum = "2eac901828f88a5241ee0600950ab981148a18f2f756900ffba1b125ca6a3ef9" dependencies = [ "cookie", - "idna 0.5.0", + "document-features", + "idna", "log", "publicsuffix", "serde", @@ -496,9 +546,9 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "cpufeatures" -version = "0.2.14" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "608697df725056feaccfa42cffdaeeec3fccc4ffc38358ecd19b243e716a78e0" +checksum = "0ca741a962e1b0bff6d724a1a0958b686406e853bb14061f218562e1896f95e6" dependencies = [ "libc", ] @@ -592,6 +642,19 @@ dependencies = [ "syn", ] +[[package]] +name = "dashmap" +version = "5.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" +dependencies = [ + "cfg-if", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "der" version = "0.7.9" @@ -643,6 +706,17 @@ dependencies = [ "winapi", ] +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "document-features" version = "0.2.10" @@ -732,9 +806,9 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8c02a5121d4ea3eb16a80748c74f5549a5665e4c21333c6098f283870fbdea6" +checksum = "486f806e73c5707928240ddc295403b1b93c96a02038563881c4a2fd84b81ac4" [[package]] name = "fiat-crypto" @@ -784,6 +858,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "forwarded-header-value" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8835f84f38484cc86f110a805655697908257fb9a7af005234060891557198e9" +dependencies = [ + "nonempty", + "thiserror 1.0.69", +] + [[package]] name = "futures" version = "0.3.31" @@ -832,6 +916,19 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +[[package]] +name = "futures-lite" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cef40d21ae2c515b51041df9ed313ed21e572df340ea58a922a0aefe7e8891a1" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.31" @@ -855,6 +952,12 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +[[package]] +name = "futures-timer" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" + [[package]] name = "futures-util" version = "0.3.31" @@ -873,6 +976,22 @@ dependencies = [ "slab", ] +[[package]] +name = "genawaiter" +version = "0.99.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c86bd0361bcbde39b13475e6e36cb24c329964aa2611be285289d1e4b751c1a0" +dependencies = [ + "futures-core", + "genawaiter-macro", +] + +[[package]] +name = "genawaiter-macro" +version = "0.99.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b32dfe1fdfc0bbde1f22a5da25355514b5e450c33a6af6770884c8750aedfbc" + [[package]] name = "generic-array" version = "0.14.7" @@ -903,6 +1022,26 @@ version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" +[[package]] +name = "governor" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68a7f542ee6b35af73b06abc0dad1c1bae89964e4e253bc4b587b91c9637867b" +dependencies = [ + "cfg-if", + "dashmap", + "futures", + "futures-timer", + "no-std-compat", + "nonzero_ext", + "parking_lot", + "portable-atomic", + "quanta", + "rand", + "smallvec", + "spinning_top", +] + [[package]] name = "h2" version = "0.4.6" @@ -933,9 +1072,15 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.15.0" +version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e087f84d4f86bf4b218b927129862374b72199ae7d8657835f1e89000eea4fb" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a9bfc1af68b1726ea47d3d5109de126281def866b33970e10fbab11b5dafab3" [[package]] name = "headers" @@ -990,7 +1135,25 @@ dependencies = [ "bitflags", "byteorder", "heed-traits", - "heed-types", + "heed-types 0.20.1", + "libc", + "lmdb-master-sys", + "once_cell", + "page_size", + "synchronoise", + "url", +] + +[[package]] +name = "heed" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd54745cfacb7b97dee45e8fdb91814b62bccddb481debb7de0f9ee6b7bf5b43" +dependencies = [ + "bitflags", + "byteorder", + "heed-traits", + "heed-types 0.21.0", "libc", "lmdb-master-sys", "once_cell", @@ -1011,6 +1174,16 @@ name = "heed-types" version = "0.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d3f528b053a6d700b2734eabcd0fd49cb8230647aa72958467527b0b7917114" +dependencies = [ + "byteorder", + "heed-traits", +] + +[[package]] +name = "heed-types" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c255bdf46e07fb840d120a36dcc81f385140d7191c76a7391672675c01a55d" dependencies = [ "bincode", "byteorder", @@ -1065,6 +1238,19 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-relay" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum", + "axum-server", + "futures-util", + "tokio", + "tracing", + "url", +] + [[package]] name = "httparse" version = "1.9.5" @@ -1134,9 +1320,9 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.9" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41296eb09f183ac68eec06e03cdbea2e759633d4067b2f6552fc2e009bcad08b" +checksum = "df2dcfbe0677734ab2f3ffa7fa7bfd4706bfdc1ef393f2ee30184aed67e631b4" dependencies = [ "bytes", "futures-channel", @@ -1152,23 +1338,142 @@ dependencies = [ ] [[package]] -name = "idna" -version = "0.3.0" +name = "icu_collections" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e14ddfc70884202db2244c223200c204c2bda1bc6e0998d11b5e024d657209e6" +checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" dependencies = [ - "unicode-bidi", - "unicode-normalization", + "displaydoc", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locid" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_locid_transform" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_locid_transform_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locid_transform_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdc8ff3388f852bede6b579ad4e978ab004f139284d7b28715f773507b946f6e" + +[[package]] +name = "icu_normalizer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "utf16_iter", + "utf8_iter", + "write16", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8cafbf7aa791e9b22bec55a167906f9e1215fd475cd22adfcf660e03e989516" + +[[package]] +name = "icu_properties" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locid_transform", + "icu_properties_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a8effbc3dd3e4ba1afa8ad918d5684b8868b3b26500753effea8d2eed19569" + +[[package]] +name = "icu_provider" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_provider_macros", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_provider_macros" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] name = "idna" -version = "0.5.0" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6" +checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" dependencies = [ - "unicode-bidi", - "unicode-normalization", + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" +dependencies = [ + "icu_normalizer", + "icu_properties", ] [[package]] @@ -1178,7 +1483,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "707907fe3c25f5424cce2cb7e1cbcafee6bdbe735ca90ef77c29e84591e5b9da" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.15.1", ] [[package]] @@ -1210,10 +1515,11 @@ checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" [[package]] name = "js-sys" -version = "0.3.72" +version = "0.3.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a88f1bda2bd75b0452a14784937d796722fdebfe50df998aeb3f0b7603019a9" +checksum = "6717b6b5b077764fb5966237269cb3c64edddde4b14ce42647430a78ced9e7b7" dependencies = [ + "once_cell", "wasm-bindgen", ] @@ -1225,9 +1531,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.161" +version = "0.2.167" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9489c2807c139ffd9c1794f4af0ebe86a828db53ecdc7fea2111d0fed085d1" +checksum = "09d6582e104315a817dff97f75133544b2e094ee22447d2acf4a74e189ba06fc" [[package]] name = "libredox" @@ -1245,6 +1551,12 @@ version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" +[[package]] +name = "litemap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "643cb0b8d4fcc284004d5fd0d67ccf61dfffadb7f75e1e71bc420f4688a3a704" + [[package]] name = "litrs" version = "0.4.1" @@ -1286,12 +1598,12 @@ checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" [[package]] name = "mainline" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b751ffb57303217bcae8f490eee6044a5b40eadf6ca05ff476cad37e7b7970d" +version = "4.1.0" +source = "git+https://github.com/pubky/mainline?branch=v5#c5e02270223bfa49a791a627a54348e0494762e5" dependencies = [ "bytes", "crc", + "document-features", "ed25519-dalek", "flume", "lru", @@ -1300,7 +1612,7 @@ dependencies = [ "serde_bencode", "serde_bytes", "sha1_smol", - "thiserror", + "thiserror 2.0.6", "tracing", ] @@ -1352,6 +1664,23 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + [[package]] name = "nanorand" version = "0.7.0" @@ -1378,6 +1707,24 @@ dependencies = [ "tempfile", ] +[[package]] +name = "no-std-compat" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c" + +[[package]] +name = "nonempty" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9e591e719385e6ebaeb5ce5d3887f7d5676fceca6411d1925ccc95745f3d6f7" + +[[package]] +name = "nonzero_ext" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21" + [[package]] name = "nu-ansi-term" version = "0.46.0" @@ -1475,6 +1822,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.3" @@ -1557,6 +1910,26 @@ dependencies = [ "siphasher", ] +[[package]] +name = "pin-project" +version = "1.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be57f64e946e500c8ee36ef6331845d40a93055567ec57e8fae13efd33759b95" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c0f5fad0874fc7abcd4d750e76917eaebbecaa2c20bde22e1dbeeba8beb758c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "pin-project-lite" version = "0.2.15" @@ -1571,29 +1944,67 @@ checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] name = "pkarr" -version = "2.2.1-alpha.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b59d10828418841f34089b861b33d966b63ffd34fe770f4bc46df2d8aba118f5" +version = "3.0.0" +source = "git+https://github.com/Pubky/pkarr?branch=v3#492639796bac0fedf42de7bbd3c069506b77fd19" dependencies = [ "base32", + "byteorder", "bytes", "document-features", "dyn-clone", "ed25519-dalek", "flume", "futures", + "futures-lite", + "genawaiter", + "getrandom", + "heed 0.20.5", "js-sys", "lru", "mainline", + "once_cell", + "page_size", + "pubky-timestamp", "rand", + "reqwest", + "rustls", + "rustls-webpki", "self_cell", "serde", + "sha1_smol", "simple-dns", - "thiserror", + "thiserror 2.0.6", + "tokio", "tracing", - "wasm-bindgen", + "url", "wasm-bindgen-futures", - "web-sys", +] + +[[package]] +name = "pkarr-relay" +version = "0.1.0" +source = "git+https://github.com/Pubky/pkarr?branch=v3#492639796bac0fedf42de7bbd3c069506b77fd19" +dependencies = [ + "anyhow", + "axum", + "axum-server", + "bytes", + "clap", + "dirs-next", + "governor", + "http", + "httpdate", + "pkarr", + "pubky-timestamp", + "rustls", + "serde", + "thiserror 2.0.6", + "tokio", + "toml", + "tower-http", + "tower_governor", + "tracing", + "tracing-subscriber", ] [[package]] @@ -1623,11 +2034,17 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "portable-atomic" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "280dc24453071f1b63954171985a0b0d30058d287960968b9b2aca264c8d4ee6" + [[package]] name = "postcard" -version = "1.0.10" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f7f0a8d620d71c457dd1d47df76bb18960378da56af4527aaa10f515eee732e" +checksum = "170a2601f67cc9dba8edd8c4870b15f71a6a2dc196daec8c83f72b59dff628a8" dependencies = [ "cobs", "embedded-io 0.4.0", @@ -1670,18 +2087,30 @@ checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" name = "pubky" version = "0.3.0" dependencies = [ + "anyhow", + "axum", + "axum-server", "base64 0.22.1", "bytes", + "console_log", + "cookie", + "cookie_store", + "futures-lite", + "futures-util", + "http-relay", "js-sys", + "log", "pkarr", "pubky-common", "pubky-homeserver", "reqwest", - "thiserror", + "thiserror 2.0.6", "tokio", + "tracing", "url", "wasm-bindgen", "wasm-bindgen-futures", + "web-sys", ] [[package]] @@ -1700,7 +2129,7 @@ dependencies = [ "pubky-timestamp", "rand", "serde", - "thiserror", + "thiserror 2.0.6", ] [[package]] @@ -1710,23 +2139,26 @@ dependencies = [ "anyhow", "axum", "axum-extra", + "axum-server", "base32", "bytes", "clap", "dirs-next", "flume", "futures-util", - "heed", + "heed 0.21.0", "hex", + "http-relay", "httpdate", - "libc", + "page_size", "pkarr", + "pkarr-relay", "postcard", "pubky-common", - "reqwest", "serde", "tokio", "toml", + "tower 0.5.1", "tower-cookies", "tower-http", "tracing", @@ -1751,19 +2183,34 @@ dependencies = [ [[package]] name = "publicsuffix" -version = "2.2.3" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96a8c1bda5ae1af7f99a2962e49df150414a43d62404644d98dd5c3a93d07457" +checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf" dependencies = [ - "idna 0.3.0", + "idna", "psl-types", ] +[[package]] +name = "quanta" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5167a477619228a0b284fac2674e3c388cba90631d7b7de620e6f1fcd08da5" +dependencies = [ + "crossbeam-utils", + "libc", + "once_cell", + "raw-cpuid", + "wasi", + "web-sys", + "winapi", +] + [[package]] name = "quinn" -version = "0.11.5" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c7c5fdde3cdae7203427dc4f0a68fe0ed09833edc525a03456b153b79828684" +checksum = "62e96808277ec6f97351a2380e6c25114bc9e67037775464979f3037c92d05ef" dependencies = [ "bytes", "pin-project-lite", @@ -1772,34 +2219,38 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror", + "thiserror 2.0.6", "tokio", "tracing", ] [[package]] name = "quinn-proto" -version = "0.11.8" +version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fadfaed2cd7f389d0161bb73eeb07b7b78f8691047a6f3e73caaeae55310a4a6" +checksum = "a2fe5ef3495d7d2e377ff17b1a8ce2ee2ec2a18cde8b6ad6619d65d0701c135d" dependencies = [ "bytes", + "getrandom", "rand", "ring", "rustc-hash", "rustls", + "rustls-pki-types", "slab", - "thiserror", + "thiserror 2.0.6", "tinyvec", "tracing", + "web-time", ] [[package]] name = "quinn-udp" -version = "0.5.5" +version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fe68c2e9e1a1234e218683dbdf9f9dfcb094113c5ac2b938dfcb9bab4c4140b" +checksum = "7d5a626c6807713b15cac82a6acaccd6043c9a5408c24baae07611fec3f243da" dependencies = [ + "cfg_aliases", "libc", "once_cell", "socket2", @@ -1846,6 +2297,15 @@ dependencies = [ "getrandom", ] +[[package]] +name = "raw-cpuid" +version = "11.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ab240315c661615f2ee9f0f2cd32d5a7343a84d5ebcccb99d46e6637565e7b0" +dependencies = [ + "bitflags", +] + [[package]] name = "redox_syscall" version = "0.5.7" @@ -1863,7 +2323,7 @@ checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" dependencies = [ "getrandom", "libredox", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -1874,7 +2334,7 @@ checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" dependencies = [ "aho-corasick", "memchr", - "regex-automata 0.4.8", + "regex-automata 0.4.9", "regex-syntax 0.8.5", ] @@ -1889,9 +2349,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.8" +version = "0.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368758f23274712b504848e9d5a6f010445cc8b87a7cdb4d7cbee666c1288da3" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" dependencies = [ "aho-corasick", "memchr", @@ -1912,9 +2372,9 @@ checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" [[package]] name = "reqwest" -version = "0.12.8" +version = "0.12.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f713147fbe92361e52392c73b8c9e48c04c6625bce969ef54dc901e58e042a7b" +checksum = "a77c62af46e79de0a562e1a9849205ffcb7fc1238876e9bd743357570e04046f" dependencies = [ "base64 0.22.1", "bytes", @@ -2019,9 +2479,9 @@ dependencies = [ [[package]] name = "rustix" -version = "0.38.37" +version = "0.38.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8acb788b847c24f28525660c4d7758620a7210875711f79e7f663cc152726811" +checksum = "99e4ea3e1cdc4b559b8e5650f9c8e5998e3e5c1343b4eaf034565f32318d63c0" dependencies = [ "bitflags", "errno", @@ -2032,9 +2492,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.15" +version = "0.23.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fbb44d7acc4e873d613422379f69f237a1b141928c02f6bc6ccfddddc2d7993" +checksum = "eee87ff5d9b36712a58574e12e9f0ea80f915a5b0ac518d322b24a465617925e" dependencies = [ "once_cell", "ring", @@ -2058,6 +2518,9 @@ name = "rustls-pki-types" version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16f1201b3c9a7ee8039bcadc17b7e605e2945b27eee7631788c1bd2b0643674b" +dependencies = [ + "web-time", +] [[package]] name = "rustls-webpki" @@ -2121,9 +2584,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.12.0" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea4a292869320c0272d7bc55a5a6aafaff59b4f63404a003887b679a2e05b4b6" +checksum = "fa39c7303dc58b5543c94d22c1766b0d31f2ee58306363ea622b10bbc075eaa2" dependencies = [ "core-foundation-sys", "libc", @@ -2143,9 +2606,9 @@ checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" [[package]] name = "serde" -version = "1.0.213" +version = "1.0.216" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ea7893ff5e2466df8d720bb615088341b295f849602c6956047f8f80f0e9bc1" +checksum = "0b9781016e935a97e8beecf0c933758c97a5520d32930e460142b4cd80c6338e" dependencies = [ "serde_derive", ] @@ -2171,9 +2634,9 @@ dependencies = [ [[package]] name = "serde_derive" -version = "1.0.213" +version = "1.0.216" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e85ad2009c50b58e87caa8cd6dac16bdf511bbfb7af6c33df902396aa480fa5" +checksum = "46f859dbbf73865c6627ed570e78961cd3ac92407a2d117204c49232485da55e" dependencies = [ "proc-macro2", "quote", @@ -2182,9 +2645,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.132" +version = "1.0.133" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d726bfaff4b320266d395898905d0eba0345aae23b54aee3a737e260fd46db03" +checksum = "c7fceb2473b9166b2294ef05efcb65a3db80803f0b03ef86a5fc88a2b85ee377" dependencies = [ "itoa", "memchr", @@ -2286,9 +2749,9 @@ dependencies = [ [[package]] name = "simple-dns" -version = "0.6.2" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01607fe2e61894468c6dc0b26103abb073fb08b79a3d9e4b6d76a1a341549958" +checksum = "b8f1740a36513fc97c5309eb1b8e8f108b0e95899c66c23fd7259625d4fdb686" dependencies = [ "bitflags", ] @@ -2333,6 +2796,15 @@ dependencies = [ "lock_api", ] +[[package]] +name = "spinning_top" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300" +dependencies = [ + "lock_api", +] + [[package]] name = "spki" version = "0.7.3" @@ -2363,9 +2835,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.85" +version = "2.0.87" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5023162dfcd14ef8f32034d8bcd4cc5ddc61ef7a247c024a33e24e1f24d21b56" +checksum = "25aa4ce346d03a6dcd68dd8b4010bcb74e54e62c90c573f394c46eae99aba32d" dependencies = [ "proc-macro2", "quote", @@ -2396,6 +2868,17 @@ dependencies = [ "crossbeam-queue", ] +[[package]] +name = "synstructure" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "system-configuration" version = "0.6.1" @@ -2419,9 +2902,9 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.13.0" +version = "3.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0f2c9fc62d0beef6951ccffd757e241266a2c833136efbe35af6cd2567dca5b" +checksum = "28cce251fcbc87fac86a866eeb0d6c2d536fc16d06f184bb61aeae11aa4cee0c" dependencies = [ "cfg-if", "fastrand", @@ -2432,18 +2915,38 @@ dependencies = [ [[package]] name = "thiserror" -version = "1.0.65" +version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d11abd9594d9b38965ef50805c5e469ca9cc6f197f883f717e0269a3057b3d5" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec2a1820ebd077e2b90c4df007bebf344cd394098a13c563957d0afc83ea47" +dependencies = [ + "thiserror-impl 2.0.6", ] [[package]] name = "thiserror-impl" -version = "1.0.65" +version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae71770322cbd277e69d762a16c444af02aa0575ac0d174f0b9562d3b37f8602" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d65750cab40f4ff1929fb1ba509e9914eb756131cef4210da8d5d700d26f6312" dependencies = [ "proc-macro2", "quote", @@ -2491,6 +2994,16 @@ dependencies = [ "time-core", ] +[[package]] +name = "tinystr" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "tinyvec" version = "1.8.0" @@ -2508,9 +3021,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.41.0" +version = "1.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "145f3413504347a2be84393cc8a7d2fb4d863b375909ea59f2158261aa258bbb" +checksum = "5cec9b21b0450273377fc97bd4c33a8acffc8c996c987a7c5b319a0083707551" dependencies = [ "backtrace", "bytes", @@ -2603,6 +3116,21 @@ dependencies = [ "winnow", ] +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "pin-project", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "tower" version = "0.5.1" @@ -2638,15 +3166,14 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.5.2" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" +checksum = "403fa3b783d4b626a8ad51d766ab03cb6d2dbfc46b1c5d4448395e6628dc9697" dependencies = [ "bitflags", "bytes", "http", "http-body", - "http-body-util", "pin-project-lite", "tower-layer", "tower-service", @@ -2665,11 +3192,27 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" +[[package]] +name = "tower_governor" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aea939ea6cfa7c4880f3e7422616624f97a567c16df67b53b11f0d03917a8e46" +dependencies = [ + "axum", + "forwarded-header-value", + "governor", + "http", + "pin-project", + "thiserror 1.0.69", + "tower 0.5.1", + "tracing", +] + [[package]] name = "tracing" -version = "0.1.40" +version = "0.1.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" dependencies = [ "log", "pin-project-lite", @@ -2679,9 +3222,9 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.27" +version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" +checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" dependencies = [ "proc-macro2", "quote", @@ -2690,9 +3233,9 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.32" +version = "0.1.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" +checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" dependencies = [ "once_cell", "valuable", @@ -2711,9 +3254,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.18" +version = "0.3.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad0f048c97dbd9faa9b7df56362b8ebcaa52adb06b498c050d2f4e32f90a7a8b" +checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" dependencies = [ "matchers", "nu-ansi-term", @@ -2739,27 +3282,12 @@ version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" -[[package]] -name = "unicode-bidi" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ab17db44d7388991a428b2ee655ce0c212e862eff1768a455c58f9aad6e7893" - [[package]] name = "unicode-ident" version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" -[[package]] -name = "unicode-normalization" -version = "0.1.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5033c97c4262335cded6d6fc3e5c18ab755e1a3dc96376350f3d8e9f009ad956" -dependencies = [ - "tinyvec", -] - [[package]] name = "universal-hash" version = "0.5.1" @@ -2778,15 +3306,27 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" -version = "2.5.2" +version = "2.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22784dbdf76fdde8af1aeda5622b546b422b6fc585325248a2bf9f5e41e94d6c" +checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" dependencies = [ "form_urlencoded", - "idna 0.5.0", + "idna", "percent-encoding", ] +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" @@ -2828,9 +3368,9 @@ checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" [[package]] name = "wasm-bindgen" -version = "0.2.95" +version = "0.2.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "128d1e363af62632b8eb57219c8fd7877144af57558fb2ef0368d0087bddeb2e" +checksum = "a474f6281d1d70c17ae7aa6a613c87fce69a127e2624002df63dcb39d6cf6396" dependencies = [ "cfg-if", "once_cell", @@ -2839,13 +3379,12 @@ dependencies = [ [[package]] name = "wasm-bindgen-backend" -version = "0.2.95" +version = "0.2.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb6dd4d3ca0ddffd1dd1c9c04f94b868c37ff5fac97c30b97cff2d74fce3a358" +checksum = "5f89bb38646b4f81674e8f5c3fb81b562be1fd936d84320f3264486418519c79" dependencies = [ "bumpalo", "log", - "once_cell", "proc-macro2", "quote", "syn", @@ -2854,21 +3393,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.45" +version = "0.4.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc7ec4f8827a71586374db3e87abdb5a2bb3a15afed140221307c3ec06b1f63b" +checksum = "38176d9b44ea84e9184eff0bc34cc167ed044f816accfe5922e54d84cf48eca2" dependencies = [ "cfg-if", "js-sys", + "once_cell", "wasm-bindgen", "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.95" +version = "0.2.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e79384be7f8f5a9dd5d7167216f022090cf1f9ec128e6e6a482a2cb5c5422c56" +checksum = "2cc6181fd9a7492eef6fef1f33961e3695e4579b9872a6f7c83aee556666d4fe" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2876,9 +3416,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.95" +version = "0.2.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26c6ab57572f7a24a4985830b120de1594465e5d500f24afe89e16b4e833ef68" +checksum = "30d7a95b763d3c45903ed6c81f156801839e5ee968bb07e534c44df0fcd330c2" dependencies = [ "proc-macro2", "quote", @@ -2889,15 +3429,25 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.95" +version = "0.2.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65fc09f10666a9f147042251e0dda9c18f166ff7de300607007e96bdebc1068d" +checksum = "943aab3fdaaa029a6e0271b35ea10b72b943135afe9bffca82384098ad0e06a6" [[package]] name = "web-sys" -version = "0.3.72" +version = "0.3.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6488b90108c040df0fe62fa815cbdee25124641df01814dd7282749234c6112" +checksum = "04dd7223427d52553d3702c004d3b2fe07c148165faa56313cb00211e31c12bc" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" dependencies = [ "js-sys", "wasm-bindgen", @@ -3121,6 +3671,42 @@ dependencies = [ "memchr", ] +[[package]] +name = "write16" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" + +[[package]] +name = "writeable" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" + +[[package]] +name = "yoke" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c5b1314b079b0930c31e3af543d8ee1757b1951ae1e1565ec704403a7240ca5" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28cc31741b18cb6f1d5ff12f5b7523e3d6eb0852bbbad19d73905511d9849b95" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + [[package]] name = "zerocopy" version = "0.7.35" @@ -3142,8 +3728,51 @@ dependencies = [ "syn", ] +[[package]] +name = "zerofrom" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91ec111ce797d0e0784a1116d0ddcdbea84322cd79e5d5ad173daeba4f93ab55" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ea7b4a3637ea8669cedf0f1fd5c286a17f3de97b8dd5a70a6c167a1730e63a5" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + [[package]] name = "zeroize" version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" + +[[package]] +name = "zerovec" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/Cargo.toml b/Cargo.toml index 3b7e4d3..b8ec7b3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,8 @@ members = [ "pubky", "pubky-*", + "http-relay", + "examples" ] @@ -10,8 +12,7 @@ members = [ resolver = "2" [workspace.dependencies] -pkarr = { version = "2.0.0", git = "https://github.com/Pubky/pkarr", branch = "serde", package = "pkarr", features = ["async", "serde"] } -serde = { version = "^1.0.209", features = ["derive"] } +pkarr = { git = "https://github.com/Pubky/pkarr", branch = "v3", package = "pkarr" } [profile.release] lto = true diff --git a/examples/Cargo.toml b/examples/Cargo.toml index 02edbbe..878c587 100644 --- a/examples/Cargo.toml +++ b/examples/Cargo.toml @@ -16,12 +16,13 @@ name = "request" path = "./request/main.rs" [dependencies] -anyhow = "1.0.86" +anyhow = "1.0.94" base64 = "0.22.1" -clap = { version = "4.5.16", features = ["derive"] } +clap = { version = "4.5.23", features = ["derive"] } pubky = { path = "../pubky" } pubky-common = { version = "0.1.0", path = "../pubky-common" } -reqwest = "0.12.8" +reqwest = "0.12.9" rpassword = "7.3.1" -tokio = { version = "1.40.0", features = ["macros", "rt-multi-thread"] } -url = "2.5.2" +tokio = { version = "1.42.0", features = ["macros", "rt-multi-thread"] } +tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } +url = "2.5.4" diff --git a/examples/authn/signup.rs b/examples/authn/signup.rs index ecafae5..850cd87 100644 --- a/examples/authn/signup.rs +++ b/examples/authn/signup.rs @@ -1,6 +1,6 @@ use anyhow::Result; use clap::Parser; -use pubky::PubkyClient; +use pubky::Client; use std::path::PathBuf; use pubky_common::crypto::PublicKey; @@ -24,7 +24,7 @@ async fn main() -> Result<()> { let homeserver = cli.homeserver; - let client = PubkyClient::builder().build(); + let client = Client::new()?; println!("Enter your recovery_file's passphrase to signup:"); let passphrase = rpassword::read_password()?; diff --git a/examples/authz/3rd-party-app/src/pubky-auth-widget.js b/examples/authz/3rd-party-app/src/pubky-auth-widget.js index 628f316..20c73ca 100644 --- a/examples/authz/3rd-party-app/src/pubky-auth-widget.js +++ b/examples/authz/3rd-party-app/src/pubky-auth-widget.js @@ -56,8 +56,8 @@ export class PubkyAuthWidget extends LitElement { this.testnet = false; this.open = false; - /** @type {import("@synonymdev/pubky").PubkyClient} */ - this.pubkyClient = new window.pubky.PubkyClient(); + /** @type {import("@synonymdev/pubky").Client} */ + this.pubkyClient = new window.pubky.Client(); this.caps = this.caps || "" } @@ -74,9 +74,9 @@ export class PubkyAuthWidget extends LitElement { console.debug("Switching testnet"); if (this.testnet) { - this.pubkyClient = window.pubky.PubkyClient.testnet() + this.pubkyClient = window.pubky.Client.testnet() } else { - this.pubkyClient = new window.pubky.PubkyClient(); + this.pubkyClient = new window.pubky.Client(); } console.debug("Pkarr Relays: " + this.pubkyClient.getPkarrRelays()) diff --git a/examples/authz/authenticator.rs b/examples/authz/authenticator.rs index 97999c0..633ae1d 100644 --- a/examples/authz/authenticator.rs +++ b/examples/authz/authenticator.rs @@ -1,6 +1,6 @@ use anyhow::Result; use clap::Parser; -use pubky::PubkyClient; +use pubky::Client; use std::path::PathBuf; use url::Url; @@ -66,7 +66,7 @@ async fn main() -> Result<()> { println!("PublicKey: {}", keypair.public_key()); let client = if cli.testnet.unwrap_or_default() { - let client = PubkyClient::testnet(); + let client = Client::testnet()?; // For the purposes of this demo, we need to make sure // the user has an account on the local homeserver. @@ -78,7 +78,7 @@ async fn main() -> Result<()> { client } else { - PubkyClient::builder().build() + Client::new()? }; println!("Sending AuthToken to the 3rd party app..."); diff --git a/examples/request/main.rs b/examples/request/main.rs index 69d5400..3d5a688 100644 --- a/examples/request/main.rs +++ b/examples/request/main.rs @@ -1,9 +1,11 @@ +use std::env; + use anyhow::Result; use clap::Parser; use reqwest::Method; use url::Url; -use pubky::PubkyClient; +use pubky::Client; #[derive(Parser, Debug)] #[command(version, about, long_about = None)] @@ -16,23 +18,31 @@ struct Cli { #[tokio::main] async fn main() -> Result<()> { - let cli = Cli::parse(); + let args = Cli::parse(); - let client = PubkyClient::builder().build(); + tracing_subscriber::fmt() + .with_env_filter(env::var("TRACING").unwrap_or("info".to_string())) + .init(); - match cli.url.scheme() { - "https" => { - unimplemented!(); - } - "pubky" => { - let response = client.get(cli.url).await.unwrap(); + let client = Client::new()?; - println!("Got a response: \n {:?}", response); - } - _ => { - panic!("Only https:// and pubky:// URL schemes are supported") + // Build the request + let response = client.get(args.url).send().await?; + + println!("< Response:"); + println!("< {:?} {}", response.version(), response.status()); + for (name, value) in response.headers() { + if let Ok(v) = value.to_str() { + println!("< {name}: {v}"); } } + let bytes = response.bytes().await?; + + match String::from_utf8(bytes.to_vec()) { + Ok(string) => println!("<\n{}", string), + Err(_) => println!("<\n{:?}", bytes), + } + Ok(()) } diff --git a/http-relay/Cargo.toml b/http-relay/Cargo.toml new file mode 100644 index 0000000..2bca62c --- /dev/null +++ b/http-relay/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "http-relay" +version = "0.1.0" +edition = "2021" + +[dependencies] +anyhow = "1.0.94" +axum = "0.7.9" +axum-server = "0.7.1" +futures-util = "0.3.31" +tokio = { version = "1.42.0", features = ["full"] } +tracing = "0.1.41" +url = "2.5.4" diff --git a/http-relay/README.md b/http-relay/README.md new file mode 100644 index 0000000..f11968e --- /dev/null +++ b/http-relay/README.md @@ -0,0 +1,7 @@ +# HTTP Relay + +A Rust implementation of _some_ of [Http relay spec](https://httprelay.io/). + +Normally you are better off running the [reference implementation's binary](https://httprelay.io/download/). + +This implementation, for the time being is meant for having a convenient library to be used in unit tests, and testnets in Pubky. diff --git a/http-relay/src/lib.rs b/http-relay/src/lib.rs new file mode 100644 index 0000000..08e066d --- /dev/null +++ b/http-relay/src/lib.rs @@ -0,0 +1,167 @@ +use std::{ + collections::HashMap, + net::{SocketAddr, TcpListener}, + sync::{Arc, Mutex}, +}; + +use anyhow::Result; + +use axum::{ + body::{Body, Bytes}, + extract::{Path, State}, + response::IntoResponse, + routing::get, + Router, +}; +use axum_server::Handle; +use tokio::sync::Notify; + +use futures_util::{stream::StreamExt, TryFutureExt}; +use url::Url; + +// Shared state to store GET requests and their notifications +type SharedState = Arc, Arc)>>>; + +#[derive(Debug, Default)] +pub struct Config { + pub http_port: u16, +} + +#[derive(Debug, Default)] +pub struct HttpRelayBuilder(Config); + +impl HttpRelayBuilder { + /// Configure the port used for HTTP server. + pub fn http_port(mut self, port: u16) -> Self { + self.0.http_port = port; + + self + } + + pub async fn build(self) -> Result { + HttpRelay::start(self.0).await + } +} + +pub struct HttpRelay { + pub(crate) http_handle: Handle, + + http_address: SocketAddr, +} + +impl HttpRelay { + pub fn builder() -> HttpRelayBuilder { + HttpRelayBuilder::default() + } + + pub async fn start(config: Config) -> Result { + let shared_state: SharedState = Arc::new(Mutex::new(HashMap::new())); + + let app = Router::new() + .route("/link/:id", get(link::get).post(link::post)) + .with_state(shared_state); + + let http_handle = Handle::new(); + + let http_listener = TcpListener::bind(SocketAddr::from(([0, 0, 0, 0], config.http_port)))?; + let http_address = http_listener.local_addr()?; + + tokio::spawn( + axum_server::from_tcp(http_listener) + .handle(http_handle.clone()) + .serve(app.into_make_service()) + .map_err(|error| tracing::error!(?error, "HttpRelay http server error")), + ); + + Ok(Self { + http_handle, + http_address, + }) + } + + pub fn http_address(&self) -> SocketAddr { + self.http_address + } + + /// Returns the localhost Url of this server. + pub fn local_url(&self) -> Url { + Url::parse(&format!("http://localhost:{}", self.http_address.port())) + .expect("local_url should be formatted fine") + } + + /// Returns the localhost URL of Link endpoints + pub fn local_link_url(&self) -> Url { + let mut url = self.local_url(); + + let mut segments = url + .path_segments_mut() + .expect("HttpRelay::local_link_url path_segments_mut"); + + segments.push("link"); + + drop(segments); + + url + } + + pub fn shutdown(&self) { + self.http_handle.shutdown(); + } +} + +mod link { + use super::*; + + pub async fn get( + Path(id): Path, + State(state): State, + ) -> impl IntoResponse { + // Create a notification for this ID + let notify = Arc::new(Notify::new()); + + { + let mut map = state.lock().unwrap(); + + // Store the notification and return it when POST arrives + map.entry(id.clone()) + .or_insert_with(|| (vec![], notify.clone())); + } + + notify.notified().await; + + // Respond with the data stored for this ID + let map = state.lock().unwrap(); + if let Some((data, _)) = map.get(&id) { + Bytes::from(data.clone()).into_response() + } else { + (axum::http::StatusCode::NOT_FOUND, "Not Found").into_response() + } + } + + pub async fn post( + Path(id): Path, + State(state): State, + body: Body, + ) -> impl IntoResponse { + // Aggregate the body into bytes + let mut stream = body.into_data_stream(); + let mut bytes = vec![]; + while let Some(next) = stream.next().await { + let chunk = next.map_err(|e| e.to_string()).unwrap(); + bytes.extend_from_slice(&chunk); + } + + // Notify any waiting GET request for this ID + let mut map = state.lock().unwrap(); + if let Some((storage, notify)) = map.get_mut(&id) { + *storage = bytes; + notify.notify_one(); + Ok(()) + } else { + Err(( + axum::http::StatusCode::NOT_FOUND, + "No waiting GET request for this ID", + )) + } + } +} diff --git a/pubky-common/Cargo.toml b/pubky-common/Cargo.toml index 8c9142f..9c0469e 100644 --- a/pubky-common/Cargo.toml +++ b/pubky-common/Cargo.toml @@ -9,22 +9,22 @@ repository = "https://github.com/pubky/pubky-core" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -base32 = "0.5.0" -blake3 = "1.5.1" +base32 = "0.5.1" +blake3 = "1.5.5" ed25519-dalek = { version = "2.1.1", features = ["serde"] } -once_cell = "1.19.0" +once_cell = "1.20.2" rand = "0.8.5" -thiserror = "1.0.60" -postcard = { version = "1.0.8", features = ["alloc"] } +thiserror = "2.0.6" +postcard = { version = "1.1.1", features = ["alloc"] } crypto_secretbox = { version = "0.1.1", features = ["std"] } argon2 = { version = "0.5.3", features = ["std"] } pubky-timestamp = { version = "0.2.0", features = ["full"] } -serde = { version = "1.0.213", features = ["derive"] } -pkarr = { version = "2.2.1-alpha.2", features = ["serde"] } +serde = { version = "1.0.216", features = ["derive"] } +pkarr = { workspace = true } [target.'cfg(target_arch = "wasm32")'.dependencies] -js-sys = "0.3.69" +js-sys = "0.3.76" [dev-dependencies] -postcard = "1.0.8" +postcard = "1.1.1" diff --git a/pubky-common/src/crypto.rs b/pubky-common/src/crypto.rs index a7adea5..6f09a33 100644 --- a/pubky-common/src/crypto.rs +++ b/pubky-common/src/crypto.rs @@ -30,7 +30,7 @@ pub fn random_bytes() -> [u8; N] { arr } -pub fn encrypt(plain_text: &[u8], encryption_key: &[u8; 32]) -> Result, Error> { +pub fn encrypt(plain_text: &[u8], encryption_key: &[u8; 32]) -> Result, EncryptError> { let cipher = XSalsa20Poly1305::new(encryption_key.into()); let nonce = XSalsa20Poly1305::generate_nonce(&mut OsRng); // unique per message let ciphertext = cipher.encrypt(&nonce, plain_text)?; @@ -42,16 +42,29 @@ pub fn encrypt(plain_text: &[u8], encryption_key: &[u8; 32]) -> Result, Ok(out) } -pub fn decrypt(bytes: &[u8], encryption_key: &[u8; 32]) -> Result, Error> { +pub fn decrypt(bytes: &[u8], encryption_key: &[u8; 32]) -> Result, DecryptError> { let cipher = XSalsa20Poly1305::new(encryption_key.into()); + if bytes.len() < 24 { + return Err(DecryptError::PayloadTooSmall(bytes.len())); + } + Ok(cipher.decrypt(bytes[..24].into(), &bytes[24..])?) } #[derive(thiserror::Error, Debug)] -pub enum Error { +pub enum EncryptError { + #[error(transparent)] + SecretBox(#[from] crypto_secretbox::Error), +} + +#[derive(thiserror::Error, Debug)] +pub enum DecryptError { #[error(transparent)] SecretBox(#[from] crypto_secretbox::Error), + + #[error("Encrypted message too small, expected at least 24 bytes nonce, receieved {0} bytes")] + PayloadTooSmall(usize), } #[cfg(test)] diff --git a/pubky-common/src/recovery_file.rs b/pubky-common/src/recovery_file.rs index 0a2f9b4..088dac9 100644 --- a/pubky-common/src/recovery_file.rs +++ b/pubky-common/src/recovery_file.rs @@ -82,7 +82,10 @@ pub enum Error { Argon(#[from] argon2::Error), #[error(transparent)] - Crypto(#[from] crate::crypto::Error), + DecryptError(#[from] crate::crypto::DecryptError), + + #[error(transparent)] + EncryptError(#[from] crate::crypto::EncryptError), } #[cfg(test)] diff --git a/pubky-common/src/session.rs b/pubky-common/src/session.rs index 6b17735..af34978 100644 --- a/pubky-common/src/session.rs +++ b/pubky-common/src/session.rs @@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize}; extern crate alloc; use alloc::vec::Vec; -use crate::{auth::AuthToken, capabilities::Capability, timestamp::Timestamp}; +use crate::{capabilities::Capability, timestamp::Timestamp}; // TODO: add IP address? // TODO: use https://crates.io/crates/user-agent-parser to parse the session @@ -22,12 +22,12 @@ pub struct Session { } impl Session { - pub fn new(token: &AuthToken, user_agent: Option) -> Self { + pub fn new(pubky: &PublicKey, capabilities: &[Capability], user_agent: Option) -> Self { Self { version: 0, - pubky: token.pubky().to_owned(), + pubky: pubky.clone(), created_at: Timestamp::now().as_u64(), - capabilities: token.capabilities().to_vec(), + capabilities: capabilities.to_vec(), user_agent: user_agent.as_deref().unwrap_or("").to_string(), name: user_agent.as_deref().unwrap_or("").to_string(), } diff --git a/pubky-common/src/timestamp.rs b/pubky-common/src/timestamp.rs new file mode 100644 index 0000000..4317484 --- /dev/null +++ b/pubky-common/src/timestamp.rs @@ -0,0 +1,280 @@ +//! Absolutely monotonic unix timestamp in microseconds + +use serde::{Deserialize, Serialize}; +use std::fmt::Display; +use std::{ + ops::{Add, Sub}, + sync::Mutex, +}; + +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; + +pub struct TimestampFactory { + clock_id: u64, + last_time: u64, +} + +impl TimestampFactory { + pub fn new() -> Self { + Self { + clock_id: rand::thread_rng().gen::() & CLOCK_MASK, + last_time: system_time() & TIME_MASK, + } + } + + pub fn now(&mut self) -> Timestamp { + // Ensure absolute monotonicity. + self.last_time = (system_time() & TIME_MASK).max(self.last_time + CLOCK_MASK + 1); + + // Add clock_id to the end of the timestamp + Timestamp(self.last_time | self.clock_id) + } +} + +impl Default for TimestampFactory { + fn default() -> Self { + Self::new() + } +} + +static DEFAULT_FACTORY: Lazy> = + Lazy::new(|| Mutex::new(TimestampFactory::default())); + +/// Absolutely monotonic timestamp since [SystemTime::UNIX_EPOCH] in microseconds as u64. +/// +/// The purpose of this timestamp is to unique per "user", not globally, +/// it achieves this by: +/// 1. Override the last byte with a random `clock_id`, reducing the probability +/// of two matching timestamps across multiple machines/threads. +/// 2. Gurantee that the remaining 3 bytes are ever increasing (absolutely monotonic) within +/// the same thread regardless of the wall clock value +/// +/// This timestamp is also serialized as BE bytes to remain sortable. +/// If a `utf-8` encoding is necessary, it is encoded as [base32::Alphabet::Crockford] +/// to act as a sortable Id. +/// +/// U64 of microseconds is valid for the next 500 thousand years! +#[derive(Debug, Clone, PartialEq, PartialOrd, Hash, Eq, Ord)] +pub struct Timestamp(u64); + +impl Timestamp { + pub fn now() -> Self { + DEFAULT_FACTORY.lock().unwrap().now() + } + + /// Return big endian bytes + pub fn to_bytes(&self) -> [u8; 8] { + self.0.to_be_bytes() + } + + pub fn difference(&self, rhs: &Timestamp) -> i64 { + (self.0 as i64) - (rhs.0 as i64) + } + + pub fn into_inner(&self) -> u64 { + self.0 + } +} + +impl Default for Timestamp { + fn default() -> Self { + Timestamp::now() + } +} + +impl Display for Timestamp { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let bytes: [u8; 8] = self.into(); + f.write_str(&base32::encode(base32::Alphabet::Crockford, &bytes)) + } +} + +impl TryFrom for Timestamp { + type Error = TimestampError; + + fn try_from(value: String) -> Result { + match base32::decode(base32::Alphabet::Crockford, &value) { + Some(vec) => { + let bytes: [u8; 8] = vec + .try_into() + .map_err(|_| TimestampError::InvalidEncoding)?; + + Ok(bytes.into()) + } + None => Err(TimestampError::InvalidEncoding), + } + } +} + +impl TryFrom<&[u8]> for Timestamp { + type Error = TimestampError; + + fn try_from(bytes: &[u8]) -> Result { + let bytes: [u8; 8] = bytes + .try_into() + .map_err(|_| TimestampError::InvalidBytesLength(bytes.len()))?; + + Ok(bytes.into()) + } +} + +impl From<&Timestamp> for [u8; 8] { + fn from(timestamp: &Timestamp) -> Self { + timestamp.0.to_be_bytes() + } +} + +impl From<[u8; 8]> for Timestamp { + fn from(bytes: [u8; 8]) -> Self { + Self(u64::from_be_bytes(bytes)) + } +} + +// === U64 conversion === + +impl From for u64 { + fn from(value: Timestamp) -> Self { + value.into_inner() + } +} + +impl Add for &Timestamp { + type Output = Timestamp; + + fn add(self, rhs: u64) -> Self::Output { + Timestamp(self.0 + rhs) + } +} + +impl Sub for &Timestamp { + type Output = Timestamp; + + fn sub(self, rhs: u64) -> Self::Output { + Timestamp(self.0 - rhs) + } +} + +impl Serialize for Timestamp { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + let bytes = self.to_bytes(); + bytes.serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for Timestamp { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let bytes: [u8; 8] = Deserialize::deserialize(deserializer)?; + Ok(Timestamp(u64::from_be_bytes(bytes))) + } +} + +#[cfg(not(target_arch = "wasm32"))] +/// Return the number of microseconds since [SystemTime::UNIX_EPOCH] +fn system_time() -> u64 { + SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .expect("time drift") + .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}")] + InvalidBytesLength(usize), + #[error("Invalid timestamp encoding")] + InvalidEncoding, +} + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + + use super::*; + + #[test] + fn absolutely_monotonic() { + const COUNT: usize = 100; + + let mut set = HashSet::with_capacity(COUNT); + let mut vec = Vec::with_capacity(COUNT); + + for _ in 0..COUNT { + let timestamp = Timestamp::now(); + + set.insert(timestamp.clone()); + vec.push(timestamp); + } + + let mut ordered = vec.clone(); + ordered.sort(); + + assert_eq!(set.len(), COUNT, "unique"); + assert_eq!(ordered, vec, "ordered"); + } + + #[test] + fn strings() { + const COUNT: usize = 100; + + let mut set = HashSet::with_capacity(COUNT); + let mut vec = Vec::with_capacity(COUNT); + + for _ in 0..COUNT { + let string = Timestamp::now().to_string(); + + set.insert(string.clone()); + vec.push(string) + } + + let mut ordered = vec.clone(); + ordered.sort(); + + assert_eq!(set.len(), COUNT, "unique"); + assert_eq!(ordered, vec, "ordered"); + } + + #[test] + fn to_from_string() { + let timestamp = Timestamp::now(); + let string = timestamp.to_string(); + let decoded: Timestamp = string.try_into().unwrap(); + + assert_eq!(decoded, timestamp) + } + + #[test] + fn serde() { + let timestamp = Timestamp::now(); + + let serialized = postcard::to_allocvec(×tamp).unwrap(); + + assert_eq!(serialized, timestamp.to_bytes()); + + let deserialized: Timestamp = postcard::from_bytes(&serialized).unwrap(); + + assert_eq!(deserialized, timestamp); + } +} diff --git a/pubky-homeserver/Cargo.toml b/pubky-homeserver/Cargo.toml index 7b37a20..de336dd 100644 --- a/pubky-homeserver/Cargo.toml +++ b/pubky-homeserver/Cargo.toml @@ -4,30 +4,32 @@ version = "0.1.0" edition = "2021" [dependencies] -anyhow = "1.0.82" -axum = { version = "0.7.5", features = ["macros"] } -axum-extra = { version = "0.9.3", features = ["typed-header", "async-read-body"] } +anyhow = "1.0.94" +axum = { version = "0.7.9", features = ["macros"] } +axum-extra = { version = "0.9.6", features = ["typed-header", "async-read-body"] } base32 = "0.5.1" -bytes = "^1.7.1" -clap = { version = "4.5.11", features = ["derive"] } +bytes = "^1.9.0" +clap = { version = "4.5.23", features = ["derive"] } dirs-next = "2.0.0" -flume = "0.11.0" -futures-util = "0.3.30" -heed = "0.20.3" +flume = "0.11.1" +futures-util = "0.3.31" +heed = "0.21.0" hex = "0.4.3" httpdate = "1.0.3" -libc = "0.2.159" -postcard = { version = "1.0.8", features = ["alloc"] } -pkarr = { version = "2.2.1-alpha.2", features = ["serde", "async"] } +postcard = { version = "1.1.1", features = ["alloc"] } +pkarr = { workspace = true } pubky-common = { version = "0.1.0", path = "../pubky-common" } -serde = { version = "1.0.213", features = ["derive"] } -tokio = { version = "1.37.0", features = ["full"] } +serde = { version = "1.0.216", features = ["derive"] } +tokio = { version = "1.42.0", features = ["full"] } toml = "0.8.19" tower-cookies = "0.10.0" -tower-http = { version = "0.5.2", features = ["cors", "trace"] } -tracing = "0.1.40" -tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } -url = "2.5.2" +tower-http = { version = "0.6.2", features = ["cors", "trace"] } +tracing = "0.1.41" +tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } +url = "2.5.4" +axum-server = { version = "0.7.1", features = ["tls-rustls-no-provider"] } +tower = "0.5.1" +page_size = "0.6.0" -[dev-dependencies] -reqwest = "0.12.8" +pkarr-relay = { git = "https://github.com/Pubky/pkarr", branch = "v3", package = "pkarr-relay" } +http-relay = { version = "0.1.0", path = "../http-relay" } diff --git a/pubky-homeserver/README.md b/pubky-homeserver/README.md index f222f7e..ee11bc0 100644 --- a/pubky-homeserver/README.md +++ b/pubky-homeserver/README.md @@ -21,5 +21,13 @@ cargo build --release Run with an optional config file ```bash -../target/release/pubky_homeserver --config=./src/config.toml +../target/release/pubky-homeserver --config=./src/config.toml +``` + +## Testnet + +To run a local homeserver for testing with an internal Pkarr Relay, hardcoded well known publickey and only connected to local Mainline testnet: + +```bash +cargo run -- --testnet ``` diff --git a/pubky-homeserver/src/config.example.toml b/pubky-homeserver/src/config.example.toml index 2012efc..9584bcb 100644 --- a/pubky-homeserver/src/config.example.toml +++ b/pubky-homeserver/src/config.example.toml @@ -1,10 +1,11 @@ -# Use testnet network (local DHT) for testing. -testnet = false # Secret key (in hex) to generate the Homeserver's Keypair -secret_key = "0000000000000000000000000000000000000000000000000000000000000000" -# Domain to be published in Pkarr records for this server to be accessible by. -domain = "localhost" +# secret_key = "0000000000000000000000000000000000000000000000000000000000000000" + +# ICANN domain pointing to this server to allow browsers to connect to it. +# domain = "example.com" + # Port for the Homeserver to listen on. port = 6287 + # Storage directory Defaults to # storage = "" diff --git a/pubky-homeserver/src/config.rs b/pubky-homeserver/src/core/config.rs similarity index 64% rename from pubky-homeserver/src/config.rs rename to pubky-homeserver/src/core/config.rs index 060fea7..9ae0414 100644 --- a/pubky-homeserver/src/config.rs +++ b/pubky-homeserver/src/core/config.rs @@ -8,9 +8,6 @@ use std::{ path::{Path, PathBuf}, time::Duration, }; -use tracing::info; - -use pubky_common::timestamp::Timestamp; // === Database === const DEFAULT_STORAGE_DIR: &str = "pubky"; @@ -22,7 +19,6 @@ pub const DEFAULT_MAX_LIST_LIMIT: u16 = 1000; #[derive(Serialize, Deserialize, Clone, PartialEq)] struct ConfigToml { - testnet: Option, port: Option, bootstrap: Option>, domain: Option, @@ -37,90 +33,44 @@ struct ConfigToml { /// Server configuration #[derive(Debug, Clone, PartialEq, Eq)] pub struct Config { - /// Whether or not this server is running in a testnet. - testnet: bool, + /// Run in [testnet](crate::Homeserver::start_testnet) mode. + pub testnet: bool, /// The configured port for this server. - port: u16, + pub port: u16, /// Bootstrapping DHT nodes. /// /// Helpful to run the server locally or in testnet. - bootstrap: Option>, + pub bootstrap: Option>, /// A public domain for this server /// necessary for web browsers running in https environment. - domain: Option, + pub domain: Option, /// Path to the storage directory. /// /// Defaults to a directory in the OS data directory - storage: PathBuf, + pub storage: PathBuf, /// Server keypair. /// /// Defaults to a random keypair. - keypair: Keypair, - dht_request_timeout: Option, + pub keypair: Keypair, + pub dht_request_timeout: Option, /// The default limit of a list api if no `limit` query parameter is provided. /// /// Defaults to `100` - default_list_limit: u16, + pub default_list_limit: u16, /// The maximum limit of a list api, even if a `limit` query parameter is provided. /// /// Defaults to `1000` - max_list_limit: u16, + pub max_list_limit: u16, // === Database params === - db_map_size: usize, + pub db_map_size: usize, } impl Config { fn try_from_str(value: &str) -> Result { let config_toml: ConfigToml = toml::from_str(value)?; - let keypair = if let Some(secret_key) = config_toml.secret_key { - let secret_key = deserialize_secret_key(secret_key)?; - Keypair::from_secret_key(&secret_key) - } else { - Keypair::random() - }; - - let storage = { - let dir = if let Some(storage) = config_toml.storage { - storage - } else { - let path = dirs_next::data_dir().ok_or_else(|| { - anyhow!("operating environment provides no directory for application data") - })?; - path.join(DEFAULT_STORAGE_DIR) - }; - - dir.join("homeserver") - }; - - let config = Config { - testnet: config_toml.testnet.unwrap_or(false), - port: config_toml.port.unwrap_or(0), - bootstrap: config_toml.bootstrap, - domain: config_toml.domain, - keypair, - storage, - dht_request_timeout: config_toml.dht_request_timeout, - default_list_limit: config_toml.default_list_limit.unwrap_or(DEFAULT_LIST_LIMIT), - max_list_limit: config_toml - .default_list_limit - .unwrap_or(DEFAULT_MAX_LIST_LIMIT), - db_map_size: config_toml.db_map_size.unwrap_or(DEFAULT_MAP_SIZE), - }; - - if config.testnet { - let testnet_config = Config::testnet(); - - return Ok(Config { - bootstrap: testnet_config.bootstrap, - port: testnet_config.port, - keypair: testnet_config.keypair, - ..config - }); - } - - Ok(config) + config_toml.try_into() } /// Load the config from a file. @@ -132,72 +82,21 @@ impl Config { Config::try_from_str(&s) } - /// Testnet configurations - pub fn testnet() -> Self { - let testnet = pkarr::mainline::Testnet::new(10); - info!(?testnet.bootstrap, "Testnet bootstrap nodes"); - - Config { - port: 15411, - dht_request_timeout: None, - db_map_size: DEFAULT_MAP_SIZE, - keypair: Keypair::from_secret_key(&[0; 32]), - ..Self::test(&testnet) - } - } - /// Test configurations pub fn test(testnet: &pkarr::mainline::Testnet) -> Self { let bootstrap = Some(testnet.bootstrap.to_owned()); let storage = std::env::temp_dir() - .join(Timestamp::now().to_string()) + .join(pubky_common::timestamp::Timestamp::now().to_string()) .join(DEFAULT_STORAGE_DIR); Self { - testnet: true, bootstrap, storage, + domain: Some("localhost".to_string()), db_map_size: 10485760, ..Default::default() } } - - pub fn port(&self) -> u16 { - self.port - } - - pub fn bootstsrap(&self) -> Option> { - self.bootstrap.to_owned() - } - - pub fn domain(&self) -> &Option { - &self.domain - } - - pub fn keypair(&self) -> &Keypair { - &self.keypair - } - - pub fn default_list_limit(&self) -> u16 { - self.default_list_limit - } - - pub fn max_list_limit(&self) -> u16 { - self.max_list_limit - } - - /// Get the path to the storage directory - pub fn storage(&self) -> &PathBuf { - &self.storage - } - - pub(crate) fn dht_request_timeout(&self) -> Option { - self.dht_request_timeout - } - - pub(crate) fn db_map_size(&self) -> usize { - self.db_map_size - } } impl Default for Config { @@ -218,6 +117,45 @@ impl Default for Config { } } +impl TryFrom for Config { + type Error = anyhow::Error; + + fn try_from(value: ConfigToml) -> std::result::Result { + let keypair = if let Some(secret_key) = value.secret_key { + let secret_key = deserialize_secret_key(secret_key)?; + Keypair::from_secret_key(&secret_key) + } else { + Keypair::random() + }; + + let storage = { + let dir = if let Some(storage) = value.storage { + storage + } else { + let path = dirs_next::data_dir().ok_or_else(|| { + anyhow!("operating environment provides no directory for application data") + })?; + path.join(DEFAULT_STORAGE_DIR) + }; + + dir.join("homeserver") + }; + + Ok(Config { + testnet: false, + port: value.port.unwrap_or(0), + bootstrap: value.bootstrap, + domain: value.domain, + keypair, + storage, + dht_request_timeout: value.dht_request_timeout, + default_list_limit: value.default_list_limit.unwrap_or(DEFAULT_LIST_LIMIT), + max_list_limit: value.default_list_limit.unwrap_or(DEFAULT_MAX_LIST_LIMIT), + db_map_size: value.db_map_size.unwrap_or(DEFAULT_MAP_SIZE), + }) + } +} + fn deserialize_secret_key(s: String) -> anyhow::Result<[u8; 32]> { let bytes = hex::decode(s).map_err(|_| anyhow!("secret_key in config.toml should hex encoded"))?; @@ -269,54 +207,37 @@ mod tests { #[test] fn config_test() { - let testnet = Testnet::new(3); + let testnet = Testnet::new(3).unwrap(); let config = Config::test(&testnet); assert_eq!( config, Config { - testnet: true, bootstrap: testnet.bootstrap.into(), db_map_size: 10485760, storage: config.storage.clone(), keypair: config.keypair.clone(), - ..Default::default() - } - ) - } - #[test] - fn config_testnet() { - let config = Config::testnet(); + domain: Some("localhost".to_string()), - assert_eq!( - config, - Config { - testnet: true, - port: 15411, - - bootstrap: config.bootstrap.clone(), - storage: config.storage.clone(), - keypair: config.keypair.clone(), ..Default::default() } ) } #[test] - fn parse_with_testnet_flag() { + fn parse() { let config = Config::try_from_str( r#" # Secret key (in hex) to generate the Homeserver's Keypair - secret_key = "0123000000000000000000000000000000000000000000000000000000000000" + secret_key = "0000000000000000000000000000000000000000000000000000000000000000" # Domain to be published in Pkarr records for this server to be accessible by. domain = "localhost" # Port for the Homeserver to listen on. port = 6287 # Storage directory Defaults to storage = "/homeserver" - testnet = true bootstrap = ["foo", "bar"] @@ -328,8 +249,8 @@ mod tests { .unwrap(); assert_eq!(config.keypair, Keypair::from_secret_key(&[0; 32])); - assert_eq!(config.port, 15411); - assert_ne!( + assert_eq!(config.port, 6287); + assert_eq!( config.bootstrap, Some(vec!["foo".to_string(), "bar".to_string()]) ); diff --git a/pubky-homeserver/src/database/migrations.rs b/pubky-homeserver/src/core/database/migrations.rs similarity index 100% rename from pubky-homeserver/src/database/migrations.rs rename to pubky-homeserver/src/core/database/migrations.rs diff --git a/pubky-homeserver/src/database/migrations/m0.rs b/pubky-homeserver/src/core/database/migrations/m0.rs similarity index 87% rename from pubky-homeserver/src/database/migrations/m0.rs rename to pubky-homeserver/src/core/database/migrations/m0.rs index 11c0e1a..27f4b9e 100644 --- a/pubky-homeserver/src/database/migrations/m0.rs +++ b/pubky-homeserver/src/core/database/migrations/m0.rs @@ -1,6 +1,6 @@ use heed::{Env, RwTxn}; -use crate::database::tables::{blobs, entries, events, sessions, users}; +use crate::core::database::tables::{blobs, entries, events, sessions, users}; pub fn run(env: &Env, wtxn: &mut RwTxn) -> anyhow::Result<()> { let _: users::UsersTable = env.create_database(wtxn, Some(users::USERS_TABLE))?; diff --git a/pubky-homeserver/src/core/database/mod.rs b/pubky-homeserver/src/core/database/mod.rs new file mode 100644 index 0000000..dd05095 --- /dev/null +++ b/pubky-homeserver/src/core/database/mod.rs @@ -0,0 +1,81 @@ +//! Internal database in [crate::HomeserverCore] + +use std::{fs, path::PathBuf}; + +use heed::{Env, EnvOpenOptions}; + +mod migrations; +pub mod tables; + +use crate::core::config::Config; + +use tables::{Tables, TABLES_COUNT}; + +pub use protected::DB; + +/// Protecting fields from being mutated by modules in crate::database +mod protected { + use super::*; + + #[derive(Debug, Clone)] + pub struct DB { + pub(crate) env: Env, + pub(crate) tables: Tables, + pub(crate) buffers_dir: PathBuf, + pub(crate) max_chunk_size: usize, + config: Config, + } + + impl DB { + /// # Safety + /// DB uses LMDB, [opening][heed::EnvOpenOptions::open] which is marked unsafe, + /// because the possible Undefined Behavior (UB) if the lock file is broken. + pub unsafe fn open(config: Config) -> anyhow::Result { + let buffers_dir = config.storage.clone().join("buffers"); + + // Cleanup buffers. + let _ = fs::remove_dir(&buffers_dir); + fs::create_dir_all(&buffers_dir)?; + + let env = unsafe { + EnvOpenOptions::new() + .max_dbs(TABLES_COUNT) + .map_size(config.db_map_size) + .open(&config.storage) + }?; + + let tables = migrations::run(&env)?; + + let db = DB { + env, + tables, + config, + buffers_dir, + max_chunk_size: max_chunk_size(), + }; + + Ok(db) + } + + // === Getters === + + pub fn config(&self) -> &Config { + &self.config + } + } +} + +/// calculate optimal chunk size: +/// - https://lmdb.readthedocs.io/en/release/#storage-efficiency-limits +/// - https://github.com/lmdbjava/benchmarks/blob/master/results/20160710/README.md#test-2-determine-24816-kb-byte-values +fn max_chunk_size() -> usize { + let page_size = page_size::get(); + + // - 16 bytes Header per page (LMDB) + // - Each page has to contain 2 records + // - 8 bytes per record (LMDB) (imperically, it seems to be 10 not 8) + // - 12 bytes key: + // - timestamp : 8 bytes + // - chunk index: 4 bytes + ((page_size - 16) / 2) - (8 + 2) - 12 +} diff --git a/pubky-homeserver/src/database/tables.rs b/pubky-homeserver/src/core/database/tables.rs similarity index 100% rename from pubky-homeserver/src/database/tables.rs rename to pubky-homeserver/src/core/database/tables.rs diff --git a/pubky-homeserver/src/database/tables/blobs.rs b/pubky-homeserver/src/core/database/tables/blobs.rs similarity index 94% rename from pubky-homeserver/src/database/tables/blobs.rs rename to pubky-homeserver/src/core/database/tables/blobs.rs index 18ec724..1162652 100644 --- a/pubky-homeserver/src/database/tables/blobs.rs +++ b/pubky-homeserver/src/core/database/tables/blobs.rs @@ -1,6 +1,6 @@ use heed::{types::Bytes, Database, RoTxn}; -use crate::database::DB; +use crate::core::database::DB; use super::entries::Entry; diff --git a/pubky-homeserver/src/database/tables/entries.rs b/pubky-homeserver/src/core/database/tables/entries.rs similarity index 93% rename from pubky-homeserver/src/database/tables/entries.rs rename to pubky-homeserver/src/core/database/tables/entries.rs index 0079a4f..c2a9712 100644 --- a/pubky-homeserver/src/database/tables/entries.rs +++ b/pubky-homeserver/src/core/database/tables/entries.rs @@ -18,7 +18,7 @@ use pubky_common::{ timestamp::Timestamp, }; -use crate::database::DB; +use crate::core::database::DB; use super::events::Event; @@ -28,6 +28,9 @@ pub type EntriesTable = Database; pub const ENTRIES_TABLE: &str = "entries"; impl DB { + /// Write an entry by an author at a given path. + /// + /// The path has to start with a forward slash `/` pub fn write_entry( &mut self, public_key: &PublicKey, @@ -36,10 +39,13 @@ impl DB { EntryWriter::new(self, public_key, path) } + /// Delete an entry by an author at a given path. + /// + /// The path has to start with a forward slash `/` pub fn delete_entry(&mut self, public_key: &PublicKey, path: &str) -> anyhow::Result { let mut wtxn = self.env.write_txn()?; - let key = format!("{public_key}/{path}"); + let key = format!("{public_key}{path}"); let deleted = if let Some(bytes) = self.tables.entries.get(&wtxn, &key)? { let entry = Entry::deserialize(bytes)?; @@ -62,7 +68,7 @@ impl DB { let deleted_entry = self.tables.entries.delete(&mut wtxn, &key)?; // create DELETE event - if path.starts_with("pub/") { + if path.starts_with("/pub/") { let url = format!("pubky://{key}"); let event = Event::delete(&url); @@ -92,7 +98,7 @@ impl DB { public_key: &PublicKey, path: &str, ) -> anyhow::Result> { - let key = format!("{public_key}/{path}"); + let key = format!("{public_key}{path}"); if let Some(bytes) = self.tables.entries.get(txn, &key)? { return Ok(Some(Entry::deserialize(bytes)?)); @@ -107,7 +113,7 @@ impl DB { /// Return a list of pubky urls. /// - /// - limit defaults to [Config::default_list_limit] and capped by [Config::max_list_limit] + /// - limit defaults to [crate::Config::default_list_limit] and capped by [crate::Config::max_list_limit] pub fn list( &self, txn: &RoTxn, @@ -121,8 +127,8 @@ impl DB { let mut results = Vec::new(); let limit = limit - .unwrap_or(self.config.default_list_limit()) - .min(self.config.max_list_limit()); + .unwrap_or(self.config().default_list_limit) + .min(self.config().max_list_limit); // TODO: make this more performant than split and allocations? @@ -336,7 +342,7 @@ impl<'db> EntryWriter<'db> { let buffer = File::create(&buffer_path)?; - let entry_key = format!("{public_key}/{path}"); + let entry_key = format!("{public_key}{path}"); Ok(Self { db, @@ -345,7 +351,7 @@ impl<'db> EntryWriter<'db> { buffer_path, entry_key, timestamp, - is_public: path.starts_with("pub/"), + is_public: path.starts_with("/pub/"), }) } @@ -447,13 +453,13 @@ mod tests { use bytes::Bytes; use pkarr::{mainline::Testnet, Keypair}; - use crate::config::Config; + use crate::Config; use super::DB; #[tokio::test] async fn entries() -> anyhow::Result<()> { - let mut db = DB::open(Config::test(&Testnet::new(0))).unwrap(); + let mut db = unsafe { DB::open(Config::test(&Testnet::new(0).unwrap())).unwrap() }; let keypair = Keypair::random(); let public_key = keypair.public_key(); @@ -495,7 +501,7 @@ mod tests { #[tokio::test] async fn chunked_entry() -> anyhow::Result<()> { - let mut db = DB::open(Config::test(&Testnet::new(0))).unwrap(); + let mut db = unsafe { DB::open(Config::test(&Testnet::new(0).unwrap())).unwrap() }; let keypair = Keypair::random(); let public_key = keypair.public_key(); diff --git a/pubky-homeserver/src/database/tables/events.rs b/pubky-homeserver/src/core/database/tables/events.rs similarity index 90% rename from pubky-homeserver/src/database/tables/events.rs rename to pubky-homeserver/src/core/database/tables/events.rs index 76a4d46..39a6f73 100644 --- a/pubky-homeserver/src/database/tables/events.rs +++ b/pubky-homeserver/src/core/database/tables/events.rs @@ -10,7 +10,7 @@ use heed::{ use postcard::{from_bytes, to_allocvec}; use serde::{Deserialize, Serialize}; -use crate::database::DB; +use crate::core::database::DB; /// Event [Timestamp] base32 => Encoded event. pub type EventsTable = Database; @@ -62,7 +62,7 @@ impl Event { impl DB { /// Returns a list of events formatted as ` `. /// - /// - limit defaults to [Config::default_list_limit] and capped by [Config::max_list_limit] + /// - limit defaults to [crate::Config::default_list_limit] and capped by [crate::Config::max_list_limit] /// - cursor is a 13 character string encoding of a timestamp pub fn list_events( &self, @@ -72,8 +72,8 @@ impl DB { let txn = self.env.read_txn()?; let limit = limit - .unwrap_or(self.config.default_list_limit()) - .min(self.config.max_list_limit()); + .unwrap_or(self.config().default_list_limit) + .min(self.config().max_list_limit); let cursor = cursor.unwrap_or("0000000000000".to_string()); diff --git a/pubky-homeserver/src/core/database/tables/sessions.rs b/pubky-homeserver/src/core/database/tables/sessions.rs new file mode 100644 index 0000000..c2fa2fb --- /dev/null +++ b/pubky-homeserver/src/core/database/tables/sessions.rs @@ -0,0 +1,42 @@ +use heed::{ + types::{Bytes, Str}, + Database, +}; +use pubky_common::session::Session; + +use crate::core::database::DB; + +/// session secret => Session. +pub type SessionsTable = Database; + +pub const SESSIONS_TABLE: &str = "sessions"; + +impl DB { + pub fn get_session(&self, session_secret: &str) -> anyhow::Result> { + let rtxn = self.env.read_txn()?; + + let session = self + .tables + .sessions + .get(&rtxn, session_secret)? + .map(|s| s.to_vec()); + + rtxn.commit()?; + + if let Some(bytes) = session { + return Ok(Some(Session::deserialize(&bytes)?)); + }; + + Ok(None) + } + + pub fn delete_session(&mut self, secret: &str) -> anyhow::Result { + let mut wtxn = self.env.write_txn()?; + + let deleted = self.tables.sessions.delete(&mut wtxn, secret)?; + + wtxn.commit()?; + + Ok(deleted) + } +} diff --git a/pubky-homeserver/src/database/tables/users.rs b/pubky-homeserver/src/core/database/tables/users.rs similarity index 100% rename from pubky-homeserver/src/database/tables/users.rs rename to pubky-homeserver/src/core/database/tables/users.rs diff --git a/pubky-homeserver/src/error.rs b/pubky-homeserver/src/core/error.rs similarity index 84% rename from pubky-homeserver/src/error.rs rename to pubky-homeserver/src/core/error.rs index 1a8f8a0..82117c3 100644 --- a/pubky-homeserver/src/error.rs +++ b/pubky-homeserver/src/core/error.rs @@ -78,8 +78,26 @@ impl From for Error { } } -impl From for Error { - fn from(error: pkarr::Error) -> Self { +impl From for Error { + fn from(error: pkarr::errors::SignedPacketVerifyError) -> Self { + Self::new(StatusCode::BAD_REQUEST, Some(error)) + } +} + +impl From for Error { + fn from(error: pkarr::errors::PublishError) -> Self { + Self::new(StatusCode::BAD_REQUEST, Some(error)) + } +} + +impl From for Error { + fn from(error: pkarr::errors::ClientWasShutdown) -> Self { + Self::new(StatusCode::BAD_REQUEST, Some(error)) + } +} + +impl From for Error { + fn from(error: pkarr::errors::PublicKeyError) -> Self { Self::new(StatusCode::BAD_REQUEST, Some(error)) } } diff --git a/pubky-homeserver/src/extractors.rs b/pubky-homeserver/src/core/extractors.rs similarity index 53% rename from pubky-homeserver/src/extractors.rs rename to pubky-homeserver/src/core/extractors.rs index 779ce65..027cb2a 100644 --- a/pubky-homeserver/src/extractors.rs +++ b/pubky-homeserver/src/core/extractors.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use axum::{ async_trait, - extract::{FromRequestParts, Path, Query}, + extract::{FromRequestParts, Query}, http::{request::Parts, StatusCode}, response::{IntoResponse, Response}, RequestPartsExt, @@ -10,68 +10,38 @@ use axum::{ use pkarr::PublicKey; -use crate::error::{Error, Result}; +use crate::core::error::Result; -#[derive(Debug)] -pub struct Pubky(PublicKey); +#[derive(Debug, Clone)] +pub struct PubkyHost(pub(crate) PublicKey); -impl Pubky { +impl PubkyHost { pub fn public_key(&self) -> &PublicKey { &self.0 } } #[async_trait] -impl FromRequestParts for Pubky +impl FromRequestParts for PubkyHost where - S: Send + Sync, + S: Sync + Send, { type Rejection = Response; async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { - let params: Path> = - parts.extract().await.map_err(IntoResponse::into_response)?; - - let pubky_id = params - .get("pubky") - .ok_or_else(|| (StatusCode::NOT_FOUND, "pubky param missing").into_response())?; - - let public_key = PublicKey::try_from(pubky_id.to_string()) - .map_err(Error::try_from) - .map_err(IntoResponse::into_response)?; - - // TODO: return 404 if the user doesn't exist, but exclude signups. - - Ok(Pubky(public_key)) - } -} - -pub struct EntryPath(pub(crate) String); - -impl EntryPath { - pub fn as_str(&self) -> &str { - self.0.as_str() - } -} - -#[async_trait] -impl FromRequestParts for EntryPath -where - S: Send + Sync, -{ - type Rejection = Response; - - async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { - let params: Path> = - parts.extract().await.map_err(IntoResponse::into_response)?; - - // TODO: enforce path limits like no trailing '/' - - let path = params - .get("path") - .ok_or_else(|| (StatusCode::NOT_FOUND, "entry path missing").into_response())?; - - Ok(EntryPath(path.to_string())) + let pubky_host = parts + .extensions + .get::() + .cloned() + .ok_or(( + StatusCode::INTERNAL_SERVER_ERROR, + "Can't extract PubkyHost. Is `PubkyHostLayer` enabled?", + )) + .map_err(|e| e.into_response())?; + + tracing::debug!(pubky_host = ?pubky_host.public_key().to_string()); + + Ok(pubky_host) } } diff --git a/pubky-homeserver/src/core/layers/authz.rs b/pubky-homeserver/src/core/layers/authz.rs new file mode 100644 index 0000000..435463e --- /dev/null +++ b/pubky-homeserver/src/core/layers/authz.rs @@ -0,0 +1,147 @@ +use axum::http::Method; +use axum::response::IntoResponse; +use axum::{ + body::Body, + http::{Request, StatusCode}, +}; +use futures_util::future::BoxFuture; +use pkarr::PublicKey; +use std::{convert::Infallible, task::Poll}; +use tower::{Layer, Service}; +use tower_cookies::Cookies; + +use crate::core::{ + error::{Error, Result}, + extractors::PubkyHost, + AppState, +}; + +/// A Tower Layer to handle authorization for write operations. +#[derive(Debug, Clone)] +pub struct AuthorizationLayer { + state: AppState, +} + +impl AuthorizationLayer { + pub fn new(state: AppState) -> Self { + Self { state } + } +} + +impl Layer for AuthorizationLayer { + type Service = AuthorizationMiddleware; + + fn layer(&self, inner: S) -> Self::Service { + AuthorizationMiddleware { + inner, + state: self.state.clone(), + } + } +} + +/// Middleware that performs authorization checks for write operations. +#[derive(Debug, Clone)] +pub struct AuthorizationMiddleware { + inner: S, + state: AppState, +} + +impl Service> for AuthorizationMiddleware +where + S: Service, Response = axum::response::Response, Error = Infallible> + + Send + + 'static + + Clone, + S::Future: Send + 'static, +{ + type Response = S::Response; + type Error = S::Error; + type Future = BoxFuture<'static, Result>; + + fn poll_ready(&mut self, cx: &mut std::task::Context<'_>) -> Poll> { + self.inner.poll_ready(cx).map_err(|_| unreachable!()) // `Infallible` conversion + } + + fn call(&mut self, req: Request) -> Self::Future { + let state = self.state.clone(); + let mut inner = self.inner.clone(); + + Box::pin(async move { + let path = req.uri().path(); + + let pubky = match req.extensions().get::() { + Some(pk) => pk, + None => { + return Ok( + Error::new(StatusCode::NOT_FOUND, "Pubky Host is missing".into()) + .into_response(), + ) + } + }; + + let cookies = req.extensions().get::(); + + // Authorize the request + if let Err(e) = authorize(&state, req.method(), cookies, pubky.public_key(), path) { + return Ok(e.into_response()); + } + + // If authorized, proceed to the inner service + inner.call(req).await.map_err(|_| unreachable!()) + }) + } +} + +/// Authorize write (PUT or DELETE) for Public paths. +fn authorize( + state: &AppState, + method: &Method, + cookies: Option<&Cookies>, + public_key: &PublicKey, + path: &str, +) -> Result<()> { + if path == "/session" { + // Checking (or deleting) one's session is ok for everyone + return Ok(()); + } else if path.starts_with("/pub/") { + if method == Method::GET { + return Ok(()); + } + } else { + return Err(Error::new( + StatusCode::FORBIDDEN, + "Writing to directories other than '/pub/' is forbidden".into(), + )); + } + + if let Some(cookies) = cookies { + let session_secret = session_secret_from_cookies(cookies, public_key) + .ok_or(Error::with_status(StatusCode::UNAUTHORIZED))?; + + let session = state + .db + .get_session(&session_secret)? + .ok_or(Error::with_status(StatusCode::UNAUTHORIZED))?; + + if session.pubky() == public_key + && session.capabilities().iter().any(|cap| { + path.starts_with(&cap.scope) + && cap + .actions + .contains(&pubky_common::capabilities::Action::Write) + }) + { + return Ok(()); + } + + return Err(Error::with_status(StatusCode::FORBIDDEN)); + } + + Err(Error::with_status(StatusCode::UNAUTHORIZED)) +} + +pub fn session_secret_from_cookies(cookies: &Cookies, public_key: &PublicKey) -> Option { + cookies + .get(&public_key.to_string()) + .map(|c| c.value().to_string()) +} diff --git a/pubky-homeserver/src/core/layers/mod.rs b/pubky-homeserver/src/core/layers/mod.rs new file mode 100644 index 0000000..585095b --- /dev/null +++ b/pubky-homeserver/src/core/layers/mod.rs @@ -0,0 +1,2 @@ +pub mod authz; +pub mod pubky_host; diff --git a/pubky-homeserver/src/core/layers/pubky_host.rs b/pubky-homeserver/src/core/layers/pubky_host.rs new file mode 100644 index 0000000..1af6d1e --- /dev/null +++ b/pubky-homeserver/src/core/layers/pubky_host.rs @@ -0,0 +1,64 @@ +use pkarr::PublicKey; + +use crate::core::extractors::PubkyHost; + +use axum::{body::Body, http::Request}; +use futures_util::future::BoxFuture; +use std::{convert::Infallible, task::Poll}; +use tower::{Layer, Service}; + +use crate::core::error::Result; + +/// A Tower Layer to handle authorization for write operations. +#[derive(Debug, Clone)] +pub struct PubkyHostLayer; + +impl Layer for PubkyHostLayer { + type Service = PubkyHostLayerMiddleware; + + fn layer(&self, inner: S) -> Self::Service { + PubkyHostLayerMiddleware { inner } + } +} + +/// Middleware that performs authorization checks for write operations. +#[derive(Debug, Clone)] +pub struct PubkyHostLayerMiddleware { + inner: S, +} + +impl Service> for PubkyHostLayerMiddleware +where + S: Service, Response = axum::response::Response, Error = Infallible> + + Send + + 'static + + Clone, + S::Future: Send + 'static, +{ + type Response = S::Response; + type Error = Infallible; + type Future = BoxFuture<'static, Result>; + + fn poll_ready(&mut self, cx: &mut std::task::Context<'_>) -> Poll> { + self.inner.poll_ready(cx).map_err(|_| unreachable!()) // `Infallible` conversion + } + + fn call(&mut self, req: Request) -> Self::Future { + let mut inner = self.inner.clone(); + let mut req = req; + + Box::pin(async move { + let headers_to_check = ["host", "pubky-host"]; + + for header in headers_to_check { + if let Some(Ok(pubky_host)) = req.headers().get(header).map(|h| h.to_str()) { + if let Ok(public_key) = PublicKey::try_from(pubky_host) { + req.extensions_mut().insert(PubkyHost(public_key)); + } + } + } + + inner.call(req).await.map_err(|_| unreachable!()) + }) + } +} diff --git a/pubky-homeserver/src/core/mod.rs b/pubky-homeserver/src/core/mod.rs new file mode 100644 index 0000000..5ab88e7 --- /dev/null +++ b/pubky-homeserver/src/core/mod.rs @@ -0,0 +1,113 @@ +use anyhow::Result; +use axum::{ + body::Body, + extract::Request, + http::{header, Method}, + response::Response, + Router, +}; +use pkarr::{Keypair, PublicKey}; +use pubky_common::{ + auth::{AuthToken, AuthVerifier}, + capabilities::Capability, +}; +use tower::ServiceExt; + +mod config; +mod database; +mod error; +mod extractors; +mod layers; +mod routes; + +use database::DB; + +pub use config::Config; + +#[derive(Clone, Debug)] +pub(crate) struct AppState { + pub(crate) verifier: AuthVerifier, + pub(crate) db: DB, +} + +#[derive(Debug, Clone)] +/// A side-effect-free Core of the [Homeserver]. +pub struct HomeserverCore { + config: Config, + pub(crate) router: Router, +} + +impl HomeserverCore { + /// Create a side-effect-free Homeserver core. + /// + /// # Safety + /// HomeserverCore uses LMDB, [opening][heed::EnvOpenOptions::open] which is marked unsafe, + /// because the possible Undefined Behavior (UB) if the lock file is broken. + pub unsafe fn new(config: &Config) -> Result { + let db = unsafe { DB::open(config.clone())? }; + + let state = AppState { + verifier: AuthVerifier::default(), + db, + }; + + let router = routes::create_app(state.clone()); + + Ok(Self { + router, + config: config.clone(), + }) + } + + #[cfg(test)] + /// Test version of [HomeserverCore::new], using a temporary storage. + pub fn test() -> Result { + let testnet = pkarr::mainline::Testnet::new(0).expect("ignore"); + + unsafe { HomeserverCore::new(&Config::test(&testnet)) } + } + + // === Getters === + + pub fn config(&self) -> &Config { + &self.config + } + + pub fn keypair(&self) -> &Keypair { + &self.config.keypair + } + + pub fn public_key(&self) -> PublicKey { + self.config.keypair.public_key() + } + + // === Public Methods === + + pub async fn create_root_user(&mut self, keypair: &Keypair) -> Result { + let auth_token = AuthToken::sign(keypair, vec![Capability::root()]); + + let response = self + .call( + Request::builder() + .uri("/signup") + .header("host", keypair.public_key().to_string()) + .method(Method::POST) + .body(Body::from(auth_token.serialize())) + .unwrap(), + ) + .await?; + + let header_value = response + .headers() + .get(header::SET_COOKIE) + .and_then(|h| h.to_str().ok()) + .expect("should return a set-cookie header") + .to_string(); + + Ok(header_value) + } + + pub async fn call(&self, request: Request) -> Result { + Ok(self.router.clone().oneshot(request).await?) + } +} diff --git a/pubky-homeserver/src/routes/auth.rs b/pubky-homeserver/src/core/routes/auth.rs similarity index 62% rename from pubky-homeserver/src/routes/auth.rs rename to pubky-homeserver/src/core/routes/auth.rs index eaf3a57..8e8445a 100644 --- a/pubky-homeserver/src/routes/auth.rs +++ b/pubky-homeserver/src/core/routes/auth.rs @@ -1,6 +1,5 @@ use axum::{ extract::{Host, State}, - http::StatusCode, response::IntoResponse, }; use axum_extra::{headers::UserAgent, TypedHeader}; @@ -9,15 +8,7 @@ use tower_cookies::{cookie::SameSite, Cookie, Cookies}; use pubky_common::{crypto::random_bytes, session::Session, timestamp::Timestamp}; -use crate::{ - database::tables::{ - sessions::{SessionsTable, SESSIONS_TABLE}, - users::User, - }, - error::{Error, Result}, - extractors::Pubky, - server::AppState, -}; +use crate::core::{database::tables::users::User, error::Result, AppState}; pub async fn signup( State(state): State, @@ -31,58 +22,6 @@ pub async fn signup( signin(State(state), user_agent, cookies, host, body).await } -pub async fn session( - State(state): State, - cookies: Cookies, - pubky: Pubky, -) -> Result { - if let Some(cookie) = cookies.get(&pubky.public_key().to_string()) { - let rtxn = state.db.env.read_txn()?; - - let sessions: SessionsTable = state - .db - .env - .open_database(&rtxn, Some(SESSIONS_TABLE))? - .expect("Session table already created"); - - if let Some(session) = sessions.get(&rtxn, cookie.value())? { - let session = session.to_owned(); - rtxn.commit()?; - - // TODO: add content-type - return Ok(session); - }; - - rtxn.commit()?; - }; - - Err(Error::with_status(StatusCode::NOT_FOUND)) -} - -pub async fn signout( - State(state): State, - cookies: Cookies, - pubky: Pubky, -) -> Result { - if let Some(cookie) = cookies.get(&pubky.public_key().to_string()) { - let mut wtxn = state.db.env.write_txn()?; - - let sessions: SessionsTable = state - .db - .env - .open_database(&wtxn, Some(SESSIONS_TABLE))? - .expect("Session table already created"); - - let _ = sessions.delete(&mut wtxn, cookie.value()); - - wtxn.commit()?; - - return Ok(()); - }; - - Err(Error::with_status(StatusCode::UNAUTHORIZED)) -} - pub async fn signin( State(state): State, user_agent: Option>, @@ -98,6 +37,7 @@ pub async fn signin( let users = state.db.tables.users; if let Some(existing) = users.get(&wtxn, public_key)? { + // TODO: why do we need this? users.put(&mut wtxn, public_key, &existing)?; } else { users.put( @@ -111,7 +51,12 @@ pub async fn signin( let session_secret = base32::encode(base32::Alphabet::Crockford, &random_bytes::<16>()); - let session = Session::new(&token, user_agent.map(|ua| ua.to_string())).serialize(); + let session = Session::new( + token.pubky(), + token.capabilities(), + user_agent.map(|ua| ua.to_string()), + ) + .serialize(); state .db @@ -119,10 +64,13 @@ pub async fn signin( .sessions .put(&mut wtxn, &session_secret, &session)?; + wtxn.commit()?; + let mut cookie = Cookie::new(public_key.to_string(), session_secret); cookie.set_path("/"); + // TODO: do we even have insecure anymore? if is_secure(&host) { cookie.set_secure(true); cookie.set_same_site(SameSite::None); @@ -131,8 +79,6 @@ pub async fn signin( cookies.add(cookie); - wtxn.commit()?; - Ok(session) } diff --git a/pubky-homeserver/src/routes/feed.rs b/pubky-homeserver/src/core/routes/feed.rs similarity index 96% rename from pubky-homeserver/src/routes/feed.rs rename to pubky-homeserver/src/core/routes/feed.rs index a54b8a5..6b11fa4 100644 --- a/pubky-homeserver/src/routes/feed.rs +++ b/pubky-homeserver/src/core/routes/feed.rs @@ -6,10 +6,10 @@ use axum::{ }; use pubky_common::timestamp::Timestamp; -use crate::{ +use crate::core::{ error::{Error, Result}, extractors::ListQueryParams, - server::AppState, + AppState, }; pub async fn feed( diff --git a/pubky-homeserver/src/core/routes/mod.rs b/pubky-homeserver/src/core/routes/mod.rs new file mode 100644 index 0000000..2d7e5da --- /dev/null +++ b/pubky-homeserver/src/core/routes/mod.rs @@ -0,0 +1,36 @@ +//! The controller part of the [crate::HomeserverCore] + +use axum::{ + routing::{get, post}, + Router, +}; +use tower_cookies::CookieManagerLayer; +use tower_http::{cors::CorsLayer, trace::TraceLayer}; + +use crate::core::AppState; + +mod auth; +mod feed; +mod root; +mod tenants; + +fn base() -> Router { + Router::new() + .route("/", get(root::handler)) + .route("/signup", post(auth::signup)) + .route("/session", post(auth::signin)) + // Events + .route("/events/", get(feed::feed)) + // TODO: add size limit + // TODO: revisit if we enable streaming big payloads + // TODO: maybe add to a separate router (drive router?). +} + +pub fn create_app(state: AppState) -> Router { + base() + .merge(tenants::router(state.clone())) + .layer(CookieManagerLayer::new()) + .layer(CorsLayer::very_permissive()) + .layer(TraceLayer::new_for_http()) + .with_state(state) +} diff --git a/pubky-homeserver/src/routes/root.rs b/pubky-homeserver/src/core/routes/root.rs similarity index 100% rename from pubky-homeserver/src/routes/root.rs rename to pubky-homeserver/src/core/routes/root.rs diff --git a/pubky-homeserver/src/core/routes/tenants/mod.rs b/pubky-homeserver/src/core/routes/tenants/mod.rs new file mode 100644 index 0000000..9a6865d --- /dev/null +++ b/pubky-homeserver/src/core/routes/tenants/mod.rs @@ -0,0 +1,37 @@ +//! Per Tenant (user / Pubky) routes. +//! +//! Every route here is relative to a tenant's Pubky host, +//! as opposed to routes relative to the Homeserver's owner. + +use axum::{ + extract::DefaultBodyLimit, + routing::{delete, get, head, put}, + Router, +}; + +use crate::core::{ + layers::{authz::AuthorizationLayer, pubky_host::PubkyHostLayer}, + AppState, +}; + +pub mod read; +pub mod session; +pub mod write; + +pub fn router(state: AppState) -> Router { + Router::new() + // - Datastore routes + .route("/pub/", get(read::get)) + .route("/pub/*path", get(read::get)) + .route("/pub/*path", head(read::head)) + .route("/pub/*path", put(write::put)) + .route("/pub/*path", delete(write::delete)) + // - Session routes + .route("/session", get(session::session)) + .route("/session", delete(session::signout)) + // Layers + // TODO: different max size for sessions and other routes? + .layer(DefaultBodyLimit::max(100 * 1024 * 1024)) + .layer(AuthorizationLayer::new(state.clone())) + .layer(PubkyHostLayer) +} diff --git a/pubky-homeserver/src/core/routes/tenants/read.rs b/pubky-homeserver/src/core/routes/tenants/read.rs new file mode 100644 index 0000000..70b6413 --- /dev/null +++ b/pubky-homeserver/src/core/routes/tenants/read.rs @@ -0,0 +1,313 @@ +use axum::{ + body::Body, + extract::{OriginalUri, State}, + http::{header, HeaderMap, HeaderValue, Response, StatusCode}, + response::IntoResponse, +}; +use httpdate::HttpDate; +use pkarr::PublicKey; +use std::str::FromStr; + +use crate::core::{ + database::tables::entries::Entry, + error::{Error, Result}, + extractors::{ListQueryParams, PubkyHost}, + AppState, +}; + +pub async fn head( + State(state): State, + pubky: PubkyHost, + headers: HeaderMap, + path: OriginalUri, +) -> Result { + let rtxn = state.db.env.read_txn()?; + + get_entry( + headers, + state + .db + .get_entry(&rtxn, pubky.public_key(), path.0.path())?, + None, + ) +} + +pub async fn get( + State(state): State, + headers: HeaderMap, + pubky: PubkyHost, + path: OriginalUri, + params: ListQueryParams, +) -> Result { + let public_key = pubky.public_key().clone(); + let path = path.0.path().to_string(); + + if path.ends_with('/') { + return list(state, &public_key, &path, params); + } + + let (entry_tx, entry_rx) = flume::bounded::>(1); + let (chunks_tx, chunks_rx) = flume::unbounded::, heed::Error>>(); + + tokio::task::spawn_blocking(move || -> anyhow::Result<()> { + let rtxn = state.db.env.read_txn()?; + + let option = state.db.get_entry(&rtxn, &public_key, &path)?; + + if let Some(entry) = option { + let iter = entry.read_content(&state.db, &rtxn)?; + + entry_tx.send(Some(entry))?; + + for next in iter { + chunks_tx.send(next.map(|b| b.to_vec()))?; + } + }; + + entry_tx.send(None)?; + + Ok(()) + }); + + get_entry( + headers, + entry_rx.recv_async().await?, + Some(Body::from_stream(chunks_rx.into_stream())), + ) +} + +pub fn list( + state: AppState, + public_key: &PublicKey, + path: &str, + params: ListQueryParams, +) -> Result> { + let txn = state.db.env.read_txn()?; + + let path = format!("{public_key}{path}"); + + if !state.db.contains_directory(&txn, &path)? { + return Err(Error::new( + StatusCode::NOT_FOUND, + "Directory Not Found".into(), + )); + } + + // Handle listing + let vec = state.db.list( + &txn, + &path, + params.reverse, + params.limit, + params.cursor, + params.shallow, + )?; + + Ok(Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "text/plain") + .body(Body::from(vec.join("\n")))?) +} + +pub fn get_entry( + headers: HeaderMap, + entry: Option, + body: Option, +) -> Result> { + if let Some(entry) = entry { + // TODO: Enable seek API (range requests) + // TODO: Gzip? or brotli? + + let mut response = HeaderMap::from(&entry).into_response(); + + // Handle IF_MODIFIED_SINCE + if let Some(condition_http_date) = headers + .get(header::IF_MODIFIED_SINCE) + .and_then(|h| h.to_str().ok()) + .and_then(|s| HttpDate::from_str(s).ok()) + { + let entry_http_date: HttpDate = entry.timestamp().to_owned().into(); + + if condition_http_date >= entry_http_date { + *response.status_mut() = StatusCode::NOT_MODIFIED; + } + }; + + // Handle IF_NONE_MATCH + if let Some(str) = headers + .get(header::IF_NONE_MATCH) + .and_then(|h| h.to_str().ok()) + { + let etag = format!("\"{}\"", entry.content_hash()); + if str + .trim() + .split(',') + .collect::>() + .contains(&etag.as_str()) + { + *response.status_mut() = StatusCode::NOT_MODIFIED; + }; + } + + if let Some(body) = body { + *response.body_mut() = body; + }; + + Ok(response) + } else { + Err(Error::with_status(StatusCode::NOT_FOUND))? + } +} + +impl From<&Entry> for HeaderMap { + fn from(entry: &Entry) -> Self { + let mut headers = HeaderMap::new(); + headers.insert(header::CONTENT_LENGTH, entry.content_length().into()); + headers.insert( + header::LAST_MODIFIED, + HeaderValue::from_str(&entry.timestamp().format_http_date()) + .expect("http date is valid header value"), + ); + headers.insert( + header::CONTENT_TYPE, + // TODO: when setting content type from user input, we should validate it as a HeaderValue + entry + .content_type() + .try_into() + .or(HeaderValue::from_str("")) + .expect("valid header value"), + ); + headers.insert( + header::ETAG, + format!("\"{}\"", entry.content_hash()) + .try_into() + .expect("hex string is valid"), + ); + + headers + } +} + +#[cfg(test)] +mod tests { + use axum::{ + body::Body, + http::{header, Method, Request, StatusCode}, + }; + use pkarr::Keypair; + + use crate::core::HomeserverCore; + + #[tokio::test] + async fn if_last_modified() { + let mut server = HomeserverCore::test().unwrap(); + + let keypair = Keypair::random(); + let public_key = keypair.public_key(); + let cookie = server.create_root_user(&keypair).await.unwrap().to_string(); + + let data = vec![1_u8, 2, 3, 4, 5]; + + let response = server + .call( + Request::builder() + .header("host", public_key.to_string()) + .uri("/pub/foo") + .method(Method::PUT) + .header(header::COOKIE, cookie) + .body(Body::from(data)) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let response = server + .call( + Request::builder() + .header("host", public_key.to_string()) + .uri("/pub/foo") + .method(Method::GET) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + let response = server + .call( + Request::builder() + .header("host", public_key.to_string()) + .uri("/pub/foo") + .method(Method::GET) + .header( + header::IF_MODIFIED_SINCE, + response.headers().get(header::LAST_MODIFIED).unwrap(), + ) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NOT_MODIFIED); + } + + #[tokio::test] + async fn if_none_match() { + let mut server = HomeserverCore::test().unwrap(); + + let keypair = Keypair::random(); + let public_key = keypair.public_key(); + + let cookie = server.create_root_user(&keypair).await.unwrap().to_string(); + + let data = vec![1_u8, 2, 3, 4, 5]; + + let response = server + .call( + Request::builder() + .uri("/pub/foo") + .header("host", public_key.to_string()) + .method(Method::PUT) + .header(header::COOKIE, cookie) + .body(Body::from(data)) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let response = server + .call( + Request::builder() + .uri("/pub/foo") + .header("host", public_key.to_string()) + .method(Method::GET) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + let response = server + .call( + Request::builder() + .uri("/pub/foo") + .header("host", public_key.to_string()) + .method(Method::GET) + .header( + header::IF_NONE_MATCH, + response.headers().get(header::ETAG).unwrap(), + ) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NOT_MODIFIED); + } +} diff --git a/pubky-homeserver/src/core/routes/tenants/session.rs b/pubky-homeserver/src/core/routes/tenants/session.rs new file mode 100644 index 0000000..d422fa8 --- /dev/null +++ b/pubky-homeserver/src/core/routes/tenants/session.rs @@ -0,0 +1,38 @@ +use axum::{extract::State, http::StatusCode, response::IntoResponse}; +use tower_cookies::Cookies; + +use crate::core::{ + error::{Error, Result}, + extractors::PubkyHost, + layers::authz::session_secret_from_cookies, + AppState, +}; + +pub async fn session( + State(state): State, + cookies: Cookies, + pubky: PubkyHost, +) -> Result { + if let Some(secret) = session_secret_from_cookies(&cookies, pubky.public_key()) { + if let Some(session) = state.db.get_session(&secret)? { + // TODO: add content-type + return Ok(session.serialize()); + }; + } + + Err(Error::with_status(StatusCode::NOT_FOUND)) +} +pub async fn signout( + State(mut state): State, + cookies: Cookies, + pubky: PubkyHost, +) -> Result { + // TODO: Set expired cookie to delete the cookie on client side. + + if let Some(secret) = session_secret_from_cookies(&cookies, pubky.public_key()) { + state.db.delete_session(&secret)?; + } + + // Idempotent Success Response (200 OK) + Ok(()) +} diff --git a/pubky-homeserver/src/core/routes/tenants/write.rs b/pubky-homeserver/src/core/routes/tenants/write.rs new file mode 100644 index 0000000..bebb415 --- /dev/null +++ b/pubky-homeserver/src/core/routes/tenants/write.rs @@ -0,0 +1,57 @@ +use std::io::Write; + +use futures_util::stream::StreamExt; + +use axum::{ + body::Body, + extract::{OriginalUri, State}, + http::StatusCode, + response::IntoResponse, +}; + +use crate::core::{ + error::{Error, Result}, + extractors::PubkyHost, + AppState, +}; + +pub async fn delete( + State(mut state): State, + pubky: PubkyHost, + path: OriginalUri, +) -> Result { + let public_key = pubky.public_key().clone(); + + // TODO: should we wrap this with `tokio::task::spawn_blocking` in case it takes too long? + let deleted = state.db.delete_entry(&public_key, path.0.path())?; + + if !deleted { + // TODO: if the path ends with `/` return a `CONFLICT` error? + return Err(Error::with_status(StatusCode::NOT_FOUND)); + }; + + Ok(()) +} + +pub async fn put( + State(mut state): State, + pubky: PubkyHost, + path: OriginalUri, + body: Body, +) -> Result { + let public_key = pubky.public_key().clone(); + + let mut entry_writer = state.db.write_entry(&public_key, path.0.path())?; + + let mut stream = body.into_data_stream(); + while let Some(next) = stream.next().await { + let chunk = next?; + entry_writer.write_all(&chunk)?; + } + + let _entry = entry_writer.commit()?; + + // TODO: return relevant headers, like Etag? + + Ok(()) +} diff --git a/pubky-homeserver/src/database.rs b/pubky-homeserver/src/database.rs deleted file mode 100644 index 8ea4f48..0000000 --- a/pubky-homeserver/src/database.rs +++ /dev/null @@ -1,63 +0,0 @@ -use std::{fs, path::PathBuf}; - -use heed::{Env, EnvOpenOptions}; - -mod migrations; -pub mod tables; - -use crate::config::Config; - -use tables::{Tables, TABLES_COUNT}; - -#[derive(Debug, Clone)] -pub struct DB { - pub(crate) env: Env, - pub(crate) tables: Tables, - pub(crate) config: Config, - pub(crate) buffers_dir: PathBuf, - pub(crate) max_chunk_size: usize, -} - -impl DB { - pub fn open(config: Config) -> anyhow::Result { - let buffers_dir = config.storage().clone().join("buffers"); - - // Cleanup buffers. - let _ = fs::remove_dir(&buffers_dir); - fs::create_dir_all(&buffers_dir)?; - - let env = unsafe { - EnvOpenOptions::new() - .max_dbs(TABLES_COUNT) - .map_size(config.db_map_size()) - .open(config.storage()) - }?; - - let tables = migrations::run(&env)?; - - let db = DB { - env, - tables, - config, - buffers_dir, - max_chunk_size: max_chunk_size(), - }; - - Ok(db) - } -} - -/// calculate optimal chunk size: -/// - https://lmdb.readthedocs.io/en/release/#storage-efficiency-limits -/// - https://github.com/lmdbjava/benchmarks/blob/master/results/20160710/README.md#test-2-determine-24816-kb-byte-values -fn max_chunk_size() -> usize { - let page_size = unsafe { libc::sysconf(libc::_SC_PAGESIZE) as usize }; - - // - 16 bytes Header per page (LMDB) - // - Each page has to contain 2 records - // - 8 bytes per record (LMDB) (imperically, it seems to be 10 not 8) - // - 12 bytes key: - // - timestamp : 8 bytes - // - chunk index: 4 bytes - ((page_size - 16) / 2) - (8 + 2) - 12 -} diff --git a/pubky-homeserver/src/database/tables/sessions.rs b/pubky-homeserver/src/database/tables/sessions.rs deleted file mode 100644 index 4ecd228..0000000 --- a/pubky-homeserver/src/database/tables/sessions.rs +++ /dev/null @@ -1,51 +0,0 @@ -use heed::{ - types::{Bytes, Str}, - Database, -}; -use pkarr::PublicKey; -use pubky_common::session::Session; -use tower_cookies::Cookies; - -use crate::database::DB; - -/// session secret => Session. -pub type SessionsTable = Database; - -pub const SESSIONS_TABLE: &str = "sessions"; - -impl DB { - pub fn get_session( - &mut self, - cookies: Cookies, - public_key: &PublicKey, - ) -> anyhow::Result> { - if let Some(bytes) = self.get_session_bytes(cookies, public_key)? { - return Ok(Some(Session::deserialize(&bytes)?)); - }; - - Ok(None) - } - - pub fn get_session_bytes( - &mut self, - cookies: Cookies, - public_key: &PublicKey, - ) -> anyhow::Result>> { - if let Some(cookie) = cookies.get(&public_key.to_string()) { - let rtxn = self.env.read_txn()?; - - let sessions: SessionsTable = self - .env - .open_database(&rtxn, Some(SESSIONS_TABLE))? - .expect("Session table already created"); - - let session = sessions.get(&rtxn, cookie.value())?.map(|s| s.to_vec()); - - rtxn.commit()?; - - return Ok(session); - }; - - Ok(None) - } -} diff --git a/pubky-homeserver/src/io/http.rs b/pubky-homeserver/src/io/http.rs new file mode 100644 index 0000000..65c0242 --- /dev/null +++ b/pubky-homeserver/src/io/http.rs @@ -0,0 +1,90 @@ +//! Http server around the HomeserverCore + +use std::{ + net::{SocketAddr, TcpListener}, + sync::Arc, +}; + +use anyhow::Result; +use axum_server::{ + tls_rustls::{RustlsAcceptor, RustlsConfig}, + Handle, +}; +use futures_util::TryFutureExt; + +use crate::core::HomeserverCore; + +#[derive(Debug)] +pub struct HttpServers { + /// Handle for the HTTP server + pub(crate) http_handle: Handle, + /// Handle for the HTTPS server using Pkarr TLS + pub(crate) https_handle: Handle, + + http_address: SocketAddr, + https_address: SocketAddr, +} + +impl HttpServers { + pub async fn start(core: &HomeserverCore) -> Result { + let http_listener = + // TODO: add config to http port + TcpListener::bind(SocketAddr::from(([0, 0, 0, 0], 0)))?; + let http_address = http_listener.local_addr()?; + + let http_handle = Handle::new(); + + tokio::spawn( + axum_server::from_tcp(http_listener) + .handle(http_handle.clone()) + .serve( + core.router + .clone() + .into_make_service_with_connect_info::(), + ) + .map_err(|error| tracing::error!(?error, "Homeserver http server error")), + ); + + let https_listener = + TcpListener::bind(SocketAddr::from(([0, 0, 0, 0], core.config().port)))?; + let https_address = https_listener.local_addr()?; + + let https_handle = Handle::new(); + + tokio::spawn( + axum_server::from_tcp(https_listener) + .acceptor(RustlsAcceptor::new(RustlsConfig::from_config(Arc::new( + core.keypair().to_rpk_rustls_server_config(), + )))) + .handle(https_handle.clone()) + .serve( + core.router + .clone() + .into_make_service_with_connect_info::(), + ) + .map_err(|error| tracing::error!(?error, "Homeserver https server error")), + ); + + Ok(Self { + http_handle, + https_handle, + + http_address, + https_address, + }) + } + + pub fn http_address(&self) -> SocketAddr { + self.http_address + } + + pub fn https_address(&self) -> SocketAddr { + self.https_address + } + + /// Shutdown all HTTP servers. + pub fn shutdown(&self) { + self.http_handle.shutdown(); + self.https_handle.shutdown(); + } +} diff --git a/pubky-homeserver/src/io/mod.rs b/pubky-homeserver/src/io/mod.rs new file mode 100644 index 0000000..bf55a54 --- /dev/null +++ b/pubky-homeserver/src/io/mod.rs @@ -0,0 +1,172 @@ +use std::path::PathBuf; + +use ::pkarr::{Keypair, PublicKey}; +use anyhow::Result; +use http::HttpServers; +use pkarr::PkarrServer; +use tracing::info; + +use crate::{Config, HomeserverCore}; + +mod http; +mod pkarr; + +#[derive(Debug, Default)] +pub struct HomeserverBuilder(Config); + +impl HomeserverBuilder { + pub fn testnet(mut self) -> Self { + self.0.testnet = true; + + self + } + + /// Configure the Homeserver's keypair + pub fn keypair(mut self, keypair: Keypair) -> Self { + self.0.keypair = keypair; + + self + } + + /// Configure the Mainline DHT bootstrap nodes. Useful for testnet configurations. + pub fn bootstrap(mut self, bootstrap: Vec) -> Self { + self.0.bootstrap = Some(bootstrap); + + self + } + + /// Configure the storage path of the Homeserver + pub fn storage(mut self, storage: PathBuf) -> Self { + self.0.storage = storage; + + self + } + + /// Start running a Homeserver + /// + /// # Safety + /// Homeserver uses LMDB, [opening][heed::EnvOpenOptions::open] which is marked unsafe, + /// because the possible Undefined Behavior (UB) if the lock file is broken. + pub async unsafe fn build(self) -> Result { + Homeserver::start(self.0).await + } +} + +#[derive(Debug)] +/// Homeserver [Core][HomeserverCore] + I/O (http server and pkarr publishing). +pub struct Homeserver { + http_servers: HttpServers, + core: HomeserverCore, +} + +impl Homeserver { + pub fn builder() -> HomeserverBuilder { + HomeserverBuilder::default() + } + + /// Start running a Homeserver + /// + /// # Safety + /// Homeserver uses LMDB, [opening][heed::EnvOpenOptions::open] which is marked unsafe, + /// because the possible Undefined Behavior (UB) if the lock file is broken. + pub async unsafe fn start(config: Config) -> Result { + tracing::debug!(?config, "Starting homeserver with configurations"); + + let core = unsafe { HomeserverCore::new(&config)? }; + + let http_servers = HttpServers::start(&core).await?; + + info!( + "Homeserver listening on http://localhost:{}", + http_servers.http_address().port() + ); + + info!("Publishing Pkarr packet.."); + + let pkarr_server = PkarrServer::new( + &config, + if config.testnet { + http_servers.http_address().port() + } else { + http_servers.https_address().port() + }, + )?; + pkarr_server.publish_server_packet().await?; + + info!("Homeserver listening on https://{}", core.public_key()); + + Ok(Self { http_servers, core }) + } + + /// Start a homeserver in a Testnet mode. + /// + /// - Homeserver address is hardcoded to `8pinxxgqs41n4aididenw5apqp1urfmzdztr8jt4abrkdn435ewo` + /// - Run a pkarr Relay on port `15411` + /// - Use a temporary storage for the both homeserver and relay + /// - Only publish http port (ignore https port or domain configurations) + /// - Run an HTTP relay on port `15412` + /// + /// # Safety + /// See [Self::start] + pub async unsafe fn start_testnet() -> Result { + let testnet = ::pkarr::mainline::Testnet::new(10)?; + + let storage = + std::env::temp_dir().join(pubky_common::timestamp::Timestamp::now().to_string()); + + let pkarr_relay = unsafe { + let mut config = pkarr_relay::Config { + http_port: 15411, + cache_path: Some(storage.join("pkarr-relay")), + rate_limiter: None, + ..Default::default() + }; + + config.pkarr_config.dht_config.bootstrap = testnet.bootstrap.clone(); + config.pkarr_config.resolvers = Some(vec![]); + + pkarr_relay::Relay::start(config).await? + }; + + let http_relay = http_relay::HttpRelay::builder() + .http_port(15412) + .build() + .await?; + + tracing::info!(http_relay=?http_relay.local_link_url().as_str(), "Running http relay in Testnet mode"); + tracing::info!(relay_address=?pkarr_relay.relay_address(), bootstrap=?pkarr_relay.resolver_address(),"Running pkarr relay in Testnet mode"); + + unsafe { + Homeserver::builder() + .testnet() + .keypair(Keypair::from_secret_key(&[0; 32])) + .bootstrap(testnet.bootstrap) + .storage(storage.join("pubky-homeserver")) + .build() + .await + } + } + + /// Unit tests version of [Homeserver::start], using mainline Testnet, and a temporary storage. + pub async fn start_test(testnet: &::pkarr::mainline::Testnet) -> Result { + unsafe { Homeserver::start(Config::test(testnet)).await } + } + + // === Getters === + + pub fn public_key(&self) -> PublicKey { + self.core.public_key() + } + + /// Return the `https://` url + pub fn url(&self) -> url::Url { + url::Url::parse(&format!("https://{}", self.public_key())).expect("valid url") + } + + // === Public Methods === + + /// Send a shutdown signal to all open resources + pub fn shutdown(&self) { + self.http_servers.shutdown(); + } +} diff --git a/pubky-homeserver/src/io/pkarr.rs b/pubky-homeserver/src/io/pkarr.rs new file mode 100644 index 0000000..4f8bfd6 --- /dev/null +++ b/pubky-homeserver/src/io/pkarr.rs @@ -0,0 +1,76 @@ +//! Pkarr related task + +use anyhow::Result; +use pkarr::{dns::rdata::SVCB, SignedPacket}; + +use crate::Config; + +pub struct PkarrServer { + client: pkarr::Client, + signed_packet: SignedPacket, +} + +impl PkarrServer { + pub fn new(config: &Config, port: u16) -> Result { + let mut dht_config = pkarr::mainline::Config::default(); + + if let Some(bootstrap) = config.bootstrap.clone() { + dht_config.bootstrap = bootstrap; + } + if let Some(request_timeout) = config.dht_request_timeout { + dht_config.request_timeout = request_timeout; + } + + let client = pkarr::Client::builder().dht_config(dht_config).build()?; + + let signed_packet = create_signed_packet(config, port)?; + + Ok(Self { + client, + signed_packet, + }) + } + + pub async fn publish_server_packet(&self) -> anyhow::Result<()> { + // TODO: warn if packet is not most recent, which means the + // user is publishing a Packet from somewhere else. + + self.client.publish(&self.signed_packet).await?; + + Ok(()) + } +} + +pub fn create_signed_packet(config: &Config, port: u16) -> Result { + // TODO: Try to resolve first before publishing. + + let default = ".".to_string(); + let target = config.domain.clone().unwrap_or(default); + let mut svcb = SVCB::new(0, target.as_str().try_into()?); + + svcb.priority = 1; + svcb.set_port(port); + + let mut signed_packet_builder = + SignedPacket::builder().https(".".try_into().unwrap(), svcb.clone(), 60 * 60); + + if config.testnet { + svcb.target = "localhost".try_into().expect("localhost is valid dns name"); + + signed_packet_builder = signed_packet_builder + .https(".".try_into().unwrap(), svcb, 60 * 60) + .address( + ".".try_into().unwrap(), + "127.0.0.1".parse().unwrap(), + 60 * 60, + ); + } else if let Some(ref domain) = config.domain { + svcb.target = domain.as_str().try_into()?; + + signed_packet_builder = signed_packet_builder.https(".".try_into().unwrap(), svcb, 60 * 60); + } + + // TODO: announce public IP with A/AAAA records (need to add options in config) + + Ok(signed_packet_builder.build(&config.keypair)?) +} diff --git a/pubky-homeserver/src/lib.rs b/pubky-homeserver/src/lib.rs index 4a1253b..ee44b10 100644 --- a/pubky-homeserver/src/lib.rs +++ b/pubky-homeserver/src/lib.rs @@ -1,9 +1,6 @@ -pub mod config; -mod database; -mod error; -mod extractors; -mod pkarr; -mod routes; -mod server; +mod core; +mod io; -pub use server::Homeserver; +pub use core::Config; +pub use core::HomeserverCore; +pub use io::Homeserver; diff --git a/pubky-homeserver/src/main.rs b/pubky-homeserver/src/main.rs index dad25df..3d04429 100644 --- a/pubky-homeserver/src/main.rs +++ b/pubky-homeserver/src/main.rs @@ -1,7 +1,7 @@ use std::path::PathBuf; use anyhow::Result; -use pubky_homeserver::{config::Config, Homeserver}; +use pubky_homeserver::{Config, Homeserver}; use clap::Parser; @@ -31,16 +31,21 @@ async fn main() -> Result<()> { ) .init(); - let server = Homeserver::start(if args.testnet { - Config::testnet() - } else if let Some(config_path) = args.config { - Config::load(config_path).await? - } else { - Config::default() - }) - .await?; + let server = unsafe { + if args.testnet { + Homeserver::start_testnet().await? + } else if let Some(config_path) = args.config { + Homeserver::start(Config::load(config_path).await?).await? + } else { + Homeserver::builder().build().await? + } + }; - server.run_until_done().await?; + tokio::signal::ctrl_c().await?; + + tracing::info!("Shutting down Homeserver"); + + server.shutdown(); Ok(()) } diff --git a/pubky-homeserver/src/pkarr.rs b/pubky-homeserver/src/pkarr.rs deleted file mode 100644 index c23755e..0000000 --- a/pubky-homeserver/src/pkarr.rs +++ /dev/null @@ -1,46 +0,0 @@ -//! Pkarr related task - -use pkarr::{ - dns::{rdata::SVCB, Packet}, - Keypair, PkarrClientAsync, SignedPacket, -}; - -pub(crate) async fn publish_server_packet( - pkarr_client: &PkarrClientAsync, - keypair: &Keypair, - domain: &str, - port: u16, -) -> anyhow::Result<()> { - // TODO: Try to resolve first before publishing. - - let mut packet = Packet::new_reply(0); - - let mut svcb = SVCB::new(0, domain.try_into()?); - - // Publishing port only for localhost domain, - // assuming any other domain will point to a reverse proxy - // at the conventional ports. - if domain == "localhost" { - svcb.priority = 1; - svcb.set_port(port); - - // TODO: Add more parameteres like the signer key! - // svcb.set_param(key, value) - }; - - // TODO: announce A/AAAA records as well for Noise connections? - // Or maybe Iroh's magic socket - - packet.answers.push(pkarr::dns::ResourceRecord::new( - "@".try_into().unwrap(), - pkarr::dns::CLASS::IN, - 60 * 60, - pkarr::dns::rdata::RData::SVCB(svcb), - )); - - let signed_packet = SignedPacket::from_packet(keypair, &packet)?; - - pkarr_client.publish(&signed_packet).await?; - - Ok(()) -} diff --git a/pubky-homeserver/src/routes.rs b/pubky-homeserver/src/routes.rs deleted file mode 100644 index 6404ffb..0000000 --- a/pubky-homeserver/src/routes.rs +++ /dev/null @@ -1,44 +0,0 @@ -use axum::{ - extract::DefaultBodyLimit, - routing::{delete, get, head, post, put}, - Router, -}; -use tower_cookies::CookieManagerLayer; -use tower_http::{cors::CorsLayer, trace::TraceLayer}; - -use crate::server::AppState; - -use self::pkarr::pkarr_router; - -mod auth; -mod feed; -mod pkarr; -mod public; -mod root; - -fn base(state: AppState) -> Router { - Router::new() - .route("/", get(root::handler)) - .route("/signup", post(auth::signup)) - .route("/session", post(auth::signin)) - .route("/:pubky/session", get(auth::session)) - .route("/:pubky/session", delete(auth::signout)) - .route("/:pubky/*path", put(public::put)) - .route("/:pubky/*path", get(public::get)) - .route("/:pubky/*path", head(public::head)) - .route("/:pubky/*path", delete(public::delete)) - .route("/events/", get(feed::feed)) - .layer(CookieManagerLayer::new()) - // TODO: revisit if we enable streaming big payloads - // TODO: maybe add to a separate router (drive router?). - .layer(DefaultBodyLimit::max(100 * 1024 * 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/pkarr.rs b/pubky-homeserver/src/routes/pkarr.rs deleted file mode 100644 index 9e40230..0000000 --- a/pubky-homeserver/src/routes/pkarr.rs +++ /dev/null @@ -1,58 +0,0 @@ -use axum::{ - body::{Body, Bytes}, - extract::State, - http::StatusCode, - response::IntoResponse, - routing::{get, put}, - Router, -}; -use futures_util::stream::StreamExt; - -use pkarr::SignedPacket; - -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(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/routes/public.rs b/pubky-homeserver/src/routes/public.rs deleted file mode 100644 index 3b9963f..0000000 --- a/pubky-homeserver/src/routes/public.rs +++ /dev/null @@ -1,380 +0,0 @@ -use axum::{ - body::Body, - debug_handler, - extract::State, - http::{header, HeaderMap, HeaderValue, Response, StatusCode}, - response::IntoResponse, -}; -use futures_util::stream::StreamExt; -use httpdate::HttpDate; -use pkarr::PublicKey; -use std::{io::Write, str::FromStr}; -use tower_cookies::Cookies; - -use crate::{ - database::tables::entries::Entry, - error::{Error, Result}, - extractors::{EntryPath, ListQueryParams, Pubky}, - server::AppState, -}; - -pub async fn put( - State(mut state): State, - pubky: Pubky, - path: EntryPath, - cookies: Cookies, - body: Body, -) -> Result { - let public_key = pubky.public_key().clone(); - let path = path.as_str().to_string(); - - verify(&path)?; - authorize(&mut state, cookies, &public_key, &path)?; - - let mut entry_writer = state.db.write_entry(&public_key, &path)?; - - let mut stream = body.into_data_stream(); - while let Some(next) = stream.next().await { - let chunk = next?; - entry_writer.write_all(&chunk)?; - } - - let _entry = entry_writer.commit()?; - - // TODO: return relevant headers, like Etag? - - Ok(()) -} - -#[debug_handler] -pub async fn get( - State(state): State, - headers: HeaderMap, - pubky: Pubky, - path: EntryPath, - params: ListQueryParams, -) -> Result { - verify(path.as_str())?; - let public_key = pubky.public_key().clone(); - let path = path.as_str().to_string(); - - if path.ends_with('/') { - let txn = state.db.env.read_txn()?; - - let path = format!("{public_key}/{path}"); - - if !state.db.contains_directory(&txn, &path)? { - return Err(Error::new( - StatusCode::NOT_FOUND, - "Directory Not Found".into(), - )); - } - - // Handle listing - let vec = state.db.list( - &txn, - &path, - params.reverse, - params.limit, - params.cursor, - params.shallow, - )?; - - return Ok(Response::builder() - .status(StatusCode::OK) - .header(header::CONTENT_TYPE, "text/plain") - .body(Body::from(vec.join("\n")))?); - } - - let (entry_tx, entry_rx) = flume::bounded::>(1); - let (chunks_tx, chunks_rx) = flume::unbounded::, heed::Error>>(); - - tokio::task::spawn_blocking(move || -> anyhow::Result<()> { - let rtxn = state.db.env.read_txn()?; - - let option = state.db.get_entry(&rtxn, &public_key, &path)?; - - if let Some(entry) = option { - let iter = entry.read_content(&state.db, &rtxn)?; - - entry_tx.send(Some(entry))?; - - for next in iter { - chunks_tx.send(next.map(|b| b.to_vec()))?; - } - }; - - entry_tx.send(None)?; - - Ok(()) - }); - - get_entry( - headers, - entry_rx.recv_async().await?, - Some(Body::from_stream(chunks_rx.into_stream())), - ) -} - -pub async fn head( - State(state): State, - headers: HeaderMap, - pubky: Pubky, - path: EntryPath, -) -> Result { - verify(path.as_str())?; - - let rtxn = state.db.env.read_txn()?; - - get_entry( - headers, - state - .db - .get_entry(&rtxn, pubky.public_key(), path.as_str())?, - None, - ) -} - -pub fn get_entry( - headers: HeaderMap, - entry: Option, - body: Option, -) -> Result> { - if let Some(entry) = entry { - // TODO: Enable seek API (range requests) - // TODO: Gzip? or brotli? - - let mut response = HeaderMap::from(&entry).into_response(); - - // Handle IF_MODIFIED_SINCE - if let Some(condition_http_date) = headers - .get(header::IF_MODIFIED_SINCE) - .and_then(|h| h.to_str().ok()) - .and_then(|s| HttpDate::from_str(s).ok()) - { - let entry_http_date: HttpDate = entry.timestamp().to_owned().into(); - - if condition_http_date >= entry_http_date { - *response.status_mut() = StatusCode::NOT_MODIFIED; - } - }; - - // Handle IF_NONE_MATCH - if let Some(str) = headers - .get(header::IF_NONE_MATCH) - .and_then(|h| h.to_str().ok()) - { - let etag = format!("\"{}\"", entry.content_hash()); - if str - .trim() - .split(',') - .collect::>() - .contains(&etag.as_str()) - { - *response.status_mut() = StatusCode::NOT_MODIFIED; - }; - } - - if let Some(body) = body { - *response.body_mut() = body; - }; - - Ok(response) - } else { - Err(Error::with_status(StatusCode::NOT_FOUND))? - } -} - -pub async fn delete( - State(mut state): State, - pubky: Pubky, - path: EntryPath, - cookies: Cookies, -) -> Result { - let public_key = pubky.public_key().clone(); - let path = path.as_str(); - - authorize(&mut state, cookies, &public_key, path)?; - verify(path)?; - - // TODO: should we wrap this with `tokio::task::spawn_blocking` in case it takes too long? - let deleted = state.db.delete_entry(&public_key, path)?; - - if !deleted { - // TODO: if the path ends with `/` return a `CONFLICT` error? - return Err(Error::with_status(StatusCode::NOT_FOUND)); - }; - - Ok(()) -} - -/// Authorize write (PUT or DELETE) for Public paths. -fn authorize( - state: &mut AppState, - cookies: Cookies, - public_key: &PublicKey, - path: &str, -) -> Result<()> { - // TODO: can we move this logic to the extractor or a layer - // to perform this validation? - let session = state - .db - .get_session(cookies, public_key)? - .ok_or(Error::with_status(StatusCode::UNAUTHORIZED))?; - - if session.pubky() == public_key - && session.capabilities().iter().any(|cap| { - path.starts_with(&cap.scope[1..]) - && cap - .actions - .contains(&pubky_common::capabilities::Action::Write) - }) - { - return Ok(()); - } - - Err(Error::with_status(StatusCode::FORBIDDEN)) -} - -fn verify(path: &str) -> Result<()> { - if !path.starts_with("pub/") { - return Err(Error::new( - StatusCode::FORBIDDEN, - "Writing to directories other than '/pub/' is forbidden".into(), - )); - } - - // TODO: should we forbid paths ending with `/`? - - Ok(()) -} - -impl From<&Entry> for HeaderMap { - fn from(entry: &Entry) -> Self { - let mut headers = HeaderMap::new(); - headers.insert(header::CONTENT_LENGTH, entry.content_length().into()); - headers.insert( - header::LAST_MODIFIED, - HeaderValue::from_str(&entry.timestamp().format_http_date()) - .expect("http date is valid header value"), - ); - headers.insert( - header::CONTENT_TYPE, - // TODO: when setting content type from user input, we should validate it as a HeaderValue - entry - .content_type() - .try_into() - .or(HeaderValue::from_str("")) - .expect("valid header value"), - ); - headers.insert( - header::ETAG, - format!("\"{}\"", entry.content_hash()) - .try_into() - .expect("hex string is valid"), - ); - - headers - } -} - -#[cfg(test)] -mod tests { - use axum::http::header; - use pkarr::{mainline::Testnet, Keypair}; - use reqwest::{self, Method, StatusCode}; - - use crate::Homeserver; - - #[tokio::test] - async fn if_last_modified() -> anyhow::Result<()> { - let testnet = Testnet::new(3); - let mut server = Homeserver::start_test(&testnet).await?; - - let public_key = Keypair::random().public_key(); - - let data = &[1, 2, 3, 4, 5]; - - server - .database_mut() - .write_entry(&public_key, "pub/foo")? - .update(data)? - .commit()?; - - let client = reqwest::Client::builder().build()?; - - let url = format!("http://localhost:{}/{public_key}/pub/foo", server.port()); - - let response = client.request(Method::GET, &url).send().await?; - - let response = client - .request(Method::GET, &url) - .header( - header::IF_MODIFIED_SINCE, - response.headers().get(header::LAST_MODIFIED).unwrap(), - ) - .send() - .await?; - - assert_eq!(response.status(), StatusCode::NOT_MODIFIED); - - let response = client - .request(Method::HEAD, &url) - .header( - header::IF_MODIFIED_SINCE, - response.headers().get(header::LAST_MODIFIED).unwrap(), - ) - .send() - .await?; - - assert_eq!(response.status(), StatusCode::NOT_MODIFIED); - - Ok(()) - } - - #[tokio::test] - async fn if_none_match() -> anyhow::Result<()> { - let testnet = Testnet::new(3); - let mut server = Homeserver::start_test(&testnet).await?; - - let public_key = Keypair::random().public_key(); - - let data = &[1, 2, 3, 4, 5]; - - server - .database_mut() - .write_entry(&public_key, "pub/foo")? - .update(data)? - .commit()?; - - let client = reqwest::Client::builder().build()?; - - let url = format!("http://localhost:{}/{public_key}/pub/foo", server.port()); - - let response = client.request(Method::GET, &url).send().await?; - - let response = client - .request(Method::GET, &url) - .header( - header::IF_NONE_MATCH, - response.headers().get(header::ETAG).unwrap(), - ) - .send() - .await?; - - assert_eq!(response.status(), StatusCode::NOT_MODIFIED); - - let response = client - .request(Method::HEAD, &url) - .header( - header::IF_NONE_MATCH, - response.headers().get(header::ETAG).unwrap(), - ) - .send() - .await?; - - assert_eq!(response.status(), StatusCode::NOT_MODIFIED); - - Ok(()) - } -} diff --git a/pubky-homeserver/src/server.rs b/pubky-homeserver/src/server.rs deleted file mode 100644 index c3f8719..0000000 --- a/pubky-homeserver/src/server.rs +++ /dev/null @@ -1,174 +0,0 @@ -use std::{future::IntoFuture, net::SocketAddr}; - -use anyhow::{Error, Result}; -use pubky_common::auth::AuthVerifier; -use tokio::{net::TcpListener, signal, task::JoinSet}; -use tracing::{debug, info, warn}; - -use pkarr::{ - mainline::dht::{DhtSettings, Testnet}, - PkarrClient, PkarrClientAsync, PublicKey, Settings, -}; - -use crate::{config::Config, database::DB, pkarr::publish_server_packet}; - -#[derive(Debug)] -pub struct Homeserver { - state: AppState, - tasks: JoinSet>, -} - -#[derive(Clone, Debug)] -pub(crate) struct AppState { - pub(crate) verifier: AuthVerifier, - pub(crate) db: DB, - pub(crate) pkarr_client: PkarrClientAsync, - pub(crate) config: Config, - pub(crate) port: u16, -} - -impl Homeserver { - pub async fn start(config: Config) -> Result { - debug!(?config); - - let db = DB::open(config.clone())?; - - let pkarr_client = PkarrClient::new(Settings { - dht: DhtSettings { - bootstrap: config.bootstsrap(), - request_timeout: config.dht_request_timeout(), - ..Default::default() - }, - ..Default::default() - })? - .as_async(); - - let mut tasks = JoinSet::new(); - - let listener = TcpListener::bind(SocketAddr::from(([0, 0, 0, 0], config.port()))).await?; - - let port = listener.local_addr()?.port(); - - let state = AppState { - verifier: AuthVerifier::default(), - db, - pkarr_client, - config: config.clone(), - port, - }; - - let app = crate::routes::create_app(state.clone()); - - // Spawn http server task - tasks.spawn( - axum::serve( - listener, - app.into_make_service_with_connect_info::(), - ) - .with_graceful_shutdown(shutdown_signal()) - .into_future(), - ); - - info!("Homeserver listening on http://localhost:{port}"); - - publish_server_packet( - &state.pkarr_client, - config.keypair(), - &state - .config - .domain() - .clone() - .unwrap_or("localhost".to_string()), - port, - ) - .await?; - - info!( - "Homeserver listening on pubky://{}", - config.keypair().public_key() - ); - - Ok(Self { tasks, state }) - } - - /// 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 - } - - // === Getters === - - pub fn port(&self) -> u16 { - self.state.port - } - - pub fn public_key(&self) -> PublicKey { - self.state.config.keypair().public_key() - } - - #[cfg(test)] - pub(crate) fn database_mut(&mut self) -> &mut DB { - &mut self.state.db - } - - // === Public Methods === - - /// Shutdown the server and wait for all tasks to complete. - pub async fn shutdown(mut self) -> Result<()> { - self.tasks.abort_all(); - self.run_until_done().await?; - Ok(()) - } - - /// Wait for all tasks to complete. - /// - /// Runs forever unless tasks fail. - pub async fn run_until_done(mut self) -> Result<()> { - let mut final_res: Result<()> = Ok(()); - while let Some(res) = self.tasks.join_next().await { - match res { - Ok(Ok(())) => {} - Err(err) if err.is_cancelled() => {} - Ok(Err(err)) => { - warn!(?err, "task failed"); - final_res = Err(Error::from(err)); - } - Err(err) => { - warn!(?err, "task panicked"); - final_res = Err(err.into()); - } - } - } - final_res - } -} - -async fn shutdown_signal() { - let ctrl_c = async { - signal::ctrl_c() - .await - .expect("failed to install Ctrl+C handler"); - }; - - #[cfg(unix)] - let terminate = async { - signal::unix::signal(signal::unix::SignalKind::terminate()) - .expect("failed to install signal handler") - .recv() - .await; - }; - - #[cfg(not(unix))] - let terminate = std::future::pending::<()>(); - - fn graceful_shutdown() { - info!("Gracefully Shutting down.."); - } - - tokio::select! { - _ = ctrl_c => graceful_shutdown(), - _ = terminate => graceful_shutdown(), - } -} diff --git a/pubky/Cargo.toml b/pubky/Cargo.toml index ca16763..859d0ed 100644 --- a/pubky/Cargo.toml +++ b/pubky/Cargo.toml @@ -11,34 +11,50 @@ keywords = ["web", "dht", "dns", "decentralized", "identity"] crate-type = ["cdylib", "rlib"] [dependencies] -thiserror = "1.0.62" -wasm-bindgen = "0.2.92" -url = "2.5.2" -bytes = "^1.7.1" +thiserror = "2.0.6" +wasm-bindgen = "0.2.99" +url = "2.5.4" +bytes = "^1.9.0" base64 = "0.22.1" -pkarr = { version = "2.2.1-alpha.2", features = ["serde", "async"] } +pkarr = { workspace = true } pubky-common = { version = "0.1.0", path = "../pubky-common" } +cookie = "0.18.1" +tracing = "0.1.41" +cookie_store = { version = "0.21.1", default-features = false } +anyhow = "1.0.94" +# Native dependencies [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -reqwest = { version = "0.12.5", features = ["cookies", "rustls-tls"], default-features = false } -tokio = { version = "1.37.0", features = ["full"] } +reqwest = { version = "0.12.9", features = ["cookies", "rustls-tls"], default-features = false } +tokio = { version = "1.42.0", features = ["full"] } +# Wasm dependencies [target.'cfg(target_arch = "wasm32")'.dependencies] -reqwest = { version = "0.12.5", default-features = false } +reqwest = { version = "0.12.9", default-features = false } +futures-lite = { version = "2.5.0", default-features = false } +wasm-bindgen = "0.2.99" +wasm-bindgen-futures = "0.4.49" +console_log = { version = "1.0.0", features = ["color"] } +log = "0.4.22" -js-sys = "0.3.69" -wasm-bindgen = "0.2.92" -wasm-bindgen-futures = "0.4.42" +js-sys = "0.3.76" +web-sys = "0.3.76" [dev-dependencies] +anyhow = "1.0.94" +axum = "0.7.9" +axum-server = "0.7.1" +futures-util = "0.3.31" +http-relay = { path = "../http-relay" } pubky-homeserver = { path = "../pubky-homeserver" } -tokio = "1.37.0" - -[features] +tokio = "1.42.0" [package.metadata.docs.rs] all-features = true [package.metadata.wasm-pack.profile.release] wasm-opt = ['-g', '-O'] + +# [lints.clippy] +unwrap_used = "deny" diff --git a/pubky/README.md b/pubky/README.md index d300281..d884443 100644 --- a/pubky/README.md +++ b/pubky/README.md @@ -8,7 +8,7 @@ Rust implementation implementation of [Pubky](https://github.com/pubky/pubky-cor use pkarr::mainline::Testnet; use pkarr::Keypair; use pubky_homeserver::Homeserver; -use pubky::PubkyClient; +use pubky::Client; #[tokio::main] async fn main () { @@ -16,10 +16,10 @@ async fn main () { let testnet = Testnet::new(10); let server = Homeserver::start_test(&testnet).await.unwrap(); - let client = PubkyClient::test(&testnet); + let client = Client::test(&testnet); // Uncomment the following line instead if you are not just testing. - // let client PubkyClient::builder().build(); + // let client Client::new().unwrap(); // Generate a keypair let keypair = Keypair::random(); diff --git a/pubky/clippy.toml b/pubky/clippy.toml new file mode 100644 index 0000000..154626e --- /dev/null +++ b/pubky/clippy.toml @@ -0,0 +1 @@ +allow-unwrap-in-tests = true diff --git a/pubky/pkg/.gitignore b/pubky/pkg/.gitignore index 7355b75..3a61269 100644 --- a/pubky/pkg/.gitignore +++ b/pubky/pkg/.gitignore @@ -1,6 +1,8 @@ -index.cjs -browser.js coverage node_modules package-lock.json -pubky* +pubky.d.ts +pubky_bg.wasm +nodejs/ +index.js +index.cjs diff --git a/pubky/pkg/README.md b/pubky/pkg/README.md index 4704fff..a32438d 100644 --- a/pubky/pkg/README.md +++ b/pubky/pkg/README.md @@ -21,10 +21,10 @@ For Nodejs, you need Node v20 or later. ## Getting started ```js -import { PubkyClient, Keypair, PublicKey } from '../index.js' +import { Client, Keypair, PublicKey } from '../index.js' -// Initialize PubkyClient with Pkarr relay(s). -let client = new PubkyClient(); +// Initialize Client with Pkarr relay(s). +let client = new Client(); // Generate a keypair let keypair = Keypair.random(); @@ -42,31 +42,40 @@ let url = `pubky://${publicKey.z32()}/pub/example.com/arbitrary`; // Verify that you are signed in. const session = await client.session(publicKey) -const body = Buffer.from(JSON.stringify({ foo: 'bar' })) - // PUT public data, by authorized client -await client.put(url, body); +await client.fetch(url, { + method: "PUT", + body: JSON.stringify({foo: "bar"}), + credentials: "include" +}); // GET public data without signup or signin { - const client = new PubkyClient(); + const client = new Client(); - let response = await client.get(url); + let response = await client.fetch(url); } // Delete public data, by authorized client -await client.delete(url); +await client.fetch(url, { method: "DELETE", credentials: "include "}); ``` ## API -### PubkyClient +### Client #### constructor ```js -let client = new PubkyClient() +let client = new Client() +``` + +#### fetch +```js +let response = await client.fetch(url, opts); ``` +Just like normal Fetch API, but it can handle `pubky://` urls and `http(s)://` urls with Pkarr domains. + #### signup ```js await client.signup(keypair, homeserver) @@ -127,27 +136,6 @@ let session = await client.session(publicKey) - publicKey: An instance of [PublicKey](#publickey). - Returns: A [Session](#session) object if signed in, or undefined if not. -#### put -```js -let response = await client.put(url, body); -``` -- url: A string representing the Pubky URL. -- body: A Buffer containing the data to be stored. - -### get -```js -let response = await client.get(url) -``` -- url: A string representing the Pubky URL. -- Returns: A Uint8Array object containing the requested data, or `undefined` if `NOT_FOUND`. - -### delete - -```js -let response = await client.delete(url); -``` -- url: A string representing the Pubky URL. - ### list ```js let response = await client.list(url, cursor, reverse, limit) @@ -257,10 +245,10 @@ Run the local testnet server npm run testnet ``` -Use the logged addresses as inputs to `PubkyClient` +Use the logged addresses as inputs to `Client` ```js -import { PubkyClient } from '../index.js' +import { Client } from '../index.js' -const client = PubkyClient().testnet(); +const client = Client().testnet(); ``` diff --git a/pubky/pkg/node-header.cjs b/pubky/pkg/node-header.cjs new file mode 100644 index 0000000..07de852 --- /dev/null +++ b/pubky/pkg/node-header.cjs @@ -0,0 +1,4 @@ +const makeFetchCookie = require("fetch-cookie").default; + +let originalFetch = globalThis.fetch; +globalThis.fetch = makeFetchCookie(originalFetch); diff --git a/pubky/pkg/package.json b/pubky/pkg/package.json index be3e6df..696a280 100644 --- a/pubky/pkg/package.json +++ b/pubky/pkg/package.json @@ -2,14 +2,14 @@ "name": "@synonymdev/pubky", "type": "module", "description": "Pubky client", - "version": "0.1.16", + "version": "0.3.0", "license": "MIT", "repository": { "type": "git", - "url": "https://github.com/pubky/pubky" + "url": "git+https://github.com/pubky/pubky-core.git" }, "scripts": { - "testnet": "cargo run -p pubky_homeserver -- --testnet", + "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", @@ -18,12 +18,12 @@ }, "files": [ "index.cjs", - "browser.js", + "index.js", "pubky.d.ts", "pubky_bg.wasm" ], "main": "index.cjs", - "browser": "browser.js", + "browser": "index.js", "types": "pubky.d.ts", "keywords": [ "web", @@ -37,5 +37,8 @@ "esmify": "^2.1.1", "tape": "^5.8.1", "tape-run": "^11.0.0" + }, + "dependencies": { + "fetch-cookie": "^3.0.1" } } diff --git a/pubky/pkg/test/auth.js b/pubky/pkg/test/auth.js index fe7e559..73179f0 100644 --- a/pubky/pkg/test/auth.js +++ b/pubky/pkg/test/auth.js @@ -1,16 +1,19 @@ import test from 'tape' -import { PubkyClient, Keypair, PublicKey } from '../index.cjs' +import { Client, Keypair, PublicKey } from '../index.cjs' -const Homeserver = PublicKey.from('8pinxxgqs41n4aididenw5apqp1urfmzdztr8jt4abrkdn435ewo') +const HOMESERVER_PUBLICKEY = PublicKey.from('8pinxxgqs41n4aididenw5apqp1urfmzdztr8jt4abrkdn435ewo') +const TESTNET_HTTP_RELAY = "http://localhost:15412/link"; -test('auth', async (t) => { - const client = PubkyClient.testnet(); +// TODO: test multiple users in wasm + +test('Auth: basic', async (t) => { + const client = Client.testnet(); const keypair = Keypair.random() const publicKey = keypair.publicKey() - await client.signup(keypair, Homeserver) + await client.signup(keypair, HOMESERVER_PUBLICKEY ) const session = await client.session(publicKey) t.ok(session, "signup") @@ -30,15 +33,42 @@ test('auth', async (t) => { } }) -test("3rd party signin", async (t) => { +test("Auth: multi-user (cookies)", async (t) => { + const client = Client.testnet(); + + const alice = Keypair.random() + const bob = Keypair.random() + + await client.signup(alice, HOMESERVER_PUBLICKEY ) + + let session = await client.session(alice.publicKey()) + t.ok(session, "signup") + + { + await client.signup(bob, HOMESERVER_PUBLICKEY ) + + const session = await client.session(bob.publicKey()) + t.ok(session, "signup") + } + + session = await client.session(alice.publicKey()); + t.is(session.pubky().z32(), alice.publicKey().z32(), "alice is still signed in") + + await client.signout(bob.publicKey()); + + session = await client.session(alice.publicKey()); + t.is(session.pubky().z32(), alice.publicKey().z32(), "alice is still signed in after signout of bob") +}) + +test("Auth: 3rd party signin", async (t) => { let keypair = Keypair.random(); let pubky = keypair.publicKey().z32(); // Third party app side let capabilities = "/pub/pubky.app/:rw,/pub/foo.bar/file:r"; - let client = PubkyClient.testnet(); + let client = Client.testnet(); let [pubkyauth_url, pubkyauthResponse] = client - .authRequest("https://demo.httprelay.io/link", capabilities); + .authRequest(TESTNET_HTTP_RELAY, capabilities); if (globalThis.document) { // Skip `sendAuthToken` in browser @@ -49,9 +79,9 @@ test("3rd party signin", async (t) => { // Authenticator side { - let client = PubkyClient.testnet(); + let client = Client.testnet(); - await client.signup(keypair, Homeserver); + await client.signup(keypair, HOMESERVER_PUBLICKEY); await client.sendAuthToken(keypair, pubkyauth_url) } diff --git a/pubky/pkg/test/http.js b/pubky/pkg/test/http.js new file mode 100644 index 0000000..2119bca --- /dev/null +++ b/pubky/pkg/test/http.js @@ -0,0 +1,41 @@ +import test from 'tape' + +import { Client, Keypair, PublicKey } from '../index.cjs' + +const TLD = '8pinxxgqs41n4aididenw5apqp1urfmzdztr8jt4abrkdn435ewo'; + +test("basic fetch", async (t) => { + let client = Client.testnet(); + + // Normal TLD + { + let response = await client.fetch(`https://relay.pkarr.org/`); + + t.equal(response.status, 200); + } + + + // Pubky + let response = await client.fetch(`https://${TLD}/`); + + t.equal(response.status, 200); +}) + +test("fetch failed", async (t) => { + + let client = Client.testnet(); + + // Normal TLD + { + let response = await client.fetch(`https://nonexistent.domain/`).catch(e => e); + + t.ok(response instanceof Error); + } + + + // Pubky + let response = await client.fetch(`https://1pinxxgqs41n4aididenw5apqp1urfmzdztr8jt4abrkdn435ew1/`).catch(e => e); + + t.ok(response instanceof Error); +}) + diff --git a/pubky/pkg/test/public.js b/pubky/pkg/test/public.js index ec30bb2..d7c3db9 100644 --- a/pubky/pkg/test/public.js +++ b/pubky/pkg/test/public.js @@ -1,123 +1,133 @@ import test from 'tape' -import { PubkyClient, Keypair, PublicKey } from '../index.cjs' +import { Client, Keypair, PublicKey ,setLogLevel} from '../index.cjs' -const Homeserver = PublicKey.from('8pinxxgqs41n4aididenw5apqp1urfmzdztr8jt4abrkdn435ewo'); +const HOMESERVER_PUBLICKEY = PublicKey.from('8pinxxgqs41n4aididenw5apqp1urfmzdztr8jt4abrkdn435ewo') test('public: put/get', async (t) => { - const client = PubkyClient.testnet(); + const client = Client.testnet(); const keypair = Keypair.random(); - await client.signup(keypair, Homeserver); + await client.signup(keypair, HOMESERVER_PUBLICKEY); const publicKey = keypair.publicKey(); let url = `pubky://${publicKey.z32()}/pub/example.com/arbitrary`; - const body = Buffer.from(JSON.stringify({ foo: 'bar' })) + const json = { foo: 'bar' } // PUT public data, by authorized client - await client.put(url, body); + await client.fetch(url, { + method:"PUT", + body: JSON.stringify(json), + contentType: "json", + credentials: "include" + }); - const otherClient = PubkyClient.testnet(); + const otherClient = Client.testnet(); // GET public data without signup or signin { - let response = await otherClient.get(url); + let response = await otherClient.fetch(url) - t.ok(Buffer.from(response).equals(body)) + t.is(response.status, 200); + + t.deepEquals(await response.json(), {foo: "bar"}) } // DELETE public data, by authorized client - await client.delete(url); + await client.fetch(url, { + method:"DELETE", + credentials: "include" + }); // GET public data without signup or signin { - let response = await otherClient.get(url); + let response = await otherClient.fetch(url); - t.notOk(response) + t.is(response.status, 404) } }) test("not found", async (t) => { - const client = PubkyClient.testnet(); + const client = Client.testnet(); const keypair = Keypair.random(); - await client.signup(keypair, Homeserver); + await client.signup(keypair, HOMESERVER_PUBLICKEY); const publicKey = keypair.publicKey(); let url = `pubky://${publicKey.z32()}/pub/example.com/arbitrary`; - let result = await client.get(url).catch(e => e); + let result = await client.fetch(url); - t.notOk(result); + t.is(result.status, 404); }) test("unauthorized", async (t) => { - const client = PubkyClient.testnet(); + const client = Client.testnet(); const keypair = Keypair.random() const publicKey = keypair.publicKey() - await client.signup(keypair, Homeserver) + await client.signup(keypair, HOMESERVER_PUBLICKEY) const session = await client.session(publicKey) t.ok(session, "signup") await client.signout(publicKey) - const body = Buffer.from(JSON.stringify({ foo: 'bar' })) - let url = `pubky://${publicKey.z32()}/pub/example.com/arbitrary`; // PUT public data, by authorized client - let result = await client.put(url, body).catch(e => e); - - t.ok(result instanceof Error); - t.is( - result.message, - `HTTP status client error (401 Unauthorized) for url (http://localhost:15411/${publicKey.z32()}/pub/example.com/arbitrary)` - ) + let response = await client.fetch(url, { + method: "PUT", + body: JSON.stringify({ foo: 'bar' }), + contentType: "json", + credentials: "include" + }); + + t.equals(response.status,401); }) test("forbidden", async (t) => { - const client = PubkyClient.testnet(); + const client = Client.testnet(); const keypair = Keypair.random() const publicKey = keypair.publicKey() - await client.signup(keypair, Homeserver) + await client.signup(keypair, HOMESERVER_PUBLICKEY) const session = await client.session(publicKey) t.ok(session, "signup") - const body = Buffer.from(JSON.stringify({ foo: 'bar' })) + const body = (JSON.stringify({ foo: 'bar' })) let url = `pubky://${publicKey.z32()}/priv/example.com/arbitrary`; // PUT public data, by authorized client - let result = await client.put(url, body).catch(e => e); - - t.ok(result instanceof Error); - t.is( - result.message, - `HTTP status client error (403 Forbidden) for url (http://localhost:15411/${publicKey.z32()}/priv/example.com/arbitrary)` - ) + let response = await client.fetch(url, { + method: "PUT", + body: JSON.stringify({ foo: 'bar' }), + credentials: "include" + }); + + t.is(response.status, 403) + t.is(await response.text(), 'Writing to directories other than \'/pub/\' is forbidden') }) test("list", async (t) => { - const client = PubkyClient.testnet(); + const client = Client.testnet(); const keypair = Keypair.random() const publicKey = keypair.publicKey() const pubky = publicKey.z32() - await client.signup(keypair, Homeserver) + await client.signup(keypair, HOMESERVER_PUBLICKEY) @@ -132,7 +142,11 @@ test("list", async (t) => { ] for (let url of urls) { - await client.put(url, Buffer.from("")); + await client.fetch(url, { + method: "PUT", + body:Buffer.from(""), + credentials: "include" + }); } let url = `pubky://${pubky}/pub/example.com/`; @@ -242,13 +256,13 @@ test("list", async (t) => { }) test('list shallow', async (t) => { - const client = PubkyClient.testnet(); + const client = Client.testnet(); const keypair = Keypair.random() const publicKey = keypair.publicKey() const pubky = publicKey.z32() - await client.signup(keypair, Homeserver) + await client.signup(keypair, HOMESERVER_PUBLICKEY) let urls = [ `pubky://${pubky}/pub/a.com/a.txt`, @@ -264,7 +278,11 @@ test('list shallow', async (t) => { ] for (let url of urls) { - await client.put(url, Buffer.from("")); + await client.fetch(url, { + method: "PUT", + body: Buffer.from(""), + credentials: "include" + }); } let url = `pubky://${pubky}/pub/`; diff --git a/pubky/src/bin/patch.mjs b/pubky/src/bin/patch.mjs index a8ed503..1760bff 100644 --- a/pubky/src/bin/patch.mjs +++ b/pubky/src/bin/patch.mjs @@ -54,7 +54,7 @@ const bytes = __toBinary(${JSON.stringify(await readFile(path.join(__dirname, `. `, ); -await writeFile(path.join(__dirname, `../../pkg/browser.js`), patched + "\nglobalThis['pubky'] = imports"); +await writeFile(path.join(__dirname, `../../pkg/index.js`), patched + "\nglobalThis['pubky'] = imports"); // Move outside of nodejs @@ -64,3 +64,12 @@ await Promise.all([".js", ".d.ts", "_bg.wasm"].map(suffix => path.join(__dirname, `../../pkg/${suffix === '.js' ? "index.cjs" : (name + suffix)}`), )) ) + +// Add index.cjs headers + +const indexcjsPath = path.join(__dirname, `../../pkg/index.cjs`); + +const headerContent = await readFile(path.join(__dirname, `../../pkg/node-header.cjs`), 'utf8'); +const indexcjsContent = await readFile(indexcjsPath, 'utf8'); + +await writeFile(indexcjsPath, headerContent + '\n' + indexcjsContent, 'utf8') diff --git a/pubky/src/error.rs b/pubky/src/error.rs deleted file mode 100644 index b1d9a19..0000000 --- a/pubky/src/error.rs +++ /dev/null @@ -1,56 +0,0 @@ -//! Main Crate Error - -use pkarr::dns::SimpleDnsError; - -// Alias Result to be the crate Result. -pub type Result = core::result::Result; - -#[derive(thiserror::Error, Debug)] -/// Pubky crate's common Error enum -pub enum Error { - /// For starter, to remove as code matures. - #[error("Generic error: {0}")] - Generic(String), - - #[error("Could not resolve endpoint for {0}")] - ResolveEndpoint(String), - - #[error("Could not convert the passed type into a Url")] - InvalidUrl, - - // === Transparent === - #[error(transparent)] - Dns(#[from] SimpleDnsError), - - #[error(transparent)] - Pkarr(#[from] pkarr::Error), - - #[error(transparent)] - Url(#[from] url::ParseError), - - #[error(transparent)] - Reqwest(#[from] reqwest::Error), - - #[error(transparent)] - Session(#[from] pubky_common::session::Error), - - #[error(transparent)] - Crypto(#[from] pubky_common::crypto::Error), - - #[error(transparent)] - RecoveryFile(#[from] pubky_common::recovery_file::Error), - - #[error(transparent)] - AuthToken(#[from] pubky_common::auth::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 f203d44..951bd62 100644 --- a/pubky/src/lib.rs +++ b/pubky/src/lib.rs @@ -1,7 +1,6 @@ #![doc = include_str!("../README.md")] //! -mod error; mod shared; #[cfg(not(target_arch = "wasm32"))] @@ -9,32 +8,32 @@ 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; +use std::fmt::Debug; -pub use error::Error; +use wasm_bindgen::prelude::*; #[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(Debug, Clone)] +#[derive(Clone)] #[wasm_bindgen] -pub struct PubkyClient { +pub struct Client { http: reqwest::Client, + pkarr: pkarr::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>>, + cookie_store: std::sync::Arc, + #[cfg(not(target_arch = "wasm32"))] + icann_http: reqwest::Client, + #[cfg(target_arch = "wasm32")] - pub(crate) pkarr_relays: Vec, + testnet: bool, +} + +impl Debug for Client { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Pubky Client").finish() + } } diff --git a/pubky/src/native.rs b/pubky/src/native.rs index 206da44..aa0dcd1 100644 --- a/pubky/src/native.rs +++ b/pubky/src/native.rs @@ -1,262 +1,110 @@ -use std::net::ToSocketAddrs; -use std::time::Duration; +use std::{net::ToSocketAddrs, sync::Arc, time::Duration}; -use bytes::Bytes; -use pubky_common::{ - capabilities::Capabilities, - recovery_file::{create_recovery_file, decrypt_recovery_file}, - session::Session, -}; -use reqwest::{RequestBuilder, Response}; -use tokio::sync::oneshot; -use url::Url; +use pkarr::mainline::Testnet; -use pkarr::{mainline::MutableItem, Keypair, PkarrClientAsync}; +use crate::Client; -use ::pkarr::{mainline::dht::Testnet, PkarrClient, PublicKey, SignedPacket}; +mod api; +mod cookies; +mod http; -use crate::{ - error::{Error, Result}, - shared::list_builder::ListBuilder, - PubkyClient, -}; +pub(crate) use cookies::CookieJar; static DEFAULT_USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"),); #[derive(Debug, Default)] -pub struct PubkyClientBuilder { - pkarr_settings: pkarr::Settings, +pub struct Settings { + pkarr_config: pkarr::Config, } -impl PubkyClientBuilder { +impl Settings { /// Set Pkarr client [pkarr::Settings]. - pub fn pkarr_settings(mut self, settings: pkarr::Settings) -> Self { - self.pkarr_settings = settings; - self - } - - /// Use the bootstrap nodes of a testnet, as the bootstrap nodes and - /// resolvers in the internal Pkarr client. - pub fn testnet(mut self, testnet: &Testnet) -> Self { - self.pkarr_settings.dht.bootstrap = testnet.bootstrap.to_vec().into(); - - self.pkarr_settings.resolvers = testnet - .bootstrap - .iter() - .flat_map(|resolver| resolver.to_socket_addrs()) - .flatten() - .collect::>() - .into(); - - self - } + pub fn pkarr_config(mut self, settings: pkarr::Config) -> Self { + self.pkarr_config = settings; - /// Set the request_timeout of the UDP socket in the Mainline DHT client in - /// the internal Pkarr client. - /// - /// Useful to speed unit tests. - /// Defaults to 2 seconds. - pub fn dht_request_timeout(mut self, timeout: Duration) -> Self { - self.pkarr_settings.dht.request_timeout = timeout.into(); self } - /// Build [PubkyClient] - pub fn build(self) -> PubkyClient { - PubkyClient { - http: reqwest::Client::builder() - .cookie_store(true) - .user_agent(DEFAULT_USER_AGENT) - .build() - .unwrap(), - pkarr: PkarrClient::new(self.pkarr_settings).unwrap().as_async(), - } - } -} - -impl Default for PubkyClient { - fn default() -> Self { - PubkyClient::builder().build() - } -} - -// === Public API === - -impl PubkyClient { - /// Returns a builder to edit settings before creating [PubkyClient]. - pub fn builder() -> PubkyClientBuilder { - PubkyClientBuilder::default() - } + /// Sets the following: + /// - Pkarr client's DHT bootstrap nodes = `testnet` bootstrap nodes. + /// - Pkarr client's resolvers = `testnet` bootstrap nodes. + /// - Pkarr client's DHT request timout = 500 milliseconds. (unless in CI, then it is left as default 2000) + pub fn testnet(mut self, testnet: &Testnet) -> Self { + let bootstrap = testnet.bootstrap.clone(); - /// Create a client connected to the local network - /// with the bootstrapping node: `localhost:6881` - pub fn testnet() -> Self { - Self::test(&Testnet { - bootstrap: vec!["localhost:6881".to_string()], - nodes: vec![], - }) - } + self.pkarr_config.resolvers = Some( + bootstrap + .iter() + .flat_map(|resolver| resolver.to_socket_addrs()) + .flatten() + .collect::>(), + ); - /// Creates a [PubkyClient] with: - /// - DHT bootstrap nodes set to the `testnet` bootstrap nodes. - /// - DHT request timout set to 500 milliseconds. (unless in CI, then it is left as default 2000) - /// - /// For more control, you can use [PubkyClient::builder] testnet option. - pub fn test(testnet: &Testnet) -> PubkyClient { - let mut builder = PubkyClient::builder().testnet(testnet); + self.pkarr_config.dht_config.bootstrap = bootstrap; if std::env::var("CI").is_err() { - builder = builder.dht_request_timeout(Duration::from_millis(500)); + self.pkarr_config.dht_config.request_timeout = Duration::from_millis(500); } - builder.build() - } - - // === Getters === - - /// Returns a reference to the internal [pkarr] Client. - pub fn pkarr(&self) -> &PkarrClientAsync { - &self.pkarr - } - - // === 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, url: T, content: &[u8]) -> Result<()> { - self.inner_put(url, content).await - } - - /// Download a small payload from a given path relative to a pubky author. - pub async fn get>(&self, url: T) -> Result> { - self.inner_get(url).await - } - - /// Delete a file at a path relative to a pubky author. - pub async fn delete>(&self, url: T) -> Result<()> { - self.inner_delete(url).await - } - - /// Returns a [ListBuilder] to help pass options before calling [ListBuilder::send]. - /// - /// `url` sets the path you want to lest within. - pub fn list>(&self, url: T) -> Result { - self.inner_list(url) - } - - // === Helpers === - - /// Create a recovery file of the `keypair`, containing the secret key encrypted - /// using the `passphrase`. - pub fn create_recovery_file(keypair: &Keypair, passphrase: &str) -> Result> { - Ok(create_recovery_file(keypair, passphrase)?) - } - - /// Recover a keypair from a recovery file by decrypting the secret key using `passphrase`. - pub fn decrypt_recovery_file(recovery_file: &[u8], passphrase: &str) -> Result { - Ok(decrypt_recovery_file(recovery_file, passphrase)?) + self } - /// Return `pubkyauth://` url and wait for the incoming [pubky_common::auth::AuthToken] - /// verifying that AuthToken, and if capabilities were requested, signing in to - /// the Pubky's homeserver and returning the [Session] information. - pub fn auth_request( - &self, - relay: impl TryInto, - capabilities: &Capabilities, - ) -> Result<(Url, tokio::sync::oneshot::Receiver)> { - let mut relay: Url = relay - .try_into() - .map_err(|_| Error::Generic("Invalid relay Url".into()))?; - - let (pubkyauth_url, client_secret) = self.create_auth_request(&mut relay, capabilities)?; - - let (tx, rx) = oneshot::channel::(); + /// Build [Client] + pub fn build(self) -> Result { + let pkarr = pkarr::Client::new(self.pkarr_config)?; - let this = self.clone(); + let cookie_store = Arc::new(CookieJar::default()); - tokio::spawn(async move { - let to_send = this - .subscribe_to_auth_response(relay, &client_secret) - .await?; - - tx.send(to_send) - .map_err(|_| Error::Generic("Failed to send the session after signing in with token, since the receiver is dropped".into()))?; - - Ok::<(), Error>(()) - }); - - Ok((pubkyauth_url, rx)) - } + // TODO: allow custom user agent, but force a Pubky user agent information + let user_agent = DEFAULT_USER_AGENT; - /// 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>( - &self, - keypair: &Keypair, - pubkyauth_url: T, - ) -> Result<()> { - let url: Url = pubkyauth_url.try_into().map_err(|_| Error::InvalidUrl)?; + let http = reqwest::ClientBuilder::from(pkarr.clone()) + // TODO: use persistent cookie jar + .cookie_provider(cookie_store.clone()) + .user_agent(user_agent) + .build() + .expect("config expected to not error"); - self.inner_send_auth_token(keypair, url).await?; + let icann_http = reqwest::ClientBuilder::new() + .cookie_provider(cookie_store.clone()) + .user_agent(user_agent) + .build() + .expect("config expected to not error"); - Ok(()) + Ok(Client { + cookie_store, + http, + icann_http, + pkarr, + }) } } -// === Internals === - -impl PubkyClient { - // === Pkarr === - - pub(crate) async fn pkarr_resolve( - &self, - public_key: &PublicKey, - ) -> Result> { - Ok(self.pkarr.resolve(public_key).await?.or(self - .pkarr - .cache() - .get(&MutableItem::target_from_key(public_key.as_bytes(), &None)))) +impl Client { + /// Create a new [Client] with default [Settings] + pub fn new() -> Result { + Self::builder().build() } - pub(crate) async fn pkarr_publish(&self, signed_packet: &SignedPacket) -> Result<()> { - Ok(self.pkarr.publish(signed_packet).await?) + /// Returns a builder to edit settings before creating [Client]. + pub fn builder() -> Settings { + Settings::default() } - // === HTTP === - - /// Make an HTTP(s) request to a URL with a Pkarr TLD - pub fn request(&self, method: reqwest::Method, url: Url) -> RequestBuilder { - self.http.request(method, url) + /// Create a client connected to the local network + /// with the bootstrapping node: `localhost:6881` + pub fn testnet() -> Result { + Self::builder() + .testnet(&Testnet { + bootstrap: vec!["localhost:6881".to_string()], + nodes: vec![], + }) + .build() } - pub(crate) fn store_session(&self, _: &Response) {} - pub(crate) fn remove_session(&self, _: &PublicKey) {} + #[cfg(test)] + /// Alias to `pubky::Client::builder().testnet(testnet).build().unwrap()` + pub(crate) fn test(testnet: &Testnet) -> Client { + Client::builder().testnet(testnet).build().unwrap() + } } diff --git a/pubky/src/native/api/auth.rs b/pubky/src/native/api/auth.rs new file mode 100644 index 0000000..1f21993 --- /dev/null +++ b/pubky/src/native/api/auth.rs @@ -0,0 +1,74 @@ +use pkarr::Keypair; +use pubky_common::session::Session; +use reqwest::IntoUrl; +use tokio::sync::oneshot; +use url::Url; + +use pkarr::PublicKey; + +use pubky_common::capabilities::Capabilities; + +use anyhow::Result; + +use crate::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 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 + } + + /// Return `pubkyauth://` url and wait for the incoming [AuthToken] + /// verifying that AuthToken, and if capabilities were requested, signing in to + /// the Pubky's homeserver and returning the [Session] information. + pub fn auth_request( + &self, + relay: T, + capabilities: &Capabilities, + ) -> Result<(Url, tokio::sync::oneshot::Receiver>)> { + let mut relay: Url = relay.into_url()?; + + let (pubkyauth_url, client_secret) = self.create_auth_request(&mut relay, capabilities)?; + + let (tx, rx) = oneshot::channel::>(); + + let this = self.clone(); + + tokio::spawn(async move { + tx.send(this.subscribe_to_auth_response(relay, &client_secret).await) + }); + + Ok((pubkyauth_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( + &self, + keypair: &Keypair, + pubkyauth_url: T, + ) -> Result<()> { + self.inner_send_auth_token(keypair, pubkyauth_url).await + } +} diff --git a/pubky/src/native/api/mod.rs b/pubky/src/native/api/mod.rs new file mode 100644 index 0000000..f43316f --- /dev/null +++ b/pubky/src/native/api/mod.rs @@ -0,0 +1,5 @@ +pub mod recovery_file; + +// TODO: put the Homeserver API behind a feature flag +pub mod auth; +pub mod public; diff --git a/pubky/src/native/api/public.rs b/pubky/src/native/api/public.rs new file mode 100644 index 0000000..00d8ce6 --- /dev/null +++ b/pubky/src/native/api/public.rs @@ -0,0 +1,14 @@ +use reqwest::IntoUrl; + +use anyhow::Result; + +use crate::{shared::list_builder::ListBuilder, Client}; + +impl Client { + /// Returns a [ListBuilder] to help pass options before calling [ListBuilder::send]. + /// + /// `url` sets the path you want to lest within. + pub fn list(&self, url: T) -> Result { + self.inner_list(url) + } +} diff --git a/pubky/src/native/api/recovery_file.rs b/pubky/src/native/api/recovery_file.rs new file mode 100644 index 0000000..2d05190 --- /dev/null +++ b/pubky/src/native/api/recovery_file.rs @@ -0,0 +1,21 @@ +use pubky_common::{ + crypto::Keypair, + recovery_file::{create_recovery_file, decrypt_recovery_file}, +}; + +use anyhow::Result; + +use crate::Client; + +impl Client { + /// Create a recovery file of the `keypair`, containing the secret key encrypted + /// using the `passphrase`. + pub fn create_recovery_file(keypair: &Keypair, passphrase: &str) -> Result> { + Ok(create_recovery_file(keypair, passphrase)?) + } + + /// Recover a keypair from a recovery file by decrypting the secret key using `passphrase`. + pub fn decrypt_recovery_file(recovery_file: &[u8], passphrase: &str) -> Result { + Ok(decrypt_recovery_file(recovery_file, passphrase)?) + } +} diff --git a/pubky/src/native/cookies.rs b/pubky/src/native/cookies.rs new file mode 100644 index 0000000..d57bb00 --- /dev/null +++ b/pubky/src/native/cookies.rs @@ -0,0 +1,83 @@ +use std::{collections::HashMap, sync::RwLock}; + +use pkarr::PublicKey; +use reqwest::{cookie::CookieStore, header::HeaderValue, Response}; + +#[derive(Default)] +pub struct CookieJar { + pubky_sessions: RwLock>, + normal_jar: RwLock, +} + +impl CookieJar { + pub(crate) fn store_session_after_signup(&self, response: &Response, pubky: &PublicKey) { + for (header_name, header_value) in response.headers() { + let cookie_name = &pubky.to_string(); + + if header_name == "set-cookie" + && header_value.as_ref().starts_with(cookie_name.as_bytes()) + { + if let Ok(Ok(cookie)) = + std::str::from_utf8(header_value.as_bytes()).map(cookie::Cookie::parse) + { + if cookie.name() == cookie_name { + let domain = format!("_pubky.{pubky}"); + tracing::debug!(?cookie, "Storing coookie after signup"); + + self.pubky_sessions + .write() + .unwrap() + .insert(domain, cookie.value().to_string()); + } + }; + } + } + } + + pub(crate) fn delete_session_after_signout(&self, pubky: &PublicKey) { + self.pubky_sessions + .write() + .unwrap() + .remove(&format!("_pubky.{pubky}")); + } +} + +impl CookieStore for CookieJar { + fn set_cookies(&self, cookie_headers: &mut dyn Iterator, url: &url::Url) { + let iter = cookie_headers.filter_map(|val| { + val.to_str() + .ok() + .and_then(|s| cookie::Cookie::parse(s.to_owned()).ok()) + }); + + self.normal_jar + .write() + .unwrap() + .store_response_cookies(iter, url); + } + + fn cookies(&self, url: &url::Url) -> Option { + let s = self + .normal_jar + .read() + .unwrap() + .get_request_values(url) + .map(|(name, value)| format!("{name}={value}")) + .collect::>() + .join("; "); + + if s.is_empty() { + let host = url.host_str().unwrap_or(""); + + if let Ok(public_key) = PublicKey::try_from(host) { + let cookie_name = public_key.to_string(); + + return self.pubky_sessions.read().unwrap().get(host).map(|secret| { + HeaderValue::try_from(format!("{cookie_name}={secret}")).unwrap() + }); + } + } + + HeaderValue::from_maybe_shared(bytes::Bytes::from(s)).ok() + } +} diff --git a/pubky/src/native/http.rs b/pubky/src/native/http.rs new file mode 100644 index 0000000..f3474e8 --- /dev/null +++ b/pubky/src/native/http.rs @@ -0,0 +1,172 @@ +//! HTTP methods that support `https://` with Pkarr domains, and `pubky://` URLs + +use pkarr::PublicKey; +use reqwest::{IntoUrl, Method, RequestBuilder}; + +use crate::Client; + +impl Client { + /// Start building a `Request` with the `Method` and `Url`. + /// + /// Returns a `RequestBuilder`, which will allow setting headers and + /// the request body before sending. + /// + /// Differs from [reqwest::Client::request], in that it can make requests to: + /// 1. HTTPs URLs with with a [pkarr::PublicKey] as Top Level Domain, by resolving + /// corresponding endpoints, and verifying TLS certificates accordingly. + /// (example: `https://o4dksfbqk85ogzdb5osziw6befigbuxmuxkuxq8434q89uj56uyy`) + /// 2. Pubky URLs like `pubky://o4dksfbqk85ogzdb5osziw6befigbuxmuxkuxq8434q89uj56uyy` + /// by converting the url into `https://_pubky.o4dksfbqk85ogzdb5osziw6befigbuxmuxkuxq8434q89uj56uyy` + /// + /// # Errors + /// + /// This method fails whenever the supplied `Url` cannot be parsed. + pub fn request(&self, method: Method, url: U) -> RequestBuilder { + let url = url.as_str(); + + if url.starts_with("pubky://") { + let url = format!("https://_pubky.{}", url.split_at(8).1); + + return self.http.request(method, url); + } else if url.starts_with("https://") && PublicKey::try_from(url).is_err() { + return self.icann_http.request(method, url); + } + + self.http.request(method, url) + } + + /// Convenience method to make a `GET` request to a URL. + /// + /// Differs from [reqwest::Client::get], in that it can make requests to: + /// 1. HTTP(s) URLs with with a [pkarr::PublicKey] as Top Level Domain, by resolving + /// corresponding endpoints, and verifying TLS certificates accordingly. + /// (example: `https://o4dksfbqk85ogzdb5osziw6befigbuxmuxkuxq8434q89uj56uyy`) + /// 2. Pubky URLs like `pubky://o4dksfbqk85ogzdb5osziw6befigbuxmuxkuxq8434q89uj56uyy` + /// by converting the url into `https://_pubky.o4dksfbqk85ogzdb5osziw6befigbuxmuxkuxq8434q89uj56uyy` + /// + /// # Errors + /// + /// This method fails whenever the supplied `Url` cannot be parsed. + pub fn get(&self, url: U) -> RequestBuilder { + self.request(Method::GET, url) + } + + /// Convenience method to make a `POST` request to a URL. + /// + /// # Errors + /// + /// This method fails whenever the supplied `Url` cannot be parsed. + pub fn post(&self, url: U) -> RequestBuilder { + self.request(Method::POST, url) + } + + /// Convenience method to make a `PUT` request to a URL. + /// + /// Differs from [reqwest::Client::put], in that it can make requests to: + /// 1. HTTP(s) URLs with with a [pkarr::PublicKey] as Top Level Domain, by resolving + /// corresponding endpoints, and verifying TLS certificates accordingly. + /// (example: `https://o4dksfbqk85ogzdb5osziw6befigbuxmuxkuxq8434q89uj56uyy`) + /// 2. Pubky URLs like `pubky://o4dksfbqk85ogzdb5osziw6befigbuxmuxkuxq8434q89uj56uyy` + /// by converting the url into `https://_pubky.o4dksfbqk85ogzdb5osziw6befigbuxmuxkuxq8434q89uj56uyy` + /// + /// # Errors + /// + /// This method fails whenever the supplied `Url` cannot be parsed. + pub fn put(&self, url: U) -> RequestBuilder { + self.request(Method::PUT, url) + } + + /// Convenience method to make a `PATCH` request to a URL. + /// + /// Differs from [reqwest::Client::patch], in that it can make requests to: + /// 1. HTTP(s) URLs with with a [pkarr::PublicKey] as Top Level Domain, by resolving + /// corresponding endpoints, and verifying TLS certificates accordingly. + /// (example: `https://o4dksfbqk85ogzdb5osziw6befigbuxmuxkuxq8434q89uj56uyy`) + /// 2. Pubky URLs like `pubky://o4dksfbqk85ogzdb5osziw6befigbuxmuxkuxq8434q89uj56uyy` + /// by converting the url into `https://_pubky.o4dksfbqk85ogzdb5osziw6befigbuxmuxkuxq8434q89uj56uyy` + /// + /// # Errors + /// + /// This method fails whenever the supplied `Url` cannot be parsed. + pub fn patch(&self, url: U) -> RequestBuilder { + self.request(Method::PATCH, url) + } + + /// Convenience method to make a `DELETE` request to a URL. + /// + /// Differs from [reqwest::Client::delete], in that it can make requests to: + /// 1. HTTP(s) URLs with with a [pkarr::PublicKey] as Top Level Domain, by resolving + /// corresponding endpoints, and verifying TLS certificates accordingly. + /// (example: `https://o4dksfbqk85ogzdb5osziw6befigbuxmuxkuxq8434q89uj56uyy`) + /// 2. Pubky URLs like `pubky://o4dksfbqk85ogzdb5osziw6befigbuxmuxkuxq8434q89uj56uyy` + /// by converting the url into `https://_pubky.o4dksfbqk85ogzdb5osziw6befigbuxmuxkuxq8434q89uj56uyy` + /// + /// # Errors + /// + /// This method fails whenever the supplied `Url` cannot be parsed. + pub fn delete(&self, url: U) -> RequestBuilder { + self.request(Method::DELETE, url) + } + + /// Convenience method to make a `HEAD` request to a URL. + /// + /// Differs from [reqwest::Client::head], in that it can make requests to: + /// 1. HTTP(s) URLs with with a [pkarr::PublicKey] as Top Level Domain, by resolving + /// corresponding endpoints, and verifying TLS certificates accordingly. + /// (example: `https://o4dksfbqk85ogzdb5osziw6befigbuxmuxkuxq8434q89uj56uyy`) + /// 2. Pubky URLs like `pubky://o4dksfbqk85ogzdb5osziw6befigbuxmuxkuxq8434q89uj56uyy` + /// by converting the url into `https://_pubky.o4dksfbqk85ogzdb5osziw6befigbuxmuxkuxq8434q89uj56uyy` + /// + /// # Errors + /// + /// This method fails whenever the supplied `Url` cannot be parsed. + pub fn head(&self, url: U) -> RequestBuilder { + self.request(Method::HEAD, url) + } + + // === Private Methods === + + pub(crate) async fn inner_request(&self, method: Method, url: T) -> RequestBuilder { + self.request(method, url) + } +} + +#[cfg(test)] +mod tests { + use pkarr::mainline::Testnet; + use pubky_homeserver::Homeserver; + + use crate::Client; + + #[tokio::test] + async fn http_get_pubky() { + let testnet = Testnet::new(10).unwrap(); + + let homeserver = Homeserver::start_test(&testnet).await.unwrap(); + + let client = Client::test(&testnet); + + let response = client + .get(format!("https://{}/", homeserver.public_key())) + .send() + .await + .unwrap(); + + assert_eq!(response.status(), 200) + } + + #[tokio::test] + async fn http_get_icann() { + let testnet = Testnet::new(10).unwrap(); + + let client = Client::test(&testnet); + + let response = client + .request(Default::default(), "https://example.com/") + .send() + .await + .unwrap(); + + assert_eq!(response.status(), 200); + } +} diff --git a/pubky/src/shared/auth.rs b/pubky/src/shared/auth.rs index 5c37f48..294260a 100644 --- a/pubky/src/shared/auth.rs +++ b/pubky/src/shared/auth.rs @@ -1,7 +1,7 @@ use std::collections::HashMap; use base64::{alphabet::URL_SAFE, engine::general_purpose::NO_PAD, Engine}; -use reqwest::{Method, StatusCode}; +use reqwest::{IntoUrl, Method, StatusCode}; use url::Url; use pkarr::{Keypair, PublicKey}; @@ -12,14 +12,11 @@ use pubky_common::{ session::Session, }; -use crate::{ - error::{Error, Result}, - PubkyClient, -}; +use anyhow::Result; -use super::pkarr::Endpoint; +use crate::{handle_http_error, Client}; -impl PubkyClient { +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 @@ -29,23 +26,22 @@ impl PubkyClient { keypair: &Keypair, homeserver: &PublicKey, ) -> Result { - let homeserver = homeserver.to_string(); - - let Endpoint { mut url, .. } = self.resolve_endpoint(&homeserver).await?; - - url.set_path("/signup"); - - let body = AuthToken::sign(keypair, vec![Capability::root()]).serialize(); - let response = self - .request(Method::POST, url.clone()) - .body(body) + .inner_request(Method::POST, format!("https://{}/signup", homeserver)) + .await + .body(AuthToken::sign(keypair, vec![Capability::root()]).serialize()) .send() .await?; - self.store_session(&response); + handle_http_error!(response); + + self.publish_homeserver(keypair, &homeserver.to_string()) + .await?; - self.publish_pubky_homeserver(keypair, &homeserver).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?; @@ -57,34 +53,35 @@ impl PubkyClient { /// 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 Endpoint { mut url, .. } = self.resolve_pubky_homeserver(pubky).await?; - - url.set_path(&format!("/{}/session", pubky)); - - let res = self.request(Method::GET, url).send().await?; + let response = self + .inner_request(Method::GET, format!("pubky://{}/session", pubky)) + .await + .send() + .await?; - if res.status() == StatusCode::NOT_FOUND { + if response.status() == StatusCode::NOT_FOUND { return Ok(None); } - if !res.status().is_success() { - res.error_for_status_ref()?; - }; + handle_http_error!(response); - let bytes = res.bytes().await?; + 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 Endpoint { mut url, .. } = self.resolve_pubky_homeserver(pubky).await?; - - url.set_path(&format!("/{}/session", pubky)); + let response = self + .inner_request(Method::DELETE, format!("pubky://{}/session", pubky)) + .await + .send() + .await?; - self.request(Method::DELETE, url).send().await?; + handle_http_error!(response); - self.remove_session(pubky); + #[cfg(not(target_arch = "wasm32"))] + self.cookie_store.delete_session_after_signout(pubky); Ok(()) } @@ -96,11 +93,18 @@ impl PubkyClient { self.signin_with_authtoken(&token).await } - pub(crate) async fn inner_send_auth_token( + pub(crate) async fn inner_send_auth_token( &self, keypair: &Keypair, - pubkyauth_url: Url, + pubkyauth_url: T, ) -> Result<()> { + let pubkyauth_url = Url::parse( + pubkyauth_url + .as_str() + .replace("pubkyauth_url", "http") + .as_str(), + )?; + let query_params: HashMap = pubkyauth_url.query_pairs().into_owned().collect(); @@ -136,36 +140,34 @@ impl PubkyClient { let engine = base64::engine::GeneralPurpose::new(&URL_SAFE, NO_PAD); - let mut callback = relay.clone(); - let mut path_segments = callback.path_segments_mut().unwrap(); + 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 - .request(Method::POST, callback) + .inner_request(Method::POST, callback_url) + .await .body(encrypted_token) .send() .await?; - response.error_for_status()?; + handle_http_error!(response); Ok(()) } pub(crate) async fn signin_with_authtoken(&self, token: &AuthToken) -> Result { - let mut url = Url::parse(&format!("https://{}/session", token.pubky()))?; - - self.resolve_url(&mut url).await?; - let response = self - .request(Method::POST, url) + .inner_request(Method::POST, format!("pubky://{}/session", token.pubky())) + .await .body(token.serialize()) .send() .await?; - self.store_session(&response); + handle_http_error!(response); let bytes = response.bytes().await?; @@ -188,7 +190,8 @@ impl PubkyClient { let mut segments = relay .path_segments_mut() - .map_err(|_| Error::Generic("Invalid relay".into()))?; + .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()); @@ -203,9 +206,11 @@ impl PubkyClient { relay: Url, client_secret: &[u8; 32], ) -> Result { - let response = self.http.request(Method::GET, relay).send().await?; + // 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)?; + 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() { @@ -218,9 +223,9 @@ impl PubkyClient { #[cfg(test)] mod tests { - use crate::*; + use http_relay::HttpRelay; use pkarr::{mainline::Testnet, Keypair}; use pubky_common::capabilities::{Capabilities, Capability}; use pubky_homeserver::Homeserver; @@ -228,10 +233,10 @@ mod tests { #[tokio::test] async fn basic_authn() { - let testnet = Testnet::new(10); + let testnet = Testnet::new(10).unwrap(); let server = Homeserver::start_test(&testnet).await.unwrap(); - let client = PubkyClient::test(&testnet); + let client = Client::test(&testnet); let keypair = Keypair::random(); @@ -269,23 +274,26 @@ mod tests { #[tokio::test] async fn authz() { - let testnet = Testnet::new(10); + 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 = PubkyClient::test(&testnet); - let (pubkyauth_url, pubkyauth_response) = client - .auth_request("https://demo.httprelay.io/link", &capabilities) - .unwrap(); + let client = Client::test(&testnet); + + let (pubkyauth_url, pubkyauth_response) = + client.auth_request(http_relay_url, &capabilities).unwrap(); // Authenticator side { - let client = PubkyClient::test(&testnet); + let client = Client::test(&testnet); client.signup(&keypair, &server.public_key()).await.unwrap(); @@ -295,37 +303,86 @@ mod tests { .unwrap(); } - let public_key = pubkyauth_response.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").as_str(), &[]) + .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").as_str(), &[]) + .put(format!("pubky://{pubky}/pub/pubky.app")) + .body(vec![]) + .send() .await - .map_err(|e| match e { - crate::Error::Reqwest(e) => e.status(), - _ => None, - }), - Err(Some(StatusCode::FORBIDDEN)) + .unwrap() + .status(), + StatusCode::FORBIDDEN ); assert_eq!( client - .put(format!("pubky://{pubky}/pub/foo.bar/file").as_str(), &[]) + .put(format!("pubky://{pubky}/pub/foo.bar/file")) + .body(vec![]) + .send() .await - .map_err(|e| match e { - crate::Error::Reqwest(e) => e.status(), - _ => None, - }), - Err(Some(StatusCode::FORBIDDEN)) + .unwrap() + .status(), + StatusCode::FORBIDDEN ); } + + #[tokio::test] + async fn multiple_users() { + let testnet = Testnet::new(10).unwrap(); + let server = Homeserver::start_test(&testnet).await.unwrap(); + + let client = Client::test(&testnet); + + let first_keypair = Keypair::random(); + let second_keypair = Keypair::random(); + + client + .signup(&first_keypair, &server.public_key()) + .await + .unwrap(); + + client + .signup(&second_keypair, &server.public_key()) + .await + .unwrap(); + + let session = client + .session(&first_keypair.public_key()) + .await + .unwrap() + .unwrap(); + + assert_eq!(session.pubky(), &first_keypair.public_key()); + assert!(session.capabilities().contains(&Capability::root())); + + let session = client + .session(&second_keypair.public_key()) + .await + .unwrap() + .unwrap(); + + assert_eq!(session.pubky(), &second_keypair.public_key()); + assert!(session.capabilities().contains(&Capability::root())); + } } diff --git a/pubky/src/shared/list_builder.rs b/pubky/src/shared/list_builder.rs index 32e787f..cc31860 100644 --- a/pubky/src/shared/list_builder.rs +++ b/pubky/src/shared/list_builder.rs @@ -1,25 +1,26 @@ -use reqwest::Method; -use url::Url; +use reqwest::{IntoUrl, Method}; -use crate::{error::Result, PubkyClient}; +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: Url, + url: String, reverse: bool, limit: Option, cursor: Option<&'a str>, - client: &'a PubkyClient, + client: &'a Client, shallow: bool, } impl<'a> ListBuilder<'a> { /// Create a new List request builder - pub(crate) fn new(client: &'a PubkyClient, url: Url) -> Self { + pub(crate) fn new(client: &'a Client, url: T) -> Self { Self { client, - url, + url: url.as_str().to_string(), limit: None, cursor: None, reverse: false, @@ -59,7 +60,7 @@ impl<'a> ListBuilder<'a> { /// respecting [ListBuilder::reverse], [ListBuilder::limit] and [ListBuilder::cursor] /// options. pub async fn send(self) -> Result> { - let mut url = self.client.pubky_to_http(self.url).await?; + let mut url = url::Url::parse(&self.url)?; if !url.path().ends_with('/') { let path = url.path().to_string(); @@ -91,9 +92,14 @@ impl<'a> ListBuilder<'a> { drop(query); - let response = self.client.request(Method::GET, url).send().await?; + let response = self + .client + .inner_request(Method::GET, url) + .await + .send() + .await?; - response.error_for_status_ref()?; + handle_http_error!(response); // TODO: bail on too large files. let bytes = response.bytes().await?; diff --git a/pubky/src/shared/mod.rs b/pubky/src/shared/mod.rs index 67b456f..63ca268 100644 --- a/pubky/src/shared/mod.rs +++ b/pubky/src/shared/mod.rs @@ -2,3 +2,15 @@ pub mod auth; pub mod list_builder; pub mod pkarr; pub mod public; + +#[macro_export] +macro_rules! handle_http_error { + ($res:expr) => { + if let Err(status) = $res.error_for_status_ref() { + return match $res.text().await { + Ok(text) => Err(anyhow::anyhow!("{status}. Error message: {text}")), + _ => Err(anyhow::anyhow!("{status}")), + }; + } + }; +} diff --git a/pubky/src/shared/pkarr.rs b/pubky/src/shared/pkarr.rs index 48ea3ca..83985e4 100644 --- a/pubky/src/shared/pkarr.rs +++ b/pubky/src/shared/pkarr.rs @@ -1,336 +1,79 @@ -use url::Url; +use pkarr::{dns::rdata::SVCB, Keypair, SignedPacket}; -use pkarr::{ - dns::{rdata::SVCB, Packet}, - Keypair, PublicKey, SignedPacket, -}; +use anyhow::Result; -use crate::{ - error::{Error, Result}, - PubkyClient, -}; +use crate::Client; -const MAX_ENDPOINT_RESOLUTION_RECURSION: u8 = 3; +impl Client { + /// Publish the HTTPS record for `_pubky.`. + pub(crate) async fn publish_homeserver(&self, keypair: &Keypair, host: &str) -> Result<()> { + // TODO: Before making public, consider the effect on other records and other mirrors -impl PubkyClient { - /// Publish the SVCB record for `_pubky.`. - pub(crate) async fn publish_pubky_homeserver( - &self, - keypair: &Keypair, - host: &str, - ) -> Result<()> { - let existing = self.pkarr_resolve(&keypair.public_key()).await?; + let existing = self.pkarr.resolve(&keypair.public_key()).await?; - let mut packet = Packet::new_reply(0); + let mut signed_packet_builder = SignedPacket::builder(); if let Some(existing) = existing { - for answer in existing.packet().answers.iter().cloned() { + for answer in existing.resource_records("_pubky") { if !answer.name.to_string().starts_with("_pubky") { - packet.answers.push(answer.into_owned()) + signed_packet_builder = signed_packet_builder.record(answer.to_owned()); } } } let svcb = SVCB::new(0, host.try_into()?); - packet.answers.push(pkarr::dns::ResourceRecord::new( - "_pubky".try_into().unwrap(), - pkarr::dns::CLASS::IN, - 60 * 60, - pkarr::dns::rdata::RData::HTTPS(svcb.into()), - )); + let signed_packet = SignedPacket::builder() + .https("_pubky".try_into().unwrap(), svcb, 60 * 60) + .sign(keypair)?; - let signed_packet = SignedPacket::from_packet(keypair, &packet)?; - - self.pkarr_publish(&signed_packet).await?; + self.pkarr.publish(&signed_packet).await?; Ok(()) } - /// Resolve the homeserver for a pubky. - pub(crate) async fn resolve_pubky_homeserver(&self, pubky: &PublicKey) -> Result { - 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 "non-pkarr url" from a Pubky domain - /// - /// "non-pkarr" url is any URL where the hostname isn't a 52 z-base32 character, - /// usually an IPv4, IPv6 or ICANN domain, but could also be any other unknown hostname. - /// - /// Recursively resolve SVCB and HTTPS endpoints, with [MAX_ENDPOINT_RESOLUTION_RECURSION] limit. - pub(crate) async fn resolve_endpoint(&self, target: &str) -> Result { - let original_target = target; - // TODO: cache the result of this function? - - let mut target = target.to_string(); - - let mut endpoint_public_key = None; - let mut origin = 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 step >= MAX_ENDPOINT_RESOLUTION_RECURSION { - break; - }; - step += 1; - - if let Some(signed_packet) = self - .pkarr_resolve(&public_key) - .await - .map_err(|_| Error::ResolveEndpoint(original_target.into()))? - { - // Choose most prior SVCB record - let svcb = signed_packet.resource_records(&target).fold( - None, - |prev: Option, answer| { - if let Some(svcb) = match &answer.rdata { - pkarr::dns::rdata::RData::SVCB(svcb) => Some(svcb), - pkarr::dns::rdata::RData::HTTPS(curr) => Some(&curr.0), - _ => None, - } { - let curr = svcb.clone(); - - if curr.priority == 0 { - return Some(curr); - } - if let Some(prev) = &prev { - // TODO return random if priority is the same - if curr.priority >= prev.priority { - return Some(curr); - } - } else { - return Some(curr); - } - } - - prev - }, - ); - - if let Some(svcb) = svcb { - endpoint_public_key = Some(public_key.clone()); - target = svcb.target.to_string(); - - if let Some(port) = svcb.get_param(pkarr::dns::rdata::SVCB::PORT) { - if port.len() < 2 { - // TODO: debug! Error encoding port! - } - let port = u16::from_be_bytes([port[0], port[1]]); - - origin = format!("{target}:{port}"); - } else { - origin.clone_from(&target); - }; - - if step >= MAX_ENDPOINT_RESOLUTION_RECURSION { - continue; - }; - } - } else { - break; - } - } - - if PublicKey::try_from(origin.as_str()).is_ok() { - return Err(Error::ResolveEndpoint(original_target.into())); - } - - if endpoint_public_key.is_some() { - let url = Url::parse(&format!( - "{}://{}", - if origin.starts_with("localhost") { - "http" - } else { - "https" - }, - origin - ))?; - - return Ok(Endpoint { url }); - } - - Err(Error::ResolveEndpoint(original_target.into())) - } - - pub(crate) async fn resolve_url(&self, url: &mut Url) -> Result<()> { - if let Some(Ok(pubky)) = url.host_str().map(PublicKey::try_from) { - let Endpoint { url: x, .. } = self.resolve_endpoint(&format!("_pubky.{pubky}")).await?; - - url.set_host(x.host_str())?; - url.set_port(x.port()).expect("should work!"); - url.set_scheme(x.scheme()).expect("should work!"); - }; - - Ok(()) - } -} - -#[derive(Debug)] -pub(crate) struct Endpoint { - pub url: Url, -} - -#[cfg(test)] -mod tests { - use super::*; - - use pkarr::{ - dns::{ - rdata::{HTTPS, SVCB}, - Packet, - }, - mainline::{dht::DhtSettings, Testnet}, - Keypair, PkarrClient, Settings, SignedPacket, - }; - use pubky_homeserver::Homeserver; - - #[tokio::test] - async fn resolve_endpoint_https() { - let testnet = Testnet::new(10); - - let pkarr_client = PkarrClient::new(Settings { - dht: DhtSettings { - bootstrap: Some(testnet.bootstrap.clone()), - ..Default::default() - }, - ..Default::default() - }) - .unwrap() - .as_async(); - - let domain = "example.com"; - let mut target; - - // Server - { - let keypair = Keypair::random(); - - let https = HTTPS(SVCB::new(0, domain.try_into().unwrap())); - - let mut packet = Packet::new_reply(0); - - packet.answers.push(pkarr::dns::ResourceRecord::new( - "foo".try_into().unwrap(), - pkarr::dns::CLASS::IN, - 60 * 60, - pkarr::dns::rdata::RData::HTTPS(https), - )); - - let signed_packet = SignedPacket::from_packet(&keypair, &packet).unwrap(); - - pkarr_client.publish(&signed_packet).await.unwrap(); - - target = format!("foo.{}", keypair.public_key()); - } - - // intermediate - { - let keypair = Keypair::random(); - - let svcb = SVCB::new(0, target.as_str().try_into().unwrap()); - - let mut packet = Packet::new_reply(0); - - packet.answers.push(pkarr::dns::ResourceRecord::new( - "bar".try_into().unwrap(), - pkarr::dns::CLASS::IN, - 60 * 60, - pkarr::dns::rdata::RData::SVCB(svcb), - )); - - let signed_packet = SignedPacket::from_packet(&keypair, &packet).unwrap(); - - pkarr_client.publish(&signed_packet).await.unwrap(); - - target = format!("bar.{}", keypair.public_key()) - } - - { - let keypair = Keypair::random(); - - let svcb = SVCB::new(0, target.as_str().try_into().unwrap()); - - let mut packet = Packet::new_reply(0); - - packet.answers.push(pkarr::dns::ResourceRecord::new( - "pubky".try_into().unwrap(), - pkarr::dns::CLASS::IN, - 60 * 60, - pkarr::dns::rdata::RData::SVCB(svcb), - )); - - let signed_packet = SignedPacket::from_packet(&keypair, &packet).unwrap(); - - pkarr_client.publish(&signed_packet).await.unwrap(); - - target = format!("pubky.{}", keypair.public_key()) - } - - let client = PubkyClient::test(&testnet); - - let endpoint = client.resolve_endpoint(&target).await.unwrap(); - - assert_eq!(endpoint.url.host_str().unwrap(), domain); - } - - #[tokio::test] - async fn resolve_homeserver() { - let testnet = Testnet::new(10); - let server = Homeserver::start_test(&testnet).await.unwrap(); - - // Publish an intermediate controller of the homeserver - let pkarr_client = PkarrClient::new(Settings { - dht: DhtSettings { - bootstrap: Some(testnet.bootstrap.clone()), - ..Default::default() - }, - ..Default::default() - }) - .unwrap() - .as_async(); - - let intermediate = Keypair::random(); - - let mut packet = Packet::new_reply(0); - - let server_tld = server.public_key().to_string(); - - let svcb = SVCB::new(0, server_tld.as_str().try_into().unwrap()); - - packet.answers.push(pkarr::dns::ResourceRecord::new( - "pubky".try_into().unwrap(), - pkarr::dns::CLASS::IN, - 60 * 60, - pkarr::dns::rdata::RData::SVCB(svcb), - )); - - let signed_packet = SignedPacket::from_packet(&intermediate, &packet).unwrap(); - - pkarr_client.publish(&signed_packet).await.unwrap(); - - { - let client = PubkyClient::test(&testnet); - - let pubky = Keypair::random(); - - client - .publish_pubky_homeserver(&pubky, &format!("pubky.{}", &intermediate.public_key())) - .await - .unwrap(); - - let Endpoint { url, .. } = client - .resolve_pubky_homeserver(&pubky.public_key()) - .await - .unwrap(); - - assert_eq!(url.host_str(), Some("localhost")); - assert_eq!(url.port(), Some(server.port())); - } - } + // pub(crate) resolve_icann_domain() { + // + // let original_url = url.as_str(); + // let mut url = Url::parse(original_url).expect("Invalid url in inner_request"); + // + // if url.scheme() == "pubky" { + // // TODO: use https for anything other than testnet + // url.set_scheme("http") + // .expect("couldn't replace pubky:// with http://"); + // url.set_host(Some(&format!("_pubky.{}", url.host_str().unwrap_or("")))) + // .expect("couldn't map pubk:// to https://_pubky."); + // } + // + // let qname = url.host_str().unwrap_or("").to_string(); + // + // if PublicKey::try_from(original_url).is_ok() { + // let mut stream = self.pkarr.resolve_https_endpoints(&qname); + // + // let mut so_far: Option = None; + // + // while let Some(endpoint) = stream.next().await { + // if let Some(ref e) = so_far { + // if e.domain() == "." && endpoint.domain() != "." { + // so_far = Some(endpoint); + // } + // } else { + // so_far = Some(endpoint) + // } + // } + // + // if let Some(e) = so_far { + // url.set_host(Some(e.domain())) + // .expect("coultdn't use the resolved endpoint's domain"); + // url.set_port(Some(e.port())) + // .expect("coultdn't use the resolved endpoint's port"); + // + // return self.http.request(method, url).fetch_credentials_include(); + // } else { + // // TODO: didn't find any domain, what to do? + // } + // } + // + // self.http.request(method, url).fetch_credentials_include() + // } } diff --git a/pubky/src/shared/public.rs b/pubky/src/shared/public.rs index 81118f7..53636a1 100644 --- a/pubky/src/shared/public.rs +++ b/pubky/src/shared/public.rs @@ -1,101 +1,19 @@ -use bytes::Bytes; +use reqwest::IntoUrl; -use pkarr::PublicKey; -use reqwest::{Method, StatusCode}; -use url::Url; +use anyhow::Result; -use crate::{ - error::{Error, Result}, - PubkyClient, -}; +use crate::Client; -use super::{list_builder::ListBuilder, pkarr::Endpoint}; +use super::list_builder::ListBuilder; -impl PubkyClient { - pub(crate) async fn inner_put>(&self, url: T, content: &[u8]) -> Result<()> { - let url = self.pubky_to_http(url).await?; - - let response = self - .request(Method::PUT, url) - .body(content.to_owned()) - .send() - .await?; - - response.error_for_status()?; - - Ok(()) - } - - pub(crate) async fn inner_get>(&self, url: T) -> Result> { - let url = self.pubky_to_http(url).await?; - - let response = self.request(Method::GET, url).send().await?; - - if response.status() == StatusCode::NOT_FOUND { - return Ok(None); - } - - response.error_for_status_ref()?; - - // TODO: bail on too large files. - let bytes = response.bytes().await?; - - Ok(Some(bytes)) - } - - pub(crate) async fn inner_delete>(&self, url: T) -> Result<()> { - let url = self.pubky_to_http(url).await?; - - let response = self.request(Method::DELETE, url).send().await?; - - response.error_for_status_ref()?; - - Ok(()) - } - - pub(crate) fn inner_list>(&self, url: T) -> Result { - Ok(ListBuilder::new( - self, - url.try_into().map_err(|_| Error::InvalidUrl)?, - )) - } - - pub(crate) async fn pubky_to_http>(&self, url: T) -> Result { - let original_url: Url = url.try_into().map_err(|_| Error::InvalidUrl)?; - - let pubky = original_url - .host_str() - .ok_or(Error::Generic("Missing Pubky Url host".to_string()))?; - - if let Ok(public_key) = PublicKey::try_from(pubky) { - let Endpoint { mut url, .. } = self.resolve_pubky_homeserver(&public_key).await?; - - // TODO: remove if we move to subdomains instead of paths. - if original_url.scheme() == "pubky" { - let path = original_url.path_segments(); - - let mut split = url.path_segments_mut().unwrap(); - split.push(pubky); - if let Some(segments) = path { - for segment in segments { - split.push(segment); - } - } - drop(split); - } - - return Ok(url); - } - - Ok(original_url) +impl Client { + pub(crate) fn inner_list(&self, url: T) -> Result { + Ok(ListBuilder::new(self, url)) } } #[cfg(test)] mod tests { - - use core::panic; - use crate::*; use bytes::Bytes; @@ -105,10 +23,10 @@ mod tests { #[tokio::test] async fn put_get_delete() { - let testnet = Testnet::new(10); + let testnet = Testnet::new(10).unwrap(); let server = Homeserver::start_test(&testnet).await.unwrap(); - let client = PubkyClient::test(&testnet); + let client = Client::test(&testnet); let keypair = Keypair::random(); @@ -117,25 +35,38 @@ mod tests { let url = format!("pubky://{}/pub/foo.txt", keypair.public_key()); let url = url.as_str(); - client.put(url, &[0, 1, 2, 3, 4]).await.unwrap(); + client + .put(url) + .body(vec![0, 1, 2, 3, 4]) + .send() + .await + .unwrap() + .error_for_status() + .unwrap(); - let response = client.get(url).await.unwrap().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).await.unwrap(); + client + .delete(url) + .send() + .await + .unwrap() + .error_for_status() + .unwrap(); - let response = client.get(url).await.unwrap(); + let response = client.get(url).send().await.unwrap(); - assert_eq!(response, None); + assert_eq!(response.status(), StatusCode::NOT_FOUND); } #[tokio::test] async fn unauthorized_put_delete() { - let testnet = Testnet::new(10); + let testnet = Testnet::new(10).unwrap(); let server = Homeserver::start_test(&testnet).await.unwrap(); - let client = PubkyClient::test(&testnet); + let client = Client::test(&testnet); let keypair = Keypair::random(); @@ -146,7 +77,7 @@ mod tests { let url = format!("pubky://{public_key}/pub/foo.txt"); let url = url.as_str(); - let other_client = PubkyClient::test(&testnet); + let other_client = Client::test(&testnet); { let other = Keypair::random(); @@ -156,19 +87,24 @@ mod tests { .await .unwrap(); - let response = other_client.put(url, &[0, 1, 2, 3, 4]).await; - - match response { - Err(Error::Reqwest(error)) => { - assert!(error.status() == Some(StatusCode::UNAUTHORIZED)) - } - _ => { - panic!("expected error StatusCode::UNAUTHORIZED") - } - } + assert_eq!( + other_client + .put(url) + .body(vec![0, 1, 2, 3, 4]) + .send() + .await + .unwrap() + .status(), + StatusCode::UNAUTHORIZED + ); } - client.put(url, &[0, 1, 2, 3, 4]).await.unwrap(); + client + .put(url) + .body(vec![0, 1, 2, 3, 4]) + .send() + .await + .unwrap(); { let other = Keypair::random(); @@ -179,29 +115,23 @@ mod tests { .await .unwrap(); - let response = other_client.delete(url).await; - - match response { - Err(Error::Reqwest(error)) => { - assert!(error.status() == Some(StatusCode::UNAUTHORIZED)) - } - _ => { - panic!("expected error StatusCode::UNAUTHORIZED") - } - } + assert_eq!( + other_client.delete(url).send().await.unwrap().status(), + StatusCode::UNAUTHORIZED + ); } - let response = client.get(url).await.unwrap().unwrap(); + 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); + let testnet = Testnet::new(10).unwrap(); let server = Homeserver::start_test(&testnet).await.unwrap(); - let client = PubkyClient::test(&testnet); + let client = Client::test(&testnet); let keypair = Keypair::random(); @@ -221,14 +151,13 @@ mod tests { ]; for url in urls { - client.put(url.as_str(), &[0]).await.unwrap(); + client.put(url).body(vec![0]).send().await.unwrap(); } let url = format!("pubky://{pubky}/pub/example.com/extra"); - let url = url.as_str(); { - let list = client.list(url).unwrap().send().await.unwrap(); + let list = client.list(&url).unwrap().send().await.unwrap(); assert_eq!( list, @@ -244,7 +173,7 @@ mod tests { } { - let list = client.list(url).unwrap().limit(2).send().await.unwrap(); + let list = client.list(&url).unwrap().limit(2).send().await.unwrap(); assert_eq!( list, @@ -258,7 +187,7 @@ mod tests { { let list = client - .list(url) + .list(&url) .unwrap() .limit(2) .cursor("a.txt") @@ -278,7 +207,7 @@ mod tests { { let list = client - .list(url) + .list(&url) .unwrap() .limit(2) .cursor("cc-nested/") @@ -298,7 +227,7 @@ mod tests { { let list = client - .list(url) + .list(&url) .unwrap() .limit(2) .cursor(&format!("pubky://{pubky}/pub/example.com/a.txt")) @@ -318,7 +247,7 @@ mod tests { { let list = client - .list(url) + .list(&url) .unwrap() .limit(2) .cursor("/a.txt") @@ -338,7 +267,7 @@ mod tests { { let list = client - .list(url) + .list(&url) .unwrap() .reverse(true) .send() @@ -360,7 +289,7 @@ mod tests { { let list = client - .list(url) + .list(&url) .unwrap() .reverse(true) .limit(2) @@ -380,7 +309,7 @@ mod tests { { let list = client - .list(url) + .list(&url) .unwrap() .reverse(true) .limit(2) @@ -402,10 +331,10 @@ mod tests { #[tokio::test] async fn list_shallow() { - let testnet = Testnet::new(10); + let testnet = Testnet::new(10).unwrap(); let server = Homeserver::start_test(&testnet).await.unwrap(); - let client = PubkyClient::test(&testnet); + let client = Client::test(&testnet); let keypair = Keypair::random(); @@ -427,15 +356,14 @@ mod tests { ]; for url in urls { - client.put(url.as_str(), &[0]).await.unwrap(); + client.put(url).body(vec![0]).send().await.unwrap(); } let url = format!("pubky://{pubky}/pub/"); - let url = url.as_str(); { let list = client - .list(url) + .list(&url) .unwrap() .shallow(true) .send() @@ -459,7 +387,7 @@ mod tests { { let list = client - .list(url) + .list(&url) .unwrap() .shallow(true) .limit(2) @@ -479,7 +407,7 @@ mod tests { { let list = client - .list(url) + .list(&url) .unwrap() .shallow(true) .limit(2) @@ -500,7 +428,7 @@ mod tests { { let list = client - .list(url) + .list(&url) .unwrap() .shallow(true) .limit(3) @@ -522,7 +450,7 @@ mod tests { { let list = client - .list(url) + .list(&url) .unwrap() .reverse(true) .shallow(true) @@ -547,7 +475,7 @@ mod tests { { let list = client - .list(url) + .list(&url) .unwrap() .reverse(true) .shallow(true) @@ -568,7 +496,7 @@ mod tests { { let list = client - .list(url) + .list(&url) .unwrap() .shallow(true) .reverse(true) @@ -590,7 +518,7 @@ mod tests { { let list = client - .list(url) + .list(&url) .unwrap() .shallow(true) .reverse(true) @@ -613,10 +541,10 @@ mod tests { #[tokio::test] async fn list_events() { - let testnet = Testnet::new(10); + let testnet = Testnet::new(10).unwrap(); let server = Homeserver::start_test(&testnet).await.unwrap(); - let client = PubkyClient::test(&testnet); + let client = Client::test(&testnet); let keypair = Keypair::random(); @@ -638,23 +566,19 @@ mod tests { ]; for url in urls { - client.put(url.as_str(), &[0]).await.unwrap(); - client.delete(url.as_str()).await.unwrap(); + client.put(&url).body(vec![0]).send().await.unwrap(); + client.delete(url).send().await.unwrap(); } - let feed_url = format!("http://localhost:{}/events/", server.port()); - let feed_url = feed_url.as_str(); + let feed_url = format!("https://{}/events/", server.public_key()); - let client = PubkyClient::test(&testnet); + let client = Client::test(&testnet); let cursor; { let response = client - .request( - Method::GET, - format!("{feed_url}?limit=10").as_str().try_into().unwrap(), - ) + .request(Method::GET, format!("{feed_url}?limit=10")) .send() .await .unwrap(); @@ -684,13 +608,7 @@ mod tests { { let response = client - .request( - Method::GET, - format!("{feed_url}?limit=10&cursor={cursor}") - .as_str() - .try_into() - .unwrap(), - ) + .request(Method::GET, format!("{feed_url}?limit=10&cursor={cursor}")) .send() .await .unwrap(); @@ -719,10 +637,10 @@ mod tests { #[tokio::test] async fn read_after_event() { - let testnet = Testnet::new(10); + let testnet = Testnet::new(10).unwrap(); let server = Homeserver::start_test(&testnet).await.unwrap(); - let client = PubkyClient::test(&testnet); + let client = Client::test(&testnet); let keypair = Keypair::random(); @@ -732,19 +650,15 @@ mod tests { let url = format!("pubky://{pubky}/pub/a.com/a.txt"); - client.put(url.as_str(), &[0]).await.unwrap(); + client.put(&url).body(vec![0]).send().await.unwrap(); - let feed_url = format!("http://localhost:{}/events/", server.port()); - let feed_url = feed_url.as_str(); + let feed_url = format!("https://{}/events/", server.public_key()); - let client = PubkyClient::test(&testnet); + let client = Client::test(&testnet); { let response = client - .request( - Method::GET, - format!("{feed_url}?limit=10").as_str().try_into().unwrap(), - ) + .request(Method::GET, format!("{feed_url}?limit=10")) .send() .await .unwrap(); @@ -763,16 +677,19 @@ mod tests { ); } - let resolved = client.get(url.as_str()).await.unwrap().unwrap(); + let response = client.get(url).send().await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + + let body = response.bytes().await.unwrap(); - assert_eq!(&resolved[..], &[0]); + assert_eq!(body.as_ref(), &[0]); } #[tokio::test] async fn dont_delete_shared_blobs() { - let testnet = Testnet::new(10); + let testnet = Testnet::new(10).unwrap(); let homeserver = Homeserver::start_test(&testnet).await.unwrap(); - let client = PubkyClient::test(&testnet); + let client = Client::test(&testnet); let homeserver_pubky = homeserver.public_key(); @@ -789,22 +706,37 @@ mod tests { let url_2 = format!("pubky://{user_2_id}/pub/pubky.app/file/file_1"); let file = vec![1]; - client.put(url_1.as_str(), &file).await.unwrap(); - client.put(url_2.as_str(), &file).await.unwrap(); + 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.as_str()).await.unwrap(); + client + .delete(url_1) + .send() + .await + .unwrap() + .error_for_status() + .unwrap(); - let blob = client.get(url_2.as_str()).await.unwrap().unwrap(); + let blob = client + .get(url_2) + .send() + .await + .unwrap() + .bytes() + .await + .unwrap(); assert_eq!(blob, file); - let feed_url = format!("http://localhost:{}/events/", homeserver.port()); + let feed_url = format!("https://{}/events/", homeserver.public_key()); let response = client - .request(Method::GET, feed_url.as_str().try_into().unwrap()) + .request(Method::GET, feed_url) .send() .await + .unwrap() + .error_for_status() .unwrap(); let text = response.text().await.unwrap(); @@ -818,17 +750,17 @@ mod tests { 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); + let testnet = Testnet::new(10).unwrap(); let server = Homeserver::start_test(&testnet).await.unwrap(); - let client = PubkyClient::test(&testnet); + let client = Client::test(&testnet); let keypair = Keypair::random(); @@ -839,16 +771,16 @@ mod tests { let bytes = Bytes::from(vec![0; 1024 * 1024]); - client.put(url, &bytes).await.unwrap(); + client.put(url).body(bytes.clone()).send().await.unwrap(); - let response = client.get(url).await.unwrap().unwrap(); + let response = client.get(url).send().await.unwrap().bytes().await.unwrap(); assert_eq!(response, bytes); - client.delete(url).await.unwrap(); + client.delete(url).send().await.unwrap(); - let response = client.get(url).await.unwrap(); + let response = client.get(url).send().await.unwrap(); - assert_eq!(response, None); + assert_eq!(response.status(), StatusCode::NOT_FOUND); } } diff --git a/pubky/src/wasm.rs b/pubky/src/wasm.rs index cbbf71b..5f364d9 100644 --- a/pubky/src/wasm.rs +++ b/pubky/src/wasm.rs @@ -1,250 +1,63 @@ -use std::{ - collections::HashSet, - sync::{Arc, RwLock}, -}; - -use js_sys::{Array, Uint8Array}; use wasm_bindgen::prelude::*; -use url::Url; - -use pubky_common::capabilities::Capabilities; - -use crate::error::Error; -use crate::PubkyClient; +use crate::Client; +mod api; mod http; -mod keys; -mod pkarr; -mod recovery_file; -mod session; - -use keys::{Keypair, PublicKey}; -use session::Session; +mod wrappers; -impl Default for PubkyClient { +impl Default for Client { fn default() -> Self { Self::new() } } -static DEFAULT_RELAYS: [&str; 1] = ["https://relay.pkarr.org"]; -static TESTNET_RELAYS: [&str; 1] = ["http://localhost:15411/pkarr"]; +static TESTNET_RELAYS: [&str; 1] = ["http://localhost:15411/"]; #[wasm_bindgen] -impl PubkyClient { +impl Client { #[wasm_bindgen(constructor)] + /// Create Client with default Settings including default relays 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(), + pkarr: pkarr::Client::builder().build().unwrap(), + testnet: false, } } /// Create a client with with configurations appropriate for local testing: - /// - set Pkarr relays to `["http://localhost:15411/pkarr"]` instead of default relay. + /// - set Pkarr relays to `["http://localhost:15411"]` instead of default relay. #[wasm_bindgen] pub fn testnet() -> Self { Self { http: reqwest::Client::builder().build().unwrap(), - session_cookies: Arc::new(RwLock::new(HashSet::new())), - pkarr_relays: TESTNET_RELAYS.into_iter().map(|s| s.to_string()).collect(), + pkarr: pkarr::Client::builder() + .relays( + TESTNET_RELAYS + .into_iter() + .map(|s| url::Url::parse(s).expect("TESTNET_RELAYS should be valid urls")) + .collect(), + ) + .build() + .unwrap(), + testnet: true, } } +} - /// Set Pkarr 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 { - self.pkarr_relays = relays; - self - } - - // Read the set of pkarr relays used by this client. - #[wasm_bindgen(js_name = "getPkarrRelays")] - pub fn get_pkarr_relays(&self) -> Vec { - self.pkarr_relays.clone() - } - - /// 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 { - Ok(Session( - self.inner_signup(keypair.as_inner(), homeserver.as_inner()) - .await - .map_err(JsValue::from)?, - )) - } - - /// 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 using the root Keypair. - #[wasm_bindgen] - pub async fn signin(&self, keypair: &Keypair) -> Result<(), JsValue> { - self.inner_signin(keypair.as_inner()) - .await - .map(|_| ()) - .map_err(|e| e.into()) - } - - /// Return `pubkyauth://` url and wait for the incoming [AuthToken] - /// 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] - #[wasm_bindgen(js_name = "authRequest")] - pub fn auth_request(&self, relay: &str, capabilities: &str) -> Result { - let mut relay: Url = relay - .try_into() - .map_err(|_| Error::Generic("Invalid relay Url".into()))?; - - let (pubkyauth_url, client_secret) = self.create_auth_request( - &mut relay, - &Capabilities::try_from(capabilities).map_err(|_| "Invalid capaiblities")?, - )?; - - 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(|err| JsValue::from_str(&format!("{:?}", err))) - }; - - 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) - } - - /// Sign an [pubky_common::auth::AuthToken], encrypt it and send it to the - /// source of the pubkyauth request url. - #[wasm_bindgen(js_name = "sendAuthToken")] - pub async fn send_auth_token( - &self, - keypair: &Keypair, - pubkyauth_url: &str, - ) -> Result<(), JsValue> { - let pubkyauth_url: Url = pubkyauth_url - .try_into() - .map_err(|_| Error::Generic("Invalid relay Url".into()))?; - - self.inner_send_auth_token(keypair.as_inner(), pubkyauth_url) - .await?; - - Ok(()) - } - - // === Public data === - - #[wasm_bindgen] - /// Upload a small payload to a given path. - pub async fn put(&self, url: &str, content: &[u8]) -> Result<(), JsValue> { - self.inner_put(url, content).await.map_err(|e| e.into()) - } - - /// Download a small payload from a given path relative to a pubky author. - #[wasm_bindgen] - pub async fn get(&self, url: &str) -> Result, JsValue> { - self.inner_get(url) - .await - .map(|b| b.map(|b| (&*b).into())) - .map_err(|e| e.into()) - } - - /// Delete a file at a path relative to a pubky author. - #[wasm_bindgen] - pub async fn delete(&self, url: &str) -> Result<(), JsValue> { - self.inner_delete(url).await.map_err(|e| e.into()) - } - - /// Returns a list of Pubky urls (as strings). - /// - /// - `url`: The Pubky url (string) to the directory you want to list its content. - /// - `cursor`: Either a full `pubky://` Url (from previous list response), - /// or a path (to a file or directory) relative to the `url` - /// - `reverse`: List in reverse order - /// - `limit` Limit the number of urls in the response - /// - `shallow`: List directories and files, instead of flat list of files. - #[wasm_bindgen] - pub async fn list( - &self, - url: &str, - cursor: Option, - reverse: Option, - limit: Option, - shallow: Option, - ) -> Result { - // TODO: try later to return Vec from async function. - - if let Some(cursor) = cursor { - return self - .inner_list(url)? - .reverse(reverse.unwrap_or(false)) - .limit(limit.unwrap_or(u16::MAX)) - .cursor(&cursor) - .shallow(shallow.unwrap_or(false)) - .send() - .await - .map(|urls| { - let js_array = Array::new(); - - for url in urls { - js_array.push(&JsValue::from_str(&url)); - } - - js_array - }) - .map_err(|e| e.into()); - } - - self.inner_list(url)? - .reverse(reverse.unwrap_or(false)) - .limit(limit.unwrap_or(u16::MAX)) - .shallow(shallow.unwrap_or(false)) - .send() - .await - .map(|urls| { - let js_array = Array::new(); - - for url in urls { - js_array.push(&JsValue::from_str(&url)); - } - - js_array - }) - .map_err(|e| e.into()) - } +#[wasm_bindgen(js_name = "setLogLevel")] +pub fn set_log_level(level: &str) -> Result<(), JsValue> { + let level = match level.to_lowercase().as_str() { + "error" => log::Level::Error, + "warn" => log::Level::Warn, + "info" => log::Level::Info, + "debug" => log::Level::Debug, + "trace" => log::Level::Trace, + _ => return Err(JsValue::from_str("Invalid log level")), + }; + + console_log::init_with_level(level).map_err(|e| JsValue::from_str(&e.to_string()))?; + log::info!("Log level set to: {}", level); + Ok(()) } diff --git a/pubky/src/wasm/api/auth.rs b/pubky/src/wasm/api/auth.rs new file mode 100644 index 0000000..931b2a6 --- /dev/null +++ b/pubky/src/wasm/api/auth.rs @@ -0,0 +1,113 @@ +//! Wasm bindings for the Auth api + +use url::Url; + +use pubky_common::capabilities::Capabilities; + +use crate::Client; + +use crate::wasm::wrappers::keys::{Keypair, PublicKey}; +use crate::wasm::wrappers::session::Session; + +use wasm_bindgen::prelude::*; + +#[wasm_bindgen] +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" + #[wasm_bindgen] + pub async fn signup( + &self, + keypair: &Keypair, + homeserver: &PublicKey, + ) -> Result { + Ok(Session( + self.inner_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. + /// + /// 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| JsValue::from_str(&e.to_string())) + } + + /// 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| JsValue::from_str(&e.to_string())) + } + + /// 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()) + .await + .map(|_| ()) + .map_err(|e| JsValue::from_str(&e.to_string())) + } + + /// Return `pubkyauth://` url and wait for the incoming [AuthToken] + /// 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] + #[wasm_bindgen(js_name = "authRequest")] + pub fn auth_request(&self, relay: &str, capabilities: &str) -> Result { + let mut relay: Url = relay.try_into().map_err(|_| "Invalid relay Url")?; + + let (pubkyauth_url, client_secret) = self + .create_auth_request( + &mut relay, + &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) + } + + /// Sign an [pubky_common::auth::AuthToken], encrypt it and send it to the + /// source of the pubkyauth request url. + #[wasm_bindgen(js_name = "sendAuthToken")] + pub async fn send_auth_token( + &self, + keypair: &Keypair, + pubkyauth_url: &str, + ) -> 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) + .await + .map_err(|e| JsValue::from_str(&e.to_string()))?; + + Ok(()) + } +} diff --git a/pubky/src/wasm/api/mod.rs b/pubky/src/wasm/api/mod.rs new file mode 100644 index 0000000..f43316f --- /dev/null +++ b/pubky/src/wasm/api/mod.rs @@ -0,0 +1,5 @@ +pub mod recovery_file; + +// TODO: put the Homeserver API behind a feature flag +pub mod auth; +pub mod public; diff --git a/pubky/src/wasm/api/public.rs b/pubky/src/wasm/api/public.rs new file mode 100644 index 0000000..febc952 --- /dev/null +++ b/pubky/src/wasm/api/public.rs @@ -0,0 +1,69 @@ +//! Wasm bindings for the /pub/ api + +use js_sys::Array; +use wasm_bindgen::prelude::*; + +use crate::Client; + +#[wasm_bindgen] +impl Client { + /// Returns a list of Pubky urls (as strings). + /// + /// - `url`: The Pubky url (string) to the directory you want to list its content. + /// - `cursor`: Either a full `pubky://` Url (from previous list response), + /// or a path (to a file or directory) relative to the `url` + /// - `reverse`: List in reverse order + /// - `limit` Limit the number of urls in the response + /// - `shallow`: List directories and files, instead of flat list of files. + #[wasm_bindgen] + pub async fn list( + &self, + url: &str, + cursor: Option, + reverse: Option, + limit: Option, + shallow: Option, + ) -> Result { + // TODO: try later to return Vec from async function. + + if let Some(cursor) = cursor { + return self + .inner_list(url) + .map_err(|e| JsValue::from_str(&e.to_string()))? + .reverse(reverse.unwrap_or(false)) + .limit(limit.unwrap_or(u16::MAX)) + .cursor(&cursor) + .shallow(shallow.unwrap_or(false)) + .send() + .await + .map(|urls| { + let js_array = Array::new(); + + for url in urls { + js_array.push(&JsValue::from_str(&url)); + } + + js_array + }) + .map_err(|e| JsValue::from_str(&e.to_string())); + } + + self.inner_list(url) + .map_err(|e| JsValue::from_str(&e.to_string()))? + .reverse(reverse.unwrap_or(false)) + .limit(limit.unwrap_or(u16::MAX)) + .shallow(shallow.unwrap_or(false)) + .send() + .await + .map(|urls| { + let js_array = Array::new(); + + for url in urls { + js_array.push(&JsValue::from_str(&url)); + } + + js_array + }) + .map_err(|e| JsValue::from_str(&e.to_string())) + } +} diff --git a/pubky/src/wasm/recovery_file.rs b/pubky/src/wasm/api/recovery_file.rs similarity index 84% rename from pubky/src/wasm/recovery_file.rs rename to pubky/src/wasm/api/recovery_file.rs index 7b85178..8a968f4 100644 --- a/pubky/src/wasm/recovery_file.rs +++ b/pubky/src/wasm/api/recovery_file.rs @@ -1,9 +1,7 @@ use js_sys::Uint8Array; use wasm_bindgen::prelude::{wasm_bindgen, JsValue}; -use crate::error::Error; - -use super::keys::Keypair; +use crate::wasm::wrappers::keys::Keypair; /// Create a recovery file of the `keypair`, containing the secret key encrypted /// using the `passphrase`. @@ -11,7 +9,7 @@ use super::keys::Keypair; pub fn create_recovery_file(keypair: &Keypair, passphrase: &str) -> Result { pubky_common::recovery_file::create_recovery_file(keypair.as_inner(), passphrase) .map(|b| b.as_slice().into()) - .map_err(|e| Error::from(e).into()) + .map_err(|e| JsValue::from_str(&e.to_string())) } /// Create a recovery file of the `keypair`, containing the secret key encrypted @@ -20,5 +18,5 @@ pub fn create_recovery_file(keypair: &Keypair, passphrase: &str) -> Result Result { pubky_common::recovery_file::decrypt_recovery_file(recovery_file, passphrase) .map(Keypair::from) - .map_err(|e| Error::from(e).into()) + .map_err(|e| JsValue::from_str(&e.to_string())) } diff --git a/pubky/src/wasm/http.rs b/pubky/src/wasm/http.rs index 61fee29..7e5a261 100644 --- a/pubky/src/wasm/http.rs +++ b/pubky/src/wasm/http.rs @@ -1,40 +1,156 @@ -use crate::PubkyClient; +//! Fetch method handling HTTP and Pubky urls with Pkarr TLD. -use reqwest::{Method, RequestBuilder, Response}; -use url::Url; +use js_sys::Promise; +use wasm_bindgen::prelude::*; +use web_sys::{Headers, RequestInit}; -impl PubkyClient { - pub(crate) fn request(&self, method: Method, url: Url) -> RequestBuilder { - let mut request = self.http.request(method, url).fetch_credentials_include(); +use reqwest::{IntoUrl, Method, RequestBuilder, Url}; - for cookie in self.session_cookies.read().unwrap().iter() { - request = request.header("Cookie", cookie); +use futures_lite::StreamExt; + +use pkarr::extra::endpoints::{Endpoint, EndpointsResolver}; +use pkarr::PublicKey; + +use crate::Client; + +#[wasm_bindgen] +impl Client { + #[wasm_bindgen] + pub async fn fetch( + &self, + url: &str, + request_init: Option, + ) -> Result { + let mut url: Url = url.try_into().map_err(|err| { + JsValue::from_str(&format!("pubky::Client::fetch(): Invalid `url`; {:?}", err)) + })?; + + let request_init = request_init.unwrap_or_default(); + + if let Some(pubky_host) = self.prepare_request(&mut url).await { + let headers = request_init.get_headers(); + + let headers = if headers.is_null() || headers.is_undefined() { + Headers::new()? + } else { + Headers::from(headers) + }; + + headers.append("pubky-host", &pubky_host)?; + + request_init.set_headers(&headers.into()); } - request + let js_req = web_sys::Request::new_with_str_and_init(url.as_str(), &request_init).map_err( + |err| { + JsValue::from_str(&format!( + "pubky::Client::fetch(): Invalid `init`; {:?}", + err + )) + }, + )?; + + Ok(js_fetch(&js_req)) } +} +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(js_name = fetch)] + fn fetch_with_request(input: &web_sys::Request) -> Promise; +} + +fn js_fetch(req: &web_sys::Request) -> Promise { + use wasm_bindgen::{JsCast, JsValue}; + let global = js_sys::global(); + + if let Ok(true) = js_sys::Reflect::has(&global, &JsValue::from_str("ServiceWorkerGlobalScope")) + { + global + .unchecked_into::() + .fetch_with_request(req) + } else { + // browser + fetch_with_request(req) + } +} - // 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()); +impl Client { + /// A wrapper around [reqwest::Client::request], with the same signature between native and wasm. + pub(crate) async fn inner_request(&self, method: Method, url: T) -> RequestBuilder { + let original_url = url.as_str(); + let mut url = Url::parse(original_url).expect("Invalid url in inner_request"); + + if let Some(pubky_host) = self.prepare_request(&mut url).await { + self.http + .request(method, url.clone()) + .header::<&str, &str>("pubky-host", &pubky_host) + .fetch_credentials_include() + } else { + self.http + .request(method, url.clone()) + .fetch_credentials_include() } } - 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)); + /// - 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 { + let host = url.host_str().unwrap_or("").to_string(); + + if url.scheme() == "pubky" { + *url = Url::parse(&format!("https{}", &url.as_str()[5..])) + .expect("couldn't replace pubky:// with https://"); + url.set_host(Some(&format!("_pubky.{}", url.host_str().unwrap_or("")))) + .expect("couldn't map pubk:// to https://_pubky."); + } + + let mut pubky_host = None; + + if PublicKey::try_from(host.clone()).is_ok() { + self.transform_url(url).await; + + pubky_host = Some(host); + }; + + pubky_host + } + + pub 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()); + + let mut stream = self.pkarr.resolve_https_endpoints(qname); + + let mut so_far: Option = None; + + while let Some(endpoint) = stream.next().await { + if endpoint.domain() != "." { + so_far = Some(endpoint); + + // TODO: currently we return the first thing we can see, + // in the future we might want to failover to other endpoints + break; + } + } + + if let Some(e) = so_far { + // TODO: detect loopback IPs and other equivilants to localhost + if self.testnet && e.domain() == "localhost" { + url.set_scheme("http") + .expect("couldn't replace pubky:// with http://"); + } + + 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"); + + log::debug!("Transformed URL to: {}", url.as_str()); + } else { + // TODO: didn't find any domain, what to do? + log::debug!("Could not resolve Pubky URL to clearnet {}", url.as_str()); + } } } diff --git a/pubky/src/wasm/pkarr.rs b/pubky/src/wasm/pkarr.rs deleted file mode 100644 index 49726f6..0000000 --- a/pubky/src/wasm/pkarr.rs +++ /dev/null @@ -1,48 +0,0 @@ -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/keys.rs b/pubky/src/wasm/wrappers/keys.rs similarity index 94% rename from pubky/src/wasm/keys.rs rename to pubky/src/wasm/wrappers/keys.rs index 3b27045..8888894 100644 --- a/pubky/src/wasm/keys.rs +++ b/pubky/src/wasm/wrappers/keys.rs @@ -1,7 +1,5 @@ use wasm_bindgen::prelude::*; -use crate::Error; - #[wasm_bindgen] pub struct Keypair(pkarr::Keypair); @@ -80,9 +78,9 @@ impl PublicKey { .as_string() .ok_or("Couldn't create a PublicKey from this type of value")?; - Ok(PublicKey( - pkarr::PublicKey::try_from(string).map_err(Error::Pkarr)?, - )) + Ok(PublicKey(pkarr::PublicKey::try_from(string).map_err( + |_| "Couldn't create a PublicKey from this type of value", + )?)) } } diff --git a/pubky/src/wasm/wrappers/mod.rs b/pubky/src/wasm/wrappers/mod.rs new file mode 100644 index 0000000..f632a1f --- /dev/null +++ b/pubky/src/wasm/wrappers/mod.rs @@ -0,0 +1,5 @@ +//! Wasm wrappers around structs that we need to be turned into Classes +//! in JavaScript. + +pub mod keys; +pub mod session; diff --git a/pubky/src/wasm/session.rs b/pubky/src/wasm/wrappers/session.rs similarity index 100% rename from pubky/src/wasm/session.rs rename to pubky/src/wasm/wrappers/session.rs