pax_global_header00006660000000000000000000000064151164561510014517gustar00rootroot0000000000000052 comment=84849bb37e174e004525f801698fb60b6abb4fcb indieweb-rust-v0.6.0/000077500000000000000000000000001511645615100144715ustar00rootroot00000000000000indieweb-rust-v0.6.0/.gitignore000066400000000000000000000000161511645615100164560ustar00rootroot00000000000000target .cargo indieweb-rust-v0.6.0/.neoconf.json000066400000000000000000000000711511645615100170670ustar00rootroot00000000000000{ "lspconfig": { "rust_analyzer": {} } } indieweb-rust-v0.6.0/Cargo.lock000066400000000000000000002512531511645615100164060ustar00rootroot00000000000000# This file is automatically @generated by Cargo. # It is not intended for manual editing. version = 4 [[package]] name = "addr2line" version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" dependencies = [ "gimli", ] [[package]] name = "adler2" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "ahash" version = "0.8.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", "once_cell", "version_check", "zerocopy", ] [[package]] name = "aho-corasick" version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" dependencies = [ "memchr", ] [[package]] name = "allocator-api2" version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "android_system_properties" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" dependencies = [ "libc", ] [[package]] name = "anstream" version = "0.6.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" dependencies = [ "anstyle", "anstyle-parse", "anstyle-query", "anstyle-wincon", "colorchoice", "is_terminal_polyfill", "utf8parse", ] [[package]] name = "anstyle" version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" [[package]] name = "anstyle-parse" version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" dependencies = [ "utf8parse", ] [[package]] name = "anstyle-query" version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ "windows-sys 0.61.2", ] [[package]] name = "anstyle-wincon" version = "3.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", "windows-sys 0.61.2", ] [[package]] name = "anyhow" version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" [[package]] name = "assert-json-diff" version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" dependencies = [ "serde", "serde_json", ] [[package]] name = "ast_node" version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2eb025ef00a6da925cf40870b9c8d008526b6004ece399cb0974209720f0b194" dependencies = [ "quote", "swc_macros_common", "syn", ] [[package]] name = "async-stream" version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" dependencies = [ "async-stream-impl", "futures-core", "pin-project-lite", ] [[package]] name = "async-stream-impl" version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "async-trait" version = "0.1.89" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "atom_syndication" version = "0.12.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2f68d23e2cb4fd958c705b91a6b4c80ceeaf27a9e11651272a8389d5ce1a4a3" dependencies = [ "chrono", "derive_builder", "diligent-date-parser", "never", "quick-xml", ] [[package]] name = "atomic-waker" version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "auto_impl" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ffdcb70bdbc4d478427380519163274ac86e52916e10f0a8889adf0f96d3fee7" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "autocfg" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "axum" version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" dependencies = [ "async-trait", "axum-core", "axum-macros", "bytes", "futures-util", "http", "http-body", "http-body-util", "hyper", "hyper-util", "itoa", "matchit", "memchr", "mime", "percent-encoding", "pin-project-lite", "rustversion", "serde", "serde_json", "serde_path_to_error", "serde_urlencoded", "sync_wrapper", "tokio", "tower", "tower-layer", "tower-service", "tracing", ] [[package]] name = "axum-core" version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" dependencies = [ "async-trait", "bytes", "futures-util", "http", "http-body", "http-body-util", "mime", "pin-project-lite", "rustversion", "sync_wrapper", "tower-layer", "tower-service", "tracing", ] [[package]] name = "axum-extra" version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c794b30c904f0a1c2fb7740f7df7f7972dfaa14ef6f57cb6178dc63e5dca2f04" dependencies = [ "axum", "axum-core", "bytes", "cookie", "fastrand", "futures-util", "http", "http-body", "http-body-util", "mime", "multer", "pin-project-lite", "serde", "tower", "tower-layer", "tower-service", ] [[package]] name = "axum-macros" version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57d123550fa8d071b7255cb0cc04dc302baa6c8c4a79f55701552684d8399bce" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "backtrace" version = "0.3.76" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" dependencies = [ "addr2line", "cfg-if", "libc", "miniz_oxide", "object", "rustc-demangle", "windows-link", ] [[package]] name = "backtrace-ext" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "537beee3be4a18fb023b570f80e3ae28003db9167a751266b259926e25539d50" dependencies = [ "backtrace", ] [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "better_scoped_tls" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd228125315b132eed175bf47619ac79b945b26e56b848ba203ae4ea8603609" dependencies = [ "scoped-tls", ] [[package]] name = "bitflags" version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" [[package]] name = "block-buffer" version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" dependencies = [ "generic-array", ] [[package]] name = "bumpalo" version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" [[package]] name = "bytes" version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" [[package]] name = "bytes-str" version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c60b5ce37e0b883c37eb89f79a1e26fbe9c1081945d024eee93e8d91a7e18b3" dependencies = [ "bytes", "serde", ] [[package]] name = "cc" version = "1.2.48" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c481bdbf0ed3b892f6f806287d72acd515b352a4ec27a208489b8c1bc839633a" dependencies = [ "find-msvc-tools", "shlex", ] [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "chrono" version = "0.4.42" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" dependencies = [ "iana-time-zone", "num-traits", "serde", "windows-link", ] [[package]] name = "colorchoice" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" [[package]] name = "colored" version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fde0e0ec90c9dfb3b4b1a0891a7dcd0e2bffde2f7efed5fe7c9bb00e5bfb915e" dependencies = [ "windows-sys 0.59.0", ] [[package]] name = "cookie" version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" dependencies = [ "percent-encoding", "time", "version_check", ] [[package]] name = "core-foundation" version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" dependencies = [ "core-foundation-sys", "libc", ] [[package]] name = "core-foundation-sys" version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "cpufeatures" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" dependencies = [ "libc", ] [[package]] name = "crypto-common" version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", "typenum", ] [[package]] name = "darling" version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" dependencies = [ "darling_core 0.20.11", "darling_macro 0.20.11", ] [[package]] name = "darling" version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" dependencies = [ "darling_core 0.21.3", "darling_macro 0.21.3", ] [[package]] name = "darling_core" version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" dependencies = [ "fnv", "ident_case", "proc-macro2", "quote", "strsim", "syn", ] [[package]] name = "darling_core" version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" dependencies = [ "fnv", "ident_case", "proc-macro2", "quote", "strsim", "syn", ] [[package]] name = "darling_macro" version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core 0.20.11", "quote", "syn", ] [[package]] name = "darling_macro" version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ "darling_core 0.21.3", "quote", "syn", ] [[package]] name = "deranged" version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" dependencies = [ "powerfmt", "serde_core", ] [[package]] name = "derive_builder" version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" dependencies = [ "derive_builder_macro", ] [[package]] name = "derive_builder_core" version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" dependencies = [ "darling 0.20.11", "proc-macro2", "quote", "syn", ] [[package]] name = "derive_builder_macro" version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", "syn", ] [[package]] name = "deunicode" version = "1.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" [[package]] name = "digest" version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", ] [[package]] name = "diligent-date-parser" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8ede7d79366f419921e2e2f67889c12125726692a313bffb474bd5f37a581e9" dependencies = [ "chrono", ] [[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 = "dyn-clone" version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" [[package]] name = "encoding_rs" version = "0.8.35" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" dependencies = [ "cfg-if", ] [[package]] name = "env_filter" version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" dependencies = [ "log", "regex", ] [[package]] name = "env_logger" version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" dependencies = [ "anstream", "anstyle", "env_filter", "jiff", "log", ] [[package]] name = "equivalent" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "errno" version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", "windows-sys 0.61.2", ] [[package]] name = "example-indieauth-client-server" version = "0.0.0" dependencies = [ "axum", "axum-extra", "indieweb", "miette", "minijinja", "minijinja-embed", "nanoid", "serde", "serde_json", "serde_qs", "tokio", "tower-livereload", "tracing", "tracing-forest", "url", ] [[package]] name = "fake" version = "4.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2b0902eb36fbab51c14eda1c186bda119fcff91e5e4e7fc2dd2077298197ce8" dependencies = [ "deunicode", "either", "rand 0.9.2", ] [[package]] name = "fastrand" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" [[package]] name = "find-msvc-tools" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" [[package]] name = "fnv" version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] name = "foreign-types" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" dependencies = [ "foreign-types-shared", ] [[package]] name = "foreign-types-shared" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" [[package]] name = "form_urlencoded" version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" dependencies = [ "percent-encoding", ] [[package]] name = "from_variant" version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5ff35a391aef949120a0340d690269b3d9f63460a6106e99bd07b961f345ea9" dependencies = [ "swc_macros_common", "syn", ] [[package]] name = "futures" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" dependencies = [ "futures-channel", "futures-core", "futures-executor", "futures-io", "futures-sink", "futures-task", "futures-util", ] [[package]] name = "futures-channel" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", "futures-sink", ] [[package]] name = "futures-core" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" [[package]] name = "futures-executor" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" dependencies = [ "futures-core", "futures-task", "futures-util", ] [[package]] name = "futures-io" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" [[package]] name = "futures-macro" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "futures-sink" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" [[package]] name = "futures-task" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" [[package]] name = "futures-util" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ "futures-channel", "futures-core", "futures-io", "futures-macro", "futures-sink", "futures-task", "memchr", "pin-project-lite", "pin-utils", "slab", ] [[package]] name = "generic-array" version = "0.14.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", ] [[package]] name = "getrandom" version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" dependencies = [ "cfg-if", "libc", "wasi", ] [[package]] name = "getrandom" version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "libc", "r-efi", "wasip2", ] [[package]] name = "gimli" version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" [[package]] name = "h2" version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" dependencies = [ "atomic-waker", "bytes", "fnv", "futures-core", "futures-sink", "http", "indexmap 2.12.1", "slab", "tokio", "tokio-util", "tracing", ] [[package]] name = "hashbrown" version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" [[package]] name = "hashbrown" version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" dependencies = [ "ahash", "allocator-api2", ] [[package]] name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hex" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "hstr" version = "3.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c43c0a9e8fbdb3bb9dc8eee85e1e2ac81605418b4c83b6b7413cbf14d56ca5c" dependencies = [ "hashbrown 0.14.5", "new_debug_unreachable", "once_cell", "rustc-hash", "serde", "triomphe", ] [[package]] name = "html-escape" version = "0.2.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d1ad449764d627e22bfd7cd5e8868264fc9236e07c752972b4080cd351cb476" dependencies = [ "utf8-width", ] [[package]] name = "http" version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" dependencies = [ "bytes", "itoa", ] [[package]] name = "http-body" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", "http", ] [[package]] name = "http-body-util" version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", "http", "http-body", "pin-project-lite", ] [[package]] name = "httparse" version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "httpdate" version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" dependencies = [ "atomic-waker", "bytes", "futures-channel", "futures-core", "h2", "http", "http-body", "httparse", "httpdate", "itoa", "pin-project-lite", "pin-utils", "smallvec", "tokio", "want", ] [[package]] name = "hyper-rustls" version = "0.27.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" dependencies = [ "http", "hyper", "hyper-util", "rustls", "rustls-pki-types", "tokio", "tokio-rustls", "tower-service", ] [[package]] name = "hyper-tls" version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" dependencies = [ "bytes", "http-body-util", "hyper", "hyper-util", "native-tls", "tokio", "tokio-native-tls", "tower-service", ] [[package]] name = "hyper-util" version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52e9a2a24dc5c6821e71a7030e1e14b7b632acac55c40e9d2e082c621261bb56" dependencies = [ "base64", "bytes", "futures-channel", "futures-core", "futures-util", "http", "http-body", "hyper", "ipnet", "libc", "percent-encoding", "pin-project-lite", "socket2", "system-configuration", "tokio", "tower-service", "tracing", "windows-registry", ] [[package]] name = "iana-time-zone" version = "0.1.64" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" dependencies = [ "android_system_properties", "core-foundation-sys", "iana-time-zone-haiku", "js-sys", "log", "wasm-bindgen", "windows-core", ] [[package]] name = "iana-time-zone-haiku" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" dependencies = [ "cc", ] [[package]] name = "icu_collections" version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" dependencies = [ "displaydoc", "potential_utf", "yoke", "zerofrom", "zerovec", ] [[package]] name = "icu_locale_core" version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" dependencies = [ "displaydoc", "litemap", "tinystr", "writeable", "zerovec", ] [[package]] name = "icu_normalizer" version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" dependencies = [ "icu_collections", "icu_normalizer_data", "icu_properties", "icu_provider", "smallvec", "zerovec", ] [[package]] name = "icu_normalizer_data" version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" [[package]] name = "icu_properties" version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" dependencies = [ "icu_collections", "icu_locale_core", "icu_properties_data", "icu_provider", "zerotrie", "zerovec", ] [[package]] name = "icu_properties_data" version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" [[package]] name = "icu_provider" version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" dependencies = [ "displaydoc", "icu_locale_core", "writeable", "yoke", "zerofrom", "zerotrie", "zerovec", ] [[package]] name = "ident_case" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" [[package]] name = "idna" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" dependencies = [ "idna_adapter", "smallvec", "utf8_iter", ] [[package]] name = "idna_adapter" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" dependencies = [ "icu_normalizer", "icu_properties", ] [[package]] name = "indexmap" version = "1.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" dependencies = [ "autocfg", "hashbrown 0.12.3", "serde", ] [[package]] name = "indexmap" version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" dependencies = [ "equivalent", "hashbrown 0.16.1", "serde", "serde_core", ] [[package]] name = "indieweb" version = "0.6.0" dependencies = [ "assert-json-diff", "async-trait", "base64", "env_logger", "fake", "futures", "http", "microformats", "miette", "mockito", "rand 0.9.2", "regex", "reqwest", "reqwest-middleware", "secrecy", "serde", "serde_json", "serde_qs", "serde_with", "sha2", "thiserror 2.0.17", "time", "tokio", "tokio-test", "tracing", "tracing-test", "url", ] [[package]] name = "ipnet" version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" [[package]] name = "iri-string" version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4f867b9d1d896b67beb18518eda36fdb77a32ea590de864f1325b294a6d14397" dependencies = [ "memchr", "serde", ] [[package]] name = "is-macro" version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d57a3e447e24c22647738e4607f1df1e0ec6f72e16182c4cd199f647cdfb0e4" dependencies = [ "heck", "proc-macro2", "quote", "syn", ] [[package]] name = "is_ci" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" [[package]] name = "is_terminal_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "itoa" version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" [[package]] name = "jiff" version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49cce2b81f2098e7e3efc35bc2e0a6b7abec9d34128283d7a26fa8f32a6dbb35" dependencies = [ "jiff-static", "log", "portable-atomic", "portable-atomic-util", "serde_core", ] [[package]] name = "jiff-static" version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "980af8b43c3ad5d8d349ace167ec8170839f753a42d233ba19e08afe1850fa69" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "js-sys" version = "0.3.83" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" dependencies = [ "once_cell", "wasm-bindgen", ] [[package]] name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" version = "0.2.178" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" [[package]] name = "linux-raw-sys" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" [[package]] name = "litemap" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" [[package]] name = "lock_api" version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" dependencies = [ "scopeguard", ] [[package]] name = "log" version = "0.4.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" [[package]] name = "matchers" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" dependencies = [ "regex-automata", ] [[package]] name = "matchit" version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" [[package]] name = "memchr" version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" [[package]] name = "microformats" version = "0.16.1" dependencies = [ "atom_syndication", "html-escape", "http", "lazy_static", "microformats-types", "regex", "serde", "serde_json", "swc_common", "swc_html_ast", "swc_html_codegen", "swc_html_parser", "swc_html_visit", "thiserror 2.0.17", "time", "tracing", "tracing-forest", "tracing-unwrap", "url", ] [[package]] name = "microformats-types" version = "0.12.2" dependencies = [ "lazy_static", "regex", "serde", "serde_json", "thiserror 2.0.17", "time", "url", ] [[package]] name = "miette" version = "7.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" dependencies = [ "backtrace", "backtrace-ext", "cfg-if", "miette-derive", "owo-colors", "supports-color", "supports-hyperlinks", "supports-unicode", "terminal_size", "textwrap", "unicode-width 0.1.14", ] [[package]] name = "miette-derive" version = "7.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "mime" version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] name = "minijinja" version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0adbe6e92a6ce0fd6c4aac593fdfd3e3950b0f61b1a63aa9731eb6fd85776fa3" dependencies = [ "serde", ] [[package]] name = "minijinja-embed" version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c77bbc78d3484afc9541b13bb17773dc915b43c0f615c528e736d885773ffa61" [[package]] name = "miniz_oxide" version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ "adler2", ] [[package]] name = "mio" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" dependencies = [ "libc", "wasi", "windows-sys 0.61.2", ] [[package]] name = "mockito" version = "1.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e0603425789b4a70fcc4ac4f5a46a566c116ee3e2a6b768dc623f7719c611de" dependencies = [ "assert-json-diff", "bytes", "colored", "futures-core", "http", "http-body", "http-body-util", "hyper", "hyper-util", "log", "pin-project-lite", "rand 0.9.2", "regex", "serde_json", "serde_urlencoded", "similar", "tokio", ] [[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 = "nanoid" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ffa00dec017b5b1a8b7cf5e2c008bfda1aa7e0697ac1508b491fdf2622fb4d8" dependencies = [ "rand 0.8.5", ] [[package]] name = "native-tls" version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" dependencies = [ "libc", "log", "openssl", "openssl-probe", "openssl-sys", "schannel", "security-framework", "security-framework-sys", "tempfile", ] [[package]] name = "never" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c96aba5aa877601bb3f6dd6a63a969e1f82e60646e81e71b14496995e9853c91" [[package]] name = "new_debug_unreachable" version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" [[package]] name = "nu-ansi-term" version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ "windows-sys 0.61.2", ] [[package]] name = "num-bigint" version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" dependencies = [ "num-integer", "num-traits", ] [[package]] name = "num-conv" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" [[package]] name = "num-integer" version = "0.1.46" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" dependencies = [ "num-traits", ] [[package]] name = "num-traits" version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", ] [[package]] name = "object" version = "0.37.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" dependencies = [ "memchr", ] [[package]] name = "once_cell" version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] name = "once_cell_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "openssl" version = "0.10.75" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" dependencies = [ "bitflags", "cfg-if", "foreign-types", "libc", "once_cell", "openssl-macros", "openssl-sys", ] [[package]] name = "openssl-macros" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "openssl-probe" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" [[package]] name = "openssl-sys" version = "0.9.111" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" dependencies = [ "cc", "libc", "pkg-config", "vcpkg", ] [[package]] name = "owo-colors" version = "4.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52" [[package]] name = "parking_lot" version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" dependencies = [ "lock_api", "parking_lot_core", ] [[package]] name = "parking_lot_core" version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", "redox_syscall", "smallvec", "windows-link", ] [[package]] name = "percent-encoding" version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pin-project-lite" version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" [[package]] name = "pin-utils" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] name = "pkg-config" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" [[package]] name = "portable-atomic" version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" [[package]] name = "portable-atomic-util" version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" dependencies = [ "portable-atomic", ] [[package]] name = "potential_utf" version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" dependencies = [ "zerovec", ] [[package]] name = "powerfmt" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" [[package]] name = "ppv-lite86" version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" dependencies = [ "zerocopy", ] [[package]] name = "proc-macro2" version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" dependencies = [ "unicode-ident", ] [[package]] name = "quick-xml" version = "0.37.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" dependencies = [ "encoding_rs", "memchr", ] [[package]] name = "quote" version = "1.0.42" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" dependencies = [ "proc-macro2", ] [[package]] name = "r-efi" version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" [[package]] name = "rand" version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", "rand_chacha 0.3.1", "rand_core 0.6.4", ] [[package]] name = "rand" version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.3", ] [[package]] name = "rand_chacha" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", "rand_core 0.6.4", ] [[package]] name = "rand_chacha" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", "rand_core 0.9.3", ] [[package]] name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ "getrandom 0.2.16", ] [[package]] name = "rand_core" version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" dependencies = [ "getrandom 0.3.4", ] [[package]] name = "redox_syscall" version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ "bitflags", ] [[package]] name = "ref-cast" version = "1.0.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" dependencies = [ "ref-cast-impl", ] [[package]] name = "ref-cast-impl" version = "1.0.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "regex" version = "1.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" dependencies = [ "aho-corasick", "memchr", "regex-automata", "regex-syntax", ] [[package]] name = "regex-automata" version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" dependencies = [ "aho-corasick", "memchr", "regex-syntax", ] [[package]] name = "regex-syntax" version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" [[package]] name = "reqwest" version = "0.12.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" dependencies = [ "base64", "bytes", "encoding_rs", "futures-core", "h2", "http", "http-body", "http-body-util", "hyper", "hyper-rustls", "hyper-tls", "hyper-util", "js-sys", "log", "mime", "native-tls", "percent-encoding", "pin-project-lite", "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", "sync_wrapper", "tokio", "tokio-native-tls", "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", ] [[package]] name = "reqwest-middleware" version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57f17d28a6e6acfe1733fe24bcd30774d13bffa4b8a22535b4c8c98423088d4e" dependencies = [ "anyhow", "async-trait", "http", "reqwest", "serde", "thiserror 1.0.69", "tower-service", ] [[package]] name = "ring" version = "0.17.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", "getrandom 0.2.16", "libc", "untrusted", "windows-sys 0.52.0", ] [[package]] name = "rustc-demangle" version = "0.1.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" [[package]] name = "rustc-hash" version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" [[package]] name = "rustix" version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" dependencies = [ "bitflags", "errno", "libc", "linux-raw-sys", "windows-sys 0.61.2", ] [[package]] name = "rustls" version = "0.23.35" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" dependencies = [ "once_cell", "rustls-pki-types", "rustls-webpki", "subtle", "zeroize", ] [[package]] name = "rustls-pki-types" version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "708c0f9d5f54ba0272468c1d306a52c495b31fa155e91bc25371e6df7996908c" dependencies = [ "zeroize", ] [[package]] name = "rustls-webpki" version = "0.103.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" dependencies = [ "ring", "rustls-pki-types", "untrusted", ] [[package]] name = "rustversion" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" [[package]] name = "schannel" version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" dependencies = [ "windows-sys 0.61.2", ] [[package]] name = "schemars" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" dependencies = [ "dyn-clone", "ref-cast", "serde", "serde_json", ] [[package]] name = "schemars" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9558e172d4e8533736ba97870c4b2cd63f84b382a3d6eb063da41b91cce17289" dependencies = [ "dyn-clone", "ref-cast", "serde", "serde_json", ] [[package]] name = "scoped-tls" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "secrecy" version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" dependencies = [ "serde", "zeroize", ] [[package]] name = "security-framework" version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ "bitflags", "core-foundation", "core-foundation-sys", "libc", "security-framework-sys", ] [[package]] name = "security-framework-sys" version = "2.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" dependencies = [ "core-foundation-sys", "libc", ] [[package]] name = "serde" version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ "serde_core", "serde_derive", ] [[package]] name = "serde_core" version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "serde_json" version = "1.0.145" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" dependencies = [ "indexmap 2.12.1", "itoa", "memchr", "ryu", "serde", "serde_core", ] [[package]] name = "serde_path_to_error" version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" dependencies = [ "itoa", "serde", "serde_core", ] [[package]] name = "serde_qs" version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3faaf9e727533a19351a43cc5a8de957372163c7d35cc48c90b75cdda13c352" dependencies = [ "percent-encoding", "serde", "thiserror 2.0.17", ] [[package]] name = "serde_urlencoded" version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" dependencies = [ "form_urlencoded", "itoa", "ryu", "serde", ] [[package]] name = "serde_with" version = "3.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4fa237f2807440d238e0364a218270b98f767a00d3dada77b1c53ae88940e2e7" dependencies = [ "base64", "chrono", "hex", "indexmap 1.9.3", "indexmap 2.12.1", "schemars 0.9.0", "schemars 1.1.0", "serde_core", "serde_json", "serde_with_macros", "time", ] [[package]] name = "serde_with_macros" version = "3.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52a8e3ca0ca629121f70ab50f95249e5a6f925cc0f6ffe8256c45b728875706c" dependencies = [ "darling 0.21.3", "proc-macro2", "quote", "syn", ] [[package]] name = "sha2" version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures", "digest", ] [[package]] name = "sharded-slab" version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" dependencies = [ "lazy_static", ] [[package]] name = "shlex" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "signal-hook-registry" version = "1.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7664a098b8e616bdfcc2dc0e9ac44eb231eedf41db4e9fe95d8d32ec728dedad" dependencies = [ "libc", ] [[package]] name = "similar" version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" [[package]] name = "siphasher" version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" [[package]] name = "slab" version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" [[package]] name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "socket2" version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" dependencies = [ "libc", "windows-sys 0.60.2", ] [[package]] name = "spin" version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" [[package]] name = "stable_deref_trait" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "string_enum" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae36a4951ca7bd1cfd991c241584a9824a70f6aff1e7d4f693fb3f2465e4030e" dependencies = [ "quote", "swc_macros_common", "syn", ] [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "subtle" version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "supports-color" version = "3.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6" dependencies = [ "is_ci", ] [[package]] name = "supports-hyperlinks" version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "804f44ed3c63152de6a9f90acbea1a110441de43006ea51bcce8f436196a288b" [[package]] name = "supports-unicode" version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2" [[package]] name = "swc_atoms" version = "9.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4ccbe2ecad10ad7432100f878a107b1d972a8aee83ca53184d00c23a078bb8a" dependencies = [ "hstr", "once_cell", "serde", ] [[package]] name = "swc_common" version = "17.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "259b675d633a26d24efe3802a9d88858c918e6e8f062d3222d3aa02d56a2cf4c" dependencies = [ "anyhow", "ast_node", "better_scoped_tls", "bytes-str", "either", "from_variant", "new_debug_unreachable", "num-bigint", "once_cell", "rustc-hash", "serde", "siphasher", "swc_atoms", "swc_eq_ignore_macros", "swc_visit", "tracing", "unicode-width 0.2.2", "url", ] [[package]] name = "swc_eq_ignore_macros" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c16ce73424a6316e95e09065ba6a207eba7765496fed113702278b7711d4b632" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "swc_html_ast" version = "17.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2edc9c69b6c444136250f1ae90ac85063e20a7e07c30e1ee983fb1d65df2f9a3" dependencies = [ "is-macro", "serde", "string_enum", "swc_atoms", "swc_common", ] [[package]] name = "swc_html_codegen" version = "18.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eaf51e63b529067c26350ef076c3d11fc731aece4f9dfdd0bdc8555cc452bc75" dependencies = [ "auto_impl", "bitflags", "rustc-hash", "swc_atoms", "swc_common", "swc_html_ast", "swc_html_codegen_macros", "swc_html_utils", ] [[package]] name = "swc_html_codegen_macros" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f98ef1f87379c816ba7d22351c9fc993af38b034bce4da3286cfe4b17e7ec9e2" dependencies = [ "quote", "syn", ] [[package]] name = "swc_html_parser" version = "17.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3b72d280aaf320d70cd5f1ad2a54e8aa916eee8161d4ec1337a81164e265720" dependencies = [ "rustc-hash", "swc_atoms", "swc_common", "swc_html_ast", "swc_html_utils", ] [[package]] name = "swc_html_utils" version = "16.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2eb1627e0e88ee72bcda640a807751af2bf4a841da12ca284679e076340602e2" dependencies = [ "once_cell", "rustc-hash", "serde", "serde_json", "swc_atoms", ] [[package]] name = "swc_html_visit" version = "17.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f2fc20d2cfdcb30d6f99773d60169d1c98b2325fa13ada6b0544e4bbc68d02" dependencies = [ "serde", "swc_atoms", "swc_common", "swc_html_ast", "swc_visit", ] [[package]] name = "swc_macros_common" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aae1efbaa74943dc5ad2a2fb16cbd78b77d7e4d63188f3c5b4df2b4dcd2faaae" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "swc_visit" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "62fb71484b486c185e34d2172f0eabe7f4722742aad700f426a494bb2de232a2" dependencies = [ "either", "new_debug_unreachable", ] [[package]] name = "syn" version = "2.0.111" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" dependencies = [ "proc-macro2", "quote", "unicode-ident", ] [[package]] name = "sync_wrapper" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" dependencies = [ "futures-core", ] [[package]] name = "synstructure" version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "system-configuration" version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" dependencies = [ "bitflags", "core-foundation", "system-configuration-sys", ] [[package]] name = "system-configuration-sys" version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" dependencies = [ "core-foundation-sys", "libc", ] [[package]] name = "tempfile" version = "3.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" dependencies = [ "fastrand", "getrandom 0.3.4", "once_cell", "rustix", "windows-sys 0.61.2", ] [[package]] name = "terminal_size" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0" dependencies = [ "rustix", "windows-sys 0.60.2", ] [[package]] name = "textwrap" version = "0.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" dependencies = [ "unicode-linebreak", "unicode-width 0.2.2", ] [[package]] name = "thiserror" version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ "thiserror-impl 1.0.69", ] [[package]] name = "thiserror" version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" dependencies = [ "thiserror-impl 2.0.17", ] [[package]] name = "thiserror-impl" version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "thiserror-impl" version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "thread_local" version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" dependencies = [ "cfg-if", ] [[package]] name = "time" version = "0.3.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" dependencies = [ "deranged", "itoa", "num-conv", "powerfmt", "serde", "time-core", "time-macros", ] [[package]] name = "time-core" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" [[package]] name = "time-macros" version = "0.2.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" dependencies = [ "num-conv", "time-core", ] [[package]] name = "tinystr" version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" dependencies = [ "displaydoc", "zerovec", ] [[package]] name = "tokio" version = "1.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" dependencies = [ "bytes", "libc", "mio", "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "tokio-native-tls" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" dependencies = [ "native-tls", "tokio", ] [[package]] name = "tokio-rustls" version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ "rustls", "tokio", ] [[package]] name = "tokio-stream" version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" dependencies = [ "futures-core", "pin-project-lite", "tokio", ] [[package]] name = "tokio-test" version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2468baabc3311435b55dd935f702f42cd1b8abb7e754fb7dfb16bd36aa88f9f7" dependencies = [ "async-stream", "bytes", "futures-core", "tokio", "tokio-stream", ] [[package]] name = "tokio-util" version = "0.7.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594" dependencies = [ "bytes", "futures-core", "futures-sink", "pin-project-lite", "tokio", ] [[package]] name = "tower" version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" dependencies = [ "futures-core", "futures-util", "pin-project-lite", "sync_wrapper", "tokio", "tower-layer", "tower-service", "tracing", ] [[package]] name = "tower-http" version = "0.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9cf146f99d442e8e68e585f5d798ccd3cad9a7835b917e09728880a862706456" dependencies = [ "bitflags", "bytes", "futures-util", "http", "http-body", "iri-string", "pin-project-lite", "tower", "tower-layer", "tower-service", ] [[package]] name = "tower-layer" version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" [[package]] name = "tower-livereload" version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5aa6b29b17d4540f2bd9ec304ad39d280c4bdf291d0ea6c4123eeba10939af84" dependencies = [ "bytes", "http", "http-body", "pin-project-lite", "tokio", "tower", ] [[package]] name = "tower-service" version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" version = "0.1.43" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d15d90a0b5c19378952d479dc858407149d7bb45a14de0142f6c534b16fc647" dependencies = [ "log", "pin-project-lite", "tracing-attributes", "tracing-core", ] [[package]] name = "tracing-attributes" version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "tracing-core" version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a04e24fab5c89c6a36eb8558c9656f30d81de51dfa4d3b45f26b21d61fa0a6c" dependencies = [ "once_cell", "valuable", ] [[package]] name = "tracing-forest" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92bdb3c949c9e81b71f78ba782f956b896019d82cc2f31025d21e04adab4d695" dependencies = [ "smallvec", "thiserror 2.0.17", "tracing", "tracing-subscriber", ] [[package]] name = "tracing-log" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" dependencies = [ "log", "once_cell", "tracing-core", ] [[package]] name = "tracing-subscriber" version = "0.3.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" dependencies = [ "matchers", "nu-ansi-term", "once_cell", "regex-automata", "sharded-slab", "smallvec", "thread_local", "tracing", "tracing-core", "tracing-log", ] [[package]] name = "tracing-test" version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "557b891436fe0d5e0e363427fc7f217abf9ccd510d5136549847bdcbcd011d68" dependencies = [ "tracing-core", "tracing-subscriber", "tracing-test-macro", ] [[package]] name = "tracing-test-macro" version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04659ddb06c87d233c566112c1c9c5b9e98256d9af50ec3bc9c8327f873a7568" dependencies = [ "quote", "syn", ] [[package]] name = "tracing-unwrap" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4e33415be97f5ae70322d6fefc696bbc08887d8835400d6c77f059469b30354" dependencies = [ "tracing", ] [[package]] name = "triomphe" version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd69c5aa8f924c7519d6372789a74eac5b94fb0f8fcf0d4a97eb0bfc3e785f39" dependencies = [ "serde", "stable_deref_trait", ] [[package]] name = "try-lock" version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "typenum" version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" [[package]] name = "unicode-ident" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" [[package]] name = "unicode-linebreak" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" [[package]] name = "unicode-width" version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" [[package]] name = "unicode-width" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" [[package]] name = "untrusted" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" version = "2.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" dependencies = [ "form_urlencoded", "idna", "percent-encoding", "serde", ] [[package]] name = "utf8-width" version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091" [[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" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "valuable" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" [[package]] name = "vcpkg" version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" [[package]] name = "version_check" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "want" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" dependencies = [ "try-lock", ] [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" version = "1.0.1+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" dependencies = [ "wit-bindgen", ] [[package]] name = "wasm-bindgen" version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" dependencies = [ "cfg-if", "once_cell", "rustversion", "wasm-bindgen-macro", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-futures" version = "0.4.56" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c" dependencies = [ "cfg-if", "js-sys", "once_cell", "wasm-bindgen", "web-sys", ] [[package]] name = "wasm-bindgen-macro" version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" dependencies = [ "quote", "wasm-bindgen-macro-support", ] [[package]] name = "wasm-bindgen-macro-support" version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" dependencies = [ "bumpalo", "proc-macro2", "quote", "syn", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" dependencies = [ "unicode-ident", ] [[package]] name = "web-sys" version = "0.3.83" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" dependencies = [ "js-sys", "wasm-bindgen", ] [[package]] name = "windows-core" version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ "windows-implement", "windows-interface", "windows-link", "windows-result", "windows-strings", ] [[package]] name = "windows-implement" version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "windows-interface" version = "0.59.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] name = "windows-registry" version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" dependencies = [ "windows-link", "windows-result", "windows-strings", ] [[package]] name = "windows-result" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ "windows-link", ] [[package]] name = "windows-strings" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ "windows-link", ] [[package]] name = "windows-sys" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ "windows-targets 0.52.6", ] [[package]] name = "windows-sys" version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ "windows-targets 0.52.6", ] [[package]] name = "windows-sys" version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ "windows-targets 0.53.5", ] [[package]] name = "windows-sys" version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ "windows-link", ] [[package]] name = "windows-targets" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", "windows_i686_gnullvm 0.52.6", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] [[package]] name = "windows-targets" version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ "windows-link", "windows_aarch64_gnullvm 0.53.1", "windows_aarch64_msvc 0.53.1", "windows_i686_gnu 0.53.1", "windows_i686_gnullvm 0.53.1", "windows_i686_msvc 0.53.1", "windows_x86_64_gnu 0.53.1", "windows_x86_64_gnullvm 0.53.1", "windows_x86_64_msvc 0.53.1", ] [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] name = "windows_aarch64_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] name = "windows_aarch64_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" [[package]] name = "windows_i686_gnu" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" [[package]] name = "windows_i686_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] name = "windows_i686_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" [[package]] name = "windows_x86_64_gnu" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] name = "windows_x86_64_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "windows_x86_64_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "wit-bindgen" version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" [[package]] name = "writeable" version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" [[package]] name = "yoke" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" dependencies = [ "stable_deref_trait", "yoke-derive", "zerofrom", ] [[package]] name = "yoke-derive" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", "syn", "synstructure", ] [[package]] name = "zerocopy" version = "0.8.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd74ec98b9250adb3ca554bdde269adf631549f51d8a8f8f0a10b50f1cb298c3" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" version = "0.8.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8a8d209fdf45cf5138cbb5a506f6b52522a25afccc534d1475dad8e31105c6a" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "zerofrom" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", "syn", "synstructure", ] [[package]] name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" [[package]] name = "zerotrie" version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" dependencies = [ "displaydoc", "yoke", "zerofrom", ] [[package]] name = "zerovec" version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" dependencies = [ "yoke", "zerofrom", "zerovec-derive", ] [[package]] name = "zerovec-derive" version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", "syn", ] indieweb-rust-v0.6.0/Cargo.toml000066400000000000000000000011531511645615100164210ustar00rootroot00000000000000[workspace] resolver = "2" members = ["examples/indieauth/*", "library"] [workspace.package] edition = "2024" authors = ["Jacky Alciné "] license = "AGPL-3.0" rust-version = "1.85" repository = "https://git.sr.ht/~jacky/indieweb-rust" homepage = "https://indieweb.org/Rust#Library" publish = false [workspace.dependencies] async-trait = "0" futures = "0.3" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" serde_qs = "0" thiserror = "2" tracing = { version = "0.1" } url = { version = "2.5", features = ["serde"] } miette = { version = "7.2" } tracing-forest = { version = "0.3" } indieweb-rust-v0.6.0/README.markdown000066400000000000000000000007341511645615100171760ustar00rootroot00000000000000# `indieweb` A collection of Rust projects implementing IndieWeb standards and algorithms. View specifications at . ## Goals - HTTP client agnostic - Normalized IndieWeb service interactions - Comprehensive error handling ## Features - Link relationship resolution - Micropub - Microsub - IndieAuth - WebSub - Webmention ## Documentation - [Library](./library/README.markdown) - [Examples](./examples/indieauth/client-server/README.markdown) indieweb-rust-v0.6.0/examples/000077500000000000000000000000001511645615100163075ustar00rootroot00000000000000indieweb-rust-v0.6.0/examples/indieauth/000077500000000000000000000000001511645615100202615ustar00rootroot00000000000000indieweb-rust-v0.6.0/examples/indieauth/client-server/000077500000000000000000000000001511645615100230435ustar00rootroot00000000000000indieweb-rust-v0.6.0/examples/indieauth/client-server/Cargo.toml000066400000000000000000000014551511645615100250000ustar00rootroot00000000000000[package] name = "example-indieauth-client-server" version = "0.0.0" edition.workspace = true authors.workspace = true license.workspace = true rust-version.workspace = true repository.workspace = true homepage.workspace = true [dependencies] axum = { version = "0.7.5", features = ["macros"] } axum-extra = { version = "0.9.3", features = ["cookie"] } indieweb = { path = "../../../library" } miette = { workspace = true, features = ["fancy"] } minijinja = "2.2.0" minijinja-embed = "2.2.0" nanoid = "0.4.0" serde.workspace = true serde_json.workspace = true serde_qs.workspace = true tokio = { version = "1.40.0", features = ["macros", "rt-multi-thread"] } tower-livereload = "0.9.3" tracing.workspace = true tracing-forest.workspace = true url.workspace = true [build-dependencies] minijinja-embed = "2.2.0" indieweb-rust-v0.6.0/examples/indieauth/client-server/README.markdown000066400000000000000000000015761511645615100255550ustar00rootroot00000000000000# IndieAuth Client-Server Example This example demonstrates a complete IndieAuth client-server implementation using the indieweb Rust library. ## Overview The example includes: - A web server handling IndieAuth flows - Client-side authentication pages - Token redemption and profile display ## Setup 1. Ensure Rust is installed (see rust-toolchain.toml for version). 2. Navigate to this directory. 3. Run `cargo build` to compile. ## Running Execute `cargo run` to start the server. Visit `http://localhost:8080` in your browser to interact with the example. ## Templates The `src/templates/` directory contains HTML templates for: - `index.html`: Main entry page - `client.html`: Authentication form - `client-redirect.html`: Redirect handling - `client-redeem.html`: Token redemption - `_root.html`: Base template These templates showcase how to structure IndieAuth user interfaces.indieweb-rust-v0.6.0/examples/indieauth/client-server/build.rs000066400000000000000000000001061511645615100245050ustar00rootroot00000000000000fn main() { minijinja_embed::embed_templates!("src/templates"); } indieweb-rust-v0.6.0/examples/indieauth/client-server/src/000077500000000000000000000000001511645615100236325ustar00rootroot00000000000000indieweb-rust-v0.6.0/examples/indieauth/client-server/src/client.rs000066400000000000000000000204121511645615100254550ustar00rootroot00000000000000use axum::{ extract::Query, http::StatusCode, response::{ErrorResponse, Html, IntoResponse, Redirect, Response}, routing::{get, post}, Extension, Form, Router, }; use axum_extra::extract::{cookie::Cookie, CookieJar}; use indieweb::standards::indieauth::{ self, AuthorizationRequestFields, CommonRedemptionFields, RedemptionClaim, RedemptionFields, RedirectErrorFields, RedirectUri, ServerMetadata, SignedRedirectFields, }; use miette::IntoDiagnostic; use minijinja::context; use url::Url; use crate::{engine, SharedState}; #[derive(serde::Serialize, serde::Deserialize, Debug, Clone)] pub struct RequestSession { me: Url, metadata: ServerMetadata, state: String, verifier: String, } impl RequestSession { const COOKIE_NAME: &str = "request-session"; fn from_cookie(jar: &CookieJar) -> Option { serde_json::from_str( &jar.get(Self::COOKIE_NAME) .map(|v| v.value().to_string()) .unwrap_or_default(), ) .ok() } fn store_in_cookie(&self, jar: CookieJar) -> CookieJar { jar.add(Cookie::new( Self::COOKIE_NAME, serde_json::json!(self).to_string(), )) } } #[tracing::instrument] pub async fn landing(jar: CookieJar) -> impl IntoResponse { let me = RequestSession::from_cookie(&jar) .map(|rs| rs.me.to_string()) .unwrap_or_default(); Html( engine() .get_template("client.html") .unwrap() .render(context! { me }) .unwrap(), ) } #[derive(serde::Deserialize, Debug)] #[serde(rename_all = "kebab-case", tag = "kind")] pub struct RequestParams { me: Url, } #[axum::debug_handler] pub async fn begin_authorization( Extension(SharedState { client, .. }): Extension, Query(RequestParams { me }): Query, jar: CookieJar, ) -> Response { let resp: miette::Result<_> = async move { let mut req_session = RequestSession { metadata: client.obtain_metadata(&me).await?, me, state: format!("st_{}", nanoid::nanoid!(16)), verifier: Default::default(), }; let request = AuthorizationRequestFields::new( &client.id, &"http://127.0.0.1:18000/client/redirect" .parse() .into_diagnostic()?, req_session.state.clone(), )?; req_session.verifier = request.challenge.verifier().to_string(); let jar = req_session.store_in_cookie(jar); let signed_url = req_session.metadata.new_authorization_request_url( request, vec![("me".to_string(), req_session.me.to_string())], )?; Ok((jar, Redirect::to(signed_url.as_str()))) } .await; match resp { Ok(resp) => resp.into_response(), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:#?}")).into_response(), } } #[derive(serde::Deserialize, serde::Serialize, Debug)] #[serde(untagged, rename_all = "snake_case")] pub enum RedirectQuery { Error(RedirectErrorFields), Response(SignedRedirectFields), } #[tracing::instrument] pub async fn complete_authorization(Query(resp): Query, jar: CookieJar) -> Response { let verifier = if let Some(RequestSession { verifier, .. }) = RequestSession::from_cookie(&jar) { verifier } else { return ( StatusCode::BAD_REQUEST, Html("You might need to try this request again; no cookie was found.".to_string()), ) .into_response(); }; Html( engine() .get_template("client-redirect.html") .unwrap() .render(context! { resp, verifier }) .unwrap(), ) .into_response() } #[derive(serde::Deserialize, serde::Serialize, Debug)] #[serde(rename_all = "snake_case")] enum RedeemVia { Profile, Token, } #[derive(serde::Deserialize, serde::Serialize, Debug)] #[serde(rename_all = "snake_case")] pub struct RedemptionForm { redeem: RedeemVia, code: String, } #[axum::debug_handler] #[tracing::instrument(skip(client))] pub async fn redeem_authorization_code( Extension(SharedState { client, client_id }): Extension, jar: CookieJar, Form(RedemptionForm { redeem, code }): Form, ) -> Result, ErrorResponse> { let req_session = if let Some(rq) = RequestSession::from_cookie(&jar) { rq } else { return Err(( StatusCode::BAD_REQUEST, Html("No session information for this request"), ) .into_response() .into()); }; let fields = RedemptionFields { code, client_id, redirect_uri: RedirectUri::from( "http://127.0.0.1:18000/client/redirect" .parse::() .unwrap(), ), verifier: req_session.verifier, }; let ctx = || async { match redeem { RedeemVia::Profile => { let resp = match client .redeem::( &req_session.metadata.authorization_endpoint, fields, ) .await { Ok(r) => r, Err(e) => { return context! { error => format!("Failed to redeem the code at the authorization endpoint: {e:#?}") }; } }; if let indieauth::RedemptionResponse::Claim(RedemptionClaim { access_token, refresh_token, scope, me, expires_in, payload, .. }) = resp { context! { token => access_token, scope, expires_in, me, refresh_token, profile => payload } } else if let indieauth::RedemptionResponse::Error(error) = resp { context! { error => "Failed to complete the redemption request for a profile.", resp => error } } else { context! { error => "The claim was not for a profile; you probably clicked the wrong button!" } } } RedeemVia::Token => { let resp = match client .redeem::(&req_session.metadata.token_endpoint, fields) .await { Ok(r) => r, Err(e) => { return context! { error => format!("Failed to redeem the code at the token endpoint: {e:#?}") }; } }; if let indieauth::RedemptionResponse::Claim(RedemptionClaim { access_token, scope, me, refresh_token, expires_in, .. }) = resp { context! { token => access_token, scope, expires_in, refresh_token, me } } else if let indieauth::RedemptionResponse::Error(error) = resp { context! { error => "Failed to complete the redemption request for a token.", resp => error } } else { context! { error => "The claim was not for a token; you probably clicked the wrong button!" } } } } }; Ok(Html( engine() .get_template("client-redeem.html") .unwrap() .render(ctx().await) .unwrap(), )) } pub(crate) fn router() -> Router { Router::new() .route("/", get(landing)) .route("/redirect", get(complete_authorization)) .route("/redeem", post(redeem_authorization_code)) .route("/auth", get(begin_authorization)) } indieweb-rust-v0.6.0/examples/indieauth/client-server/src/main.rs000066400000000000000000000033671511645615100251350ustar00rootroot00000000000000use std::sync::Arc; use axum::{ response::{Html, IntoResponse}, routing::get, Extension, Router, }; use indieweb::standards::indieauth::{self, ClientId}; use miette::IntoDiagnostic; use minijinja::{Environment, Value}; use tower_livereload::LiveReloadLayer; fn engine() -> minijinja::Environment<'static> { let mut env = Environment::new(); minijinja_embed::load_templates!(&mut env); env } mod client; #[tracing::instrument] async fn landing() -> impl IntoResponse { Html( engine() .get_template("index.html") .unwrap() .render(Value::default()) .unwrap(), ) } #[derive(Clone)] pub struct SharedState { client_id: ClientId, client: Arc>, } impl SharedState { fn new() -> miette::Result { let client_id = ClientId::new("http://127.0.0.1:18000")?; let client = Arc::new(indieauth::Client::new( client_id.as_str(), indieweb::http::reqwest::Client::default(), )?); Ok(Self { client_id, client }) } } #[tokio::main] #[tracing::instrument] async fn main() -> miette::Result<()> { tracing_forest::init(); let livereload_layer = LiveReloadLayer::new(); let app: Router<()> = Router::new() .route("/", get(landing)) .nest("/client", client::router()) .layer(Extension(SharedState::new()?)) .layer(livereload_layer) .with_state(()); let listener = tokio::net::TcpListener::bind("127.0.0.1:18000") .await .into_diagnostic()?; tracing::trace!("Listening at http://127.0.0.1:18000 with a sample IndieWeb client server"); axum::serve(listener, app).await.into_diagnostic()?; Ok(()) } indieweb-rust-v0.6.0/examples/indieauth/client-server/src/templates/000077500000000000000000000000001511645615100256305ustar00rootroot00000000000000indieweb-rust-v0.6.0/examples/indieauth/client-server/src/templates/_root.html000066400000000000000000000004171511645615100276420ustar00rootroot00000000000000 {{ title|default("IndieAuth Compliance Suite") }}
{% block body %}{% endblock %}
indieweb-rust-v0.6.0/examples/indieauth/client-server/src/templates/client-redeem.html000066400000000000000000000021271511645615100312350ustar00rootroot00000000000000{% extends "_root.html" %} {% block body %}

Post Redemption

{% if error %}

{{ error }}

{% if resp %}

Your server responded with a {{ resp.error }} kind of error. {% if resp.error_description %}It stated that: {{ resp.error_description }}{% endif %}

{% endif %} {% else %} {% if profile %}

Your server returned a profile response!.

  • Name: {{ profile.profile.name }}
  • {% if profile.profile.email %}
  • Email: {{ profile.profile.email }}
  • {% endif %} {% if profile.profile.photo %}
  • Photo: {{ profile.profile.photo }}
  • {% endif %} {% if profile.profile.url %}
  • URL: {{ profile.profile.url }}
  • {% endif %}
{% endif %}

Your server returned a token.

  • Secret: {{ token }}
  • {% if refresh_token %}
  • Refresh secret: {{ refresh_token }}
  • {% endif %}
  • Scopes: {{ scope }}
  • Expires in {{ expires_in }} seconds
  • User profile is {{ me }}
{% endif %}

You can try again at the client.

{% endblock body %} indieweb-rust-v0.6.0/examples/indieauth/client-server/src/templates/client-redirect.html000066400000000000000000000017421511645615100315770ustar00rootroot00000000000000{% extends "_root.html" %} {% block body %}

Post Authorization Request

{% if resp and resp.error %}

It looks like your server returned an error; it being {{ resp.error }}.

{% if resp.error_description %}

{{ resp.error_description }}

{% endif %} {% else %}

Your server approved the request. By claiming a token, that'll communicate to this testing server which scopes you've given it permission to. By claiming a profile, you'll give this testing server information about who/what gave permission to this now-successful authorization request.

Or you can start this over at the client.

{% endif %} {% endblock body %} indieweb-rust-v0.6.0/examples/indieauth/client-server/src/templates/client.html000066400000000000000000000017711511645615100300020ustar00rootroot00000000000000{% extends "_root.html" %} {% block body %}

Sample IndieAuth app

This is meant to be a simple IndieAuth app that developers can use to test out their IndieAuth implementations. You can exchange an authorization code for a profile or token.

The icons used for this project are from https://github.com/apancik/public-domain-icons.

{% endblock %} indieweb-rust-v0.6.0/examples/indieauth/client-server/src/templates/index.html000066400000000000000000000011131511645615100276210ustar00rootroot00000000000000{% extends "_root.html" %} {% block body %}

An IndieAuth Compliance Tool

This Web server is meant to help folks building applications interoperating with IndieAuth determine the breadth of alignment that their implementations have.

Tools

  • You can use the sample client to test sign in support. It'll confirm your ability to provide a token and additionally a profile.
{% endblock body %} indieweb-rust-v0.6.0/library/000077500000000000000000000000001511645615100161355ustar00rootroot00000000000000indieweb-rust-v0.6.0/library/Cargo.toml000066400000000000000000000043301511645615100200650ustar00rootroot00000000000000[package] name = "indieweb" version = "0.6.0" authors = ["Jacky Alciné "] description = "A collection of utilities for working with the IndieWeb." readme = "README.md" edition.workspace = true license.workspace = true repository.workspace = true homepage.workspace = true keywords = ["indieweb", "webmention", "indieauth", "micropub", "microsub"] categories = ["web-programming::http-client", "api-bindings", "web-programming"] [badges] maintenance = { status = "actively-developed" } [dependencies] async-trait = "0.1.82" base64 = "0.22" time = { version = "0.3", features = ["serde", "serde-well-known"] } futures = "0.3" http = { version = "1" } microformats = { version = "0" } reqwest-middleware = { version = "0.4", optional = true } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" serde_qs = "0" sha2 = "0.10" thiserror = "2.0" tracing = { version = "0.1", features = ["attributes"] } url = { version = "2.5", features = ["serde"] } regex = { version = "1.10.6", features = [ "perf", "unicode-bool", ], optional = true, default-features = false } reqwest = { version = "0.12", optional = true } miette = { version = "7.2", default-features = false, features = ["derive"] } secrecy = { version = "0.10", features = ["serde"] } rand = { version = "0.9", default-features = false, features = ["os_rng"] } fake = { version = "4", optional = true } serde_with = "3.12.0" [features] default = ["reqwest", "reaction", "experimental", "reqwest_middleware"] experimental = [ "experimental_channels", "experimental_syndication", "experimental_relation", ] experimental_channels = [] experimental_relation = [] experimental_syndication = [] reaction = ["regex"] reqwest = ["dep:reqwest"] reqwest_middleware = ["dep:reqwest-middleware", "reqwest"] fake = ["dep:fake"] [dev-dependencies] mockito = "1.5" env_logger = "0.11" assert-json-diff = "2.0" tokio = { version = "1.40", features = ["test-util", "full"] } tracing-test = "0.2" tokio-test = "0.4.4" miette = { version = "7.2", default-features = true, features = ["fancy"] } fake = "4" [package.metadata.docs.rs] all-features = true default-target = "x86_64-unknown-linux-gnu" rustdoc-args = ["--cfg", "docsrs"] [package.metadata.playground] all-features = true indieweb-rust-v0.6.0/library/README.md000066400000000000000000000014771511645615100174250ustar00rootroot00000000000000# IndieWeb Library A Rust library for implementing IndieWeb standards and algorithms. ## Overview Provides tools for IndieWeb protocols including authentication, publishing, and communication standards. ## Installation Install from crates.io for stable releases: ```bash cargo add indieweb ``` Or from Git for the latest development version: ```bash cargo add --git https://git.sr.ht/~jacky/indieweb-rust indieweb ``` ## Modules - **Algorithms**: Link resolution, authorship detection, representative h-card parsing - **Standards**: IndieAuth, Micropub, Webmention, WebSub implementations - **Traits**: Common interfaces for IndieWeb interactions ## Usage For local development, add to your Cargo.toml: ```toml [dependencies] indieweb = { path = "../path/to/library" } ``` For detailed API usage, see the source code.indieweb-rust-v0.6.0/library/src/000077500000000000000000000000001511645615100167245ustar00rootroot00000000000000indieweb-rust-v0.6.0/library/src/algorithms/000077500000000000000000000000001511645615100210755ustar00rootroot00000000000000indieweb-rust-v0.6.0/library/src/algorithms/authorship.rs000066400000000000000000000000011511645615100236200ustar00rootroot00000000000000 indieweb-rust-v0.6.0/library/src/algorithms/link_rel/000077500000000000000000000000001511645615100226745ustar00rootroot00000000000000indieweb-rust-v0.6.0/library/src/algorithms/link_rel/mod.rs000066400000000000000000000112071511645615100240220ustar00rootroot00000000000000use std::collections::HashMap; use http::{header::ACCEPT, HeaderValue}; use url::Url; use crate::mf2; // NOTE: Expand to allow direct deserialization of this from a response. // NOTE: Add support for parsing from a string into this type. /// Provides a wrapper of what relations look like. #[derive(Debug, Clone)] pub struct RelMap(HashMap>); impl From>> for RelMap { fn from(v: HashMap>) -> Self { Self(v) } } impl TryInto for RelMap { type Error = crate::Error; fn try_into(self) -> Result { let link_value = self .iter() .flat_map(|(name, urls)| { urls.iter() .map(move |url| format!("<{url}>; rel=\"{name}\"")) }) .collect::>() .join(" , "); Ok(HeaderValue::from_str(&link_value)?) } } impl std::ops::Deref for RelMap { type Target = HashMap>; fn deref(&self) -> &Self::Target { &self.0 } } fn get_rels_for_header(header_value: &HeaderValue, rel: &str, base_url: &Url) -> Vec { let header_value = String::from_utf8(header_value.as_bytes().to_vec()).unwrap_or_default(); header_value .split(',') .collect::>() .into_iter() .map(|link_rel: &str| { let mut parameters = link_rel.split(';').collect::>(); let uri_value = parameters.remove(0).to_owned().trim().to_string(); (uri_value, parameters) }) .filter(|(_uri, parameters)| { parameters .iter() .map(|s| s.to_lowercase()) .map(|s| s.trim().to_string()) .filter(|r| r.starts_with("rel=")) .flat_map(|rv| { rv.replace("rel=", "") .trim_matches('"') .split(' ') .map(|s| s.to_string()) .collect::>() }) .any(|s| s == rel) }) .filter_map(|(uri, _parameters)| { uri.strip_prefix('<') .map(|u| u.to_string()) .unwrap_or_default() .strip_suffix('>') .and_then(|u| u.parse().or_else(|_| base_url.join(u)).ok()) }) .collect::>() } fn get_rels_from_headers(headers: &http::HeaderMap, rel: &str, base_url: &Url) -> Vec { headers .get_all("link") .into_iter() .flat_map(|uri_header_value| get_rels_for_header(uri_header_value, rel, base_url)) .collect::>() } /// Resolves all of the relating links for a particular URL. // TODO : Send a HEAD request to get headers instead // TODO: Return the response's status code. // FIXME: Refactor this to use the mf2 library. #[tracing::instrument(skip(client))] pub async fn for_url( client: &impl crate::http::Client, url: &Url, rels: &[&str], method: &str, ) -> Result { let req = http::Request::builder() .uri(url.as_str()) .header(ACCEPT, "text/html, text/plain, */*") .method(method) .body(Default::default()) .map_err(crate::Error::Http)?; let resp = client.send_request(req).await?; let headers = resp.headers().clone(); let header_rels: HashMap> = HashMap::from_iter( rels.iter() .map(|rel| (rel.to_string(), get_rels_from_headers(&headers, rel, url))), ); let mut rels_map = HashMap::from_iter(header_rels); if method == "GET" { // FIXME: Confirm that we got back a HTML document via content-type. let document = mf2::parser::http::to_mf2_document( resp.map(|body| body.as_bytes().to_vec()), url.as_str(), ) .map_err(mf2::Error::Parser)?; let body_rels: HashMap> = HashMap::from_iter(document.rels.by_rels().into_iter()); for (header, rels) in body_rels { if !rels_map.contains_key(&header) { rels_map.insert(header.clone(), Vec::default()); } if let Some(r) = rels_map.get_mut(&header) { r.extend(rels) } } } rels_map = HashMap::from_iter(rels_map.into_iter().filter(|(_, v)| !v.is_empty())); if !rels_map.is_empty() { tracing::trace!(rels = format!("{:?}", rels_map), "Found relations."); } else { tracing::trace!(rels = format!("{:?}", rels), "No relations found."); } Ok(rels_map.into()) } #[cfg(test)] mod test; indieweb-rust-v0.6.0/library/src/algorithms/link_rel/test.rs000066400000000000000000000000001511645615100242070ustar00rootroot00000000000000indieweb-rust-v0.6.0/library/src/algorithms/mod.rs000066400000000000000000000117071511645615100222300ustar00rootroot00000000000000use crate::mf2; use mf2::types::PropertyValue; use mf2::types::{Fragment, Item}; use serde::{Deserialize, Serialize}; /// Logic around finding the representative h-card of a URL. /// /// This kind of parsing allows for deeper introspection and fuller discovery /// of how and where entities represent themselves. Learn more at /// . pub mod representative_hcard; /// Logic for running post type discovery. /// /// This module provides a means of detecting the known and experimental /// post types provided by the IndieWeb community. pub mod ptd; /// Logic around discerning relationships between two URLs. /// /// This module provides an implementation for resolving and /// discovering the advertised link relationships. This is one /// of the more popular methods of resource discovery in the IndieWeb /// of providers of the [standards][crate::standards]. pub mod link_rel; /// A normalized representation of properties from Microformats2 JSON. /// /// This represents a "middle" type for converting Microformats2 JSON into /// something more structured like an [item][microformats::types::Item]. #[derive(Clone, Debug, Serialize, Deserialize, Default, PartialEq, Eq)] pub struct Properties(pub serde_json::Map); impl std::ops::Deref for Properties { type Target = serde_json::Map; fn deref(&self) -> &Self::Target { &self.0 } } impl std::ops::DerefMut for Properties { fn deref_mut(&mut self) -> &mut Self::Target { &mut self.0 } } impl From for serde_json::Value { fn from(val: Properties) -> Self { serde_json::Value::Object(val.0) } } impl TryFrom for Properties { type Error = serde_json::Error; fn try_from(val: serde_json::Value) -> Result { serde_json::from_value::(val) } } impl Properties { /// Creates a copy of this set of properties that's been normalized. /// /// # Examples /// ``` /// # use indieweb::algorithms::Properties; /// # use serde_json::json; /// # /// assert_eq!( /// Properties::try_from(json!({"properties": {"actual": "value"}})).unwrap().normalize(), /// Properties::try_from(json!({"actual": "value"})).unwrap(), /// "use 'properties' as the value" /// ); /// # /// assert_eq!( /// Properties::try_from(json!({"actual": "value"})).unwrap().normalize(), /// Properties::try_from(json!({"actual": "value"})).unwrap(), /// "returns the keys and values as they are" /// ); /// # /// ``` pub fn normalize(&self) -> Properties { if self.contains_key("properties") { Properties( self.get("properties") .and_then(|p| p.as_object().cloned()) .unwrap_or_default(), ) } else { self.clone() } } } /// Pulls all of the URLs from this item. /// /// This extracts all of the discoverable URLs from an item. This will /// pull from: /// /// * [item.value][microformats::types::Item::value] if it's a [URL value][microformats::types::ValueKind::Url] /// * any property whose values are hinted as a [URL][microformats::types::ValueKind::Url] /// * the declared [links of a HTML fragment][microformats::types::Fragment] pub fn extract_urls(item: &Item) -> Vec { let mut all_urls = vec![]; all_urls.extend( item.children .iter() .filter_map(|child| child.value.to_owned()) .filter_map(|v| match v { microformats::types::ValueKind::Url(u) => Some(u), microformats::types::ValueKind::Plain(_) => None, }), ); all_urls.extend( item.properties .values() .flatten() .filter_map(|v| match v { PropertyValue::Url(u) => Some(vec![u.clone()]), PropertyValue::Item(i) => Some(extract_urls(i)), PropertyValue::Fragment(Fragment { links, .. }) => Some( links .iter() .filter_map(|v| v.parse().ok()) .collect::>(), ), _ => None, }) .flatten(), ); all_urls } #[test] fn extract_urls_test() { let item_result = Item::try_from(serde_json::json!({ "type": ["h-entry"], "properties": { "content": [{"html": "Well this is a link fooo", "value": "Well this is a link fooo"}], "like-of": ["http://example.com/", "http://example.com/2"] } })); assert_eq!(item_result.as_ref().err(), None); let item = item_result.unwrap(); // This _should_ be three but it's two because the Microformats library doesn't do extra // procesing yet on deserializing of values. assert_eq!(extract_urls(&item).len(), 2); } indieweb-rust-v0.6.0/library/src/algorithms/ptd.rs000066400000000000000000000453611511645615100222430ustar00rootroot00000000000000#![allow(clippy::borrow_interior_mutable_const)] /// Logic around handling post type discovery. /// /// PTD (a.k.a [post type discovery](https://indieweb.org/ptd)) is a means of resolving /// the semantically relevant post type of a provided [item][microformats::types::Item]. /// By default, everything falls back to a [Entry][microformats::types::KnownClass::Entry]. use crate::mf2::types; use regex::Regex; use std::{ collections::{HashMap, HashSet}, iter::FromIterator, str::FromStr, sync::OnceLock, }; /// A canonical list of the recognized post types. /// /// A full list of them can be found at // FIXME: Move 'experimental' types into a separate enum. #[derive( Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Deserialize, serde::Serialize, )] #[serde(rename_all = "kebab-case")] #[derive(Default)] pub enum Type { /// Like, /// Bookmark, /// Reply, #[cfg(feature = "reaction")] /// Reaction, /// Repost, /// #[default] Note, /// Article, /// Photo, /// Video, /// Audio, /// A catch-all type for more than one of a [Type::Photo], [Type::Audio] or [Type::Video] in one post. Media, /// Quotation, /// #[serde(rename = "gameplay")] GamePlay, /// #[serde(rename = "rsvp")] RSVP, /// #[serde(rename = "checkin")] CheckIn, /// Listen, /// Watch, /// Review, /// Read, /// Jam, /// Follow, /// Event, /// Issue, /// Venue, /// Collection, /// Presentation, /// Exercise, /// Recipe, /// Wish, /// Edit, /// Sleep, /// Session, /// Snark, /// Donation, /// Want, /// Mention, /// Invite, /// An unknown and unrecognized post type. Other(String), } impl Type { const REACTIONS: [Self; 13] = [ Type::Reply, Type::Like, Type::RSVP, Type::Reaction, Type::Review, Type::Bookmark, Type::Repost, Type::Quotation, Type::Issue, Type::Edit, Type::Follow, Type::Listen, Type::Invite, ]; /// Determines if this [`Type`] is that of a reaction. pub fn is_reaction(&self) -> bool { Self::REACTIONS.contains(self) } } impl std::fmt::Display for Type { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let type_str = if let Self::Other(s) = self { s.to_string() } else { serde_json::to_value(self) .map(|v| v.to_string().trim_matches('"').to_string()) .unwrap_or_else(|_| "other".to_string()) }; f.write_str(&type_str) } } impl FromStr for Type { type Err = std::convert::Infallible; fn from_str(s: &str) -> Result { Ok(serde_json::from_str(&format!("\"{}\"", s)) .unwrap_or_else(|_| Type::Other(s.to_string()))) } } #[test] fn type_to_string() { assert_eq!(Type::Note.to_string(), "note"); assert_eq!(Type::Mention.to_string(), "mention"); assert_eq!(Type::RSVP.to_string(), "rsvp"); assert_eq!(Type::CheckIn.to_string(), "checkin"); assert_eq!(Type::GamePlay.to_string(), "gameplay"); assert_eq!(Type::Other("magic".to_string()).to_string(), "magic"); } /// Represents the potential forms of defining a post type. The similar form /// as represented by a single string is the one conventionally used. The /// expanded form is one that's being experimented on to allow for the definition /// of the constraints that a type can set. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[serde(untagged, rename_all = "kebab-case")] pub enum PostType { /// Represents a simpler (textual) form of a post type. Simple(Type), /// Represents an expanded way to describe a post type. Expanded { /// The presentational name of the post type. name: String, /// The known post type being expanded. #[serde(rename = "type")] kind: Type, /// The container type represented as a [microformats::types::Class][]. #[serde(default = "default_class")] h: microformats::types::Class, /// Recognized properties for this post type. #[serde(default)] properties: Vec, /// Properties for this post type that are required for it to be defined as this post type. #[serde(default)] required_properties: Vec, }, } fn default_class() -> types::Class { types::Class::Known(types::KnownClass::Entry) } impl From for Type { fn from(post_type: PostType) -> Type { match post_type { PostType::Simple(kind) => kind, PostType::Expanded { kind, .. } => kind, } } } impl From<&PostType> for Type { fn from(post_type: &PostType) -> Type { match post_type { PostType::Simple(kind) => kind.clone(), PostType::Expanded { kind, .. } => kind.clone(), } } } impl PartialEq for PostType { fn eq(&self, other: &Self) -> bool { let ltype: Type = self.into(); let rtype: Type = other.into(); ltype == rtype } } impl PostType { /// Provides a human-friendly descriptor of this post type. pub fn name(&self) -> String { match self { Self::Simple(simple_type) => simple_type.to_string(), Self::Expanded { name, .. } => name.to_string(), } } /// Provides the actual type represented. pub fn kind(&self) -> String { match self { Self::Simple(simple_type) => simple_type.to_string(), Self::Expanded { kind, .. } => kind.to_string(), } } } #[test] fn post_type_name() { assert_eq!(PostType::Simple(Type::Note).name(), "note".to_string()); assert_eq!(PostType::Simple(Type::RSVP).name(), "rsvp".to_string()); } fn properties_from_type() -> HashMap { HashMap::from_iter( vec![ ("like-of".to_owned(), Type::Like), ("in-reply-to".to_owned(), Type::Reply), ("bookmark-of".to_owned(), Type::Bookmark), ("repost-of".to_owned(), Type::Repost), ("quotation-of".to_owned(), Type::Quotation), ("gameplay-of".to_owned(), Type::GamePlay), ("follow-of".to_owned(), Type::Follow), ("jam-of".to_owned(), Type::Jam), ("listen-of".to_owned(), Type::Listen), ("rsvp".to_owned(), Type::RSVP), ("photo".to_owned(), Type::Photo), ("video".to_owned(), Type::Video), ("audio".to_owned(), Type::Audio), ("checkin".to_owned(), Type::CheckIn), ("read-of".to_owned(), Type::Read), ("media".to_owned(), Type::Media), ("mention-of".to_owned(), Type::Mention), ] .iter() .cloned(), ) } static REPLY_CONTEXT_PROPERTIES: [&str; 12] = [ "in-reply-to", "like-of", "bookmark-of", "repost-of", "quotation-of", "follow-of", "listen-of", "gameplay-of", "mention-of", "rsvp", "read-of", "checkin", ]; /// Determines if the provided property is one that implies a contextual response. /// /// This is mainly opinionated so please send PRs for property names you'd like to see here. /// /// # Examples /// ``` /// # use indieweb::algorithms::ptd::is_reply_context_property; /// assert!(is_reply_context_property("read-of"), "'read-of' is a reaction to reading something"); /// assert!(!is_reply_context_property("content"), "'content' does not indicate a reaction to anything"); /// ``` pub fn is_reply_context_property(property_name: &str) -> bool { REPLY_CONTEXT_PROPERTIES.contains(&property_name) } /// Determines the type of reactionary post this is from the provided property names. /// /// See [resolve_from_property_names] for more information. pub fn resolve_reaction_property_name(property_names: &[&str]) -> Option { let hashed = REPLY_CONTEXT_PROPERTIES .iter() .map(|s| s.to_string()) .collect::>(); let got = property_names.iter().map(|s| s.to_string()).collect(); let mut reaction_types = hashed.intersection(&got).cloned().collect::>(); reaction_types.sort(); reaction_types.dedup(); resolve_from_property_names(reaction_types) .filter(|v| !matches!(v, Type::Note) || !matches!(v, Type::Article)) .or(Some(Type::Mention)) } #[test] fn resolve_reaction_property_name_test() { assert_eq!( resolve_reaction_property_name(&["content", "in-reply-to"]), Some(Type::Reply) ); assert_eq!( resolve_reaction_property_name(&["url", "gameplay-of"]), Some(Type::GamePlay) ); } // FIXME: Figure out how to consider RSVPs. // FIXME: Should reacjis be different here. /// Determines the property name to use for publishing for a post type. /// /// This is useful for semantically organizing things like Webmentions /// or items in a collection of posts (or feed). pub fn type_to_reaction_property_name(t: Type) -> String { if t == Type::Reply { "comment".to_string() } else if t.is_reaction() { t.to_string() } else { "mention".to_string() } } #[test] fn type_to_reaction_property_name_test() { assert_eq!(type_to_reaction_property_name(Type::Like), "like"); #[cfg(feature = "reaction")] assert_eq!(type_to_reaction_property_name(Type::Reaction), "reaction"); assert_eq!(type_to_reaction_property_name(Type::Reply), "comment"); assert_eq!(type_to_reaction_property_name(Type::Note), "mention"); assert_eq!(type_to_reaction_property_name(Type::Article), "mention"); assert_eq!( type_to_reaction_property_name(Type::Other("grr".to_string())), "mention" ); } const PTD_COMPLAINT_CLASSES: [types::Class; 3] = [ types::Class::Known(types::KnownClass::Entry), types::Class::Known(types::KnownClass::Cite), types::Class::Known(types::KnownClass::Review), ]; /// Aims to resolve the known post type of the provided [microformats::types::Item]. pub fn resolve_from_object(item_mf2: types::Item) -> Option { let keys = item_mf2.properties.keys().cloned().collect::>(); if item_mf2.r#type == vec![types::Class::Known(types::KnownClass::Event)] { Some(Type::Event) } else if PTD_COMPLAINT_CLASSES .iter() .any(|klass| item_mf2.r#type.contains(klass)) { resolve_from_property_names(keys).map(|post_type| { #[cfg(feature = "reaction")] if post_type == Type::Reply && has_reaction_emoji_as_content(item_mf2.clone()) { return Type::Reaction; } post_type }) } else { None } } static RE_IS_ONLY_EMOJI_OR_PICTOGRAPH: OnceLock = OnceLock::new(); fn has_emoji(text: &str) -> bool { RE_IS_ONLY_EMOJI_OR_PICTOGRAPH .get_or_init(|| { Regex::new(r#"^(\p{Extended_Pictographic}|\p{Emoji_Presentation})+$"#) .expect("Failed to compile emoji matching regex") }) .is_match(text) } #[cfg(feature = "reaction")] fn has_reaction_emoji_as_content(into_item_mf2: V) -> bool where V: TryInto, { if let Ok(contents) = into_item_mf2 .try_into() .map(|item: types::Item| item.content()) { contents .unwrap_or_default() .into_iter() .any(|content_value| match content_value { types::PropertyValue::Plain(text) | types::PropertyValue::Fragment(types::Fragment { value: text, .. }) => { has_emoji(&text) } types::PropertyValue::Item(item) => has_reaction_emoji_as_content(item), _ => false, }) } else { false } } /// Determines a potential post type from a list of property names. /// /// # Examples /// ``` /// # use indieweb::algorithms::ptd::*; /// assert_eq!( /// resolve_from_property_names(vec!["like-of".into()]), /// Some(Type::Like), /// "'like-of' indicates a like post."); /// /// assert_eq!( /// resolve_from_property_names(vec!["content".into()]), /// Some(Type::Note), /// "Just 'content' is a note."); /// ``` pub fn resolve_from_property_names(names: Vec) -> Option { let has_content = names.contains(&"content".to_owned()); let has_name = names.contains(&"name".to_string()); let mut types: Vec = vec![]; properties_from_type().iter().for_each(|(key, val)| { if names.contains(key) { types.push(val.clone()); } }); if has_name && has_content { types.push(Type::Article) } else if !has_name && has_content { types.push(Type::Note) } let first_type = types.first().cloned(); combinatory_type(types.clone()) .or(first_type) .or(Some(Type::Note)) } fn combinatory_type(types: Vec) -> Option { [ (Type::RSVP, vec![Type::Reply, Type::RSVP]), (Type::Photo, vec![Type::Photo, Type::Note]), (Type::Video, vec![Type::Video, Type::Photo, Type::Note]), ( Type::Media, vec![Type::Audio, Type::Video, Type::Photo, Type::Note], ), ] .iter() .find_map(|(combined_type, expected_types)| { if expected_types .iter() .all(|post_type| types.contains(post_type)) { Some(combined_type.to_owned()) } else { None } }) } #[test] fn post_type_from_json() { assert_eq!( serde_json::from_str::( r#" { "name": "Note", "type": "note" } "# ) .ok(), Some(PostType::Expanded { name: "Note".to_string(), kind: Type::Note, h: default_class(), properties: Vec::default(), required_properties: Vec::default() }) ); assert_eq!( serde_json::from_str::>( r#" [{ "name": "Note", "type": "note" }, "like"] "# ) .ok(), Some(vec![ PostType::Expanded { name: "Note".to_string(), kind: Type::Note, h: default_class(), properties: Vec::default(), required_properties: Vec::default() }, PostType::Simple(Type::Like) ]) ); assert_eq!( serde_json::from_str::(r#""note""#).ok(), Some(PostType::Simple(Type::Note)) ); assert_eq!( serde_qs::from_str::("note").map_err(|e| e.to_string()), Ok(Type::Note) ); #[derive(serde::Deserialize, PartialEq, Debug)] struct V { v: Vec, } assert_eq!( serde_qs::from_str::("v[0]=note&v[1]=like").map_err(|e| e.to_string()), Ok(V { v: vec![Type::Note, Type::Like] }) ); } #[test] fn type_from_str() { assert_eq!(Type::from_str("note"), Ok(Type::Note)); } #[test] fn post_type_partial_eq() { assert_eq!( PostType::Simple(Type::Like), PostType::Expanded { kind: Type::Like, name: "Like".to_string(), h: default_class(), properties: Vec::default(), required_properties: Vec::default() } ); } #[test] fn resolve_from_object_test() { assert_eq!( resolve_from_object( serde_json::json!({ "type": ["h-entry"], "properties": { "like-of": ["https://indieweb.org/like"] } }) .try_into() .unwrap() ), Some(Type::Like) ); assert_eq!( resolve_from_object( serde_json::json!({ "type": ["h-entry"], "properties": { "in-reply-to": ["https://indieweb.org/rsvp"], "rsvp": ["yes"] } }) .try_into() .unwrap() ), Some(Type::RSVP) ); #[cfg(feature = "reaction")] assert_eq!( resolve_from_object( serde_json::json!({ "type": ["h-entry"], "properties": { "in-reply-to": ["https://indieweb.org/rsvp"], "content": "🚀" } }) .try_into() .unwrap() ), Some(Type::Reaction), "detected a single emoji reaction" ); #[cfg(feature = "reaction")] assert_eq!( resolve_from_object( serde_json::json!({ "type": ["h-entry"], "properties": { "in-reply-to": ["https://indieweb.org/rsvp"], "content": ["👋🏿"] } }) .try_into() .unwrap() ), Some(Type::Reaction), "detected a reaction with a skin tone modifier" ); assert_eq!( resolve_from_object( serde_json::json!({ "type": ["h-entry"], "properties": { "in-reply-to": ["https://indieweb.org/rsvp"], "content": ["hey there! 👋🏿"] } }) .try_into() .unwrap() ), Some(Type::Reply), "ignores if there's text included" ); } #[test] fn resolve_from_property_names_test() { assert_eq!( resolve_from_property_names(vec!["like-of".into()]), Some(Type::Like) ); } indieweb-rust-v0.6.0/library/src/algorithms/representative_hcard/000077500000000000000000000000001511645615100252765ustar00rootroot00000000000000indieweb-rust-v0.6.0/library/src/algorithms/representative_hcard/mod.rs000066400000000000000000000072441511645615100264320ustar00rootroot00000000000000use crate::{http, mf2}; use ::http::Request; use microformats::types::{Class, KnownClass, PropertyValue}; fn check_if_property_values_have_url(values: &[PropertyValue], url: &url::Url) -> bool { values .iter() .filter_map(|v| { if let PropertyValue::Url(u) = v { Some(u) } else { None } }) .any(|v| v == url) } /// Fetches the representative h-card of the remote URL. /// /// # Examples /// /// ```not-rust /// # use indieweb::algorithms::representative_hcard as rep_hcard; /// # use indieweb::http::ureq::Client as UreqHttpClient; /// # use url::Url; /// # /// # let http_client = UreqHttpClient::default(); /// # let u: Url = "https://jacky.wtf".parse().unwrap(); /// # /// assert!(rep_hcard::for_url(&http_client, &u).await.is_ok(), "found the h-card"); /// ``` #[tracing::instrument(skip(client))] pub async fn for_url( client: &impl http::Client, url: &url::Url, ) -> Result { let resp = client.send_request( Request::builder() .method("GET") .uri(url.to_string()) .header(::http::header::ACCEPT, "text/html, text/mf2+html, application/json, application/mf2+json, application/jf2+json") .body(crate::http::Body::Empty) .map_err(crate::Error::Http)?, ).await?; let doc = mf2::parser::http::to_mf2_document(resp.map(|body| body.as_bytes().to_vec()), url.as_str()) .map_err(mf2::Error::Parser)?; from_document(&doc, url).await } /// Determines the representative h-card for a [document][microformats::types::Document]. /// /// # Errors /// /// This function will return an error if no representative card could be found. pub async fn from_document( document: µformats::types::Document, url: &url::Url, ) -> Result { let cards = document .items .iter() .filter(|item| item.r#type.contains(&Class::Known(KnownClass::Card))) .cloned() .collect::>(); tracing::trace!(card_count = cards.len(), "Found cards at this URL."); let rel_for_url = document.rels.items.get(url).cloned().unwrap_or_default(); if let Some(directly_specified_card) = cards .iter() .find(|card_item| { let item_uids = card_item.get_property("uid").unwrap_or_default(); let item_urls = card_item.get_property("url").unwrap_or_default(); check_if_property_values_have_url(&item_uids, url) && check_if_property_values_have_url(&item_urls, url) }) .cloned() { tracing::trace!("Found the representative h-card directly on the page."); Ok(directly_specified_card.to_owned()) } else if let Some(rel_me_assoc_card) = cards .iter() .find(|card_item| { rel_for_url.rels.contains(&"me".into()) && check_if_property_values_have_url( &card_item.get_property("url").unwrap_or_default(), url, ) }) .cloned() { tracing::trace!("Found the representative h-card via rel=me discovery."); Ok(rel_me_assoc_card.to_owned()) } else if cards.len() == 1 { tracing::trace!("Attempting to use the only card found on the page."); cards .first() .ok_or_else(|| crate::Error::NoRepresentativeHCardFound(url.clone())) .cloned() } else { tracing::trace!("Algorithm exhausted all options on discovery; found nothing."); Err(crate::Error::NoRepresentativeHCardFound(url.clone())) } } #[cfg(test)] mod test; indieweb-rust-v0.6.0/library/src/algorithms/representative_hcard/test.rs000066400000000000000000000061031511645615100266230ustar00rootroot00000000000000use microformats::types::PropertyValue; use crate::algorithms::representative_hcard::for_url; #[tokio::test] async fn directly_on_page() { let mut client = crate::test::Client::new().await; let page_mock = client .mock_server .mock("GET", "/rep-hcard/on-page") .expect(1) .match_header( "accept", mockito::Matcher::Regex("(.*)text/html(.*)".into()), ) .with_header("content-type", "text/html") .with_body( r#" "#, ) .create_async() .await; let item_result = for_url( &client, &format!("{}/rep-hcard/on-page", client.mock_server.url()) .parse() .unwrap(), ) .await; assert_eq!( item_result.as_ref().err(), None, "no errors when parsing rep h-card" ); page_mock.assert_async().await; } #[tokio::test] async fn rel_me_association_on_page() { let mut client = crate::test::Client::new().await; let page_mock = client .mock_server .mock("GET", "/rep-hcard/via-relme") .expect(1) .match_header( "accept", mockito::Matcher::Regex("(.*)text/html(.*)".into()), ) .with_header("content-type", "text/html") .with_body( r#" "#, ) .create_async() .await; let r = for_url( &client, &format!("{}/rep-hcard/via-relme", client.mock_server.url()) .parse() .unwrap(), ) .await; assert_eq!(r.as_ref().err(), None, "no errors when parsing rep h-card"); page_mock.assert_async().await; } #[tracing_test::traced_test] #[tokio::test] async fn solo_card() { let mut client = crate::test::Client::new().await; let page_mock = client .mock_server .mock("GET", "/rep-hcard/only-one") .expect(1) .match_header( "accept", mockito::Matcher::Regex("(.*)text/html(.*)".into()), ) .with_header("content-type", "text/html") .with_body( r#" "#, ) .create_async() .await; let r = for_url( &client, &format!("{}/rep-hcard/only-one", client.mock_server.url()) .parse() .unwrap(), ) .await; assert_eq!(r.as_ref().err(), None, "no errors when parsing rep h-card"); page_mock.assert_async().await; assert_eq!( r.map(|i| i.get_property("url")) .ok() .flatten() .and_then(|v| v.first().cloned()), format!("{}/solo-dolo", client.mock_server.url()) .parse() .ok() .map(PropertyValue::Url), "gave expected URL" ); } indieweb-rust-v0.6.0/library/src/error.rs000066400000000000000000000066471511645615100204400ustar00rootroot00000000000000use http::StatusCode; /// Represents all of the error states of this crate. #[derive(thiserror::Error, Debug, miette::Diagnostic)] pub enum Error { #[error("No {rel:?} endpoints were found at {url:?}")] NoEndpointsFound { rel: String, url: String }, #[error("The Webmention endpoint returned a unexpected status code of {0:?})")] WebmentionUnsupportedStatusCode(u16), #[error("No Webmention endpoint was provided.")] NoWebmentionEndpointProvided, #[error("The scopes {0:?} were missing in the provided list of scopes.")] MissingScope(crate::standards::indieauth::Scopes), #[error("IndieAuth error: {0}")] IndieAuth(#[from] crate::standards::indieauth::Error), #[error("Failed to build a CSS selector for {0}")] SelectorCompileFailure(String), #[error(transparent)] Io(#[from] std::io::Error), #[cfg(feature = "reqwest")] #[error(transparent)] Reqwest(#[from] Box), #[cfg(feature = "reqwest")] #[error(transparent)] ReqwestMethod(#[from] http::method::InvalidMethod), #[cfg(feature = "reqwest")] #[error(transparent)] ReqwestMiddleware(#[from] reqwest_middleware::Error), #[error(transparent)] JSON(#[from] serde_json::Error), #[error(transparent)] FromUTF8(#[from] std::string::FromUtf8Error), #[error(transparent)] Other(#[from] Box), #[error(transparent)] Url(#[from] url::ParseError), #[error(transparent)] Qs(#[from] serde_qs::Error), #[error(transparent)] Microformats(#[from] crate::mf2::Error), #[error("The value {0} is not recognized as a valid 'order'.")] InvalidOrder(String), #[error("The value {0} is not recognized as a valid status for posts.")] InvalidPostStatus(String), #[error("The value {0} is not recognized as a known visibility of posts.")] InvalidVisibility(String), #[error("No representative h-card could be found at {0}")] NoRepresentativeHCardFound(url::Url), #[error(transparent)] Micropub(#[from] crate::standards::micropub::Error), #[error(transparent)] Http(#[from] ::http::Error), #[error(transparent)] HeaderValue(#[from] ::http::header::InvalidHeaderValue), #[error("Failed to parse the content {0} as a Microformats2 document.")] InvalidDocumentType(String), #[error("Got {0} when trying to read a remote resource.")] RemoteServerFailure(StatusCode), #[error("The content type provided was {0}")] ResponseNotJson(String), #[error("No incoming Webmention was found at {url:?}")] WebmentionNotFound { url: url::Url }, #[error("The incoming Webmention at {url:?} was deleted.")] WebmentionDeleted { url: url::Url }, #[error("The incoming Webmention at {url:?} could not be authenticated.")] WebmentionUnauthorized { url: url::Url }, #[error("The incoming Webmention at {url:?} provided a unrecognized content type of {content_type:?}.")] WebmentionUnsupportedContentType { content_type: String, url: url::Url }, #[error("The source and target of the Webmention at {url:?} are the same.")] WebmentionSourceAndTargetAreSame { url: url::Url }, } impl From for Error { fn from(err: reqwest::Error) -> Self { Error::Reqwest(Box::new(err)) } } impl PartialEq for Error { fn eq(&self, other: &Self) -> bool { std::mem::discriminant(self) == std::mem::discriminant(other) } } indieweb-rust-v0.6.0/library/src/http.rs000066400000000000000000000121341511645615100202520ustar00rootroot00000000000000pub use ::http::{Request, Response}; use http::header::CONTENT_TYPE; use crate::Error; /// Represents the value for HTTP headers to show a form-encoded response. pub static CONTENT_TYPE_FORM_URLENCODED: &str = "application/x-www-form-urlencoded"; /// Represents the value for HTTP headers to show a JSON-encoded response. pub static CONTENT_TYPE_JSON: &str = "application/json"; #[derive(Clone)] #[derive(Default)] pub enum Body { Bytes(Vec), #[default] Empty, } impl std::fmt::Debug for Body { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Bytes(bytes) => f.debug_tuple("Bytes").field(&bytes.len()).finish(), Self::Empty => write!(f, "no bytes"), } } } impl From> for Body { fn from(value: Vec) -> Self { Self::Bytes(value) } } impl From for Body { fn from(value: String) -> Self { value.into_bytes().into() } } impl Body { pub fn as_bytes(&self) -> &[u8] { match self { Self::Empty => &[], Self::Bytes(b) => b, } } pub fn text(&self) -> Result { String::from_utf8(self.as_bytes().to_vec()).map_err(Error::FromUTF8) } } pub(crate) fn from_json_value( response: Response, ) -> Result { let ct_header = String::from_utf8( response .headers() .get(CONTENT_TYPE) .map(|hv| hv.as_bytes()) .unwrap_or_default() .to_vec(), )?; if ct_header.starts_with(CONTENT_TYPE_JSON) { Ok(serde_json::from_slice(response.into_body().as_bytes())?) } else { Err(Error::ResponseNotJson(ct_header)) } } #[async_trait::async_trait] pub trait Client: Send + Sync { async fn send_request(&self, request: Request) -> Result, Error>; } #[cfg(feature = "reqwest")] pub mod reqwest { use std::time::Duration; use futures::TryFutureExt; fn into_local_request( request: ::http::Request, ) -> Result { let method = reqwest::Method::from_bytes(request.method().as_str().as_bytes()) .map_err(crate::Error::ReqwestMethod)?; let url = request.uri().to_string().parse()?; let mut req = reqwest::Request::new(method, url); for (name, value) in request.headers() { req.headers_mut().insert(name, value.clone()); } let _ = req .body_mut() .insert(request.into_body().as_bytes().to_vec().into()); Ok(req) } async fn into_local_response( response: reqwest::Response, ) -> Result<::http::Response, crate::Error> { let mut resp = ::http::Response::builder().status(response.status().as_u16()); for (name, value) in response.headers() { resp = resp.header(name, value); } let body = response .bytes() .await .map_err(crate::Error::from)? .to_vec() .into(); resp.body(body).map_err(crate::Error::Http) } pub struct Client(::reqwest::Client); impl std::default::Default for Client { fn default() -> Self { Self( ::reqwest::Client::builder() .timeout(Duration::from_secs(5)) .build() .expect("failed to build a http client"), ) } } impl From<::reqwest::Client> for Client { fn from(client: ::reqwest::Client) -> Self { Self(client) } } #[async_trait::async_trait] impl super::Client for Client { #[tracing::instrument(skip(self))] async fn send_request( &self, request: ::http::Request, ) -> Result<::http::Response, crate::Error> { let local_request = into_local_request(request)?; let resp = self.0.execute(local_request).await; match resp { Ok(resp) => into_local_response(resp).await, Err(err) => Err(Box::new(err).into()), } } } #[cfg(feature = "reqwest_middleware")] pub struct MiddlewareClient(::reqwest_middleware::ClientWithMiddleware); #[cfg(feature = "reqwest_middleware")] impl From<::reqwest_middleware::ClientWithMiddleware> for MiddlewareClient { fn from(client: ::reqwest_middleware::ClientWithMiddleware) -> Self { Self(client) } } #[cfg(feature = "reqwest_middleware")] #[async_trait::async_trait] impl super::Client for MiddlewareClient { async fn send_request( &self, request: ::http::Request, ) -> Result<::http::Response, crate::Error> { let local_request = into_local_request(request)?; self.0 .execute(local_request) .map_err(crate::Error::ReqwestMiddleware) .and_then(into_local_response) .await } } } indieweb-rust-v0.6.0/library/src/lib.rs000066400000000000000000000032131511645615100200370ustar00rootroot00000000000000//! This crate provides implementations of the [standards][standards] and [algorithms][algorithms] used with the IndieWeb. //! //! More information about what's available is in either the [algorithms][algorithms] or //! [standards][standards] module. A required trait to use is the [HTTP Client][http::Client] //! if you'd like to use your own networking stack that's compatible with [http][::http]. This //! library also provides some [traits][traits] to extend common values with IndieWeb-adjacent //! capabilities. #![warn(invalid_doc_attributes, unused, deprecated, clippy::perf)] #![deny(rustdoc::broken_intra_doc_links, dead_code, unsafe_code)] /// A collection of algorithms commonly used in the IndieWeb. /// This module provides a collection of implementation of known /// algorithms when working with the IndieWeb or adjacent tooling. pub mod algorithms; /// A representation of errors from the IndieWeb error. pub mod error; /// A facade for HTTP interactions when working with this library. pub mod http; /// A collection of standards that the IndieWeb can support. /// /// View for more information. pub mod standards; /// Traits to extend everyday functionality with IndieWeb-adjacent tooling. pub mod traits; mod test; #[doc(inline)] pub use error::Error; pub mod mf2 { #[derive(thiserror::Error, Debug)] pub enum Error { #[error(transparent)] Parser(#[from] parser::Error), #[error(transparent)] Types(#[from] types::Error), } #[doc(inline)] pub use microformats as parser; #[doc(inline)] pub use microformats::types; } #[doc(inline)] pub use serde_qs as qs; indieweb-rust-v0.6.0/library/src/standards/000077500000000000000000000000001511645615100207075ustar00rootroot00000000000000indieweb-rust-v0.6.0/library/src/standards/indieauth/000077500000000000000000000000001511645615100226615ustar00rootroot00000000000000indieweb-rust-v0.6.0/library/src/standards/indieauth/mod.rs000066400000000000000000000754761511645615100240310ustar00rootroot00000000000000use std::str::FromStr; use base64::Engine; use http::header::{ACCEPT, CONTENT_TYPE}; use rand::rand_core::OsError; use rand::Rng; use rand::TryRngCore; pub use secrecy::ExposeSecret; use secrecy::SecretString; use sha2::Digest; use sha2::Sha256; use sha2::Sha512; use url::Url; use crate::http::CONTENT_TYPE_FORM_URLENCODED; use crate::{ algorithms::link_rel, http::{Body, CONTENT_TYPE_JSON}, traits::as_string_or_list, }; #[derive(thiserror::Error, Debug, miette::Diagnostic)] pub enum Error { #[error("No metadata endpoint could be found.")] NoMetadataEndpoint, #[error("The metadata endpoint did not declare itself as JSON but as {content_type:?}")] MetadataContentTypeInvalid { content_type: String }, #[error("Failed to parse the JSON of the metadata endpoint.")] ParseMetadataJson(#[source] serde_json::Error), #[error("Client IDs using IndieAuth have to be a URL.")] ClientIdMustBeUrl, #[error("A scope can't be an empty string.")] EmptyScope, #[error("The size of the raw challenge does not comply with the specification.")] ChallengeOutOfBounds(usize), #[error("Failed to parse the JSON of the redemption response.")] ParseRedemption { #[source] source: serde_json::Error, body: String, }, #[error("Failed to parse the JSON of the metadata endpoint.")] ParseTokenRedemption(#[source] serde_json::Error), #[error("The redemeption endpoint at {url:?} did not declare itself as JSON but as {content_type:?}")] RedemptionResponseContentType { url: Url, content_type: String }, #[error("Failed to invoke the random number generator: {0:#?}")] Random(OsError), #[error("The client ID must be a HTTP-based URL.")] ClientIdMustBeHttp, } #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)] pub struct Scope(String); #[derive(Clone, Debug, serde::Deserialize)] pub struct AccessToken(SecretString); impl std::ops::Deref for AccessToken { type Target = SecretString; fn deref(&self) -> &Self::Target { &self.0 } } impl AccessToken { pub fn new(token: impl ToString) -> Self { Self(SecretString::new(token.to_string().into_boxed_str())) } } impl Scope { pub fn new(scope: impl ToString) -> Result { let scope_str = scope.to_string(); if scope_str.is_empty() { return Err(Error::EmptyScope.into()); } Ok(Self(scope_str)) } } impl From for Scope { fn from(scope: String) -> Self { Self(scope) } } impl std::ops::Deref for Scope { type Target = String; fn deref(&self) -> &Self::Target { &self.0 } } /// A representation of a list of [scopes][Scope]. /// /// This structure allows for the normalization of common behaviors when working /// with scopes. /// /// # Examples /// ``` /// # use indieweb::standards::indieauth::Scopes; /// # use std::str::FromStr; /// # /// let scopes = Scopes::from_str("read create:read").unwrap(); /// /// assert!(scopes.has("read"), /// "can report if it has a scope"); /// /// assert_eq!(scopes.assert("aircraft"), /// Err(indieweb::Error::MissingScope(Scopes::from_str("aircraft").unwrap())), /// "can report if it does not have a scope"); /// /// assert_eq!(scopes.to_string(), /// "read create:read".to_string(), /// "easy to pass as a string"); /// ``` #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct Scopes(Vec); impl std::fmt::Display for Scopes { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str( &self .0 .iter() .map(|s| s.to_string()) .collect::>() .join(" "), ) } } impl FromStr for Scopes { type Err = crate::Error; fn from_str(scopes_string: &str) -> Result { scopes_string .split(' ') .try_fold(Vec::default(), |mut acc, scope_str| { acc.push(Scope::new(scope_str)?); Ok(acc) }) .map(Self) } } impl Scopes { /// Reports if this has no scopes defined. /// /// ``` /// # use indieweb::standards::indieauth::Scopes; /// # use std::str::FromStr; /// assert_eq!( /// Ok(false), /// Scopes::from_str("read") /// .map(|scopes| scopes.is_empty()), /// "there's at least one scope in here" /// ) /// ``` pub fn is_empty(&self) -> bool { self.0.is_empty() } /// Confirms if there's a scope matching or beginning to match with the provided value. /// /// This works with matching a direct scope (like 'create') or a scope with prefixes in /// its authored order (like 'update:note'). There's no real "format" to what a scope /// can look like, that's completely up to your implementation. For example, capitalist /// companies like [Slack](https://en.wikipedia.org/wiki/Slack_(software)) use a combination /// of colons and dots for scopes (like "files.metadata:read") and monopolies like /// [Google](https://en.wikipedia.org/wiki/Google) use URLs as scopes. /// /// # Examples /// /// ``` /// # use indieweb::standards::indieauth::Scopes; /// # use std::str::FromStr; /// # /// let scopes = Scopes::from_str("read update:note").unwrap(); /// assert!(scopes.has("read")); /// assert!(scopes.has("update:note")); /// ``` pub fn has(&self, scope: &str) -> bool { self.0.iter().any(|s| s.to_string().starts_with(scope)) } /// Returns the scopes as a list. pub fn as_vec(&self) -> &Vec { &self.0 } /// Confirm the presence of a provided scope. /// /// # Examples /// /// ``` /// # use indieweb::standards::indieauth::Scopes; /// # use std::str::FromStr; /// # /// let scopes = "read create:note".parse::(); /// /// assert_eq!( /// Ok(()), /// scopes.as_ref().unwrap().assert("read"), /// "confirms explicit scope match without prefix"); /// assert_eq!( /// Ok(()), /// scopes.as_ref().unwrap().assert("create:note"), /// "confirms explicit scope match with prefix"); /// assert_eq!( /// Err(indieweb::Error::MissingScope(Scopes::from_str("airplane").unwrap())), /// scopes.as_ref().unwrap().assert("create:note airplane"), /// "denies provided scopes if one is not valid"); /// ``` pub fn assert(&self, needed_scopes: &str) -> Result<(), crate::Error> { tracing::debug!("Asserting that {:?} exists in {:?}", needed_scopes, self); let scope_list: Scopes = needed_scopes.parse().unwrap_or_else(|_| Scopes::default()); let missing_scopes: Scopes = scope_list .as_vec() .iter() .filter(|expected_scope| !self.has(expected_scope)) .cloned() .collect::>() .into(); if missing_scopes.is_empty() { Ok(()) } else { Err(crate::Error::MissingScope(missing_scopes)) } } /// Returns a list of scopes that are matching the list of scopes; a union of the two lists. /// /// # Examples /// /// ``` /// # use indieweb::standards::indieauth::Scopes; /// # use std::str::FromStr; /// /// let wanted_scopes = Scopes::from_str("read update:note").unwrap(); /// let scopes = Scopes::from_str("read create update:note").unwrap(); /// assert_eq!(scopes.matching("read update:note"), wanted_scopes); /// ``` pub fn matching(&self, wanted_scopes: &str) -> Self { wanted_scopes .parse::() .unwrap_or_default() .as_vec() .iter() .filter(|expected_scope| self.has(expected_scope)) .cloned() .collect::() } pub fn minimal() -> Self { Self(vec![Scope("read".to_string())]) } } impl From> for Scopes { fn from(scopes: Vec) -> Self { scopes.into_iter().map(Scope::from).collect() } } impl FromIterator for Scopes { fn from_iter>(iter: T) -> Self { Self(iter.into_iter().collect::>()) } } impl From> for Scopes { fn from(scopes: Vec) -> Self { Self(scopes) } } impl From for Vec { fn from(val: Scopes) -> Self { val.0 } } impl serde::Serialize for Scopes { fn serialize(&self, serializer: S) -> Result where S: serde::Serializer, { serializer.serialize_str(&self.to_string()) } } impl<'de> serde::de::Deserialize<'de> for Scopes { fn deserialize(deserializer: D) -> Result where D: serde::Deserializer<'de>, { as_string_or_list::deserialize(deserializer)? .into_iter() .filter(|s: &String| !s.is_empty()) .collect::>() .join(" ") .parse() .map_err(serde::de::Error::custom) } } #[derive(Clone, serde::Serialize, serde::Deserialize, Debug, PartialEq, Eq)] pub enum CodeChallengeMethod { S256, S512, Plain, } impl CodeChallengeMethod { pub fn recommended() -> Self { Self::S256 } pub fn validate( &self, CodeChallenge { challenge, .. }: &CodeChallenge, verifier: &str, ) -> bool { let CodeChallenge { challenge: expected_challenge, .. } = CodeChallenge::from_verifier(self, verifier.to_string()); expected_challenge == *challenge } } #[allow(clippy::to_string_trait_impl)] impl ToString for CodeChallengeMethod { fn to_string(&self) -> String { match self { Self::S256 => "S256".to_string(), Self::S512 => "S512".to_string(), Self::Plain => "PLAIN".to_string(), } } } #[derive(Clone, Debug, PartialEq, Eq)] pub struct CodeChallenge { challenge: String, verifier: String, } impl serde::Serialize for CodeChallenge { fn serialize(&self, serializer: S) -> Result where S: serde::Serializer, { self.challenge.serialize(serializer) } } impl<'de> serde::Deserialize<'de> for CodeChallenge { fn deserialize(deserializer: D) -> Result where D: serde::Deserializer<'de>, { Ok(Self { challenge: String::deserialize(deserializer)?, verifier: Default::default(), }) } } impl std::ops::Deref for CodeChallenge { type Target = String; fn deref(&self) -> &Self::Target { &self.challenge } } impl CodeChallenge { pub fn from_verifier(method: &CodeChallengeMethod, verifier: String) -> Self { let challenge_bytes = match method { CodeChallengeMethod::S256 => { let mut hasher = Sha256::new(); hasher.update(verifier.as_bytes()); hasher.finalize().to_vec() } CodeChallengeMethod::S512 => { let mut hasher = Sha512::new(); hasher.update(verifier.as_bytes()); hasher.finalize().to_vec() } CodeChallengeMethod::Plain => verifier.as_bytes().to_vec(), }; let challenge = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(challenge_bytes); Self { challenge, verifier, } } pub fn generate(method: CodeChallengeMethod) -> Result<(Self, CodeChallengeMethod), Error> { let mut bytes = [0u8; 64]; let mut rng = rand::rngs::OsRng {}; rng.try_fill_bytes(&mut bytes[..]).map_err(Error::Random)?; let verifier = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes); let challenge = Self::from_verifier(&method, verifier); Ok((challenge, method)) } pub fn verifier(&self) -> &str { &self.verifier } } #[derive(serde::Deserialize, serde::Serialize, Debug, Clone, PartialEq, Eq)] pub struct AuthorizationRequestFields { pub client_id: ClientId, pub redirect_uri: RedirectUri, pub state: String, #[serde(rename = "code_challenge")] pub challenge: CodeChallenge, #[serde(rename = "code_challenge_method")] pub challenge_method: CodeChallengeMethod, #[serde(rename = "scope", default = "Scopes::minimal")] pub scope: Scopes, } impl AuthorizationRequestFields { /// Forms a new authorization request for the intending client to the expecting location. pub fn new( client_id: &ClientId, redirect_uri: &Url, state: impl ToString, ) -> Result { let (code_challenge, code_challenge_method) = CodeChallenge::generate(CodeChallengeMethod::recommended())?; Ok(Self { client_id: client_id.clone(), redirect_uri: redirect_uri.clone().into(), state: state.to_string(), challenge: code_challenge, challenge_method: code_challenge_method, scope: Default::default(), }) } pub fn into_authorization_url( self, authorization_endpoint_url: impl Into, extra_fields: Vec<(String, String)>, ) -> Result { let mut signed_authorization_endpoint_url = authorization_endpoint_url.into(); let mut fields = signed_authorization_endpoint_url.query_pairs_mut(); fields .append_pair("response_type", "code") .append_pair("client_id", &self.client_id) .append_pair("redirect_uri", self.redirect_uri.as_str()) .append_pair("state", &self.state) .append_pair("code_challenge", &self.challenge) .append_pair("code_challenge_method", &self.challenge_method.to_string()); if !self.scope.is_empty() { fields.append_pair("scope", &self.scope.to_string()); } for (name, value) in extra_fields { fields.append_pair(&name, &value); } fields.finish(); drop(fields); Ok(signed_authorization_endpoint_url) } } #[derive(Clone, serde::Deserialize, Debug)] pub struct RedemptionFields { pub code: String, pub client_id: ClientId, pub redirect_uri: RedirectUri, #[serde(rename = "code_verifier")] pub verifier: String, } impl RedemptionFields { pub fn into_query_parameters(self) -> Vec<(String, String)> { vec![ ("grant_type".to_string(), "authorization_code".to_string()), ("code".to_string(), self.code), ("client_id".to_string(), self.client_id.0), ("redirect_uri".to_string(), self.redirect_uri.to_string()), ("code_verifier".to_string(), self.verifier), ] } } #[derive(serde::Deserialize, serde::Serialize, Debug, Clone, PartialEq, Eq)] pub struct ServerMetadata { pub issuer: Url, pub authorization_endpoint: Url, pub token_endpoint: Url, #[serde(default, skip_serializing_if = "Option::is_none")] pub ticket_endpoint: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub introspection_endpoint: Option, #[serde(default = "ServerMetadata::recommended_code_challenge_methods")] pub code_challenge_methods_supported: Vec, #[serde(default, skip_serializing_if = "Scopes::is_empty")] pub scopes_supported: Scopes, } impl ServerMetadata { pub fn recommended_code_challenge_methods() -> Vec { vec!["S256".to_string()] } /// A helper method for starting an authorization request with a [request payload][AuthorizationRequestPayload]. pub fn new_authorization_request_url( &self, request: AuthorizationRequestFields, extra_fields: Vec<(String, String)>, ) -> Result { Ok(request.into_authorization_url(self.authorization_endpoint.clone(), extra_fields)?) } } #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct ClientId(String); impl ClientId { pub fn new(client_id: impl ToString) -> Result { let client_id_str = client_id.to_string(); if !(client_id_str.starts_with("http://") || client_id_str.starts_with("https://")) { return Err(Error::ClientIdMustBeHttp.into()); } if client_id_str.parse::().is_ok() { Ok(Self(client_id_str)) } else { Err(Error::ClientIdMustBeUrl.into()) } } } #[cfg(any(test, feature = "fake"))] impl fake::Dummy for ClientId { fn dummy_with_rng(_: &fake::Faker, rng: &mut R) -> Self { use fake::Fake; let subdomain: Vec = fake::faker::lorem::en::Words(3..5).fake_with_rng(rng); Self(format!("http://{}.example.com", subdomain.join("-"))) } } impl std::ops::Deref for ClientId { type Target = String; fn deref(&self) -> &Self::Target { &self.0 } } #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct RedirectUri(Url); impl std::ops::Deref for RedirectUri { type Target = Url; fn deref(&self) -> &Self::Target { &self.0 } } impl From for RedirectUri { fn from(value: Url) -> Self { Self(value) } } #[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum ResponseErrorCode { InvalidRequest, UnauthorizedClient, AccessDenied, UnsupportedResponseType, InvalidScope, ServerError, TemporarilyUnavailable, } impl std::fmt::Display for ResponseErrorCode { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(match self { ResponseErrorCode::InvalidRequest => "invalid_request", ResponseErrorCode::UnauthorizedClient => "unauthorized_client", ResponseErrorCode::AccessDenied => "access_denied", ResponseErrorCode::UnsupportedResponseType => "unsupported_response_type", ResponseErrorCode::InvalidScope => "invalid_scope", ResponseErrorCode::ServerError => "server_error", ResponseErrorCode::TemporarilyUnavailable => "temporarily_unavailable", }) } } #[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)] pub struct RedirectErrorFields { pub state: String, #[serde(rename = "error")] pub reason: ResponseErrorCode, #[serde(rename = "error_description", skip_serializing_if = "Option::is_none")] pub description: Option, #[serde(rename = "error_uri", skip_serializing_if = "Option::is_none")] pub uri: Option, } impl RedirectErrorFields { pub fn into_query_parameters(self) -> Vec<(String, String)> { let mut qps = Vec::default(); qps.push(("state".to_string(), self.state)); if let Some(description) = self.description { qps.push(("error_description".to_string(), description)); } if let Some(uri) = self.uri { qps.push(("error_uri".to_string(), uri.to_string())); } qps.push(("error".to_string(), self.reason.to_string())); qps } } #[derive(Clone, serde::Serialize, serde::Deserialize, Debug, PartialEq, Eq)] pub struct SignedRedirectFields { pub state: String, pub code: String, #[serde(rename = "iss")] pub issuer: Url, } impl SignedRedirectFields { pub fn into_query_parameters(self) -> Vec<(String, String)> { vec![ ("state".to_string(), self.state), ("code".to_string(), self.code), ("iss".to_string(), self.issuer.to_string()), ] } } impl RedirectUri { pub fn for_approved_request(&self, fields: SignedRedirectFields) -> Url { let mut u = self.0.clone(); let mut qp = u.query_pairs_mut(); qp.extend_pairs(fields.into_query_parameters()); qp.finish(); drop(qp); u } pub fn for_rejected_request(&self, fields: RedirectErrorFields) -> Url { let mut u = self.0.clone(); let mut qp = u.query_pairs_mut(); qp.extend_pairs(fields.into_query_parameters()); qp.finish(); drop(qp); u } } #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)] pub enum RedemptionTokenType { Bearer, } #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub struct RedemptionClaim { pub token_type: RedemptionTokenType, pub access_token: String, pub scope: Scopes, pub me: Url, #[serde(default)] pub expires_in: i64, #[serde(default, skip_serializing_if = "Option::is_none")] pub refresh_token: Option, pub payload: Payload, } #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub struct RedemptionError { #[serde(rename = "error")] pub code: ResponseErrorCode, #[serde( default, skip_serializing_if = "Option::is_none", rename = "error_description" )] pub description: Option, #[serde(default, skip_serializing_if = "Option::is_none", rename = "error_uri")] pub uri: Option, } #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)] #[serde(untagged, rename_all = "snake_case")] pub enum RedemptionResponse { /// A [successful response][RedemptionClaim] from a code redemption endpoint. Claim(RedemptionClaim), /// A [failing resonse][RedemptionError] from a code redemption endpoint. Error(RedemptionError), } #[derive(serde::Deserialize, serde::Serialize, Debug, Clone, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub struct ProfileRedemptionFields { pub name: String, pub url: Url, #[serde(skip_serializing_if = "Option::is_none")] pub photo: Option, #[serde(skip_serializing_if = "Option::is_none")] pub email: Option, } #[derive(serde::Deserialize, serde::Serialize, Default, Debug, Clone, PartialEq, Eq)] #[allow(clippy::large_enum_variant)] #[serde(untagged, rename_all = "snake_case")] pub enum CommonRedemptionFields { Profile(ProfileRedemptionFields), #[default] Empty, } pub struct Client { pub id: ClientId, client: HttpClient, } impl Client { const LINK_REL: &'static str = "indieauth-metadata"; pub fn new(id: impl ToString, http_client: HttpClient) -> Result { Ok(Self { id: ClientId(id.to_string()), client: http_client, }) } pub async fn obtain_metadata(&self, remote_url: &Url) -> Result { // First, try HEAD request for all rel types let head_rels = link_rel::for_url( &self.client, remote_url, &[Self::LINK_REL, "authorization_endpoint", "token_endpoint"], "HEAD", ) .await?; let mut rels = head_rels .get(Self::LINK_REL) .cloned() .unwrap_or_default(); let mut individual_endpoints = ( head_rels .get("authorization_endpoint") .cloned() .unwrap_or_default(), head_rels .get("token_endpoint") .cloned() .unwrap_or_default(), ); // If we found individual endpoints from HEAD and no metadata, use them if rels.is_empty() && !individual_endpoints.0.is_empty() && !individual_endpoints.1.is_empty() && let (Some(auth_endpoint), Some(token_endpoint)) = (individual_endpoints.0.first(), individual_endpoints.1.first()) { let issuer = if remote_url.has_authority() { format!("{}://{}", remote_url.scheme(), remote_url.authority()) } else { format!( "{}://{}", remote_url.scheme(), remote_url.host_str().unwrap_or_default() ) } .parse()?; let metadata = ServerMetadata { issuer, authorization_endpoint: auth_endpoint.clone(), token_endpoint: token_endpoint.clone(), ticket_endpoint: None, introspection_endpoint: None, code_challenge_methods_supported: ServerMetadata::recommended_code_challenge_methods(), scopes_supported: Scopes::default(), }; return Ok(metadata); } if rels.is_empty() { // If HEAD didn't find indieauth-metadata, try GET for all rel types let all_rels = link_rel::for_url( &self.client, remote_url, &[Self::LINK_REL, "authorization_endpoint", "token_endpoint"], "GET", ) .await?; // Check for indieauth-metadata first rels = all_rels .get(Self::LINK_REL) .cloned() .unwrap_or_default(); // Update individual endpoints (GET might find more in body) let auth_endpoints = all_rels .get("authorization_endpoint") .cloned() .unwrap_or_default(); let token_endpoints = all_rels .get("token_endpoint") .cloned() .unwrap_or_default(); individual_endpoints = (auth_endpoints, token_endpoints); } let metadata_endpoint_url = if let Some(rel) = rels.first().cloned() { rel } else { let well_known_url: Url = if remote_url.has_authority() { format!( "{}://{}/.well-known/oauth-authorization-server", remote_url.scheme(), remote_url.authority() ) } else { format!( "{}://{}/.well-known/oauth-authorization-server", remote_url.scheme(), remote_url.host_str().unwrap_or_default() ) } .parse()?; let req = crate::http::Request::builder() .uri(well_known_url.as_str()) .header(ACCEPT, CONTENT_TYPE_JSON) .body(Body::Empty)?; let resp = self.client.send_request(req).await?; let content_type_header_value = resp .headers() .get(CONTENT_TYPE) .and_then(|hv| hv.to_str().ok()) .unwrap_or_default(); if content_type_header_value.starts_with(CONTENT_TYPE_JSON) { let body = resp.body(); if let Ok(metadata) = serde_json::from_slice::(body.as_bytes()) { return Ok(metadata); } } // Fallback: Try to find individual authorization_endpoint and token_endpoint rels // (already fetched in the GET request above) let (auth_endpoints, token_endpoints) = &individual_endpoints; if let (Some(auth_endpoint), Some(token_endpoint)) = (auth_endpoints.first(), token_endpoints.first()) { // Construct issuer URL from the profile URL let issuer = if remote_url.has_authority() { format!("{}://{}", remote_url.scheme(), remote_url.authority()) } else { format!( "{}://{}", remote_url.scheme(), remote_url.host_str().unwrap_or_default() ) } .parse()?; let metadata = ServerMetadata { issuer, authorization_endpoint: auth_endpoint.clone(), token_endpoint: token_endpoint.clone(), ticket_endpoint: None, introspection_endpoint: None, code_challenge_methods_supported: ServerMetadata::recommended_code_challenge_methods(), scopes_supported: Scopes::default(), }; return Ok(metadata); } return Err(Error::NoMetadataEndpoint.into()); }; let req = crate::http::Request::builder() .uri(metadata_endpoint_url.as_str()) .header(ACCEPT, CONTENT_TYPE_JSON) .body(Body::Empty)?; let resp = self .client .send_request(req) .await? .map(|body| body.as_bytes().to_vec()); let content_type_header_value = resp .headers() .get(CONTENT_TYPE) .map(|hv| hv.to_str().unwrap_or_default().to_string()) .unwrap_or_default(); if !content_type_header_value.starts_with(CONTENT_TYPE_JSON) { return Err(Error::MetadataContentTypeInvalid { content_type: content_type_header_value, } .into()); } Ok(serde_json::from_slice(&resp.into_body()).map_err(Error::ParseMetadataJson)?) } pub async fn redeem( &self, endpoint: &Url, redemption_fields: RedemptionFields, ) -> Result, crate::Error> where R: serde::de::DeserializeOwned, { let fields = redemption_fields .into_query_parameters() .into_iter() .map(|(name, value)| format!("{name}={value}")) .collect::>() .join("&"); let req = crate::http::Request::builder() .uri(endpoint.as_str()) .method("POST") .header(ACCEPT, CONTENT_TYPE_JSON) .header(CONTENT_TYPE, CONTENT_TYPE_FORM_URLENCODED) .body(Body::Bytes(fields.into_bytes()))?; let resp = self .client .send_request(req) .await? .map(|b| b.as_bytes().to_vec()); let content_type_header_value = resp .headers() .get(CONTENT_TYPE) .map(|hv| hv.to_str().unwrap_or_default().to_string()) .unwrap_or_default(); let body_str = String::from_utf8(resp.body().to_vec())?; dbg!(&body_str); if !content_type_header_value.starts_with(CONTENT_TYPE_JSON) { return Err(Error::RedemptionResponseContentType { url: endpoint.to_owned(), content_type: content_type_header_value, } .into()); } serde_json::from_str(&body_str).map_err(|e| { Error::ParseRedemption { source: e, body: body_str, } .into() }) } } mod test; indieweb-rust-v0.6.0/library/src/standards/indieauth/test.rs000066400000000000000000000330561511645615100242150ustar00rootroot00000000000000#![cfg(test)] use http::header::{CONTENT_TYPE, LINK}; use miette::IntoDiagnostic; use url::Url; use crate::{ http::CONTENT_TYPE_JSON, standards::indieauth::{Scopes, ServerMetadata}, }; use super::{ClientId, CodeChallenge}; #[tokio::test] async fn obtain_metadata_none_found() -> miette::Result<()> { let mut server = mockito::Server::new_async().await; let html_remote_page_mock = server .mock("GET", "/profile") .expect_at_most(1) .create_async() .await; let headers_remote_page_mock = server .mock("HEAD", "/profile") .expect_at_most(1) .create_async() .await; let client = super::Client::new( "http://example.com", crate::http::reqwest::Client::default(), )?; let remote_url = format!("{}/profile", server.url()).parse().unwrap(); let resp = client.obtain_metadata(&remote_url).await; assert_eq!( resp, Err(super::Error::NoMetadataEndpoint.into()), "expected no metadata anywhere due to lack of an endpoint" ); headers_remote_page_mock.assert_async().await; html_remote_page_mock.assert_async().await; Ok(()) } #[tokio::test] async fn obtain_metadata_via_individual_endpoints() -> miette::Result<()> { let mut server = mockito::Server::new_async().await; let server_url = server.url(); let html_remote_page_mock = server .mock("GET", "/profile") .expect_at_most(1) .with_body(format!( r#" "# )) .create_async() .await; let headers_remote_page_mock = server .mock("HEAD", "/profile") .expect_at_most(1) .create_async() .await; let client = super::Client::new( "http://example.com", crate::http::reqwest::Client::default(), )?; let remote_url = format!("{}/profile", server.url()).parse().unwrap(); let resp = client.obtain_metadata(&remote_url).await; headers_remote_page_mock.assert_async().await; html_remote_page_mock.assert_async().await; let metadata = resp?; assert_eq!(metadata.issuer, server.url().parse().unwrap()); assert_eq!( metadata.authorization_endpoint, format!("{}/auth", server.url()).parse().unwrap() ); assert_eq!( metadata.token_endpoint, format!("{}/token", server.url()).parse().unwrap() ); assert_eq!(metadata.scopes_supported, super::Scopes::default()); assert_eq!( metadata.code_challenge_methods_supported, super::ServerMetadata::recommended_code_challenge_methods() ); Ok(()) } #[tokio::test] async fn obtain_metadata_via_individual_endpoints_headers() -> miette::Result<()> { let mut server = mockito::Server::new_async().await; let server_url = server.url(); let html_remote_page_mock = server .mock("GET", "/profile") .expect_at_most(0) .create_async() .await; let headers_remote_page_mock = server .mock("HEAD", "/profile") .with_header( LINK, &format!( r#"<{}/auth>; rel="authorization_endpoint""#, server.url() ), ) .with_header( LINK, &format!(r#"<{}/token>; rel="token_endpoint""#, server.url()), ) .expect_at_most(1) .create_async() .await; let client = super::Client::new( "http://example.com", crate::http::reqwest::Client::default(), )?; let remote_url = format!("{}/profile", server.url()).parse().unwrap(); let resp = client.obtain_metadata(&remote_url).await; headers_remote_page_mock.assert_async().await; html_remote_page_mock.assert_async().await; let metadata = resp?; assert_eq!(metadata.issuer, server.url().parse().unwrap()); assert_eq!( metadata.authorization_endpoint, format!("{}/auth", server.url()).parse().unwrap() ); assert_eq!( metadata.token_endpoint, format!("{}/token", server.url()).parse().unwrap() ); Ok(()) } #[tokio::test] async fn obtain_metadata_individual_endpoints_missing_auth() -> miette::Result<()> { let mut server = mockito::Server::new_async().await; let server_url = server.url(); let html_remote_page_mock = server .mock("GET", "/profile") .expect_at_most(1) .with_body(format!( r#" "# )) .create_async() .await; let headers_remote_page_mock = server .mock("HEAD", "/profile") .expect_at_most(1) .create_async() .await; let client = super::Client::new( "http://example.com", crate::http::reqwest::Client::default(), )?; let remote_url = format!("{}/profile", server.url()).parse().unwrap(); let resp = client.obtain_metadata(&remote_url).await; headers_remote_page_mock.assert_async().await; html_remote_page_mock.assert_async().await; assert_eq!( resp, Err(super::Error::NoMetadataEndpoint.into()), "expected no metadata when authorization_endpoint is missing" ); Ok(()) } #[tokio::test] async fn obtain_metadata_individual_endpoints_missing_token() -> miette::Result<()> { let mut server = mockito::Server::new_async().await; let server_url = server.url(); let html_remote_page_mock = server .mock("GET", "/profile") .expect_at_most(1) .with_body(format!( r#" "# )) .create_async() .await; let headers_remote_page_mock = server .mock("HEAD", "/profile") .expect_at_most(1) .create_async() .await; let client = super::Client::new( "http://example.com", crate::http::reqwest::Client::default(), )?; let remote_url = format!("{}/profile", server.url()).parse().unwrap(); let resp = client.obtain_metadata(&remote_url).await; headers_remote_page_mock.assert_async().await; html_remote_page_mock.assert_async().await; assert_eq!( resp, Err(super::Error::NoMetadataEndpoint.into()), "expected no metadata when token_endpoint is missing" ); Ok(()) } #[tokio::test] async fn obtain_metadata_individual_endpoints_relative_urls() -> miette::Result<()> { let mut server = mockito::Server::new_async().await; let server_url = server.url(); let html_remote_page_mock = server .mock("GET", "/profile") .expect_at_most(1) .with_body( r#" "# .to_string(), ) .create_async() .await; let headers_remote_page_mock = server .mock("HEAD", "/profile") .expect_at_most(1) .create_async() .await; let client = super::Client::new( "http://example.com", crate::http::reqwest::Client::default(), )?; let remote_url = format!("{}/profile", server.url()).parse().unwrap(); let resp = client.obtain_metadata(&remote_url).await; headers_remote_page_mock.assert_async().await; html_remote_page_mock.assert_async().await; let metadata = resp?; assert_eq!(metadata.issuer, server.url().parse().unwrap()); assert_eq!( metadata.authorization_endpoint, format!("{}/auth", server.url()).parse().unwrap() ); assert_eq!( metadata.token_endpoint, format!("{}/token", server.url()).parse().unwrap() ); Ok(()) } #[tokio::test] async fn obtain_metadata_fallback_priority() -> miette::Result<()> { let mut server = mockito::Server::new_async().await; let server_url = server.url(); let metadata = super::ServerMetadata { issuer: server.url().parse().unwrap(), authorization_endpoint: format!("{server_url}/endpoints/auth").parse().unwrap(), token_endpoint: format!("{server_url}/endpoints/token").parse().unwrap(), ticket_endpoint: None, introspection_endpoint: None, code_challenge_methods_supported: super::ServerMetadata::recommended_code_challenge_methods(), scopes_supported: super::Scopes::minimal(), }; let html_remote_page_mock = server .mock("GET", "/profile") .expect_at_most(1) .with_body(format!( r#" "# )) .create_async() .await; let headers_remote_page_mock = server .mock("HEAD", "/profile") .expect_at_most(1) .create_async() .await; let metadata_endpoint_mock = server .mock("GET", "/metadata") .with_header(CONTENT_TYPE, CONTENT_TYPE_JSON) .with_body(serde_json::json!(metadata).to_string()) .expect_at_most(1) .create_async() .await; let client = super::Client::new( "http://example.com", crate::http::reqwest::Client::default(), )?; let remote_url = format!("{}/profile", server.url()).parse().unwrap(); let resp = client.obtain_metadata(&remote_url).await; headers_remote_page_mock.assert_async().await; metadata_endpoint_mock.assert_async().await; html_remote_page_mock.assert_async().await; // Should use metadata endpoint, not individual endpoints assert_eq!(resp, Ok(metadata)); Ok(()) } #[tokio::test] async fn obtain_metadata_via_endpoint_headers() -> miette::Result<()> { let mut server = mockito::Server::new_async().await; let server_url = server.url(); let var_name = ServerMetadata { issuer: server.url().parse().unwrap(), authorization_endpoint: format!("{server_url}/endpoints/auth").parse().unwrap(), token_endpoint: format!("{server_url}/endpoints/token").parse().unwrap(), ticket_endpoint: format!("{server_url}/endpoints/ticket").parse().ok(), introspection_endpoint: format!("{server_url}/endpoints/token").parse().ok(), code_challenge_methods_supported: ServerMetadata::recommended_code_challenge_methods(), scopes_supported: Scopes::minimal(), }; let metadata = var_name; let html_remote_page_mock = server .mock("GET", "/profile") .expect_at_most(0) .create_async() .await; let headers_remote_page_mock = server .mock("HEAD", "/profile") .with_header( LINK, &format!( r#"<{}/metadata>; rel="{}""#, server.url(), super::Client::::LINK_REL ), ) .expect_at_most(1) .create_async() .await; let metadata_endpoint_mock = server .mock("GET", "/metadata") .with_header(CONTENT_TYPE, CONTENT_TYPE_JSON) .with_body(serde_json::json!(metadata).to_string()) .expect_at_most(1) .create_async() .await; let client = super::Client::new( "http://example.com", crate::http::reqwest::Client::default(), )?; let remote_url = format!("{}/profile", server.url()).parse().unwrap(); let resp = client.obtain_metadata(&remote_url).await; headers_remote_page_mock.assert_async().await; html_remote_page_mock.assert_async().await; metadata_endpoint_mock.assert_async().await; assert_eq!( resp, Ok(metadata), "expected no metadata anywhere due to lack of an endpoint" ); Ok(()) } #[test] fn server_metadata_new_authorization_request_url() -> miette::Result<()> { let issuer: Url = "https://example.com".parse().unwrap(); let metadata = ServerMetadata { authorization_endpoint: format!("{}/auth", issuer.as_str()).parse().unwrap(), token_endpoint: format!("{}/token", issuer.as_str()).parse().unwrap(), ticket_endpoint: format!("{}/endpoints/ticket", issuer.as_str()).parse().ok(), introspection_endpoint: format!("{}/introspect", issuer.as_str()).parse().ok(), code_challenge_methods_supported: vec!["S256".to_string()], scopes_supported: Scopes::minimal(), issuer, }; let (code_challenge, code_challenge_method) = CodeChallenge::generate(super::CodeChallengeMethod::S256)?; let formed_url = metadata.new_authorization_request_url( super::AuthorizationRequestFields { client_id: ClientId::new("http://client.example.com")?, redirect_uri: "http://client.example.com/redirect" .parse::() .into_diagnostic()? .into(), state: "nu-state".to_string(), challenge: code_challenge, challenge_method: code_challenge_method, scope: Default::default(), }, Default::default(), )?; assert!( !formed_url.query().unwrap_or_default().contains("scope="), "does not includes an empty scope string" ); assert!( formed_url .query() .unwrap_or_default() .contains("state=nu-state"), "includes the provided state value" ); Ok(()) } indieweb-rust-v0.6.0/library/src/standards/micropub/000077500000000000000000000000001511645615100225275ustar00rootroot00000000000000indieweb-rust-v0.6.0/library/src/standards/micropub/action/000077500000000000000000000000001511645615100240045ustar00rootroot00000000000000indieweb-rust-v0.6.0/library/src/standards/micropub/action/mod.rs000066400000000000000000000655241511645615100251450ustar00rootroot00000000000000use secrecy::ExposeSecret; use std::collections::HashMap; use http::StatusCode; use crate::standards::indieauth::AccessToken; use serde::Deserialize; use crate::{ algorithms::Properties, http::{from_json_value, Client}, standards::micropub::Parameters, }; /// Represents the properties known to Micropub extensions. pub fn known_properties() -> Vec { vec![ "post-status".into(), "status".into(), "category".into(), "slug".into(), #[cfg(feature = "experimental_channels")] "channel".into(), #[cfg(feature = "experimental_syndication")] "syndicate-to".into(), "destination".into(), "audience".into(), "visibility".into(), ] } #[derive(serde::Serialize, Clone, Debug, PartialEq, Eq)] pub struct CreationProperties { pub r#type: microformats::types::Class, #[serde(flatten)] pub parameters: Parameters, #[serde(flatten)] pub extra_fields: Properties, } impl Default for CreationProperties { fn default() -> Self { Self { r#type: microformats::types::Class::Known(microformats::types::KnownClass::Entry), parameters: Default::default(), extra_fields: Default::default(), } } } impl<'de> serde::Deserialize<'de> for CreationProperties { fn deserialize(deserializer: D) -> Result where D: serde::de::Deserializer<'de>, { struct V {} impl V { fn extract_parameters(properties: &mut Properties) -> Result where E: serde::de::Error, { let known_props = known_properties(); let matching_parameters = properties .0 .iter() .filter(|(key, _)| { known_props.contains(key) || known_props.contains(&format!("mp-{}", key)) }) .map(|(k, v)| (k.to_owned(), v.to_owned())) .collect::>(); matching_parameters.keys().for_each(|key| { properties.remove_entry(key); }); let props: Properties = serde_json::from_value(serde_json::Value::Object(matching_parameters)) .map_err(serde::de::Error::custom)?; props.try_into().map_err(serde::de::Error::custom) } fn parse(mut root_properties: Properties) -> Result where E: serde::de::Error, { let (type_value, parameters, extra_fields) = if let Some(class_value) = root_properties.remove("h") { let parameters = Self::extract_parameters(&mut root_properties)?; root_properties.values_mut().for_each(|value| { if !value.is_array() { *value = serde_json::Value::Array(vec![value.clone()]); } }); (class_value, parameters, root_properties) } else if let Some(class_value) = root_properties.remove("type") { let mut properties: Properties = root_properties .remove(PROPERTY_PROPERTIES) .ok_or_else(|| serde::de::Error::missing_field(".properties")) .and_then(serde_json::from_value) .map_err(serde::de::Error::custom)?; let parameters = Self::extract_parameters(&mut properties)?; (class_value, parameters, properties) } else { return Err(serde::de::Error::unknown_variant( "encoding", &["json (via 'type')", "form (via 'h')"], )); }; let r#type = Self::extract_class_name(type_value)?; Ok(CreationProperties { r#type, parameters, extra_fields, }) } fn extract_class_name( value: serde_json::Value, ) -> Result where E: serde::de::Error, { let specific_value = if let serde_json::Value::Array(values) = value { values .into_iter() .find(|v| v.is_string()) .unwrap_or_default() } else { value }; serde_json::from_value(specific_value).map_err(serde::de::Error::custom) } } impl<'de> serde::de::Visitor<'de> for V { type Value = CreationProperties; fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { formatter.write_str("a map of values representing either a form-encoded or JSON-encoded Micropub creation payload") } fn visit_seq(self, mut seq: A) -> Result where A: serde::de::SeqAccess<'de>, A::Error: serde::de::Error, { while let Some(v) = seq.next_element()? { if let Ok(vz) = V::parse::(v) { return Ok(vz); } } Err(serde::de::Error::custom( "Failed to parse the sequence of values into a valid CreationProperties", )) } fn visit_map(self, mut map: A) -> Result where A: serde::de::MapAccess<'de>, { let mut root_properties = Properties::default(); while let Some((key, value)) = map.next_entry::()? { if let Some(original_value) = root_properties.get_mut(&key) { if let serde_json::Value::Array(values) = original_value { values.push(value); } else { *original_value = serde_json::Value::Array(vec![original_value.clone(), value]); } } else { root_properties.insert(key, value); } } V::parse(root_properties) } fn visit_newtype_struct(self, deserializer: D) -> Result where D: serde::de::Deserializer<'de>, { let root_properties = Properties::deserialize(deserializer)?; V::parse(root_properties) } } deserializer.deserialize_map(V {}) } } #[test] fn creation_properties_from_json() { let creation_properties = CreationProperties { parameters: Parameters { category: vec!["foo".into(), "bar".into()], status: Some(crate::standards::micropub::extension::PostStatus::Drafted), ..Default::default() }, extra_fields: Properties::try_from(serde_json::json!({ "content": ["hello world"] })) .expect("failed to build extra fields of creation properties"), r#type: microformats::types::Class::Known(microformats::types::KnownClass::Entry), }; assert_eq!( Ok(creation_properties), serde_json::from_value(serde_json::json!({ "type": ["h-entry"], "properties": { "content": ["hello world"], "category": ["foo", "bar"], "post-status": "draft" } })) .map_err(|e| e.to_string()) ); } #[derive(serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq, Eq)] #[serde(untagged, rename_all = "kebab-case")] pub enum UpdateOperation { Replace, Add, Delete, } #[derive(serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq, Eq)] #[serde(untagged, rename_all = "kebab-case")] pub enum OperationValue { WholeProperties(Vec), SpecificValues(HashMap>), } /// Represents all of the actions known to be taken by a Micropub server. #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] pub enum Action { /// Represented by . Create { properties: Box, files: HashMap>, }, /// . Update { url: url::Url, operation: (UpdateOperation, OperationValue), }, /// Delete(url::Url), /// Undelete(url::Url), } fn parse_location_into_url( header_value: Option, ) -> Result, crate::Error> { if let Some(location_value) = header_value { String::from_utf8(location_value.as_bytes().to_vec()) .map_err(crate::Error::FromUTF8)? .parse() .map_err(crate::Error::Url) .map(Option::Some) } else { Ok(None) } } fn obtain_url_from_location( header_value: Option, ) -> Result { parse_location_into_url(header_value)? .ok_or_else(|| super::Error::missing_header("location").into()) } fn handle_manipulation_response( response: http::Response, ) -> Result<(Option, Option), crate::Error> { let expected_codes = [StatusCode::OK, StatusCode::CREATED, StatusCode::NO_CONTENT]; if !expected_codes.contains(&response.status()) { return Err( super::Error::unexpected_status_code(response.status(), &expected_codes).into(), ); } let location = if response.status() == StatusCode::CREATED { Some(obtain_url_from_location( response.headers().get(http::header::LOCATION).cloned(), )?) } else { None }; let changes = if response.status() == StatusCode::OK { Some(serde_json::from_slice(response.body().as_bytes())?) } else { None }; Ok((changes, location)) } impl Action { /// FIXME: Check if files are present and switch to a form-encoded request. #[tracing::instrument] fn prepare_request( &self, access_token: &AccessToken, endpoint: &url::Url, ) -> Result, crate::Error> { let (content_type, bytes) = if matches!(self, Self::Create { files, .. } if files.is_empty()) { ("application/json; charset=utf-8", serde_json::to_vec(self)?) } else { ("multipart/form-data; charset=utf-8", Vec::default()) }; http::Request::post(endpoint.as_str()) .header(http::header::ACCEPT, "application/json; charset=utf-8") .header(http::header::CONTENT_TYPE, content_type) .header( http::header::AUTHORIZATION, format!("Bearer {}", access_token.expose_secret()), ) .body(crate::http::Body::Bytes(bytes)) .map_err(|e| e.into()) } /// Sends this request over to the requested Micropub server. #[tracing::instrument(skip(client))] pub async fn send( &self, client: &impl Client, endpoint: &url::Url, access_token: &AccessToken, ) -> Result { tracing::trace!(endpoint = endpoint.to_string(), "Sending request"); let req = self.prepare_request(access_token, endpoint)?; client .send_request(req) .await .map_err(super::convert_error) .and_then(|response: http::Response| { match self { Action::Create { .. } => { let location = obtain_url_from_location( response.headers().get(http::header::LOCATION).cloned(), )?; let sync = match response.status() { StatusCode::CREATED => true, StatusCode::ACCEPTED => false, _ => { return Err(super::Error::unexpected_status_code( response.status(), &[StatusCode::CREATED, StatusCode::ACCEPTED], ) .into()); } }; // MAY have a Link value. // FIXME: Parse the returned header value. let rel = microformats::types::Relations::default(); Ok(ActionResponse::Created { sync, location, rel, }) } Action::Delete(_) => { let expected_codes = [StatusCode::OK, StatusCode::NO_CONTENT]; if !expected_codes.contains(&response.status()) { return Err(super::Error::unexpected_status_code( response.status(), &expected_codes, ) .into()); } let changes = if response.status() == StatusCode::OK { Some(from_json_value(response)?) } else { None }; Ok(ActionResponse::Deleted(changes)) } Action::Update { .. } => { let (changes, location) = handle_manipulation_response(response)?; Ok(ActionResponse::Updated { changes, location }) } Action::Undelete(_) => { let (changes, location) = handle_manipulation_response(response)?; Ok(ActionResponse::Undeleted { changes, location }) } } }) } /// Converts this action into its JSON form. pub fn into_json(&self) -> serde_json::Value { match self { Action::Create { properties, .. } => { let mut all_properties: serde_json::Map = serde_json::to_value(properties.parameters.clone()) .map(|v| v.as_object().cloned().unwrap_or_default()) .unwrap_or_default(); all_properties.extend(properties.extra_fields.0.clone()); serde_json::json!({ "type": [properties.r#type], "properties": all_properties }) } Action::Update { url: updated_url, operation, } => { let mut all_properties: serde_json::Map = Default::default(); all_properties.insert("action".to_string(), "update".into()); all_properties.insert("url".to_string(), updated_url.to_string().into()); let (operation, operation_values) = operation; let operational_key = match *operation { UpdateOperation::Replace => "replace".to_string(), UpdateOperation::Add => "add".to_string(), UpdateOperation::Delete => "delete".to_string(), }; all_properties.insert(operational_key, serde_json::json!(operation_values)); serde_json::json!(all_properties) } Action::Delete(deleted_url) => serde_json::json!({ "action": "delete", "url": deleted_url }), Action::Undelete(undeleted_url) => serde_json::json!({ "action": "undelete", "url": undeleted_url }), } } } const PROPERTY_ACTION: &str = "action"; const PROPERTY_PROPERTIES: &str = "properties"; const PROPERTY_URL: &str = "url"; const PROPERTY_ADD: &str = "add"; const PROPERTY_DELETE: &str = "delete"; const PROPERTY_REPLACE: &str = "replace"; impl<'de> serde::Deserialize<'de> for Action { fn deserialize(deserializer: D) -> Result where D: serde::de::Deserializer<'de>, { let mut params: serde_json::Map = serde_json::Map::deserialize(deserializer)?; let action_value = params.remove(PROPERTY_ACTION).unwrap_or_default(); if action_value == serde_json::Value::String("update".into()) { let url_value = params .remove(PROPERTY_URL) .ok_or_else(|| serde::de::Error::missing_field("url")) .and_then(|uv| serde_json::from_value(uv).map_err(serde::de::Error::custom))?; let delete_value = params.remove(PROPERTY_DELETE); let add_value = params.remove(PROPERTY_ADD); let replace_value = params.remove(PROPERTY_REPLACE); let operation = if let Some(value) = replace_value { serde_json::from_value(value) .map(|rv: OperationValue| (UpdateOperation::Replace, rv)) .map_err(serde::de::Error::custom) } else if let Some(value) = add_value { serde_json::from_value(value) .map(|rv: OperationValue| (UpdateOperation::Add, rv)) .map_err(serde::de::Error::custom) } else if let Some(value) = delete_value { serde_json::from_value(value) .map(|rv: OperationValue| (UpdateOperation::Delete, rv)) .map_err(serde::de::Error::custom) } else { serde_json::from_value(serde_json::Value::Object(params)) .map(|rv| (UpdateOperation::Replace, rv)) .map_err(serde::de::Error::custom) }; operation.map(|operation| Self::Update { url: url_value, operation, }) } else if action_value == serde_json::Value::String("delete".into()) { params .remove(PROPERTY_URL) .ok_or_else(|| serde::de::Error::missing_field("url")) .and_then(|uv| serde_json::from_value(uv).map_err(serde::de::Error::custom)) .map(Self::Delete) } else if action_value == serde_json::Value::String("undelete".into()) { params .remove(PROPERTY_URL) .ok_or_else(|| serde::de::Error::missing_field("url")) .and_then(|uv| serde_json::from_value(uv).map_err(serde::de::Error::custom)) .map(Self::Undelete) } else { let properties = serde_json::from_value(serde_json::Value::Object(params)) .map_err(serde::de::Error::custom)?; Ok(Self::Create { properties, files: Default::default(), }) } } } #[test] fn action_representation_into_json_create() { let creation_properties = CreationProperties { parameters: Parameters { category: vec!["foo".into(), "bar".into()], status: Some(crate::standards::micropub::extension::PostStatus::Drafted), ..Default::default() }, extra_fields: Properties::try_from(serde_json::json!({ "content": ["hello world"] })) .expect("failed to build extra fields of creation properties"), r#type: microformats::types::Class::Known(microformats::types::KnownClass::Entry), }; assert_eq!( Action::Create { properties: Box::new(creation_properties), files: Default::default(), } .into_json(), serde_json::json!({ "type": ["h-entry"], "properties": { "category": ["foo", "bar"], "content": ["hello world"], "status": "draft" } }), "converts object into payload JSON" ) } #[test] fn action_representation_into_json_update_delete() { let object = Action::Update { url: "http://example.com".parse().unwrap(), operation: ( UpdateOperation::Delete, OperationValue::WholeProperties(vec!["content".to_string()]), ), }; assert_eq!( object.into_json(), serde_json::json!({ "action": "update", "url": "http://example.com/", "delete": ["content"] }), "converts object into payload JSON for a update-delete operation" ) } #[test] fn action_representation_into_json_delete() { assert_eq!( Action::Delete("http://example.com".parse().unwrap()).into_json(), serde_json::json!({ "action": "delete", "url": "http://example.com/", }), "converts object into payload JSON for a delete operation" ); } #[test] fn action_representation_into_json_undelete() { assert_eq!( Action::Undelete("http://example.com".parse().unwrap()).into_json(), serde_json::json!({ "action": "undelete", "url": "http://example.com/", }), "converts object into payload JSON for a undelete operation" ); } #[test] fn action_representation_create() { crate::test::Client::default(); let creation_properties = CreationProperties { parameters: Parameters { category: vec!["foo".into(), "bar".into()], ..Default::default() }, extra_fields: Properties::try_from(serde_json::json!({ "content": ["hello world"], "photo": ["https://photos.example.com/592829482876343254.jpg"] })) .expect("failed to build extra fields of creation properties"), r#type: microformats::types::Class::Known(microformats::types::KnownClass::Entry), }; assert_eq!( serde_json::from_value(serde_json::json!({ "type": ["h-entry"], "properties": { "content": ["hello world"], "category": ["foo","bar"], "photo": ["https://photos.example.com/592829482876343254.jpg"] } })) .map_err(|e| e.to_string()), Ok(Action::Create { properties: Box::new(creation_properties.clone()), files: Default::default() }), "converts create payload from JSON" ); assert_eq!( serde_qs::from_str("h=entry&content=hello+world&category[]=foo&category[]=bar&photo=https://photos.example.com/592829482876343254.jpg") .map_err(|e| e.to_string()), Ok(Action::Create { properties: Box::new(creation_properties.clone()), files: Default::default() }), "converts create payload from form encoded payload" ); } #[test] fn action_representation_delete() { crate::test::Client::default(); assert_eq!( serde_json::from_value( serde_json::json!({"action": "delete", "url": "http://example.com"}) ) .map_err(|e| e.to_string()), Ok(Action::Delete("http://example.com".parse().unwrap())), "converts delete payload from JSON" ); assert_eq!( serde_json::from_value( serde_json::json!({"action": "undelete", "url": "http://example.com"}) ) .map_err(|e| e.to_string()), Ok(Action::Undelete("http://example.com".parse().unwrap())), "converts undelete payload from JSON" ) } #[test] fn action_representation_update_replace() { crate::test::Client::default(); assert_eq!( serde_json::from_value(serde_json::json!({ "action": "update", "url": "http://example.com", "replace": { "category": ["tag1", "tag2"] } })) .map_err(|e| e.to_string()), Ok(Action::Update { url: "http://example.com".parse().unwrap(), operation: ( UpdateOperation::Replace, serde_json::from_value(serde_json::json!({ "category": ["tag1", "tag2"] })) .unwrap() ) }), "converts upload payload for replace from JSON" ); } #[test] fn action_representation_update_delete() { crate::test::Client::default(); assert_eq!( serde_json::from_value(serde_json::json!({ "action": "update", "url": "http://example.com", "delete": { "category": ["tag1", "tag2"] } })) .map_err(|e| e.to_string()), Ok(Action::Update { url: "http://example.com".parse().unwrap(), operation: ( UpdateOperation::Delete, serde_json::from_value(serde_json::json!({ "category": ["tag1", "tag2"] })) .unwrap() ) }), "converts upload payload from delete from JSON" ); } #[test] fn action_representation_update_replace_whole() { crate::test::Client::default(); assert_eq!( serde_json::from_value(serde_json::json!({ "action": "update", "url": "http://example.com", "add": ["category"] })) .map_err(|e| e.to_string()), Ok(Action::Update { url: "http://example.com".parse().unwrap(), operation: ( UpdateOperation::Add, serde_json::from_value(serde_json::json!(["category"])).unwrap() ) }), "converts upload payload from add from JSON" ); } /// Represents a collection of the known response sttes of a Micropub server. #[derive(Clone, Debug, PartialEq, Eq)] pub enum ActionResponse { // The creation response. // Created { sync: bool, location: url::Url, rel: microformats::types::Relations, }, // The updating response. // Updated { changes: Option, location: Option, }, // The deletion response. // Deleted(Option), // The undeletion response. // Undeleted { location: Option, changes: Option, }, } mod test; indieweb-rust-v0.6.0/library/src/standards/micropub/action/test.rs000066400000000000000000000051171511645615100253350ustar00rootroot00000000000000#![cfg(test)] use microformats::types::{Class, KnownClass}; use super::Action; use crate::standards::{ indieauth::AccessToken, micropub::action::{ActionResponse, CreationProperties}, }; async fn mock_micropub_request( client: impl crate::http::Client, endpoint: &url::Url, token: &AccessToken, action: &Action, response: &ActionResponse, message: impl ToString, ) { assert_eq!( action.send(&client, endpoint, token).await, Ok(response.to_owned()), "crafts expected response for {}", message.to_string() ); } #[tracing_test::traced_test] #[tokio::test] async fn action_send_request() { let mut client = crate::test::Client::new().await; let token = AccessToken::new("a-bad-token"); client .mock_server .mock("POST", "/action-create-sync") .with_header(http::header::CONTENT_TYPE.to_string(), "application/json") .with_header(http::header::LOCATION.to_string(), "http://example.com/new") .with_status(http::StatusCode::CREATED.as_u16().into()) .expect(1) .create_async() .await; let u = format!("{}/action-create-sync", client.mock_server.url()) .parse() .unwrap(); mock_micropub_request( client, &u, &token, &Action::Create { properties: Box::new(CreationProperties { r#type: Class::Known(KnownClass::Entry), parameters: Default::default(), extra_fields: Default::default(), }), files: Default::default(), }, &ActionResponse::Created { sync: true, location: "http://example.com/new".parse().unwrap(), rel: Default::default(), }, "create sync", ) .await; } #[test] fn deser_creation_props() { let props_json_in_list = serde_json::json!({ "type": ["h-entry"], "properties": { "slug": ["wow"], "title": "grrr" } }); let create_props: CreationProperties = serde_json::from_value(props_json_in_list).expect("converted props from json"); assert_eq!(create_props.parameters.slug, Some("wow".to_string())); let props_json_in_str = serde_json::json!({ "type": ["h-entry"], "properties": { "slug": "wower", "title": "grrr" } }); let create_props: CreationProperties = serde_json::from_value(props_json_in_str).expect("converted props from json"); assert_eq!(create_props.parameters.slug, Some("wower".to_string())); } indieweb-rust-v0.6.0/library/src/standards/micropub/extension/000077500000000000000000000000001511645615100245435ustar00rootroot00000000000000indieweb-rust-v0.6.0/library/src/standards/micropub/extension/channel.rs000066400000000000000000000041751511645615100265300ustar00rootroot00000000000000use crate::{algorithms::Properties, standards::micropub::paging}; use serde::{Deserialize, Serialize}; use std::str::FromStr; /// The default name for a channel. /// /// [Channels](https://github.com/indieweb/micropub-extensions/issues/40) are an experimental /// feature. pub const DEFAULT_NAME: &str = "default"; /// Provides a uniform way to represent a channel. /// /// [Channels](https://github.com/indieweb/micropub-extensions/issues/40) are an experimental /// feature being proposed to provide a means of organizing content stored in a Micropub server. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Eq)] #[serde(untagged, rename_all = "kebab-case")] pub enum Form { Expanded { uid: String, name: String, #[serde(flatten, default)] properties: Properties, }, Simple(String), } #[allow(clippy::derived_hash_with_manual_eq)] impl std::hash::Hash for Form { fn hash(&self, state: &mut H) { std::mem::discriminant(self).hash(state); } } impl Default for Form { fn default() -> Self { Self::Simple(DEFAULT_NAME.to_owned()) } } impl Form { pub fn name(&self) -> String { match self { Self::Simple(name) => name.to_owned(), Self::Expanded { name, .. } => name.to_owned(), } } pub fn uid(&self) -> String { match self { Self::Simple(name) => name.to_owned(), Self::Expanded { uid, .. } => uid.to_owned(), } } } impl std::fmt::Display for Form { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(&self.uid()) } } impl FromStr for Form { type Err = crate::Error; fn from_str(s: &str) -> Result { Ok(Self::Simple(s.to_string())) } } /// A representation of what a Micropub server would response to a query of channels. #[derive(PartialEq, Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub struct QueryResponse { pub channels: Vec
, #[serde(default, flatten, rename = "paging")] pub paging: paging::Fields, } indieweb-rust-v0.6.0/library/src/standards/micropub/extension/mod.rs000066400000000000000000000005221511645615100256670ustar00rootroot00000000000000 mod post_status; #[doc(inline)] pub use post_status::Status as PostStatus; mod order; #[doc(inline)] pub use order::Direction as Order; mod visibility; #[doc(inline)] pub use visibility::Level as Visibility; #[cfg(feature = "experimental_syndication")] pub mod syndication; #[cfg(feature = "experimental_channels")] pub mod channel; indieweb-rust-v0.6.0/library/src/standards/micropub/extension/order.rs000066400000000000000000000023071511645615100262260ustar00rootroot00000000000000/// Provides representation of how content should be sorted in response to a query. #[derive(Copy, Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize, Hash, Eq)] #[serde(try_from = "String", into = "String")] pub enum Direction { Ascending, Descending, } impl std::str::FromStr for Direction { type Err = crate::Error; fn from_str(v: &str) -> Result { match v.to_lowercase().as_str() { "ascending" | "asc" | "a" => Ok(Self::Ascending), "descending" | "desc" | "d" => Ok(Self::Descending), i => Err(Self::Err::InvalidOrder(i.to_string())), } } } impl std::fmt::Display for Direction { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(match self { Self::Descending => "desc", Self::Ascending => "asc", }) } } #[allow(clippy::from_over_into)] impl Into for Direction { fn into(self) -> String { self.to_string() } } impl TryFrom for Direction { type Error = crate::Error; fn try_from(value: String) -> Result { use std::str::FromStr; Self::from_str(&value) } } indieweb-rust-v0.6.0/library/src/standards/micropub/extension/post_status.rs000066400000000000000000000050231511645615100275010ustar00rootroot00000000000000/// Represents the potential statuses a post can take. /// /// More information about this can be found at . #[derive(Debug, Clone, PartialEq, Hash, Eq, Default)] pub enum Status { /// The content is available for general use. #[default] Published, /// The content is not yet ready for general use. Drafted, /// The content has been considered "deleted". Deleted, /// The content has expired from general use. Expired, /// A custom status that posts can have. Other(String), } impl serde::Serialize for Status { fn serialize(&self, serializer: S) -> Result where S: serde::Serializer, { self.to_string().serialize(serializer) } } impl<'de> serde::Deserialize<'de> for Status { fn deserialize(deserializer: D) -> Result where D: serde::Deserializer<'de>, { String::deserialize(deserializer) .and_then(|status_str| status_str.parse().map_err(serde::de::Error::custom)) } } impl std::fmt::Display for Status { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(match self { Status::Published => "published", Status::Drafted => "draft", Status::Deleted => "deleted", Status::Expired => "expired", Status::Other(status) => status.as_str(), }) } } impl std::str::FromStr for Status { type Err = crate::Error; fn from_str(s: &str) -> Result { let status_str = s.trim().trim_matches('"').to_lowercase(); if status_str.is_empty() { // This is the default status according to the extension spec at // return Ok(Self::Published); } match status_str.as_str() { "published" => Ok(Self::Published), "draft" => Ok(Self::Drafted), "deleted" => Ok(Self::Deleted), "expired" => Ok(Self::Expired), other => Ok(Self::Other(other.to_string())), } } } #[test] fn post_status() { use std::str::FromStr; assert_eq!(Some(Status::Drafted), Status::from_str("draft").ok()); assert_eq!(Some(Status::Published), Status::from_str("published").ok()); assert_eq!(Some(Status::Published), "published".parse().ok()); assert_eq!(Some(Status::Published), Status::from_str("PublisHed").ok()); assert_eq!(Some(Status::Published), "PublisHed".parse().ok()); } indieweb-rust-v0.6.0/library/src/standards/micropub/extension/syndication.rs000066400000000000000000000031101511645615100274300ustar00rootroot00000000000000use serde::{Deserialize, Serialize}; /// Provides a representation of a syndication-adjacent resource. /// /// This can be used to describe a [target][SyndicationTarget] or /// a profile of a target. This is defined in the Micropub spec at /// and /// . #[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Default)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub struct Info { /// The name of the resource. pub name: String, /// The URL of the associated resource. #[serde(default, skip_serializing_if = "Option::is_none")] pub url: Option, /// A URL meant to represent the associated resource. #[serde(default, skip_serializing_if = "Option::is_none")] pub photo: Option, } /// Provides a representation of a syndication target. /// /// This is defined by . #[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Default)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub struct Target { /// The UID representing this target. pub uid: String, /// A name associated to this target. #[serde(default)] pub name: String, /// Optional information relating to the service. #[serde(default, skip_serializing_if = "Option::is_none")] pub service: Option, /// Optional information relating to the user. #[serde(default, skip_serializing_if = "Option::is_none")] pub user: Option, } indieweb-rust-v0.6.0/library/src/standards/micropub/extension/visibility.rs000066400000000000000000000031151511645615100273000ustar00rootroot00000000000000use std::str::FromStr; use serde::{Deserialize, Serialize}; /// Provides representation of what content visibility can be shown as. #[derive(Copy, Clone, Debug, PartialEq, Hash, Eq, Default)] pub enum Level { /// No restrictions on who can see this content. #[default] Public, /// Same as public, but hidden from associated feeds. Unlisted, /// Content can live in a feed but may require more information to view. Private, } impl FromStr for Level { type Err = crate::Error; fn from_str(v: &str) -> Result { match v.to_lowercase().as_str() { "private" => Ok(Level::Private), "unlisted" => Ok(Level::Unlisted), "public" => Ok(Level::Public), invalid => Err(Self::Err::InvalidVisibility(invalid.to_string())), } } } impl std::fmt::Display for Level { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(match self { Level::Public => "public", Level::Unlisted => "unlisted", Level::Private => "private", }) } } impl Serialize for Level { fn serialize(&self, serializer: S) -> Result where S: serde::Serializer, { self.to_string().serialize(serializer) } } impl<'de> Deserialize<'de> for Level { fn deserialize(deserializer: D) -> Result where D: serde::Deserializer<'de>, { String::deserialize(deserializer) .and_then(|vs| Self::from_str(&vs).map_err(serde::de::Error::custom)) } } indieweb-rust-v0.6.0/library/src/standards/micropub/mod.rs000066400000000000000000000160651511645615100236640ustar00rootroot00000000000000use crate::{algorithms::Properties, traits::as_string_or_list}; use std::string::ToString; pub mod action; pub mod extension; pub mod paging; pub mod query; /// A collection of the kind of properties one can expect in an incoming Micropub creation request. /// /// These properties represent all of the kind of fields one can expect as extensions to Micropub. /// Conventionally, these are considered 'server' directives in some cases in and other just /// properties with specific intentions on the result of the content's presentation or storage. #[derive(Clone, Debug, Default, serde::Deserialize, serde::Serialize, PartialEq, Eq)] #[serde(rename_all = "kebab-case")] pub struct Parameters { /// Provides an optional value of a [post's status][PostStatus]. #[serde( default, alias = "mp-post-status", alias = "post-status", skip_serializing_if = "Option::is_none" )] pub status: Option, #[serde( default, alias = "mp-category", with = "as_string_or_list", skip_serializing_if = "Vec::is_empty" )] pub category: Vec, /// The slug to use for this content. /// /// See #[serde(alias = "mp-slug", default, skip_serializing_if = "Option::is_none")] pub slug: Option, /// Associated channels to this content. /// /// See #[serde( default, alias = "mp-channel", alias = "channel", with = "as_string_or_list", skip_serializing_if = "Vec::is_empty" )] pub channels: Vec, /// A list of values representing syndication targets that this post should also be sent to. #[serde( default, alias = "mp-syndicate-to", with = "as_string_or_list", skip_serializing_if = "Vec::is_empty" )] pub syndicate_to: Vec, /// The destination to send this content to. /// /// See #[serde( default, alias = "mp-destination", skip_serializing_if = "Option::is_none" )] pub destination: Option, /// A list of the audiences that can view this post. /// /// See #[serde( default, alias = "audience", alias = "mp-audience", with = "as_string_or_list", skip_serializing_if = "Vec::is_empty" )] pub audience: Vec, /// What kind of content visibility this post has. /// /// See for more information. #[serde( alias = "visibility", alias = "mp-visibility", default, skip_serializing_if = "Option::is_none" )] pub visibility: Option, } impl TryFrom for Parameters { type Error = serde_json::Error; fn try_from(props: Properties) -> Result { let obj = props .0 .into_iter() .map(|(k, v)| { ( k, if let serde_json::Value::Array(ref va) = v { if va.len() == 1 { va.first().unwrap().clone() } else { v } } else { v }, ) }) .collect::<_>(); serde_json::from_value(serde_json::Value::Object(obj)) } } #[test] fn deser_to_params() { assert_eq!( serde_json::from_value(serde_json::json!({ "category": ["tag", "tag2"] })) .ok(), Some(Parameters { category: vec!["tag".into(), "tag2".into()], ..Default::default() }), "deserializes from direct JSON" ); } /// A general representation of errors from Micropub. /// /// Pulled from #[derive(thiserror::Error, Debug, serde::Deserialize, PartialEq, Eq, Clone, serde::Serialize)] #[error("Failed to process a request from the Micropub server: {error:?}")] pub struct Error { pub error: ErrorKind, pub error_description: String, #[serde(flatten, default)] pub fields: serde_json::Map, } impl Error { /// Crafts an error about a missing header in a response. /// /// * `header_name`: [std::fmt::Display] The name of the header that's missing. pub fn missing_header(header_name: impl std::fmt::Display) -> Self { Self { error: ErrorKind::InvalidRequest, error_description: format!("The header {} was missing in the response.", header_name), fields: Default::default(), } } pub fn bad_request(message: impl std::fmt::Display) -> Self { Self { error: ErrorKind::InvalidRequest, error_description: format!("{}", message), fields: Default::default(), } } pub fn unexpected_status_code( status: http::StatusCode, accepted: &[http::StatusCode], ) -> Error { Self { error: ErrorKind::InvalidRequest, error_description: format!( "The response's status code was {} and not one of the expected {}", status.as_u16(), accepted .iter() .map(|s| s.as_u16().to_string()) .collect::>() .join(", ") ), fields: Default::default(), } } } /// A representation of the kind of failures that can occur. /// /// Pulled from #[derive(Debug, serde::Deserialize, serde::Serialize, PartialEq, Eq, Clone)] #[serde(rename_all = "snake_case")] pub enum ErrorKind { /// Forbidden, /// InsufficentScope, /// Unauthorized, /// InvalidRequest, } #[test] fn error_from_json() { assert_eq!( Some(Error { error: ErrorKind::Forbidden, error_description: "Welp".to_string(), fields: Default::default() }), serde_json::from_value(serde_json::json!({ "error": "forbidden", "error_description": "Welp" })) .ok(), "converts safely" ); } fn convert_error(e: crate::Error) -> crate::Error { e } #[test] fn deser_params() { let params_json = serde_json::json!({ "slug": ["wow"] }); let props: Properties = serde_json::from_value(params_json).expect("failed to deser json"); let params: Parameters = props.try_into().expect("failed to convert"); assert_eq!(params.slug, Some("wow".to_string())); } indieweb-rust-v0.6.0/library/src/standards/micropub/paging.rs000066400000000000000000000104241511645615100243430ustar00rootroot00000000000000use super::extension; use serde::{Deserialize, Serialize}; use serde_with::serde_as; use url::Url; /// Represents the normalized form of pagination in Micropub queries. #[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Default, Hash, Eq)] #[serde_as] #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub struct Query { /// Provides a limit of how many items can be returned. /// /// View for more information. #[serde_as(as = "DisplayFromStr")] #[serde(default, skip_serializing_if = "Option::is_none")] pub limit: Option, /// Provides an offset from which to query or fetch items from. /// /// View for more information. #[serde(default, skip_serializing_if = "Option::is_none")] pub offset: Option, /// Provides a value that can be filtered upon. /// /// View for more information. #[serde(default, skip_serializing_if = "Option::is_none")] pub filter: Option, /// Provides a value that can be used as the left-handed boundary for a search. /// /// View for more information. #[serde(default, skip_serializing_if = "Option::is_none")] pub before: Option, /// Provides a value that can be used as the right-handed boundary for a search. /// /// View for more information. #[serde(default, skip_serializing_if = "Option::is_none")] pub after: Option, /// Provides a normalized means of ordering the content in the returned list. /// /// View for more information. #[serde(skip_serializing_if = "Option::is_none")] pub order: Option, } impl Query { /// Determines if this query has no values defined. pub fn is_empty(&self) -> bool { *self == Default::default() } /// Determines if there's any values set to scope where in the slice this query takes place. pub fn is_ranged(&self) -> bool { let a = self .after .clone() .filter(|c| !c.is_empty()) .unwrap_or_default() .is_empty(); let b = self .before .clone() .filter(|c| !c.is_empty()) .unwrap_or_default() .is_empty(); !a || !b } pub fn extend_url(&self, base_url: &Url) -> Url { let mut extended_url = base_url.clone(); if *self == Default::default() { return extended_url; }; let mut qp = extended_url.query_pairs_mut(); if let Some(ref limit) = self.limit { qp.append_pair("limit", limit.as_str()); } let _ = qp.finish(); drop(qp); extended_url.clone() } } /// Represents the extended fields of a paginated response. #[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Clone, Default)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub struct Fields { /// Defines the current paging options used. #[serde(default, skip_serializing_if = "Query::is_empty")] pub paging: Query, /// An optional representation of a cursor pointing to the most recent items for this query. #[serde(alias = "_latest", default, skip_serializing_if = "Option::is_none")] pub latest: Option, /// An optional representation of a cursor pointing to the earliest items for this query. #[serde(alias = "_earliest", default, skip_serializing_if = "Option::is_none")] pub earliest: Option, } impl Fields { pub fn is_empty(&self) -> bool { self.earliest.is_none() && self.latest.is_none() && self.paging.is_empty() } } /// Represents the response of a paginated request. #[derive(Serialize, Deserialize, PartialEq, Debug, Clone)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub struct Response { /// A list of JSON-serialized objects. pub items: Vec, /// All of the shared pagination fields. #[serde(default, flatten, skip_serializing_if = "Fields::is_empty")] pub paging: Fields, } indieweb-rust-v0.6.0/library/src/standards/micropub/query/000077500000000000000000000000001511645615100236745ustar00rootroot00000000000000indieweb-rust-v0.6.0/library/src/standards/micropub/query/mod.rs000066400000000000000000000356741511645615100250400ustar00rootroot00000000000000use crate::{ algorithms::ptd::Type, http::{Client, CONTENT_TYPE_JSON}, standards::{indieauth::AccessToken, micropub::convert_error}, traits::as_string_or_list, }; use serde::{Deserialize, Serialize}; use super::{ extension::{self, PostStatus, Visibility}, paging, }; use microformats::types::Item; use secrecy::ExposeSecret; use std::collections::HashMap; #[derive(Serialize, Deserialize, Debug, PartialEq)] #[serde(rename_all = "kebab-case")] pub struct Query { /// Pagination options to modify where in the list to look #[serde(flatten)] pub pagination: paging::Query, /// A representation of the base querying components. #[serde(flatten)] pub kind: QueryKind, } #[derive(Debug, Clone, Default, Eq)] pub struct MatchingPropertyValuesMap(HashMap>); impl std::ops::Deref for MatchingPropertyValuesMap { type Target = HashMap>; fn deref(&self) -> &Self::Target { &self.0 } } impl std::hash::Hash for MatchingPropertyValuesMap { fn hash(&self, state: &mut H) { serde_json::to_string(&self.0) .unwrap_or_default() .hash(state); } } impl PartialEq for MatchingPropertyValuesMap { fn eq(&self, other: &Self) -> bool { self.0.eq(&other.0) } } struct PropertyValueVisitor; impl<'de> serde::de::Visitor<'de> for PropertyValueVisitor { type Value = MatchingPropertyValuesMap; fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { formatter.write_str("expecting a map whose matching property names of 'property-' are stripped of said match") } fn visit_map(self, mut map: A) -> Result where A: serde::de::MapAccess<'de>, { let mut props = HashMap::>::default(); while let Some((key, value)) = map .next_entry()? .filter(|(key, _): &(String, serde_json::Value)| key.starts_with("property-")) .map(|(key, value): (String, serde_json::Value)| (key.replace("property-", ""), value)) { let values = value .as_array() .cloned() .unwrap_or_else(|| vec![value.clone()]); if let Some(list) = props.get_mut(&key) { list.extend(values); } else { props.insert(key, values); } } Ok(MatchingPropertyValuesMap(HashMap::from_iter( props .iter() .map(|(name, values)| (name.to_owned(), values.to_vec())) .collect::>() .into_iter() .rev(), ))) } } impl From>> for MatchingPropertyValuesMap { fn from(v: HashMap>) -> Self { Self(v) } } impl<'de> serde::Deserialize<'de> for MatchingPropertyValuesMap { fn deserialize(deserializer: D) -> Result where D: serde::de::Deserializer<'de>, { deserializer.deserialize_map(PropertyValueVisitor) } } impl serde::Serialize for MatchingPropertyValuesMap { fn serialize(&self, serializer: S) -> Result where S: serde::Serializer, { let mut map = serializer.serialize_map(Some(self.0.len()))?; for (k, v) in &self.0 { serde::ser::SerializeMap::serialize_entry(&mut map, &format!("property-{}", k), &v)?; } serde::ser::SerializeMap::end(map) } } impl TryFrom for MatchingPropertyValuesMap { type Error = serde_json::Error; fn try_from(value: serde_json::Value) -> Result { serde_json::from_value(value) } } impl FromIterator<(String, Vec)> for MatchingPropertyValuesMap { fn from_iter)>>(iter: T) -> Self { Self(HashMap::from_iter(iter)) } } impl IntoIterator for MatchingPropertyValuesMap { type Item = (String, Vec); type IntoIter = std::collections::hash_map::IntoIter>; fn into_iter(self) -> Self::IntoIter { self.0.into_iter() } } #[test] fn matching_property_values_map() { assert_eq!( serde_json::from_str::( r#" { "property-foo": null, "property-bar": [3, "jump"] } "# ) .map_err(|e| e.to_string()), Ok(MatchingPropertyValuesMap(HashMap::from_iter([ ("foo".to_string(), vec![serde_json::Value::Null]), ( "bar".to_string(), vec![ serde_json::Value::Number(3.into()), serde_json::Value::String("jump".to_string()) ] ) ]))) .map_err(|e: serde_json::Error| e.to_string()), "deserializing from JSON" ); assert_eq!( serde_qs::from_str::( "property-foo=kick&property-bar[]=jump&property-bar[]=high" ) .map_err(|e: serde_qs::Error| e.to_string()), Ok(MatchingPropertyValuesMap(HashMap::from_iter([ ( "foo".to_string(), vec![serde_json::Value::String("kick".to_string())] ), ( "bar".to_string(), vec![ serde_json::Value::String("jump".to_string()), serde_json::Value::String("high".to_string()), ] ) ]))), "deserializing from query string" ); } #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, Hash)] #[serde(rename_all = "kebab-case")] pub struct SourceQuery { /// Properties to match in this query. #[serde(flatten)] pub matching_properties: MatchingPropertyValuesMap, /// The URL of the resource being discovered. #[serde(skip_serializing_if = "Option::is_none", default)] pub url: Option, /// Represents the destination of this item when filtering this query. #[serde(skip_serializing_if = "Option::is_none", default)] pub destination: Option, /// Represents one or many post types to ask for when filtering this query. #[serde( with = "as_string_or_list", skip_serializing_if = "Vec::is_empty", default )] pub post_type: Vec, /// Represents the expected post status of the items returned when filtering this query. #[serde( default, skip_serializing_if = "SourceQuery::is_non_serializable_status" )] pub post_status: Option, /// Provides a list of the associated audiences to look for when filtering this query. #[serde( with = "as_string_or_list", default, skip_serializing_if = "Vec::is_empty" )] pub audience: Vec, /// Represents the expected visibilities of the items returned when filtering this query. #[serde( default, skip_serializing_if = "SourceQuery::is_non_serializable_visibility" )] pub visibility: Option, /// Represents one or many channels to ask for when filtering this query. #[serde( skip_serializing_if = "Vec::is_empty", default, with = "as_string_or_list" )] #[cfg(feature = "experimental_channels")] pub channel: Vec, /// Represents one or many targets used for syndication when filtering this query. #[serde( skip_serializing_if = "Vec::is_empty", default, with = "as_string_or_list" )] pub syndicate_to: Vec, /// Represents one or many categories when filtering this query. #[serde( skip_serializing_if = "Vec::is_empty", default, with = "as_string_or_list" )] pub category: Vec, /// Represents one or many properties to check for the presence of. #[serde( skip_serializing_if = "Vec::is_empty", default, with = "as_string_or_list" )] pub exists: Vec, /// Represents one or many properties to check for the lack of a presence of. #[serde( skip_serializing_if = "Vec::is_empty", default, with = "as_string_or_list" )] pub not_exists: Vec, /// Represents a value to filter against. #[serde(skip_serializing_if = "Option::is_none", default)] pub filter: Option, } impl SourceQuery { pub fn post_status_is_published_by_default() -> PostStatus { PostStatus::Published } pub fn visibility_is_public_by_default() -> Visibility { Visibility::Public } pub fn is_non_serializable_visibility(visibility: &Option) -> bool { visibility.is_none() || *visibility != Some(Visibility::Public) } pub fn is_default_post_type(post_types: &Vec) -> bool { *post_types == vec![Type::default()] } pub fn is_non_serializable_status(post_status: &Option) -> bool { post_status.is_none() || *post_status != Some(PostStatus::default()) } } #[derive(Serialize, Deserialize, Debug, Eq)] #[serde(rename_all = "kebab-case", tag = "q", deny_unknown_fields)] #[non_exhaustive] pub enum QueryKind { /// Pulls the configuration of the Micropub server. #[serde(rename = "config")] Configuration, /// Represents a call to `?q=source` for post information Source(Box), /// Represents a call to `?q=channel` to fetch channels. /// To learn about channels, view #[cfg(feature = "experimental_channels")] Channel, /// Represents a call to `?q=syndicate-to` to fetch syndication targets. #[serde(rename_all = "kebab-case")] #[cfg(feature = "experimental_syndication")] SyndicateTo { /// Represents a single or many post types to ask for when filtering this query. #[serde(with = "as_string_or_list", skip_serializing_if = "Vec::is_empty")] post_type: Vec, }, /// Represents a call to `?q=rel` to fetch relationship links. #[serde(alias = "rel", rename_all = "kebab-case")] #[cfg(feature = "experimental_relation")] Relation { /// The relations to explicitly look for. #[serde(default, skip_serializing_if = "Vec::is_empty")] rel: Vec, /// The identity to look up relationships for. url: url::Url, }, /// Represents a call to `?q=category` to fetch tags. Category, } impl PartialEq for QueryKind { fn eq(&self, other: &Self) -> bool { use std::mem::discriminant; discriminant(self) == discriminant(other) } } impl Query { /// Sends this request over to the requested Micropub server. #[tracing::instrument(skip(client))] pub async fn send( &self, client: &impl Client, endpoint: &url::Url, access_token: &AccessToken, ) -> Result { let mut url = endpoint.clone(); let self_string = serde_qs::to_string(&self).map_err(crate::Error::Qs)?; if url.query().is_none() { url.set_query(Some(self_string.as_str())); } else { url.query_pairs_mut() .extend_pairs(url::form_urlencoded::parse(self_string.as_bytes())); } tracing::trace!( endpoint = format!("{endpoint}"), query = self_string, "Sending request to Micropub server" ); let req = http::Request::get(url.as_str()) .header(http::header::ACCEPT, CONTENT_TYPE_JSON) .header( http::header::AUTHORIZATION, format!("Bearer {}", access_token.expose_secret()), ) .body(crate::http::Body::Empty)?; let resp = client.send_request(req).await?; dbg!(&resp); crate::http::from_json_value(resp) .and_then(|response: Response| Result::::from(response)) .map_err(convert_error) } } /// A representation of what can be returned from a configuration request. /// /// View for more information. #[derive(Serialize, Deserialize, PartialEq, Debug, Clone)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub struct ConfigurationResponse { /// A list of the supported queries available to the querying client. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub q: Vec, /// A list of channels available for use to the querying client. #[cfg(feature = "experimental_channels")] #[serde(default, skip_serializing_if = "Vec::is_empty")] pub channels: Vec, /// The media endpoint suggested for use with this Micropub server. #[serde(default, skip_serializing_if = "Option::is_none")] pub media_endpoint: Option, /// The supported post types this Micropub server recognizes. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub post_types: Vec, /// The available syndication targets from the remote Micropub server. #[serde(default, skip_serializing_if = "Vec::is_empty")] #[cfg(feature = "experimental_syndication")] pub syndicate_to: Vec, /// The categories available to the querying client. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub category: Vec, } #[derive(Serialize, Deserialize, PartialEq, Debug, Clone)] #[serde(rename_all = "kebab-case")] pub struct SourceResponse { /// The hinted post type. #[serde( default, rename = "post-type", with = "as_string_or_list", skip_serializing_if = "Vec::is_empty" )] pub post_type: Vec, /// The [Microformats][microformats] item returned in this request. #[serde(flatten)] pub item: Item, } #[derive(PartialEq, Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub struct CategoryResponse { pub categories: Vec, #[serde(flatten, skip_serializing_if = "paging::Fields::is_empty")] pagination: paging::Fields, } /// Represents the response for a query. // FIXME: Add the representation of syndication targets. #[derive(Serialize, Deserialize, PartialEq, Debug, Clone)] #[serde(untagged, rename_all = "kebab-case")] pub enum Response { Source(SourceResponse), #[cfg(feature = "experimental_channels")] Channel(extension::channel::QueryResponse), Category(CategoryResponse), Configuration(ConfigurationResponse), Paginated(paging::Response), Error(super::Error), } impl From for Result { fn from(value: Response) -> Self { if let Response::Error(e) = value { Err(crate::Error::Micropub(e)) } else { Ok(value) } } } #[cfg(test)] mod test; indieweb-rust-v0.6.0/library/src/standards/micropub/query/test.rs000066400000000000000000000253041511645615100252250ustar00rootroot00000000000000use crate::{ http::CONTENT_TYPE_JSON, standards::indieauth::AccessToken, standards::micropub::{ paging, query::{ CategoryResponse, ConfigurationResponse, MatchingPropertyValuesMap, Query, Response, SourceQuery, SourceResponse, }, }, }; use http::header::CONTENT_TYPE; use microformats::types::{Class, KnownClass}; use crate::{ algorithms::ptd::Type, standards::micropub::{query::QueryKind, Error}, }; #[tracing_test::traced_test] #[tokio::test] async fn query_send() { let mut client = crate::test::Client::new().await; let token = AccessToken::new("a-bad-token"); let query = crate::standards::micropub::query::Query { pagination: Default::default(), kind: QueryKind::Configuration, }; let endpoint_mock = client .mock_server .mock("get", "/micropub/auth-failure") .match_query("q=config") .with_status(400) .with_header(CONTENT_TYPE.as_str(), CONTENT_TYPE_JSON) .with_body( serde_json::to_string(&serde_json::json!({ "error": "invalid_request", "error_description": "This was a bad, bad request." })) .unwrap(), ) .expect(1) .create_async() .await; let endpoint = format!("{}/micropub/auth-failure", client.mock_server.url()) .parse() .unwrap(); let query_result = query.send(&client, &endpoint, &token).await; endpoint_mock.assert_async().await; assert_eq!( query_result, Err(Error::bad_request("This was a bad, bad request").into()), "reports an authorization failure" ); } #[test] fn query_from_qs_str() { crate::test::Client::default(); assert_eq!( Ok(vec![Type::Note, Type::Read]), serde_qs::from_str::( "q=source&post-type[]=note&post-type[]=read&channel=jump&limit=5" ) .map_err(|e| e.to_string()) .map(|q| q.kind) .map(|k| match k { QueryKind::Source(query) => query.post_type, _ => vec![], }), "provides deserialization of the source query with multiple post type asked for" ); assert_eq!( Ok(vec![Type::Video, Type::Read]), serde_qs::Config::new(1, false) .deserialize_str::( "q=source&post-type%5B0%5D=video&post-type%5B1%5D=read&limit=5" ) .map_err(|e| e.to_string()) .map(|q| q.kind) .map(|k| match k { QueryKind::Source(query) => query.post_type, _ => vec![], }), "provides deserialization of the source query with multiple post type asked for" ); assert_eq!( None, serde_qs::from_str::("q=source&post-type=") .err() .map(|e| e.to_string()), "ignores if the post type value is 'empty'" ); assert_eq!( None, serde_qs::from_str::("q=source&syndicate-to=3") .err() .map(|e| e.to_string()), "supports filtering by syndication targets" ); assert_eq!( None, serde_qs::from_str::("q=config") .err() .map(|e| e.to_string()), "provides deserialization of the config query" ); } #[test] fn query_from_qs_str_with_property_filtering() { crate::test::Client::default(); assert_eq!( Ok(QueryKind::Source(Box::new(SourceQuery { exists: vec!["in-reply-to".to_string()], not_exists: vec!["like-of".to_string()], matching_properties: MatchingPropertyValuesMap::from_iter(vec![ ("byline".into(), vec!["today".into()]), ("range".into(), vec!["3".into(), "10".into()]) ]), ..Default::default() }))), serde_qs::from_str::("q=source¬-exists=like-of&exists=in-reply-to&property-byline=today&property-range[]=3&property-range[]=10") .map(|q| q.kind) .map_err(|e| e.to_string()), "provides deserialization of the config query" ); } #[test] fn query_to_str() { crate::test::Client::default(); let result1 = serde_qs::to_string(&QueryKind::Source(Box::new(SourceQuery { post_type: vec![Type::Article], ..Default::default() }))); assert_eq!( result1.as_ref().err().map(|s| s.to_string()), None, "can query a list of articles" ); assert_eq!( Some("q=source&post-type=article".to_string()), result1.ok(), "can query a list of articles" ); let result2 = serde_qs::to_string(&QueryKind::Source(Box::new(SourceQuery { post_type: vec![Type::Article, Type::Note], ..Default::default() }))); assert_eq!( Some("q=source&post-type[0]=article&post-type[1]=note".to_string()), result2.ok(), "can query a list of articles and notes" ); } #[test] #[cfg(feature = "experimental_channels")] fn query_response_for_channels() { use super::extension; assert_eq!( Some(Response::Channel(extension::channel::QueryResponse { channels: vec![extension::channel::Form::Expanded { uid: "magic".to_string(), name: "Magic".to_string(), properties: serde_json::json!({"grr": "bark"}).try_into().unwrap() }], paging: Default::default() })), serde_json::from_value(serde_json::json!({ "channels": [{ "uid": "magic", "name": "Magic", "grr": "bark" }] })) .ok() ) } #[test] fn query_response_for_configuration() { assert_eq!( Ok(Response::Configuration(ConfigurationResponse { q: vec!["channels".to_owned()], category: vec!["tag".into()], media_endpoint: None, post_types: vec![Type::Note], channels: Default::default(), syndicate_to: Default::default() })), serde_json::from_value(serde_json::json!({ "q": ["channels"], "post-types": ["note"], "category": ["tag"] })) .map(Response::Configuration) .map_err(crate::Error::JSON) ); } #[test] #[cfg(feature = "experimental_syndication")] fn query_response_for_configuration_with_syndication() { use crate::standards::micropub::extension; assert_eq!( Ok(Response::Configuration(ConfigurationResponse { q: vec!["channels".to_owned()], category: vec!["tag".into()], media_endpoint: None, post_types: vec![Type::Note], channels: Default::default(), syndicate_to: vec![extension::syndication::Target { uid: "magic".into(), name: "cookie".into(), ..Default::default() }] })), serde_json::from_value(serde_json::json!({ "q": ["channels"], "post-types": ["note"], "category": ["tag"], "syndicate-to": [ { "uid": "magic", "name": "cookie" } ] })) .map(Response::Configuration) .map_err(crate::Error::JSON) ); } #[test] #[cfg(feature = "experimental_channels")] fn query_response_for_configuration_with_channels() { assert_eq!( Ok(Response::Configuration(ConfigurationResponse { q: vec!["channels".to_owned()], category: vec!["tag".into()], media_endpoint: None, post_types: vec![Type::Note], channels: Default::default(), syndicate_to: Default::default() })), serde_json::from_value(serde_json::json!({ "q": ["channels"], "post-types": ["note"], "category": ["tag"] })) .map(Response::Configuration) .map_err(crate::Error::JSON) ); } #[test] fn query_response_for_categories() { assert_eq!( Ok(Response::Category(CategoryResponse { categories: vec!["jump".into(), "kick".into(), "spin".into()], pagination: Default::default() })), serde_json::from_value(serde_json::json!({ "categories": ["jump", "kick", "spin"] })) .map_err(|e| format!("{:#?}", e)), "works without paging" ); } #[test] fn query_response_for_categories_with_paging() { use super::extension::Order; assert_eq!( Ok(CategoryResponse { categories: vec!["jump".into(), "kick".into(), "spin".into()], pagination: paging::Fields { paging: paging::Query { order: Some(Order::Descending), ..Default::default() }, ..Default::default() } }), serde_json::from_value(serde_json::json!({ "categories": ["jump", "kick", "spin"], "paging": { "order": "desc" } })) .map_err(|e| format!("{:#?}", e)), "works with paging" ) } #[test] fn query_response_for_source() { crate::test::Client::default(); let item = microformats::types::Item { r#type: vec![Class::Known(KnownClass::Entry)], ..Default::default() }; assert_eq!( Ok(Response::Source(SourceResponse { post_type: vec![], item })), serde_json::from_value(serde_json::json!({ "type": ["h-entry"], "properties": {} })) .map_err(|e| e.to_string()) ); assert_eq!( serde_json::from_value::(serde_json::json!( { "post-type": [ "article" ], "properties": { "audience": [], "category": [], "channel": [ "all" ], "content": { "html": "

well-here-we-go

" }, "name": "magic-omg", "post-status": [ "published" ], "published": [ "2022-02-12T23:22:27+00:00" ], "slug": [ "Gzg043ii" ], "syndication": [], "updated": [ "2022-02-12T23:22:27+00:00" ], "url": [ "http://localhost:3112/Gzg043ii" ], "visibility": [ "public" ] }, "type": [ "h-entry" ] } )) .map_err(|e| e.to_string()) .err(), None, ) } indieweb-rust-v0.6.0/library/src/standards/mod.rs000066400000000000000000000151321511645615100220360ustar00rootroot00000000000000/// Provides an implementation of the [IndieAuth](https://indieauth.spec.indieweb.org) standard. /// /// The implementations in this crate allows for one to /// [request][indieauth::Request::BuildAuthorizationUrl] a [Token][indieauth::Token], /// [verify][indieauth::Request::VerifyAccessToken] it and other common operations. All /// known operations can be found under the [request][indieauth::Request] representation /// as well as [the responses][indieauth::Response]. /// /// # Requesting A Token /// In order to request a [token][indieauth::Token] or a [profile][indieauth::Profile], one needs /// to construct an authorization URL that'll allow the user to provide you with an authorization /// code. You can build the such by doing the following: /// /// ``` /// use indieweb::standards::indieauth; /// use indieweb::http::reqwest::Client as HttpClient; /// use indieauth::Client; /// use url::Url; /// /// // Get networking client. /// let http_client = HttpClient::default(); /// /// // Define who I want to be. /// let me: Url = "https://jacky.wtf".parse().unwrap(); /// /// // Create a simple client for making requests. /// let client: indieauth::Client = ( /// indieauth::ClientId::new("https://jacky.wtf".into()), /// indieauth::EndpointDiscovery::Classic { /// authorization: "https://jacky.wtf/auth/auth".parse().unwrap(), /// token: "https://jacky.wtf/auth/token".parse().unwrap(), /// ticket: None /// }).into(); /// /// // Build the authorization URL to get an authorization code. /// let response = client.dispatch(&http_client, /// indieauth::Request::BuildAuthorizationUrl { /// me: Some(me.clone()), /// scope: None, /// redirect_uri: None /// }); /// /// // Prune out the fields from the request. /// # tokio_test::block_on(async { /// let response_opt: Option<(_, _, _, _)> = response.await.as_ref() /// .ok() /// .and_then(|r| { /// if let indieauth::Response::AuthenticationUrl { url, verifier, challenge, /// csrf_token, issuer: _ } = r { /// Some((url.clone(), challenge.clone(), verifier.clone(), csrf_token.clone())) /// .filter(|_| { /// url.query_pairs().any(|(key, value)| { /// key == "me" && value == me.as_str() /// }) /// }) /// } else { /// None /// } /// }) /// .clone(); /// /// assert!( /// response_opt /// .as_ref() /// .map(|(url, _, _, _)| url) /// .map(|url| { /// url.query_pairs().any(|(key, value)| { /// key == "me" && value == me.as_str() /// }) /// }).unwrap_or(false), /// "confirms the authorization URL to send the user to on behalf of them"); /// # }) /// // Direct the user to the location of `response_opt.unwrap().0`. /// ``` /// /// # Redeeming an Authorization Code /// The IndieAuth server will do the work of confirming the identity and scopes /// that the site ends up permitting. You'll be redirected back to with an authoriz /// ation code or error information. This step is meant to help capture that kind /// of information. /// /// ## Note /// * The logic for checking CSRF tokens is up to your implementation. It's /// strongly recommended to do to prevent forged requests. /// /// ``` /// use indieweb::{ /// standards::indieauth::{ /// Client, EndpointDiscovery, ClientId, AuthUrl, TokenUrl, /// Request, DesiredResourceAuthorization, AuthorizationCode /// }, /// http::reqwest::Client as HttpClient /// }; /// /// let http_client = HttpClient::default(); /// /// let client: Client = ( /// ClientId::new("https://jacky.wtf".into()), /// EndpointDiscovery::Classic { /// authorization: "https://jacky.wtf/auth/auth".parse().unwrap(), /// token: "https://jacky.wtf/auth/token".parse().unwrap(), /// ticket: None /// } /// ).into(); /// /// let code = AuthorizationCode::new("abcdefghijklmnopqrstuvxwyz123456".to_string()); /// /// client.dispatch(&http_client, Request::CompleteAuthorization { /// code, /// resource: DesiredResourceAuthorization::Profile, /// code_verifier: String::default(), /// redirect_uri: None /// }); /// ``` /// /// The expected response from that dispatch call could be either the providing of /// a [profile][indieauth::Response::Profile] or of a [token][indieauth::Response::AccessToken]. // FIXME: Work on a mocked out client that'll provide fixture information for the IndieAuth flow. /// /// # Verifying A Token /// The act of verification is done by doing the following: /// /// ``` /// use indieweb::{ /// standards::indieauth::{AccessToken, Client, /// EndpointDiscovery, EndpointMetadata, /// ClientId, AuthUrl, TokenUrl, Request, DesiredResourceAuthorization}, /// http::reqwest::Client as HttpClient /// }; /// /// let http_client = HttpClient::default(); /// /// let client: Client = ( /// ClientId::new("https://jacky.wtf".into()), /// EndpointDiscovery::Classic { /// authorization: "https://jacky.wtf/auth/auth".parse().unwrap(), /// token: "https://jacky.wtf/auth/token".parse().unwrap(), /// ticket: None /// } /// ).into(); /// let token = AccessToken::new("magic-token".to_string()); /// /// client.dispatch(&http_client, Request::VerifyAccessToken(token)); /// ``` /// /// /// What's missing from this implementation is logic for things like /// [AutoAuth](https://indieweb.org/AutoAuth) or /// [TicketAuth](https://indieweb.org/IndieAuth_Ticket_Auth). This also /// is locked at the /// [26 Nov 2020 version](https://indieauth.spec.indieweb.org/#changes-from-26-november-2020-to-this-version=). pub mod indieauth; /// Provides an implementation of the [Micropub](https://micropub.spec.indieweb.org) standard. /// /// This provides a means of representing a Micropub [query][crate::standards::micropub::Query] /// and parsing the [responses][crate::standards::micropub::QueryResponse] produced by /// conforming servers or invoking an [action][micropub::Action] and handling that /// [response][crate::standards::micropub::ActionResponse] accordingly. pub mod micropub; /// Provides an implementation of the [Webmention](https://www.w3.org/TR/webmention/) standard. /// /// This provides logic for [sending Webmentions][crate::standards::webmention::send], /// [determining the kind of Webmention][crate::standards::webmention::mention_relationship] and /// structures around things like [private Webmentions][crate::standards::webmention::PrivateRequest]. /// /// See [send][crate::standards::webmention::send] for more information. pub mod webmention; indieweb-rust-v0.6.0/library/src/standards/webmention/000077500000000000000000000000001511645615100230565ustar00rootroot00000000000000indieweb-rust-v0.6.0/library/src/standards/webmention/mod.rs000066400000000000000000000412151511645615100242060ustar00rootroot00000000000000use crate::{algorithms::ptd, mf2, traits::as_string_or_list}; use http::{ header::{ACCEPT, CONTENT_TYPE}, StatusCode, }; use mf2::types::{FindItemById, FindItemByProperty, FindItemByUrl, PropertyValue}; use serde::{Deserialize, Serialize}; use std::cmp::PartialEq; use url::Url; /// Resolves the Webmention endpoints for the provided URL. /// /// # Examples /// /// ``` /// use indieweb::standards::webmention::endpoint_for; /// use indieweb::http::{Client, reqwest::Client as HttpClient}; /// use url::Url; /// /// # tokio_test::block_on(async { /// let the_url: Url = "https://news.indieweb.org".parse().unwrap(); /// let client = HttpClient::default(); /// assert_eq!( /// endpoint_for(&client, &the_url).await, /// "https://news.indieweb.org/en/webmention" /// .parse() /// .map_err(indieweb::Error::Url), /// "found the Webmention endpoint"); /// # }) /// ``` #[tracing::instrument(skip(client))] pub async fn endpoint_for( client: &impl crate::http::Client, url: &Url, ) -> Result { let rels = crate::algorithms::link_rel::for_url(client, url, &["webmention"], "GET") .await? .get("webmention") .cloned() .unwrap_or_default(); if let Some(endpoint_url) = rels.first().cloned() { tracing::debug!( rels = format!("{:?}", rels), url = format!("{endpoint_url}"), "Found the relations for Webmention; selecting the first URL" ); Ok(endpoint_url) } else { tracing::trace!( url = format!("{}", url), "No Webmention endpoints were found." ); Err(crate::Error::NoEndpointsFound { url: url.to_string(), rel: "webmention".to_owned(), }) } } /// Handles the work of sending a Webmention from a [request][Request]. /// /// This uses the [request][Request] to send a Webmention. You can use /// the [builder][Request::builder] to simplify the act of crafting it. /// /// # Examples /// This is what it looks like sending a Webmention that processes it synchronously. /// ``` /// use indieweb::standards::webmention::{Request, Response, send, Status}; /// use indieweb::http::{Client, reqwest::Client as HttpClient}; /// use url::Url; /// # use mockito::*; /// /// # let mut server = Server::new(); /// let client = HttpClient::default(); /// # let source = format!("{}/source", server.url()).parse().unwrap(); /// # let target = format!("{}/target", server.url()).parse().unwrap(); /// # let endpoint: Url = format!("{}/endpoint", server.url()).parse().unwrap(); /// # let mock_endpoint = server.mock("POST", endpoint.path()).with_status(200).expect(1).create(); /// let public_request = Request::builder() /// .source(source) // Any `url::Url` will do. /// .target(target); // Same here /// /// # tokio_test::block_on(async { /// let public_result = send(&client, &endpoint, &public_request.build()).await; /// assert_eq!( /// public_result, /// Ok(Response { /// status: Status::Sent, /// location: None, /// body: None /// }) /// ); /// # mock_endpoint.assert(); /// # }) /// ``` /// /// This is how it'd look when it's processed asynchronously pointing to a status page. /// ``` /// use indieweb::standards::webmention::{Request, Response, send, Status}; /// use indieweb::http::{Client, reqwest::Client as HttpClient}; /// use url::Url; /// # use mockito::*; /// /// # let mut server = Server::new(); /// let client = HttpClient::default(); /// # let source = format!("{}/source", server.url()).parse().unwrap(); /// # let target = format!("{}/target", server.url()).parse().unwrap(); /// # let endpoint: Url = format!("{}/endpoint", server.url()).parse().unwrap(); /// # let mock_endpoint = server.mock("POST", endpoint.path()) /// # .with_status(201) /// # .with_header("Location", "http://webmention.example/status") /// # .expect(1) /// # .create(); /// let public_request = Request::builder() /// .source(source) // Any `url::Url` will do. /// .target(target); // Same here /// /// # tokio_test::block_on(async { /// let public_result = send(&client, &endpoint, &public_request.build()).await; /// assert_eq!( /// public_result, /// Ok(Response { /// status: Status::Accepted, /// location: "http://webmention.example/status".parse().ok(), /// body: None /// }) /// ); /// # mock_endpoint.assert(); /// # }) /// ``` /// #[tracing::instrument(skip(client))] pub async fn send( client: &impl crate::http::Client, endpoint: &Url, request: &Request, ) -> Result { use std::str::FromStr; let local_request = request.clone(); let mut req: http::Request = local_request.try_into()?; *req.uri_mut() = http::Uri::from_str(endpoint.as_str()).map_err(|e| crate::Error::Http(e.into()))?; client.send_request(req).await.and_then(|r| r.try_into()) } /// Represents the state of a Webmention for a client or a server. #[derive(Clone, Debug, Serialize, Deserialize, Eq)] #[serde(untagged, rename_all = "lowercase")] pub enum Status { /// Used to highlight that a stored Webmention or a response about a Webmention has /// been rejected by a remote endpoint or the implementation of a server is informing a client /// that it has been rejected. Rejected, /// Used to highlight that an asynchronous Webmention has been accepted for processing /// by a remote endpoint. Accepted, /// Represents a successful synchronous Webmention. Sent, /// Reports that the sender failed to satisfy constraints for this Webmention. /// /// This value is usually in the `4xx` range. SenderError(u16), /// Reports that the receiver failed to satisfy constraints for this Webmention. /// /// This value is usually in the `5xx` range. ReceiverError(u16), } impl PartialEq for Status { fn eq(&self, other: &Self) -> bool { std::mem::discriminant(self) == std::mem::discriminant(other) } } impl Status { /// Determines if this status is not of an error status. /// /// # Examples /// /// ``` /// use indieweb::standards::webmention::Status; /// /// assert!(Status::Accepted.is_ok()); /// assert!(Status::Sent.is_ok()); /// assert!(!Status::Rejected.is_ok()); /// assert!(!Status::SenderError(409).is_ok()); /// assert!(!Status::ReceiverError(503).is_ok()); /// ``` pub fn is_ok(&self) -> bool { match self { Status::Accepted | Status::Sent => true, Status::SenderError(_) | Status::ReceiverError(_) | Status::Rejected => false, } } } impl From for Status { fn from(code: u16) -> Self { match code { 200 => Self::Sent, 201 | 202 => Self::Accepted, 400..=499 => Self::SenderError(code), _ => Self::ReceiverError(code), } } } /// Represents a incoming request to an endpoint for a Webmention. /// /// The foundation of this request is defined at /// but /// additional fields can be used to extend the functionality of this Webmention. #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] pub struct Request { /// The URL of the resource that's sending this Webmention. /// /// See for more information. pub source: Url, /// The URL of the resource that's receiving this Webmention. /// /// See for more information. pub target: Url, /// Parameters for private Webmention support. /// /// See for more information. #[serde(skip_serializing_if = "Option::is_none", flatten)] pub private: Option, /// A list of URLs that can be used for vouching this URL. /// /// This is part of the sending flow described at . /// /// See for more information. #[serde( default, skip_serializing_if = "Vec::is_empty", with = "as_string_or_list" )] pub vouch: Vec, /// A token to use to authorize this request when sending a Webmention. #[serde(skip)] pub token: Option, } impl TryInto> for Request { type Error = crate::Error; fn try_into(mut self) -> Result, Self::Error> { let mut request_builder = http::Request::builder() .method("POST") .header( ::http::header::ACCEPT, "text/plain; q=0.8, text/html, application/json; q=0.8, application/mf2+json; q=0.9, *.*; q=0.7", ) .header( ::http::header::CONTENT_TYPE, crate::http::CONTENT_TYPE_FORM_URLENCODED, ); if let Some(token) = self.token.take() { request_builder = request_builder.header(::http::header::AUTHORIZATION, format!("Bearer {}", token)); } let req_qs = serde_qs::to_string(&self).map(|s| crate::http::Body::Bytes(s.into_bytes()))?; request_builder.body(req_qs).map_err(crate::Error::Http) } } impl Default for Request { fn default() -> Self { Self { source: "urn:indieweb:invalid-source".parse().unwrap(), target: "urn:indieweb:invalid-target".parse().unwrap(), private: None, vouch: Default::default(), token: None, } } } impl Request { pub fn validate(&self) -> Result<(), crate::Error> { // https://webmention.net/draft/#request-verification-p-2 if self.source == self.target { return Err(crate::Error::WebmentionSourceAndTargetAreSame { url: self.source.clone(), }); } Ok(()) } } /// Represents parameters to use when sending a private Webmention. /// See for more information. #[derive(Clone, Default, Debug, Serialize, Deserialize, PartialEq, Eq)] pub struct PrivateRequest { /// The code to exchange for a private Webmention. /// /// See for more information. #[serde(skip_serializing_if = "String::is_empty")] pub code: String, /// The realm to exchange for a private Webmention. /// /// See #[serde(skip_serializing_if = "Option::is_none")] pub realm: Option, } /// Represents a incoming response from an endpoint for a Webmention. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Response { /// The resulting status of a Webmention. pub status: Status, /// A optional URL presenting the result of this URL. It can also be used /// at time to show status information or acceptance information. pub location: Option, /// A optional body representing the immediate body of the response. pub body: Option, } impl PartialEq for Response { fn eq(&self, other: &Self) -> bool { self.status == other.status && self.location == other.location && self.body == other.body } } impl TryFrom> for Response { type Error = crate::Error; fn try_from(resp: http::Response) -> Result { let locations = resp.headers().get_all("location"); let status = resp.status(); let location = locations .into_iter() .filter(|&_| Status::from(status.as_u16()) == Status::Accepted) .cloned() .filter_map(|v| v.to_str().ok().map(ToString::to_string)) .collect::>() .first() .filter(|l| !l.is_empty()) .and_then(|v| v.as_str().parse().ok()); let body = Some(String::from_utf8(resp.into_body().as_bytes().to_vec())?) .filter(|b| !b.is_empty()); match status.as_u16() { 200..=299 | 400..=499 | 500..=599 => { let status = match status.as_u16() { 201 | 202 => Status::Accepted, 200 | 203..=299 => Status::Sent, 400..=499 => Status::SenderError(status.as_u16()), _ => Status::ReceiverError(status.as_u16()), }; Ok(Self { body, location, status, }) } _ => Err(crate::Error::WebmentionUnsupportedStatusCode( status.as_u16(), )), } } } #[derive(Debug, Clone, PartialEq, Eq)] pub struct Relationship { pub r#type: ptd::Type, pub document: mf2::types::Document, pub source: Option, } const ACCEPT_HEADER_VALUE: &str = "text/html,text/mf2+html"; /// Determines the Webmention relationship between the source and target URL by processing it. #[tracing::instrument(skip(client), ret, err)] pub async fn process_incoming_webmention( client: &impl crate::http::Client, Request { source, target, token, .. }: &Request, ) -> Result { let mut req_builder = http::Request::builder().method("GET").uri(source.as_str()); // FIXME: Extract the headers of content to expect. req_builder = req_builder.header(ACCEPT, ACCEPT_HEADER_VALUE); if let Some(token) = token { req_builder = req_builder.header(::http::header::AUTHORIZATION, format!("Bearer {}", token)); } let req = req_builder .body(crate::http::Body::default()) .map_err(crate::Error::Http)?; tracing::trace!("Executing Webmention request"); let resp = client.send_request(req).await?; if resp.status() == StatusCode::UNAUTHORIZED { // FIXME: Add logic to load remote page with authorization. return Err(crate::Error::WebmentionUnauthorized { url: source.to_owned(), }); } else if resp.status() == StatusCode::NOT_FOUND { return Err(crate::Error::WebmentionNotFound { url: source.to_owned(), }); } else if resp.status() == StatusCode::GONE { return Err(crate::Error::WebmentionDeleted { url: source.to_owned(), }); } let incoming_content_type_header_value = resp .headers() .get(CONTENT_TYPE) .and_then(|v| v.to_str().ok()) .map(ToString::to_string) .unwrap_or_default(); let matches_provided_content_type = !incoming_content_type_header_value.is_empty() && ACCEPT_HEADER_VALUE.contains(incoming_content_type_header_value.as_str()); if !matches_provided_content_type { return Err(crate::Error::WebmentionUnsupportedContentType { url: source.to_owned(), content_type: incoming_content_type_header_value, }); } let document = crate::mf2::parser::http::to_mf2_document( resp.map(|b| b.as_bytes().to_vec()), source.as_str(), ) .map_err(mf2::Error::Parser) .map_err(crate::Error::Microformats)?; let property_names = document .find_items_with_matching_property_value(PropertyValue::Url(target.clone())) .into_iter() .map(|(property_name, _)| property_name) .collect::>(); tracing::trace!( property_names = format!("{property_names:?}"), "Mapped matching items by their property name" ); let r#type = if property_names.is_empty() { let item = document.find_item_by_url(source).or_else(|| { source .fragment() .and_then(|fragment| document.find_item_by_id(fragment)) }); tracing::trace!( source_has_url = item.is_some(), url = source.to_string(), "Did the source expose itself in its MF2?" ); item.and_then(ptd::resolve_from_object) .unwrap_or(ptd::Type::Mention) } else { ptd::resolve_reaction_property_name( &property_names .iter() .map(|property_name| property_name.as_str()) .collect::>(), ) .unwrap_or(ptd::Type::Mention) }; let source_item = document.find_item_by_url(source); let source_item = if source_item.is_none() && document.items.len() == 1 { tracing::warn!("The source item is not in the MF2 by its URL; used the only root item"); Some(document.items[0].clone()) } else { source_item }; Ok(Relationship { document, r#type, source: source_item, }) } mod test; indieweb-rust-v0.6.0/library/src/standards/webmention/test.rs000066400000000000000000000576251511645615100244220ustar00rootroot00000000000000#![cfg(test)] use crate::{algorithms::ptd::Type, test::Client, Error}; use super::{endpoint_for, process_incoming_webmention}; use miette::IntoDiagnostic; use url::Url; #[macro_export] macro_rules! run_stock_webmention_test { ($slug: expr, $mock_page: expr, $resulting_url: expr) => { run_webmention_test_with_client!( $crate::test::Client::new().await, $slug, $mock_page, $resulting_url ) }; } #[macro_export] macro_rules! run_webmention_test_with_client { ($client: expr, $slug: expr, $mock_page: expr, $resulting_url: expr) => { let lpath = format!("/page-{}", $slug); let resource_url = $client.merge_into_url(&lpath); let mut resource_mock = $client .mock_server .mock("GET", lpath.as_str()) .with_status($mock_page.status().as_u16().into()); for (name, value) in $mock_page.headers().into_iter() { resource_mock = resource_mock.with_header(name.as_str(), value.to_str().unwrap()); } resource_mock .with_body($mock_page.into_body().as_bytes()) .create(); let result = endpoint_for(&$client, &resource_url).await; let compared_url = if $resulting_url.starts_with("/") { Ok($client.merge_into_url(&$resulting_url)) } else { Ok($resulting_url.parse::().unwrap()) }; assert_eq!( result.map(|u| u.to_string()), compared_url.map(|u| u.to_string()), "resolution found the URL {resource_url}", ); }; } #[tokio::test] // Implements async fn http_link_header_unquoted_rel_relative_url() { run_stock_webmention_test!( "1", ::http::Response::builder() .status(::http::StatusCode::OK) .header(::http::header::LINK, "; rel=webmention") .body(crate::http::Body::Empty) .unwrap_or_default(), "/test/1/webmention" ); } #[tokio::test] // Implments async fn http_link_header_unquoted_rel_absolute_url() { run_stock_webmention_test!( "2", ::http::Response::builder() .status(::http::StatusCode::OK) .header(::http::header::LINK, "; rel=webmention",) .body(crate::http::Body::Empty) .unwrap_or_default(), "/test/2/webmention" ); } #[tokio::test] // Implements async fn http_link_tag_relative_url() { let whole_url = "/test/3/webmention"; run_stock_webmention_test!( "3", ::http::Response::builder() .status(::http::StatusCode::OK) .header("content-type", "text/html") .body(crate::http::Body::from(format!( r#" "#, whole_url ))) .unwrap(), whole_url ); } #[tokio::test] // Implements async fn http_link_tag_absolute_url() { let mut client = Client::new().await; let whole_url = format!("{}/test/4/webmention", client.mock_server.url()); run_webmention_test_with_client!( client, "4", ::http::Response::builder() .status(::http::StatusCode::OK) .body(crate::http::Body::from(format!( r#" "#, whole_url ))) .unwrap_or_default(), whole_url ); } #[tokio::test] // Implements async fn http_a_tag_relative_url() { let whole_url = "/test/5/webmention"; run_stock_webmention_test!( "5", ::http::Response::builder() .status(::http::StatusCode::OK) .body(crate::http::Body::from(format!( r#" "#, whole_url ))) .unwrap_or_default(), whole_url ); } #[tokio::test] // Implements async fn http_a_tag_absolute_url() { let mut client = Client::new().await; let whole_url: Url = format!("{}/test/6/webmention", client.mock_server.url()) .parse() .unwrap(); run_webmention_test_with_client!( client, "6", ::http::Response::builder() .status(::http::StatusCode::OK) .body(crate::http::Body::from(format!( r#"
"#, whole_url ))) .unwrap_or_default(), whole_url.as_str() ); } #[tokio::test] // Implements async fn http_link_header_strange_casing() { let mut client = Client::new().await; let whole_url: Url = format!("{}/test/7/webmention", client.mock_server.url()) .parse() .unwrap(); let resp = ::http::Response::builder() .header("Link", format!("<{}>; rel=webmention", whole_url).as_str()) .body(crate::http::Body::Empty) .unwrap_or_default(); run_webmention_test_with_client!(client, "7", resp, whole_url.as_str()); } // Implements #[tracing_test::traced_test] #[tokio::test] async fn http_link_header_quoted_rel() { let mut client = Client::new().await; let whole_url: Url = format!("{}/test/8/webmention", client.mock_server.url()) .parse() .unwrap(); let resp = ::http::Response::builder() .header( "Link", format!("<{}>; rel=\"webmention\"", whole_url).as_str(), ) .body(crate::http::Body::Empty) .unwrap_or_default(); run_webmention_test_with_client!(client, "8", resp, whole_url.as_str()); } // Implements #[tracing_test::traced_test] #[tokio::test] async fn http_multiple_rel_values_on_link_tag() { let mut client = Client::new().await; let whole_url = client.merge_into_url("/test/9/webmention"); let resp = ::http::Response::builder() .body(crate::http::Body::Bytes( format!( r#" "#, whole_url ) .into(), )) .unwrap_or_default(); run_webmention_test_with_client!(client, "9", resp, whole_url.path()); } // Implements #[tracing_test::traced_test] #[tokio::test] async fn http_rel_values_on_rel_headers() { let mut client = Client::new().await; let whole_url: Url = format!("{}/test/10/webmention", client.mock_server.url()) .parse() .unwrap(); let resp = ::http::Response::builder() .header( "LinK", format!("<{}>; rel=\"webmention somethingelse\"", whole_url).as_str(), ) .body(crate::http::Body::Empty) .unwrap_or_default(); run_webmention_test_with_client!(client, "10", resp, whole_url.path()); } #[tokio::test] // Implements async fn http_rel_multiple_webmention_endpoints_advertised() { let mut client = Client::new().await; let resp = ::http::Response::builder() .header( "LinK", format!( "<{}/test/11/webmention/header>; rel=\"webmention somethingelse\"", client.mock_server.url() ) .as_str(), ) .body(crate::http::Body::from(format!( r#" "#, client.mock_server.url() ))) .unwrap_or_default(); run_webmention_test_with_client!( client, "11", resp, format!("{}/test/11/webmention/header", client.mock_server.url()) ); } #[tokio::test] // Implements async fn http_rel_checking_for_exact_match_of_rel_webmention() { let mut client = Client::new().await; let resp = ::http::Response::builder() .body(crate::http::Body::from(format!( r#" "#, client.mock_server.url() ))) .unwrap_or_default(); run_webmention_test_with_client!( client, "12", resp, format!("{}/test/12/webmention/right", client.mock_server.url()) ); } #[tokio::test] // Implements async fn http_rel_false_endpoint_inside_html_element() { let mut client = Client::new().await; let resp = ::http::Response::builder() .body(crate::http::Body::from(format!( r#" "#, client.mock_server.url() ))) .unwrap_or_default(); run_webmention_test_with_client!( client, "13", resp, format!("{}/test/13/webmention/right", client.mock_server.url()) ); } #[tokio::test] // Implements async fn http_rel_false_endpoint_inside_escaped_html() { let mut client = Client::new().await; let resp = ::http::Response::builder() .body(crate::http::Body::from( r#" This post contains sample code with escaped HTML which should not be discovered by the Webmention client. <a href="/test/14/webmention/error" rel="webmention"></a> There is also a correct endpoint defined, so if your comment appears below, it means you successfully ignored the false endpoint. "# .to_string())) .unwrap_or_default(); run_webmention_test_with_client!( client, "14", resp, format!("{}/test/14/webmention/right", client.mock_server.url()) ); } #[tokio::test] // Implements async fn http_rel_webmention_href_empty_string() { let mut client = Client::new().await; let resp = ::http::Response::builder() .body(crate::http::Body::from( r#" "# .to_string(), )) .unwrap_or_default(); run_webmention_test_with_client!( client, "15", resp, format!("{}/page-15", client.mock_server.url()) ); } // Implements #[tracing_test::traced_test] #[tokio::test] async fn http_rel_multiple_webmention_endpoints_advertised_link_a() { let resp = ::http::Response::builder() .body(crate::http::Body::from( r#" This post advertises its Webmention endpoint in an HTML <a> tag, followed by a later definition in a <link> tag. Your Webmention client must only send a Webmention to the one in the <a> tag since it appears first in the document. "# .to_string(), )) .expect("yeup"); run_stock_webmention_test!("16", resp, "https://webmention.rocks/test/16/webmention"); } #[tokio::test] // Implements async fn http_rel_multiple_webmention_endpoints_advertised_a_link() { let mut client = Client::new().await; let resp = ::http::Response::builder() .body(crate::http::Body::from( r#" This post advertises its Webmention endpoint in an HTML <link> tag followed by a later definition in an <a> tag. Your Webmention client must only send a Webmention to the one in the <link> tag since it appears first in the document "# .to_string(), )) .unwrap_or_default(); run_webmention_test_with_client!( client, "17", resp, format!("{}/test/17/webmention", client.mock_server.url()) ); } #[tokio::test] // Implements async fn http_rel_multiple_link_http_headers() { let mut client = Client::new().await; let resp = ::http::Response::builder() .header( "LinK", format!( r#"<{0}/test/18/webmention>; rel="webmention""#, client.mock_server.url() ) .as_str(), ) .header( ::http::header::LINK, format!( r#"<{0}/test/18/webmention/error>; rel="other""#, client.mock_server.url() ) .as_str(), ) .body(crate::http::Body::Empty) .unwrap_or_default(); run_webmention_test_with_client!( client, "18", resp, format!("{}/test/18/webmention", client.mock_server.url()) ); } #[tokio::test] // Implements async fn http_rel_single_link_header_multiple_values() { let mut client = Client::new().await; let resp = ::http::Response::builder() .header( "LinK", format!( r#"<{0}/test/19/webmention/error>; rel="other", <{0}/test/19/webmention>; rel="webmention""#, client.mock_server.url() ) .as_str(), ) .body(crate::http::Body::Empty) .unwrap_or_default(); run_webmention_test_with_client!( client, "18", resp, format!("{}/test/19/webmention", client.mock_server.url()) ); } #[tracing_test::traced_test] #[tokio::test] // Implements async fn link_tag_with_no_href_attribute() { let mut client = Client::new().await; let resp = ::http::Response::builder() .body(crate::http::Body::from( r#" This post has a <link> tag which has no href attribute. Your Webmention client should not find this link tag, and should send the webmention to this endpoint instead. "# .to_string(), )) .unwrap_or_default(); run_webmention_test_with_client!( client, "20", resp, format!("{}/test/20/webmention", client.mock_server.url()) ); } #[tokio::test] // Implements async fn webmention_endpoint_with_query_params() { let mut client = Client::new().await; let resp = ::http::Response::builder() .body(crate::http::Body::from( r#" "# .to_string(), )) .unwrap_or_default(); run_webmention_test_with_client!( client, "21", resp, format!("{}/test/21/webmention?query=yes", client.mock_server.url()) ); } #[tokio::test] // Implements async fn webmention_endpoint_relative_to_path() { let mut client = Client::new().await; let resp = ::http::Response::builder() .body(crate::http::Body::from( r#" "# .to_string(), )) .unwrap_or_default(); run_webmention_test_with_client!( client, "22", resp, format!("{}/22/webmention", client.mock_server.url()) ); } #[tokio::test] async fn incoming_ok() -> miette::Result<()> { let mut test_client = Client::new().await; let target_url = format!("{}/target", test_client.mock_server.url()) .parse() .into_diagnostic()?; let source_url = format!("{}/source", test_client.mock_server.url()) .parse() .into_diagnostic()?; let src_mock = test_client .mock_server .mock("GET", "/source") .with_header("Content-Type", "text/html") .with_body(format!( r#" I mentioned your page. "# )) .with_status(200) .create(); let relation = process_incoming_webmention( &test_client, &crate::standards::webmention::Request { source: source_url, target: target_url, private: None, vouch: Default::default(), token: None, }, ) .await; src_mock.assert(); assert!(relation.is_ok()); let relation = relation?; assert_eq!(relation.r#type, Type::Note); assert!( relation.source.is_some(), "resolves a MF2 item from the incoming source" ); Ok(()) } #[tokio::test] async fn incoming_deleted() -> miette::Result<()> { let mut test_client = Client::new().await; let target_url = format!("{}/target", test_client.mock_server.url()) .parse() .into_diagnostic()?; let source_url = format!("{}/source", test_client.mock_server.url()) .parse() .into_diagnostic()?; let src_mock = test_client .mock_server .mock("GET", "/source") .with_status(401) .with_header("WWW-Authenticate", "fome sit") .create(); let relation = process_incoming_webmention( &test_client, &crate::standards::webmention::Request { source: source_url, target: target_url, private: None, vouch: Default::default(), token: None, }, ) .await; src_mock.assert(); assert!(relation.is_err()); Ok(()) } #[tokio::test] async fn incoming_need_auth() -> miette::Result<()> { let mut test_client = Client::new().await; let target_url: Url = format!("{}/target", test_client.mock_server.url()) .parse() .into_diagnostic()?; let source_url: Url = format!("{}/source", test_client.mock_server.url()) .parse() .into_diagnostic()?; let src_mock = test_client .mock_server .mock("GET", "/source") .with_status(401) .create(); let relation = process_incoming_webmention( &test_client, &crate::standards::webmention::Request { source: source_url.clone(), target: target_url.clone(), private: None, vouch: Default::default(), token: None, }, ) .await; src_mock.assert(); assert_eq!( relation, Err(Error::WebmentionUnauthorized { url: source_url }) ); Ok(()) } #[tokio::test] async fn incoming_invalid_creds() -> miette::Result<()> { let mut test_client = Client::new().await; let target_url: url::Url = format!("{}/target", test_client.mock_server.url()) .parse() .into_diagnostic()?; let source_url: url::Url = format!("{}/source", test_client.mock_server.url()) .parse() .into_diagnostic()?; let src_mock = test_client .mock_server .mock("GET", "/source") .with_status(401) .create(); let relation = process_incoming_webmention( &test_client, &crate::standards::webmention::Request { source: source_url.clone(), target: target_url.clone(), private: None, vouch: Default::default(), token: None, }, ) .await; src_mock.assert(); assert_eq!( relation, Err(Error::WebmentionUnauthorized { url: source_url }) ); Ok(()) } #[tokio::test] async fn private_incoming_process() -> miette::Result<()> { let mut test_client = Client::new().await; let target_url: url::Url = format!("{}/target", test_client.mock_server.url()) .parse() .into_diagnostic()?; let source_url: url::Url = format!("{}/source", test_client.mock_server.url()) .parse() .into_diagnostic()?; let authenticated_source_mock = test_client .mock_server .mock("GET", source_url.path()) .match_header("Authorization", "Bearer the-expected-token") .with_status(200) .with_header("Content-Type", "text/html") .with_body(format!( r#" I mentioned your page. "# )) .create_async() .await; let relation = process_incoming_webmention( &test_client, &crate::standards::webmention::Request { source: source_url.clone(), target: target_url.clone(), private: Some(crate::standards::webmention::PrivateRequest { code: "the-expected-code".to_string(), realm: None, }), vouch: Default::default(), token: Some("the-expected-token".to_string()), }, ) .await; authenticated_source_mock.assert(); assert_eq!(relation.err(), None); Ok(()) } #[tokio::test] async fn incoming_not_found() -> miette::Result<()> { let mut test_client = Client::new().await; let target_url = format!("{}/target", test_client.mock_server.url()) .parse() .into_diagnostic()?; let source_url = format!("{}/source", test_client.mock_server.url()) .parse() .into_diagnostic()?; let src_mock = test_client .mock_server .mock("GET", "/source") .with_status(404) .create(); let relation = process_incoming_webmention( &test_client, &crate::standards::webmention::Request { source: source_url, target: target_url, private: None, vouch: Default::default(), token: None, }, ) .await; src_mock.assert(); assert!(relation.is_err()); assert!(matches!(relation.err().unwrap(), crate::Error::WebmentionNotFound { .. })); Ok(()) } indieweb-rust-v0.6.0/library/src/standards/websub.rs000066400000000000000000000000261511645615100225420ustar00rootroot00000000000000pub struct Request {} indieweb-rust-v0.6.0/library/src/test.rs000066400000000000000000000056031511645615100202550ustar00rootroot00000000000000#![cfg(test)] use std::str::FromStr; use async_trait::async_trait; use http::header::CONNECTION; use reqwest::{NoProxy, Proxy}; static USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "-test/", env!("CARGO_PKG_VERSION"),); pub struct Client { pub mock_server: mockito::ServerGuard, pub http_client: reqwest::Client, } impl Default for Client { fn default() -> Self { let mut b = reqwest::Client::builder(); if let Ok(proxy) = std::env::var("http_proxy") { b = b.proxy(Proxy::http(proxy).unwrap().no_proxy(NoProxy::from_env())); } let http_client = b.user_agent(USER_AGENT).build().unwrap(); Self { mock_server: mockito::Server::new(), http_client, } } } impl Client { pub fn merge_into_url(&self, path: &str) -> url::Url { self.mock_server .url() .parse::() .expect("failed to make http URL") .join(path) .expect("failed to join URL") } pub(crate) async fn new() -> Self { Self { http_client: reqwest::Client::new(), mock_server: mockito::Server::new_async().await, } } } #[async_trait] impl crate::http::Client for Client { #[tracing::instrument(skip(self))] async fn send_request( &self, request: http::Request, ) -> Result, crate::Error> { let method = reqwest::Method::from_str(request.method().as_ref()).unwrap(); let mut req = reqwest::Request::new( method, request .uri() .to_string() .parse() .map_err(crate::Error::Url)?, ); let it = request .headers() .iter() .filter(|(h, _)| h != &CONNECTION) .map(move |(header_name, header_value)| { ( reqwest::header::HeaderName::from_str(header_name.as_str()).unwrap(), reqwest::header::HeaderValue::from_bytes(header_value.as_bytes()).unwrap(), ) }); req.headers_mut().extend(it); *req.body_mut() = Some(reqwest::Body::from(request.body().as_bytes().to_vec())); let resp = self .http_client .execute(req) .await .map_err(crate::Error::from)?; let status = resp.status().as_u16(); let mut whole_resp = http::Response::builder().status(status); for (name, value) in resp.headers() { whole_resp = whole_resp.header(name.as_str(), value.as_bytes()); } let body = resp .bytes() .await .map_err(crate::Error::from)? .into_iter() .collect::>() .into(); whole_resp.body(body).map_err(crate::Error::Http) } } indieweb-rust-v0.6.0/library/src/traits/000077500000000000000000000000001511645615100202325ustar00rootroot00000000000000indieweb-rust-v0.6.0/library/src/traits/mod.rs000066400000000000000000000107211511645615100213600ustar00rootroot00000000000000use async_trait::async_trait; use http::header::ACCEPT; use url::Url; use crate::{ algorithms::ptd::Type, mf2::{self, types::Document}, }; /// Provides a facade for determining the post type of an value. pub trait ItemPostTypeResolutionExt { /// Computes the distinct post type of this item. fn post_type(&self) -> Type; } impl ItemPostTypeResolutionExt for crate::mf2::types::Item { fn post_type(&self) -> Type { crate::algorithms::ptd::resolve_from_object(self.clone()).unwrap_or(Type::Note) } } #[test] fn item_post_type_resultion_ext() { let item: crate::mf2::types::Item = serde_json::from_value(serde_json::json!({ "type": ["h-entry"], "properties": { "photo": [ "http://example.com/image.jpg" ] } })) .unwrap(); assert_eq!(item.post_type(), Type::Photo); } /// Provides a facade for resolving the Microformats2 document for a value. #[async_trait] pub trait FetchMF2FromExt { /// With the provided [Client][crate::http::Client], obtain the Microformats document of this value. async fn fetch_mf2_from( &self, client: C, ) -> Result; } #[async_trait] impl FetchMF2FromExt for Url { async fn fetch_mf2_from( &self, client: C, ) -> Result { let req = http::Request::get(self.as_str()) .header( ACCEPT, "text/html; charset=utf-8, text/mf2+html; charset=utf-8, */*; q=0.6", ) .body(crate::http::Body::default()) .map_err(crate::Error::Http)?; let response = client .send_request(req) .await? .map(|body| body.as_bytes().to_vec()); Ok(mf2::parser::http::to_mf2_document(response, self.as_str()) .map_err(mf2::Error::Parser)?) } } /// Used to convert a value or a list of values into a normalized list of values. pub mod as_string_or_list { use std::{fmt, str::FromStr}; use serde::{ de::{self, Visitor}, ser::SerializeSeq, Deserialize, Deserializer, Serialize, }; pub fn deserialize<'de, Item, D, E>(deserializer: D) -> Result, D::Error> where Item: Deserialize<'de> + FromStr + fmt::Debug, E: std::error::Error, D: Deserializer<'de>, { struct CocereIntoList; impl<'de> Visitor<'de> for CocereIntoList { type Value = Vec; fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { formatter.write_str( "expecting no value, a string or a list of values, all to be made into a list", ) } fn visit_str(self, v: &str) -> Result where E: de::Error, { if v.is_empty() { Ok(Vec::default()) } else { Ok(vec![v.to_string()]) } } fn visit_seq(self, mut seq: A) -> Result where A: de::SeqAccess<'de>, { let mut values = Vec::with_capacity(seq.size_hint().unwrap_or_default()); while let Some(value) = seq.next_element()? { values.push(value); } Ok(values) } } deserializer .deserialize_any(CocereIntoList) .and_then(|strings| { strings.into_iter().try_fold( Vec::default(), |mut acc, string| match Item::from_str(&string) { Ok(v) => { acc.push(v); Ok(acc) } Err(e) => Err(de::Error::custom(e)), }, ) }) } pub fn serialize(list: &[Item], serializer: S) -> Result where Item: Serialize, S: serde::Serializer, { if let Some(value) = list.first().filter(|_| list.len() == 1) { value.serialize(serializer) } else { let mut seq = serializer.serialize_seq(Some(list.len()))?; for v in list { seq.serialize_element(v)?; } seq.end() } } } mod test; indieweb-rust-v0.6.0/library/src/traits/test.rs000066400000000000000000000016631511645615100215650ustar00rootroot00000000000000#![cfg(test)] use url::Url; use crate::traits::FetchMF2FromExt; #[tracing_test::traced_test] #[tokio::test] async fn fetch_mf2_from_ext_url() { let mut client = crate::test::Client::new().await; let u: Url = format!("{}/test-endpoint-fetch", client.mock_server.url()) .parse() .unwrap(); let _ = client .mock_server .mock("GET", u.path()) .with_status(200) .with_header("content-type", "text/html") .with_body( r#"
"#, ) .expect_at_most(1) .create_async() .await; let result = u.fetch_mf2_from(client).await; assert!(result.is_ok()); assert_eq!(result.map(|doc| doc.items.len()), Ok(1)); } indieweb-rust-v0.6.0/rust-toolchain.toml000066400000000000000000000001341511645615100203370ustar00rootroot00000000000000[toolchain] channel = "stable" components = ["clippy", "rust-analyzer"] profile = "minimal"